···3333type ClientConfig struct {
3434 ClientID string
3535 CallbackURL string
3636- // set of scope strings; should not include "atproto"
3636+ // set of scope strings; must include "atproto"
3737 Scopes []string
38383939 UserAgent string
···5555 }
5656 if config.UserAgent != "" {
5757 app.Resolver.UserAgent = config.UserAgent
5858- // TODO: some way to wire UserAgent through to identity directory
5858+5959+ // unpack DefaultDirectory nested type and insert UserAgent (and log failure in case default types change)
6060+ dirAgent := false
6161+ cdir, ok := app.Dir.(*identity.CacheDirectory)
6262+ if ok {
6363+ bdir, ok := cdir.Inner.(*identity.BaseDirectory)
6464+ if ok {
6565+ dirAgent = true
6666+ bdir.UserAgent = config.UserAgent
6767+ }
6868+ }
6969+ if !dirAgent {
7070+ slog.Info("OAuth ClientApp identity directory User-Agent not configured")
7171+ }
5972 }
6073 return app
6174}
62757676+// Creates a basic [ClientConfig] for use as a public (non-confidential) client. To upgrade to a confidential client, use this method and then [ClientConfig.AddClientSecret()].
7777+//
7878+// The "scopes" array must include "atproto".
6379func NewPublicConfig(clientID, callbackURL string, scopes []string) ClientConfig {
6480 c := ClientConfig{
6581 ClientID: clientID,
···7086 return c
7187}
72888989+// Creats a basic [ClientConfig] for use with localhost developmnet. Such a client is always public (non-confidential).
9090+//
9191+// The "scopes" array must include "atproto".
7392func NewLocalhostConfig(callbackURL string, scopes []string) ClientConfig {
7493 params := make(url.Values)
7594 params.Set("redirect_uri", callbackURL)
···129148130149// helper to turn a list of scope strings in to a single space-separated scope string
131150func scopeStr(scopes []string) string {
132132- if len(scopes) == 0 {
133133- return "atproto"
134134- }
135135- return "atproto " + strings.Join(scopes, " ")
151151+ return strings.Join(scopes, " ")
136152}
137153138154// Returns a ClientMetadata struct with the required fields populated based on this client configuration. Clients may want to populate additional metadata fields on top of this response.
···275291 return token.SignedString(privKey)
276292}
277293294294+// attempts to read an HTTP response body as JSON, and determine an error reason. always closes the response body
295295+func parseAuthErrorReason(resp *http.Response, reqType string) string {
296296+ defer resp.Body.Close()
297297+ var errResp map[string]any
298298+ if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
299299+ slog.Warn("auth server request failed", "request", reqType, "statusCode", resp.StatusCode, "err", err)
300300+ return "unknown"
301301+ }
302302+ slog.Warn("auth server request failed", "request", reqType, "statusCode", resp.StatusCode, "body", errResp)
303303+ return fmt.Sprintf("%s", errResp["error"])
304304+}
305305+278306// Sends PAR request to auth server
279307func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, scope, loginHint string) (*AuthRequestData, error) {
280308···351379 // check for an error condition caused by an out of date DPoP nonce
352380 // note that the HTTP status code would be 400 Bad Request on token endpoint, not 401 Unauthorized like it would be on Resource Server requests
353381 if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" {
354354-355355- // parse the error body to confirm the error type
356356- var errResp map[string]any
357357- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
358358- slog.Warn("PAR request failed, and could not parse response body", "authServer", parURL, "err", err, "statusCode", resp.StatusCode)
359359- resp.Body.Close()
360360- return nil, fmt.Errorf("token refresh failed: HTTP %d", resp.StatusCode)
361361- } else if errResp["error"] != "use_dpop_nonce" {
362362- slog.Warn("PAR request failed", "authServer", parURL, "body", errResp, "statusCode", resp.StatusCode)
363363- return nil, fmt.Errorf("PAR request failed: %s", errResp["error"])
382382+ // parseAuthErrorReason() always closes resp.Body
383383+ reason := parseAuthErrorReason(resp, "PAR")
384384+ if reason == "use_dpop_nonce" {
385385+ // already updated nonce value above; loop around and try again
386386+ continue
364387 }
365365-366366- // already updated nonce value above; loop around and try again
367367- // NOTE: having already parsed the body means that the error handling below could fail if we call out of 'for' loop
368368- resp.Body.Close()
369369- continue
388388+ return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason)
370389 }
371390372391 // otherwise process result
···375394376395 defer resp.Body.Close()
377396 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
378378- var errResp map[string]any
379379- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
380380- slog.Warn("PAR request failed", "authServer", parURL, "err", err, "statusCode", resp.StatusCode)
381381- } else {
382382- slog.Warn("PAR request failed", "authServer", parURL, "resp", errResp, "statusCode", resp.StatusCode)
383383- }
384384- return nil, fmt.Errorf("auth request (PAR) failed: HTTP %d", resp.StatusCode)
397397+ reason := parseAuthErrorReason(resp, "PAR")
398398+ return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason)
385399 }
386400387401 var parResp PushedAuthResponse
···468482 // check for an error condition caused by an out of date DPoP nonce
469483 // note that the HTTP status code would be 400 Bad Request on token endpoint, not 401 Unauthorized like it would be on Resource Server requests
470484 if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" {
471471-472472- // parse the error body to confirm the error type
473473- var errResp map[string]any
474474- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
475475- slog.Warn("initial token request failed, and could not parse response body", "authServer", authServerMeta.TokenEndpoint, "err", err, "statusCode", resp.StatusCode)
476476- resp.Body.Close()
477477- return nil, fmt.Errorf("initial token request failed: HTTP %d", resp.StatusCode)
478478- } else if errResp["error"] != "use_dpop_nonce" {
479479- slog.Warn("initial token request failed", "authServer", authServerMeta.TokenEndpoint, "body", errResp, "statusCode", resp.StatusCode)
480480- return nil, fmt.Errorf("initial token request failed: %s", errResp["error"])
485485+ // parseAuthErrorReason() always closes resp.Body
486486+ reason := parseAuthErrorReason(resp, "initial-token")
487487+ if reason == "use_dpop_nonce" {
488488+ // already updated nonce value above; loop around and try again
489489+ continue
481490 }
482482-483483- // already updated nonce value above; loop around and try again
484484- // NOTE: having already parsed the body means that the error handling below could fail if we call out of 'for' loop
485485- resp.Body.Close()
486486- continue
491491+ return nil, fmt.Errorf("initial token request failed (HTTP %d): %s", resp.StatusCode, reason)
487492 }
488493489494 // otherwise process result
···492497493498 defer resp.Body.Close()
494499 if resp.StatusCode != http.StatusOK {
495495- var errResp map[string]any
496496- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
497497- slog.Warn("initial token request failed", "authServer", authServerMeta.TokenEndpoint, "err", err, "statusCode", resp.StatusCode)
498498- } else {
499499- slog.Warn("initial token request failed", "authServer", authServerMeta.TokenEndpoint, "resp", errResp, "statusCode", resp.StatusCode)
500500- }
501501- return nil, fmt.Errorf("initial token request failed: HTTP %d", resp.StatusCode)
500500+ reason := parseAuthErrorReason(resp, "initial-token")
501501+ return nil, fmt.Errorf("initial token request failed (HTTP %d): %s", resp.StatusCode, reason)
502502 }
503503504504 var tokenResp TokenResponse
···562562 params.Set("client_id", app.Config.ClientID)
563563 params.Set("request_uri", info.RequestURI)
564564565565- // TODO: check that 'authorization_endpoint' is "safe" (?)
565565+ // NOTE: AuthorizationEndpoint was already checked to be a clean URL
566566 redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode())
567567 return redirectURL, nil
568568}
+10-25
atproto/auth/oauth/session.go
···123123 }
124124125125 // check for an error condition caused by an out of date DPoP nonce
126126- // note that the HTTP status code would be 400 Bad Request on token endpoint, not 401 Unauthorized like it would be on Resource Server requests
126126+ // note that the HTTP status code is 400 Bad Request on the Auth Server token endpoint, not 401 Unauthorized like it would be on Resource Server requests
127127 if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" {
128128-129129- // parse the error body to confirm the error type
130130- var errResp map[string]any
131131- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
132132- slog.Warn("token refresh failed, and could not parse response body", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode)
133133- resp.Body.Close()
134134- return "", fmt.Errorf("token refresh failed: HTTP %d", resp.StatusCode)
135135- } else if errResp["error"] != "use_dpop_nonce" {
136136- slog.Warn("token refresh failed", "authServer", tokenURL, "body", errResp, "statusCode", resp.StatusCode)
137137- resp.Body.Close()
138138- return "", fmt.Errorf("token refresh failed: %s", errResp["error"])
128128+ // parseAuthErrorReason() always closes resp.Body
129129+ reason := parseAuthErrorReason(resp, "token-refresh")
130130+ if reason == "use_dpop_nonce" {
131131+ // already updated nonce value above; loop around and try again
132132+ continue
139133 }
140140-141141- // already updated nonce value above; loop around and try again
142142- // NOTE: having already parsed the body means that the error handling below could fail if we call out of 'for' loop
143143- resp.Body.Close()
144144- continue
134134+ return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason)
145135 }
146136147147- // otherwise process result
137137+ // otherwise process response (success or other error type)
148138 break
149139 }
150140151141 defer resp.Body.Close()
152142 if resp.StatusCode != http.StatusOK {
153153- var errResp map[string]any
154154- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
155155- slog.Warn("token refresh failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode)
156156- return "", fmt.Errorf("token refresh failed: HTTP %d", resp.StatusCode)
157157- }
158158- slog.Warn("token refresh failed", "authServer", tokenURL, "body", errResp, "statusCode", resp.StatusCode)
159159- return "", fmt.Errorf("token refresh failed: %s", errResp["error"])
143143+ reason := parseAuthErrorReason(resp, "token-refresh")
144144+ return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason)
160145 }
161146162147 var tokenResp TokenResponse
+9
atproto/auth/oauth/types.go
···218218 return fmt.Errorf("%w: issuer must match request URL", ErrInvalidAuthServerMetadata)
219219 }
220220221221+ // check that authorization endpoint is a valid HTTPS URL with no fragment or query params (we will be appending query params latter)
222222+ aeurl, err := url.Parse(m.AuthorizationEndpoint)
223223+ if err != nil {
224224+ return fmt.Errorf("%w: invalid auth endpoint URL (%s): %w", ErrInvalidAuthServerMetadata, m.AuthorizationEndpoint, err)
225225+ }
226226+ if aeurl.Scheme != "https" || u.Fragment != "" || u.RawQuery != "" {
227227+ return fmt.Errorf("%w: invalid auth endpoint URL: %s", ErrInvalidAuthServerMetadata, m.AuthorizationEndpoint)
228228+ }
229229+221230 if !slices.Contains(m.ResponseTypesSupported, "code") {
222231 return fmt.Errorf("%w: response_types_supported must include 'code'", ErrInvalidAuthServerMetadata)
223232 }