···11+/*
22+OAuth implementation for atproto, currently focused on clients.
33+44+Feature set includes:
55+66+- client and server metadata resolution
77+- PKCE: computing and verifying challenges
88+- DPoP client implementation: JWT signing and nonces for requests to Auth Server and Resource Server
99+- PAR client submission
1010+- both public and confidential clients, with support for signed client attestations in the later case
1111+1212+Most OAuth client applications will use the high-level [ClientApp] and supporting interfaces to manage session logins, persistance, and token refreshes. Lower-level components are designed to be used in isolation if needed.
1313+1414+This package does not contain supporting code for atproto permissions or permission sets. It treats scopes as simple strings.
1515+1616+## Quickstart
1717+1818+Create a single [ClientApp] instance during service setup that will be used (concurrently) across all users and sessions:
1919+2020+```
2121+oauthScope := "atproto transition:generic"
2222+config := oauth.NewPublicConfig(
2323+ "https://app.example.com/client-metadata.json",
2424+ "https://app.example.com/oauth/callback",
2525+)
2626+2727+// clients are "public" by default, but if they have secure access to a secret attestation key can be "confidential"
2828+if CLIENT_SECRET_KEY != "" {
2929+ priv, err := crypto.ParsePrivateMultibase(CLIENT_SECRET_KEY)
3030+ if err != nil {
3131+ return err
3232+ }
3333+ config.AddClientSecret(priv, "example1")
3434+}
3535+3636+oauthApp := oauth.NewClientApp(&config, oauth.NewMemStore())
3737+```
3838+3939+For a real service, you would want to use a database or other peristant storage instead of [MemStore]. Otherwise all user sessions are dropped every time the process restarts.
4040+4141+The client metadata document needs to be served at the URL indicated by the `client_id`. This can be done statically, or dynamically generated and served from the configuation:
4242+4343+```
4444+http.HandleFunc("GET /client-metadata.json", HandleClientMetadata)
4545+4646+func HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
4747+ doc := oauthApp.Config.ClientMetadata(oauthScope)
4848+ w.Header().Set("Content-Type", "application/json")
4949+ if err := json.NewEncoder(w).Encode(doc); err != nil {
5050+ http.Error(w, err.Error(), http.StatusInternalServerError)
5151+ return
5252+ }
5353+}
5454+```
5555+5656+The login auth flow starts with a user identifier, which could be an atproto handle, DID, or a host URL. The high-level [StartAuthFlow()] method will resolve the identifier, send an auth request (PAR) to the server, persist request metadata in the [OAuthStore], and return a redirect URL for the user to visit:
5757+5858+```
5959+http.HandleFunc("GET /oauth/login", HandleLogin)
6060+6161+func HandleLogin(w http.ResponseWriter, r *http.Request) {
6262+ ctx := r.Context()
6363+6464+ // parse login identifier from the request
6565+ identifier := "..."
6666+6767+ redirectURL, err := oauthApp.StartAuthFlow(ctx, identifier)
6868+ if err != nil {
6969+ http.Error(w, err.Error(), http.StatusInternalServerError)
7070+ }
7171+ http.Redirect(w, r, redirectURL, http.StatusFound)
7272+}
7373+```
7474+7575+The service then waits for a callback request on the configured endpoint. The [ProcessCallback()] method will load the earlier request metadata from the [OAuthStore], send an initial token request to the auth server, and validate that the session is consistent with the identifier from the begining of the login flow.
7676+7777+```
7878+http.HandleFunc("GET /client-metadata.json", HandleClientMetadata)
7979+8080+func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
8181+ ctx := r.Context()
8282+8383+ sessData, err := oauthApp.ProcessCallback(ctx, r.URL.Query())
8484+ if err != nil {
8585+ http.Error(w, err.Error(), http.StatusInternalServerError)
8686+ }
8787+8888+ // web services might record the DID in a secure session cookie
8989+ _ = sessData.AccountDID
9090+9191+ http.Redirect(w, r, "/app", http.StatusFound)
9292+}
9393+```
9494+9595+Finally, sessions can be resumed and used to make authenticated API calls to the user's host:
9696+9797+```
9898+// web services might use a secure session cookie to determine user's DID for a request
9999+did := syntax.DID("did:plc:abc123")
100100+101101+sess, err := oauthApp.ResumeSession(ctx, did)
102102+if err != nil {
103103+ return err
104104+}
105105+106106+c := sess.APIClient()
107107+108108+body := map[string]any{
109109+ "repo": *c.AccountDID,
110110+ "collection": "app.bsky.feed.post",
111111+ "record": map[string]any{
112112+ "$type": "app.bsky.feed.post",
113113+ "text": "Hello World via OAuth!",
114114+ "createdAt": syntax.DatetimeNow(),
115115+ },
116116+}
117117+118118+if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil {
119119+ return err
120120+}
121121+```
122122+123123+The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [OAuthStore].
124124+125125+TODO: logout
126126+*/
127127+package oauth
+8-6
atproto/auth/oauth/memstore.go
···88 "github.com/bluesky-social/indigo/atproto/syntax"
99)
10101111-// Simple in-memory implementation of OAuthStore
1111+// Simple in-memory implementation of [ClientAuthStore], for use in development and demos.
1212+//
1313+// This is not appropriate even casual real-world use: all users will be logged-out every time the process is restarted.
1214type MemStore struct {
1315 requests map[string]AuthRequestData
1414- sessions map[string]SessionData
1616+ sessions map[string]ClientSessionData
15171618 lk sync.Mutex
1719}
18201919-var _ OAuthStore = &MemStore{}
2121+var _ ClientAuthStore = &MemStore{}
20222123func NewMemStore() *MemStore {
2224 return &MemStore{
2325 requests: make(map[string]AuthRequestData),
2424- sessions: make(map[string]SessionData),
2626+ sessions: make(map[string]ClientSessionData),
2527 }
2628}
27292828-func (m *MemStore) GetSession(ctx context.Context, did syntax.DID) (*SessionData, error) {
3030+func (m *MemStore) GetSession(ctx context.Context, did syntax.DID) (*ClientSessionData, error) {
2931 m.lk.Lock()
3032 defer m.lk.Unlock()
3133···3638 return &sess, nil
3739}
38403939-func (m *MemStore) SaveSession(ctx context.Context, sess SessionData) error {
4141+func (m *MemStore) SaveSession(ctx context.Context, sess ClientSessionData) error {
4042 m.lk.Lock()
4143 defer m.lk.Unlock()
4244
+161-33
atproto/auth/oauth/oauth.go
···77 "fmt"
88 "log/slog"
99 "net/http"
1010+ "net/url"
1011 "time"
11121213 "github.com/bluesky-social/indigo/atproto/crypto"
1414+ "github.com/bluesky-social/indigo/atproto/identity"
1315 "github.com/bluesky-social/indigo/atproto/syntax"
14161517 "github.com/golang-jwt/jwt/v5"
···2224type ClientApp struct {
2325 Client *http.Client
2426 Resolver *Resolver
2727+ Dir identity.Directory
2528 Config *ClientConfig
2626- Store OAuthStore
2929+ Store ClientAuthStore
2730}
28312932type ClientConfig struct {
···3942 KeyID *string
4043}
41444545+func NewClientApp(config *ClientConfig, store ClientAuthStore) *ClientApp {
4646+ app := &ClientApp{
4747+ Client: http.DefaultClient,
4848+ Resolver: NewResolver(),
4949+ Dir: identity.DefaultDirectory(),
5050+ Config: config,
5151+ Store: store,
5252+ }
5353+ if config.UserAgent != "" {
5454+ app.Resolver.UserAgent = config.UserAgent
5555+ // TODO: some way to wire UserAgent through to identity directory
5656+ }
5757+ return app
5858+}
5959+4260func NewPublicConfig(clientID, callbackURL string) ClientConfig {
4361 c := ClientConfig{
4462 ClientID: clientID,
···4866 return c
4967}
50685151-func (conf *ClientConfig) IsConfidential() bool {
5252- return conf.PrivateKey != nil && conf.KeyID != nil
6969+func (config *ClientConfig) IsConfidential() bool {
7070+ return config.PrivateKey != nil && config.KeyID != nil
7171+}
7272+7373+func (config *ClientConfig) AddClientSecret(priv crypto.PrivateKey, keyID string) {
7474+ config.PrivateKey = priv
7575+ config.KeyID = &keyID
5376}
54775578// Returns a "JWKS" representation of public keys for the client. This can be returned as JSON, as part of client metadata.
5679//
5780// If the client does not have any keys (eg, public client), returns an empty set.
5858-func (conf *ClientConfig) PublicJWKS() JWKS {
8181+func (config *ClientConfig) PublicJWKS() JWKS {
5982 // public client with no keys
6060- if conf.PrivateKey == nil || conf.KeyID == nil {
8383+ if config.PrivateKey == nil || config.KeyID == nil {
6184 return JWKS{}
6285 }
63866464- pub, err := conf.PrivateKey.PublicKey()
8787+ pub, err := config.PrivateKey.PublicKey()
6588 if err != nil {
6689 return JWKS{}
6790 }
···6992 if err != nil {
7093 return JWKS{}
7194 }
7272- jwk.KeyID = conf.KeyID
9595+ jwk.KeyID = config.KeyID
73967497 jwks := JWKS{
7598 Keys: []crypto.JWK{*jwk},
···80103// 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.
81104//
82105// TODO: confidential clients currently must provide JWKSUri after the fact
8383-func (c *ClientConfig) ClientMetadata(scope string) ClientMetadata {
106106+func (config *ClientConfig) ClientMetadata(scope string) ClientMetadata {
84107 if scope == "" {
85108 scope = "atproto"
86109 }
87110 m := ClientMetadata{
8888- ClientID: c.ClientID,
111111+ ClientID: config.ClientID,
89112 ApplicationType: strPtr("web"),
90113 GrantTypes: []string{"authorization_code", "refresh_token"},
91114 Scope: scope,
92115 ResponseTypes: []string{"code"},
9393- RedirectURIs: []string{c.CallbackURL},
116116+ RedirectURIs: []string{config.CallbackURL},
94117 DpopBoundAccessTokens: true,
95118 }
9696- if c.IsConfidential() {
119119+ if config.IsConfidential() {
97120 m.TokenEndpointAuthMethod = strPtr("private_key_jwt")
98121 m.TokenEndpointAuthSigningAlg = strPtr("ES256") // XXX
99122 // TODO: what is the correct format for in-line JWKS?
100100- //m.JWKS = c.JWKS()
123123+ //m.JWKS = config.JWKS()
101124 }
102125 return m
103126}
104127105105-func (c *ClientApp) ResumeSession(ctx context.Context, did syntax.DID) (*Session, error) {
128128+func (app *ClientApp) ResumeSession(ctx context.Context, did syntax.DID) (*ClientSession, error) {
106129107107- sd, err := c.Store.GetSession(ctx, did)
130130+ sd, err := app.Store.GetSession(ctx, did)
108131 if err != nil {
109132 return nil, err
110133 }
111134112112- sess := Session{
113113- Client: c.Client,
114114- Config: c.Config,
135135+ sess := ClientSession{
136136+ Client: app.Client,
137137+ Config: app.Config,
115138 Data: sd,
116139 }
117140 // XXX: configure token refresh callback
···143166 Nonce *string `json:"nonce,omitempty"`
144167}
145168146146-func (cfg *ClientConfig) NewAssertionJWT(authURL string) (string, error) {
169169+func (cfg *ClientConfig) NewClientAssertion(authURL string) (string, error) {
147170 if !cfg.IsConfidential() {
148171 return "", fmt.Errorf("non-confidential client")
149172 }
···167190 return token.SignedString(cfg.PrivateKey)
168191}
169192170170-func NewDPoPJWT(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) {
193193+// Creates a DPoP token (JWT) for use with an OAuth Auth Server (not to be used with Resource Server). The returned JWT is not bound to an Access Token (no 'ath'), and does not indicate an issuer ('iss').
194194+//
195195+// This is used during initial auth request (PAR), initial token request, and subsequent refresh token requests. Note that a full [ClientSession] is not available in several of these circumstances, so this is a stand-alone function.
196196+func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) {
171197172198 claims := dpopClaims{
173199 HTTPMethod: httpMethod,
···187213 return "", err
188214 }
189215190190- // TODO: parse/cache this elsewhere
216216+ // TODO: parse/cache this public JWK, for efficiency
191217 pub, err := privKey.PublicKey()
192218 if err != nil {
193219 return "", err
···204230}
205231206232// Sends PAR request to auth server
207207-func (c *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) {
233233+func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) {
208234 // TODO: pass as argument?
209235 httpClient := http.DefaultClient
210236···217243218244 // self-signed JWT using private key in client metadata (confidential client)
219245 // TODO: make "confidential client" mode optional
220220- assertionJWT, err := c.Config.NewAssertionJWT(authMeta.Issuer)
246246+ assertionJWT, err := app.Config.NewClientAssertion(authMeta.Issuer)
221247 if err != nil {
222248 return nil, err
223249 }
224250225251 body := PushedAuthRequest{
226226- ClientID: c.Config.ClientID,
252252+ ClientID: app.Config.ClientID,
227253 State: state,
228228- RedirectURI: c.Config.CallbackURL,
254254+ RedirectURI: app.Config.CallbackURL,
229255 Scope: scope,
230256 ResponseType: "code",
231257 ClientAssertionType: CLIENT_ASSERTION_JWT_BEARER,
···250276 return nil, err
251277 }
252278253253- slog.Info("sending auth request", "scope", scope, "state", state, "redirectURI", c.Config.CallbackURL)
279279+ slog.Info("sending auth request", "scope", scope, "state", state, "redirectURI", app.Config.CallbackURL)
254280255281 var resp *http.Response
256282 for range 2 {
257257- dpopJWT, err := NewDPoPJWT("POST", parURL, dpopServerNonce, dpopPrivKey)
283283+ dpopJWT, err := NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey)
258284 if err != nil {
259285 return nil, err
260286 }
···319345 return &parInfo, nil
320346}
321347322322-func (c *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) {
348348+func (app *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) {
323349324324- clientAssertion, err := c.Config.NewAssertionJWT(info.AuthServerURL)
350350+ clientAssertion, err := app.Config.NewClientAssertion(info.AuthServerURL)
325351 if err != nil {
326352 return nil, err
327353 }
328354329355 // TODO: don't re-fetch? caching?
330330- authServerMeta, err := c.Resolver.ResolveAuthServerMetadata(ctx, info.AuthServerURL)
356356+ authServerMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, info.AuthServerURL)
331357 if err != nil {
332358 return nil, err
333359 }
334360335361 body := InitialTokenRequest{
336336- ClientID: c.Config.ClientID,
337337- RedirectURI: c.Config.CallbackURL,
362362+ ClientID: app.Config.ClientID,
363363+ RedirectURI: app.Config.CallbackURL,
338364 GrantType: "authorization_code",
339365 Code: authCode,
340366 CodeVerifier: info.PKCEVerifier,
···357383358384 var resp *http.Response
359385 for range 2 {
360360- dpopJWT, err := NewDPoPJWT("POST", authServerMeta.TokenEndpoint, dpopServerNonce, dpopPrivKey)
386386+ dpopJWT, err := NewAuthDPoP("POST", authServerMeta.TokenEndpoint, dpopServerNonce, dpopPrivKey)
361387 if err != nil {
362388 return nil, err
363389 }
···369395 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
370396 req.Header.Set("DPoP", dpopJWT)
371397372372- resp, err = c.Client.Do(req)
398398+ resp, err = app.Client.Do(req)
373399 if err != nil {
374400 return nil, err
375401 }
···411437412438 return &tokenResp, nil
413439}
440440+441441+func (app *ClientApp) StartAuthFlow(ctx context.Context, username string) (string, error) {
442442+ // TODO: auth server URL support
443443+ atid, err := syntax.ParseAtIdentifier(username)
444444+ if err != nil {
445445+ return "", fmt.Errorf("not a valid account identifier (%s): %w", username, err)
446446+ }
447447+ ident, err := app.Dir.Lookup(ctx, *atid)
448448+ if err != nil {
449449+ return "", fmt.Errorf("failed to resolve username (%s): %w", username, err)
450450+ }
451451+ host := ident.PDSEndpoint()
452452+ if host == "" {
453453+ return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
454454+ }
455455+456456+ logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host)
457457+ logger.Info("resolving to auth server metadata")
458458+ authserverURL, err := app.Resolver.ResolveAuthServerURL(ctx, host)
459459+ if err != nil {
460460+ return "", fmt.Errorf("resolving auth server: %w", err)
461461+ }
462462+ authserverMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, authserverURL)
463463+ if err != nil {
464464+ return "", fmt.Errorf("fetching auth server metadata: %w", err)
465465+ }
466466+467467+ callbackURL, err := url.Parse(app.Config.ClientID)
468468+ if err != nil {
469469+ return "", fmt.Errorf("invalid client_id URL: %w", err)
470470+ }
471471+ callbackURL.Path = "/oauth/callback"
472472+ app.Config.CallbackURL = callbackURL.String()
473473+474474+ scope := "atproto transition:generic"
475475+ info, err := app.SendAuthRequest(ctx, authserverMeta, username, scope)
476476+ if err != nil {
477477+ return "", fmt.Errorf("auth request failed: %w", err)
478478+ }
479479+480480+ // XXX:
481481+ info.AccountDID = &ident.DID
482482+483483+ // persist auth request info
484484+ app.Store.SaveAuthRequestInfo(ctx, *info)
485485+486486+ params := url.Values{}
487487+ params.Set("client_id", app.Config.ClientID)
488488+ params.Set("request_uri", info.RequestURI)
489489+ // TODO: check that 'authorization_endpoint' is "safe" (?)
490490+ redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode())
491491+ return redirectURL, nil
492492+}
493493+494494+func (app *ClientApp) ProcessCallback(ctx context.Context, params url.Values) (*ClientSessionData, error) {
495495+496496+ state := params.Get("state")
497497+ authserverURL := params.Get("iss")
498498+ authCode := params.Get("code")
499499+ if state == "" || authserverURL == "" || authCode == "" {
500500+ return nil, fmt.Errorf("missing required query param")
501501+ }
502502+503503+ info, err := app.Store.GetAuthRequestInfo(ctx, state)
504504+ if err != nil {
505505+ return nil, fmt.Errorf("loading auth request info: %w", err)
506506+ }
507507+508508+ if info.State != state || info.AuthServerURL != authserverURL {
509509+ return nil, fmt.Errorf("callback params don't match request info")
510510+ }
511511+512512+ tokenResp, err := app.SendInitialTokenRequest(ctx, authCode, *info)
513513+ if err != nil {
514514+ return nil, fmt.Errorf("initial token request: %w", err)
515515+ }
516516+517517+ // XXX: verify against initial request info (DID, handle, etc)
518518+ // - account identifier (if started with that)
519519+ // - if started with PDS URL, resolve identity, and then resolve PDS to auth server, and check it all matches
520520+ if info.AccountDID == nil || tokenResp.Subject != info.AccountDID.String() {
521521+ return nil, fmt.Errorf("token subject didn't match original DID")
522522+ }
523523+524524+ // TODO: could be flexible instead of considering this a hard failure?
525525+ if tokenResp.Scope != info.Scope {
526526+ return nil, fmt.Errorf("token scope didn't match original request")
527527+ }
528528+529529+ sessData := ClientSessionData{
530530+ AccountDID: *info.AccountDID, // nil checked above
531531+ HostURL: info.AuthServerURL, // XXX
532532+ AuthServerURL: info.AuthServerURL,
533533+ AccessToken: tokenResp.AccessToken,
534534+ RefreshToken: tokenResp.RefreshToken,
535535+ DpopAuthServerNonce: info.DpopAuthServerNonce,
536536+ DpopHostNonce: info.DpopAuthServerNonce, // XXX
537537+ DpopPrivateKeyMultibase: info.DpopPrivateKeyMultibase,
538538+ }
539539+ app.Store.SaveSession(ctx, sessData)
540540+ return &sessData, nil
541541+}
+125-31
atproto/auth/oauth/session.go
···77 "fmt"
88 "log/slog"
99 "net/http"
1010+ "net/url"
1111+ "strings"
1012 "sync"
1113 "time"
12141515+ "github.com/bluesky-social/indigo/atproto/client"
1316 "github.com/bluesky-social/indigo/atproto/crypto"
1417 "github.com/bluesky-social/indigo/atproto/syntax"
1518···1720 "github.com/google/go-querystring/query"
1821)
19222020-type RefreshCallback = func(ctx context.Context, data SessionData)
2323+type RefreshCallback = func(ctx context.Context, data ClientSessionData)
2424+2525+// Persisted information about an OAuth session. Used to resume an active session.
2626+type ClientSessionData struct {
2727+ // Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this infromation.
2828+ AccountDID syntax.DID `json:"account_did"`
2929+3030+ // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
3131+ HostURL string `json:"host_url"`
3232+3333+ // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
3434+ AuthServerURL string `json:"authserver_url"`
3535+3636+ // Full token endpoint
3737+ AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
3838+3939+ // Token which can be used directly against host ("resource server", eg PDS)
4040+ AccessToken string `json:"access_token"`
4141+4242+ // Token which can be sent to auth server (eg, PDS or entryway) to get a new access token
4343+ RefreshToken string `json:"refresh_token"`
4444+4545+ // Current auth server DPoP nonce
4646+ DpopAuthServerNonce string `json:"dpop_authserver_nonce"`
4747+4848+ // Current host ("resource server", eg PDS) DPoP nonce
4949+ DpopHostNonce string `json:"dpop_host_nonce"`
5050+5151+ // The secret cryptographic key generated by the client for this specific OAuth session
5252+ DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
5353+5454+ // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT
5555+}
21562222-type Session struct {
5757+type ClientSession struct {
2358 // HTTP client used for token refresh requests
2459 Client *http.Client
25602661 Config *ClientConfig
2727- Data *SessionData
6262+ Data *ClientSessionData
2863 DpopPrivateKey crypto.PrivateKey
29643065 RefreshCallback RefreshCallback
···3368 lk sync.RWMutex
3469}
35703636-func (sess *Session) RefreshTokens(ctx context.Context) error {
7171+func (sess *ClientSession) RefreshTokens(ctx context.Context) error {
37723873 // TODO: assuming confidential client
3939- clientAssertion, err := sess.Config.NewAssertionJWT(sess.Data.AuthServerURL)
7474+ clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL)
4075 if err != nil {
4176 return err
4277 }
···61966297 var resp *http.Response
6398 for range 2 {
6464- dpopJWT, err := NewDPoPJWT("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey)
9999+ dpopJWT, err := NewAuthDPoP("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey)
65100 if err != nil {
66101 return err
67102 }
···120155 return nil
121156}
122157123123-func (sess *Session) NewAccessDPoP(method, reqURL string) (string, error) {
158158+func (sess *ClientSession) NewAccessDPoP(method, reqURL string) (string, error) {
124159125160 ath := S256CodeChallenge(sess.Data.AccessToken)
126161 claims := dpopClaims{
···143178 return "", err
144179 }
145180146146- // TODO: parse/cache this elsewhere
181181+ // TODO: store a copy of this JWK on the ClientSession as a private field, for efficiency
147182 pub, err := sess.DpopPrivateKey.PublicKey()
148183 if err != nil {
149184 return "", err
···159194 return token.SignedString(sess.DpopPrivateKey)
160195}
161196162162-func (sess *Session) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) {
197197+// copy a request URL and strip query params and fragment, for DPoP
198198+func dpopURL(u *url.URL) string {
199199+ u2 := *u
200200+ u2.RawQuery = ""
201201+ u2.ForceQuery = false
202202+ u2.Fragment = ""
203203+ u2.RawFragment = ""
204204+ return u2.String()
205205+}
206206+207207+func (sess *ClientSession) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) {
163208164164- // XXX: copy URL and strip query params
165165- u := req.URL.String()
209209+ durl := dpopURL(req.URL)
210210+211211+ // XXX: fetch with mutex lock
212212+ accessToken := sess.Data.AccessToken
213213+ originalNonce := sess.Data.DpopHostNonce
214214+ dpopNonce := originalNonce
166215167167- dpopServerNonce := sess.Data.DpopHostNonce
168216 var resp *http.Response
169169- for range 2 {
170170- dpopJWT, err := sess.NewAccessDPoP(req.Method, u)
217217+ for range 3 {
218218+ dpopJWT, err := sess.NewAccessDPoP(req.Method, durl)
171219 if err != nil {
172220 return nil, err
173221 }
174174- req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", sess.Data.AccessToken))
222222+ req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", accessToken))
175223 req.Header.Set("DPoP", dpopJWT)
176224177225 resp, err = c.Do(req)
···179227 return nil, err
180228 }
181229182182- // check if a nonce was provided
183183- dpopServerNonce = resp.Header.Get("DPoP-Nonce")
184184- if resp.StatusCode == 400 && dpopServerNonce != "" {
185185- // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce'
186186- var errResp map[string]any
187187- if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
188188- slog.Warn("authorized request failed", "url", u, "err", err, "statusCode", resp.StatusCode)
189189- } else {
190190- slog.Warn("authorized request failed", "url", u, "resp", errResp, "statusCode", resp.StatusCode)
230230+ // on success, or most errors, just return HTTP response
231231+ if resp.StatusCode != http.StatusBadRequest || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
232232+ return resp, nil
233233+ }
234234+235235+ // parse the error response body (JSON) and check the error name
236236+ defer resp.Body.Close()
237237+ var eb client.ErrorBody
238238+ if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil {
239239+ return nil, &client.APIError{StatusCode: resp.StatusCode}
240240+ }
241241+242242+ // if DPoP nonce was stale, simply retry
243243+ if eb.Name == "use_dpop_nonce" && resp.Header.Get("DPoP-Nonce") != "" {
244244+ dpopNonce = resp.Header.Get("DPoP-Nonce")
245245+ if dpopNonce != originalNonce {
246246+ // XXX: persist new nonce value via callback
191247 }
192248193193- // XXX: doesn't really work, body might be drained second time
194194- // loop around try again
195195- resp.Body.Close()
249249+ retry := req.Clone(req.Context())
250250+ if req.GetBody != nil {
251251+ retry.Body, err = req.GetBody()
252252+ if err != nil {
253253+ return nil, fmt.Errorf("API request retry GetBody failed: %w", err)
254254+ }
255255+ }
256256+ req = retry
196257 continue
197258 }
198198- // otherwise process result
199199- break
259259+260260+ // if this is anything other than an expired token, bail out now
261261+ if eb.Name != "ExpiredToken" {
262262+ return nil, eb.APIError(resp.StatusCode)
263263+ }
264264+265265+ if err := sess.RefreshTokens(req.Context()); err != nil {
266266+ return nil, err
267267+ }
268268+269269+ // XXX: fetch with mutex lock
270270+ accessToken = sess.Data.AccessToken
271271+272272+ retry := req.Clone(req.Context())
273273+ if req.GetBody != nil {
274274+ retry.Body, err = req.GetBody()
275275+ if err != nil {
276276+ return nil, fmt.Errorf("API request retry GetBody failed: %w", err)
277277+ }
278278+ }
279279+ req = retry
280280+ continue
200281 }
201201- // TODO: check for auth-specific errors, and return them as err
202202- return resp, nil
282282+283283+ return nil, fmt.Errorf("OAuth client ran out of retries")
284284+}
285285+286286+func (sess *ClientSession) APIClient() *client.APIClient {
287287+ c := client.APIClient{
288288+ Client: sess.Client,
289289+ Host: sess.Data.HostURL,
290290+ Auth: sess,
291291+ AccountDID: &sess.Data.AccountDID,
292292+ }
293293+ if sess.Config.UserAgent != "" {
294294+ c.Headers.Set("User-Agent", sess.Config.UserAgent)
295295+ }
296296+ return &c
203297}
+7-3
atproto/auth/oauth/store.go
···66 "github.com/bluesky-social/indigo/atproto/syntax"
77)
8899-type OAuthStore interface {
1010- GetSession(ctx context.Context, did syntax.DID) (*SessionData, error)
1111- SaveSession(ctx context.Context, sess SessionData) error
99+// Interface for persisting session data and auth request data, required as part of an OAuth client app.
1010+//
1111+// Implementations should allow for concurrent access.
1212+type ClientAuthStore interface {
1313+ GetSession(ctx context.Context, did syntax.DID) (*ClientSessionData, error)
1414+ SaveSession(ctx context.Context, sess ClientSessionData) error
1215 DeleteSession(ctx context.Context, did syntax.DID) error
1616+1317 GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error)
1418 SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error
1519 DeleteAuthRequestInfo(ctx context.Context, state string) error
-31
atproto/auth/oauth/types.go
···329329 DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
330330}
331331332332-// Persisted information about an OAuth session. Used to resume an active session.
333333-type SessionData struct {
334334- // Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this infromation.
335335- AccountDID syntax.DID `json:"account_did"`
336336-337337- // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
338338- HostURL string `json:"host_url"`
339339-340340- // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
341341- AuthServerURL string `json:"authserver_url"`
342342-343343- AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
344344-345345- // Token which can be used directly against host ("resource server", eg PDS)
346346- AccessToken string `json:"access_token"`
347347-348348- // Token which can be sent to auth server (eg, PDS or entryway) to get a new access token
349349- RefreshToken string `json:"refresh_token"`
350350-351351- // Current auth server DPoP nonce
352352- DpopAuthServerNonce string `json:"dpop_authserver_nonce"`
353353-354354- // Current host ("resource server", eg PDS) DPoP nonce
355355- DpopHostNonce string `json:"dpop_host_nonce"`
356356-357357- // The secret cryptographic key generated by the client for this specific OAuth session
358358- DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
359359-360360- // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT
361361-}
362362-363332// The fields which are included in an initial token refresh request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON.
364333type InitialTokenRequest struct {
365334 // Client ID, aka client metadata URL