aboutsummaryrefslogtreecommitdiff
path: root/auth/oauth.go
diff options
context:
space:
mode:
Diffstat (limited to 'auth/oauth.go')
-rw-r--r--auth/oauth.go221
1 files changed, 221 insertions, 0 deletions
diff --git a/auth/oauth.go b/auth/oauth.go
new file mode 100644
index 0000000..a1b0419
--- /dev/null
+++ b/auth/oauth.go
@@ -0,0 +1,221 @@
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type OAuthConfig struct {
+ ClientID string
+ RedirectURI string
+ Scopes []string
+}
+
+type OAuthState struct {
+ State string
+ CodeVerifier string
+ DID string
+ PDS string
+ AccessToken string
+ RefreshToken string
+ ExpiresAt time.Time
+ KeyPair *DPoPKeyPair
+}
+
+type OAuthMetadata struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ Scopes []string `json:"scopes_supported"`
+}
+
+type TokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+func (s *OAuthState) Copy() *OAuthState {
+ return &OAuthState{
+ State: s.State,
+ DID: s.DID,
+ PDS: s.PDS,
+ AccessToken: s.AccessToken,
+ RefreshToken: s.RefreshToken,
+ ExpiresAt: s.ExpiresAt,
+ KeyPair: s.KeyPair,
+ }
+}
+
+func GenerateRandomToken() (string, error) {
+ b := make([]byte, 32)
+ _, err := rand.Read(b)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func GenerateCodeChallenge(verifier string) string {
+ h := sha256.Sum256([]byte(verifier))
+ return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h[:])
+}
+
+func FetchOAuthMetadata(pdsURL string) (*OAuthMetadata, error) {
+ // first get the resource metadata to find the auth server
+ resourceURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", pdsURL)
+ fmt.Printf("Fetching oauth metadata from: %s\n", resourceURL)
+ resp, err := http.Get(resourceURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var resource struct {
+ AuthorizationServers []string `json:"authorization_servers"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
+ return nil, err
+ }
+
+ if len(resource.AuthorizationServers) == 0 {
+ return nil, fmt.Errorf("no authorization servers found")
+ }
+
+ // now fetch the actual oauth metadata from the auth server
+ authServer := resource.AuthorizationServers[0]
+ metadataURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServer)
+
+ resp, err = http.Get(metadataURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var metadata OAuthMetadata
+ if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+ return nil, err
+ }
+
+ return &metadata, nil
+}
+
+func BuildAuthorizationURL(metadata *OAuthMetadata, config *OAuthConfig, state, codeChallenge, handle string) string {
+ params := url.Values{
+ "response_type": {"code"},
+ "client_id": {config.ClientID},
+ "redirect_uri": {config.RedirectURI},
+ "state": {state},
+ "code_challenge": {codeChallenge},
+ "code_challenge_method": {"S256"},
+ "scope": {"atproto"},
+ "login_hint": {handle},
+ }
+
+ return metadata.AuthorizationEndpoint + "?" + params.Encode()
+}
+
+func postWithDPoP(url string, data url.Values, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ dpopProof, err := keypair.GenerateDPoPProof("POST", url, "")
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("DPoP", dpopProof)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 400 {
+ body, _ := io.ReadAll(resp.Body)
+ bodyStr := string(body)
+
+ if strings.Contains(bodyStr, "use_dpop_nonce") {
+ nonce := resp.Header.Get("DPoP-Nonce")
+ if nonce == "" {
+ return nil, fmt.Errorf("server requires nonce but didn't provide one")
+ }
+
+ dpopProof, err = keypair.GenerateDPoPProof("POST", url, nonce)
+ if err != nil {
+ return nil, err
+ }
+
+ retryReq, err := http.NewRequest("POST", url, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ retryReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ retryReq.Header.Set("DPoP", dpopProof)
+
+ resp, err = http.DefaultClient.Do(retryReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ } else {
+ return nil, fmt.Errorf("request failed: 400 - %s", bodyStr)
+ }
+ }
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("request failed: %d - %s", resp.StatusCode, string(body))
+ }
+
+ var tokens TokenResponse
+ if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
+ return nil, err
+ }
+
+ return &tokens, nil
+}
+
+func ExchangeCodeForTokens(pdsURL, code, verifier, callbackURL string, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ metadata, err := FetchOAuthMetadata(pdsURL)
+ if err != nil {
+ return nil, err
+ }
+
+ data := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {callbackURL},
+ "code_verifier": {verifier},
+ "client_id": {callbackURL},
+ }
+
+ return postWithDPoP(metadata.TokenEndpoint, data, keypair)
+}
+
+func RefreshAccessToken(pdsURL, refreshToken, callbackURL string, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ metadata, err := FetchOAuthMetadata(pdsURL)
+ if err != nil {
+ return nil, err
+ }
+
+ data := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {refreshToken},
+ "client_id": {callbackURL},
+ }
+
+ return postWithDPoP(metadata.TokenEndpoint, data, keypair)
+}