this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

more refactor progress

+445 -237
+17 -133
atproto/auth/cmd/atp-oauth-demo/main.go
··· 7 7 "html/template" 8 8 "log/slog" 9 9 "net/http" 10 - "net/url" 11 10 "os" 12 11 13 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 - "github.com/bluesky-social/indigo/atproto/client" 15 13 "github.com/bluesky-social/indigo/atproto/crypto" 16 14 "github.com/bluesky-social/indigo/atproto/identity" 17 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 58 56 type Server struct { 59 57 CookieStore *sessions.CookieStore 60 58 Dir identity.Directory 61 - OAuth oauth.ClientApp 59 + OAuth *oauth.ClientApp 62 60 } 63 61 64 62 //go:embed "base.html" ··· 82 80 83 81 func runServer(cctx *cli.Context) error { 84 82 85 - // XXX: localhost dev mode if hostname is empty 83 + // TODO: localhost dev mode if hostname is empty 86 84 hostname := cctx.String("hostname") 87 85 conf := oauth.NewPublicConfig( 88 86 fmt.Sprintf("https://%s/oauth/client-metadata.json", hostname), 89 87 fmt.Sprintf("https://%s/oauth/callback", hostname), 90 88 ) 91 89 90 + // If a client secret key is provided (as a multibase string), turn this in to a confidential client 92 91 if cctx.String("client-secret-key") != "" { 93 92 priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 94 93 if err != nil { 95 94 return err 96 95 } 97 - conf.PrivateKey = priv 98 - conf.KeyID = strPtr(cctx.String("client-secret-key-id")) 96 + conf.AddClientSecret(priv, cctx.String("client-secret-key-id")) 99 97 } 98 + 99 + oauthClient := oauth.NewClientApp(&conf, oauth.NewMemStore()) 100 + 100 101 srv := Server{ 101 102 CookieStore: sessions.NewCookieStore([]byte(cctx.String("session-secret"))), 102 103 Dir: identity.DefaultDirectory(), 103 - OAuth: oauth.ClientApp{ 104 - Client: http.DefaultClient, 105 - Config: &conf, 106 - Resolver: oauth.NewResolver(), 107 - Store: oauth.NewMemStore(), 108 - }, 104 + OAuth: oauthClient, 109 105 } 110 106 111 107 http.HandleFunc("GET /", srv.Homepage) ··· 127 123 return nil 128 124 } 129 125 130 - func (s *Server) loadOAuthSession(r *http.Request) (*oauth.Session, error) { 126 + func (s *Server) loadOAuthSession(r *http.Request) (*oauth.ClientSession, error) { 131 127 ctx := r.Context() 132 128 133 129 sess, _ := s.CookieStore.Get(r, "oauth-demo") ··· 147 143 return &raw 148 144 } 149 145 150 - func (s *Server) finishConfig(r *http.Request) { 151 - host := r.Host 152 - if host == "" { 153 - return 154 - } 155 - if s.OAuth.Config.ClientID == "" { 156 - s.OAuth.Config.ClientID = fmt.Sprintf("https://%s/oauth/client-metadata.json", host) 157 - s.OAuth.Config.CallbackURL = fmt.Sprintf("https://%s/oauth/callback", host) 158 - } 159 - return 160 - } 161 - 162 146 func (s *Server) ClientMetadata(w http.ResponseWriter, r *http.Request) { 163 147 slog.Info("client metadata request", "url", r.URL, "host", r.Host) 164 - s.finishConfig(r) 165 148 166 149 scope := "atproto transition:generic" 167 150 meta := s.OAuth.Config.ClientMetadata(scope) ··· 181 164 http.Error(w, err.Error(), http.StatusInternalServerError) 182 165 return 183 166 } 184 - 185 167 } 186 168 187 169 func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) { ··· 195 177 196 178 func (s *Server) OAuthLogin(w http.ResponseWriter, r *http.Request) { 197 179 ctx := r.Context() 198 - s.finishConfig(r) 199 180 200 181 if r.Method != "POST" { 201 182 tmplLogin.Execute(w, nil) ··· 207 188 return 208 189 } 209 190 210 - // TODO: auth server URL support 211 191 username := r.PostFormValue("username") 212 - atid, err := syntax.ParseAtIdentifier(username) 213 - if err != nil { 214 - http.Error(w, fmt.Errorf("not a valid account identifier (%s): %w", username, err).Error(), http.StatusBadRequest) 215 - return 216 - } 217 - ident, err := s.Dir.Lookup(ctx, *atid) 218 - if err != nil { 219 - http.Error(w, fmt.Errorf("failed to resolve username (%s): %w", username, err).Error(), http.StatusBadRequest) 220 - return 221 - } 222 - host := ident.PDSEndpoint() 223 - if host == "" { 224 - http.Error(w, "identity does not link to an atproto host (PDS)", http.StatusBadRequest) 225 - return 226 - } 227 192 228 - logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host) 229 - logger.Info("resolving to auth server metadata") 230 - authserverURL, err := s.OAuth.Resolver.ResolveAuthServerURL(ctx, host) 231 - if err != nil { 232 - http.Error(w, fmt.Errorf("resolving auth server: %w", err).Error(), http.StatusBadRequest) 233 - return 234 - } 235 - authserverMeta, err := s.OAuth.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 236 - if err != nil { 237 - http.Error(w, fmt.Errorf("fetching auth server metadata: %w", err).Error(), http.StatusBadRequest) 238 - return 239 - } 240 - 241 - callbackURL, err := url.Parse(s.OAuth.Config.ClientID) 242 - if err != nil { 243 - http.Error(w, err.Error(), http.StatusInternalServerError) 244 - return 245 - } 246 - callbackURL.Path = "/oauth/callback" 247 - s.OAuth.Config.CallbackURL = callbackURL.String() 248 - 249 - scope := "atproto transition:generic" 250 - info, err := s.OAuth.SendAuthRequest(ctx, authserverMeta, username, scope) 193 + redirectURL, err := s.OAuth.StartAuthFlow(ctx, username) 251 194 if err != nil { 252 - http.Error(w, fmt.Errorf("auth request failed: %w", err).Error(), http.StatusBadRequest) 195 + http.Error(w, fmt.Errorf("OAuth login failed: %w", err).Error(), http.StatusBadRequest) 253 196 return 254 197 } 255 198 256 - // XXX: 257 - info.AccountDID = &ident.DID 258 - 259 - // persist auth request info 260 - s.OAuth.Store.SaveAuthRequestInfo(ctx, *info) 261 - 262 - params := url.Values{} 263 - params.Set("client_id", s.OAuth.Config.ClientID) 264 - params.Set("request_uri", info.RequestURI) 265 - // TODO: check that 'authorization_endpoint' is "safe" (?) 266 - redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 267 199 http.Redirect(w, r, redirectURL, http.StatusFound) 268 200 return 269 201 } ··· 273 205 274 206 params := r.URL.Query() 275 207 slog.Info("received callback", "params", params) 276 - state := params.Get("state") 277 - authserverURL := params.Get("iss") 278 - authCode := params.Get("code") 279 - if state == "" || authserverURL == "" || authCode == "" { 280 - http.Error(w, "missing required query param", http.StatusBadRequest) 281 - return 282 - } 283 208 284 - info, err := s.OAuth.Store.GetAuthRequestInfo(ctx, state) 209 + sessData, err := s.OAuth.ProcessCallback(ctx, r.URL.Query()) 285 210 if err != nil { 286 - http.Error(w, fmt.Errorf("loading auth request info: %w", err).Error(), http.StatusNotFound) 287 - return 288 - } 289 - 290 - if info.State != state || info.AuthServerURL != authserverURL { 291 - http.Error(w, "callback params don't match request info", http.StatusBadRequest) 292 - return 293 - } 294 - 295 - tokenResp, err := s.OAuth.SendInitialTokenRequest(ctx, authCode, *info) 296 - if err != nil { 297 - http.Error(w, fmt.Errorf("initial token request: %w", err).Error(), http.StatusInternalServerError) 298 - return 211 + http.Error(w, fmt.Errorf("processing OAuth callback: %w", err).Error(), http.StatusBadRequest) 299 212 } 300 213 301 - // XXX: verify against initial request info (DID, handle, etc) 302 - // - account identifier (if started with that) 303 - // - if started with PDS URL, resolve identity, and then resolve PDS to auth server, and check it all matches 304 - if info.AccountDID == nil || tokenResp.Subject != info.AccountDID.String() { 305 - http.Error(w, "token subject didn't match original DID", http.StatusBadRequest) 306 - return 307 - } 308 - 309 - // TODO: could be flexible instead of considering this a hard failure? 310 - if tokenResp.Scope != info.Scope { 311 - http.Error(w, "token scope didn't match original request", http.StatusBadRequest) 312 - return 313 - } 314 - 315 - authSess := oauth.SessionData{ 316 - AccountDID: *info.AccountDID, // nil checked above 317 - HostURL: info.AuthServerURL, // XXX 318 - AuthServerURL: info.AuthServerURL, 319 - AccessToken: tokenResp.AccessToken, 320 - RefreshToken: tokenResp.RefreshToken, 321 - DpopAuthServerNonce: info.DpopAuthServerNonce, 322 - DpopHostNonce: info.DpopAuthServerNonce, // XXX 323 - DpopPrivateKeyMultibase: info.DpopPrivateKeyMultibase, 324 - } 325 - s.OAuth.Store.SaveSession(ctx, authSess) 326 - 327 214 // create signed cookie session, indicating account DID 328 215 sess, _ := s.CookieStore.Get(r, "oauth-demo") 329 - sess.Values["account_did"] = authSess.AccountDID.String() 216 + sess.Values["account_did"] = sessData.AccountDID.String() 330 217 if err := sess.Save(r, w); err != nil { 331 218 http.Error(w, err.Error(), http.StatusInternalServerError) 332 219 return 333 220 } 334 221 335 - slog.Info("login successful", "did", authSess.AccountDID.String()) 222 + slog.Info("login successful", "did", sessData.AccountDID.String()) 336 223 http.Redirect(w, r, "/bsky/post", http.StatusFound) 337 224 } 338 225 ··· 385 272 http.Error(w, "not authenticated", http.StatusUnauthorized) 386 273 return 387 274 } 275 + c := oauthSess.APIClient() 388 276 389 277 if err := r.ParseForm(); err != nil { 390 278 http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest) ··· 392 280 } 393 281 text := r.PostFormValue("post_text") 394 282 395 - c := client.NewAPIClient(oauthSess.Data.HostURL) 396 - c.Auth = oauthSess 397 - c.Headers.Set("User-Agent", "indigo-oauth-demo") 398 - 399 283 body := map[string]any{ 400 - "repo": oauthSess.Data.AccountDID.String(), 284 + "repo": c.AccountDID.String(), 401 285 "collection": "app.bsky.feed.post", 402 286 "record": map[string]any{ 403 287 "$type": "app.bsky.feed.post",
+127
atproto/auth/oauth/doc.go
··· 1 + /* 2 + OAuth implementation for atproto, currently focused on clients. 3 + 4 + Feature set includes: 5 + 6 + - client and server metadata resolution 7 + - PKCE: computing and verifying challenges 8 + - DPoP client implementation: JWT signing and nonces for requests to Auth Server and Resource Server 9 + - PAR client submission 10 + - both public and confidential clients, with support for signed client attestations in the later case 11 + 12 + 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. 13 + 14 + This package does not contain supporting code for atproto permissions or permission sets. It treats scopes as simple strings. 15 + 16 + ## Quickstart 17 + 18 + Create a single [ClientApp] instance during service setup that will be used (concurrently) across all users and sessions: 19 + 20 + ``` 21 + oauthScope := "atproto transition:generic" 22 + config := oauth.NewPublicConfig( 23 + "https://app.example.com/client-metadata.json", 24 + "https://app.example.com/oauth/callback", 25 + ) 26 + 27 + // clients are "public" by default, but if they have secure access to a secret attestation key can be "confidential" 28 + if CLIENT_SECRET_KEY != "" { 29 + priv, err := crypto.ParsePrivateMultibase(CLIENT_SECRET_KEY) 30 + if err != nil { 31 + return err 32 + } 33 + config.AddClientSecret(priv, "example1") 34 + } 35 + 36 + oauthApp := oauth.NewClientApp(&config, oauth.NewMemStore()) 37 + ``` 38 + 39 + 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. 40 + 41 + 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: 42 + 43 + ``` 44 + http.HandleFunc("GET /client-metadata.json", HandleClientMetadata) 45 + 46 + func HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 47 + doc := oauthApp.Config.ClientMetadata(oauthScope) 48 + w.Header().Set("Content-Type", "application/json") 49 + if err := json.NewEncoder(w).Encode(doc); err != nil { 50 + http.Error(w, err.Error(), http.StatusInternalServerError) 51 + return 52 + } 53 + } 54 + ``` 55 + 56 + 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: 57 + 58 + ``` 59 + http.HandleFunc("GET /oauth/login", HandleLogin) 60 + 61 + func HandleLogin(w http.ResponseWriter, r *http.Request) { 62 + ctx := r.Context() 63 + 64 + // parse login identifier from the request 65 + identifier := "..." 66 + 67 + redirectURL, err := oauthApp.StartAuthFlow(ctx, identifier) 68 + if err != nil { 69 + http.Error(w, err.Error(), http.StatusInternalServerError) 70 + } 71 + http.Redirect(w, r, redirectURL, http.StatusFound) 72 + } 73 + ``` 74 + 75 + 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. 76 + 77 + ``` 78 + http.HandleFunc("GET /client-metadata.json", HandleClientMetadata) 79 + 80 + func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { 81 + ctx := r.Context() 82 + 83 + sessData, err := oauthApp.ProcessCallback(ctx, r.URL.Query()) 84 + if err != nil { 85 + http.Error(w, err.Error(), http.StatusInternalServerError) 86 + } 87 + 88 + // web services might record the DID in a secure session cookie 89 + _ = sessData.AccountDID 90 + 91 + http.Redirect(w, r, "/app", http.StatusFound) 92 + } 93 + ``` 94 + 95 + Finally, sessions can be resumed and used to make authenticated API calls to the user's host: 96 + 97 + ``` 98 + // web services might use a secure session cookie to determine user's DID for a request 99 + did := syntax.DID("did:plc:abc123") 100 + 101 + sess, err := oauthApp.ResumeSession(ctx, did) 102 + if err != nil { 103 + return err 104 + } 105 + 106 + c := sess.APIClient() 107 + 108 + body := map[string]any{ 109 + "repo": *c.AccountDID, 110 + "collection": "app.bsky.feed.post", 111 + "record": map[string]any{ 112 + "$type": "app.bsky.feed.post", 113 + "text": "Hello World via OAuth!", 114 + "createdAt": syntax.DatetimeNow(), 115 + }, 116 + } 117 + 118 + if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil { 119 + return err 120 + } 121 + ``` 122 + 123 + The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [OAuthStore]. 124 + 125 + TODO: logout 126 + */ 127 + package oauth
+8 -6
atproto/auth/oauth/memstore.go
··· 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 ) 10 10 11 - // Simple in-memory implementation of OAuthStore 11 + // Simple in-memory implementation of [ClientAuthStore], for use in development and demos. 12 + // 13 + // This is not appropriate even casual real-world use: all users will be logged-out every time the process is restarted. 12 14 type MemStore struct { 13 15 requests map[string]AuthRequestData 14 - sessions map[string]SessionData 16 + sessions map[string]ClientSessionData 15 17 16 18 lk sync.Mutex 17 19 } 18 20 19 - var _ OAuthStore = &MemStore{} 21 + var _ ClientAuthStore = &MemStore{} 20 22 21 23 func NewMemStore() *MemStore { 22 24 return &MemStore{ 23 25 requests: make(map[string]AuthRequestData), 24 - sessions: make(map[string]SessionData), 26 + sessions: make(map[string]ClientSessionData), 25 27 } 26 28 } 27 29 28 - func (m *MemStore) GetSession(ctx context.Context, did syntax.DID) (*SessionData, error) { 30 + func (m *MemStore) GetSession(ctx context.Context, did syntax.DID) (*ClientSessionData, error) { 29 31 m.lk.Lock() 30 32 defer m.lk.Unlock() 31 33 ··· 36 38 return &sess, nil 37 39 } 38 40 39 - func (m *MemStore) SaveSession(ctx context.Context, sess SessionData) error { 41 + func (m *MemStore) SaveSession(ctx context.Context, sess ClientSessionData) error { 40 42 m.lk.Lock() 41 43 defer m.lk.Unlock() 42 44
+161 -33
atproto/auth/oauth/oauth.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 + "net/url" 10 11 "time" 11 12 12 13 "github.com/bluesky-social/indigo/atproto/crypto" 14 + "github.com/bluesky-social/indigo/atproto/identity" 13 15 "github.com/bluesky-social/indigo/atproto/syntax" 14 16 15 17 "github.com/golang-jwt/jwt/v5" ··· 22 24 type ClientApp struct { 23 25 Client *http.Client 24 26 Resolver *Resolver 27 + Dir identity.Directory 25 28 Config *ClientConfig 26 - Store OAuthStore 29 + Store ClientAuthStore 27 30 } 28 31 29 32 type ClientConfig struct { ··· 39 42 KeyID *string 40 43 } 41 44 45 + func NewClientApp(config *ClientConfig, store ClientAuthStore) *ClientApp { 46 + app := &ClientApp{ 47 + Client: http.DefaultClient, 48 + Resolver: NewResolver(), 49 + Dir: identity.DefaultDirectory(), 50 + Config: config, 51 + Store: store, 52 + } 53 + if config.UserAgent != "" { 54 + app.Resolver.UserAgent = config.UserAgent 55 + // TODO: some way to wire UserAgent through to identity directory 56 + } 57 + return app 58 + } 59 + 42 60 func NewPublicConfig(clientID, callbackURL string) ClientConfig { 43 61 c := ClientConfig{ 44 62 ClientID: clientID, ··· 48 66 return c 49 67 } 50 68 51 - func (conf *ClientConfig) IsConfidential() bool { 52 - return conf.PrivateKey != nil && conf.KeyID != nil 69 + func (config *ClientConfig) IsConfidential() bool { 70 + return config.PrivateKey != nil && config.KeyID != nil 71 + } 72 + 73 + func (config *ClientConfig) AddClientSecret(priv crypto.PrivateKey, keyID string) { 74 + config.PrivateKey = priv 75 + config.KeyID = &keyID 53 76 } 54 77 55 78 // Returns a "JWKS" representation of public keys for the client. This can be returned as JSON, as part of client metadata. 56 79 // 57 80 // If the client does not have any keys (eg, public client), returns an empty set. 58 - func (conf *ClientConfig) PublicJWKS() JWKS { 81 + func (config *ClientConfig) PublicJWKS() JWKS { 59 82 // public client with no keys 60 - if conf.PrivateKey == nil || conf.KeyID == nil { 83 + if config.PrivateKey == nil || config.KeyID == nil { 61 84 return JWKS{} 62 85 } 63 86 64 - pub, err := conf.PrivateKey.PublicKey() 87 + pub, err := config.PrivateKey.PublicKey() 65 88 if err != nil { 66 89 return JWKS{} 67 90 } ··· 69 92 if err != nil { 70 93 return JWKS{} 71 94 } 72 - jwk.KeyID = conf.KeyID 95 + jwk.KeyID = config.KeyID 73 96 74 97 jwks := JWKS{ 75 98 Keys: []crypto.JWK{*jwk}, ··· 80 103 // 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. 81 104 // 82 105 // TODO: confidential clients currently must provide JWKSUri after the fact 83 - func (c *ClientConfig) ClientMetadata(scope string) ClientMetadata { 106 + func (config *ClientConfig) ClientMetadata(scope string) ClientMetadata { 84 107 if scope == "" { 85 108 scope = "atproto" 86 109 } 87 110 m := ClientMetadata{ 88 - ClientID: c.ClientID, 111 + ClientID: config.ClientID, 89 112 ApplicationType: strPtr("web"), 90 113 GrantTypes: []string{"authorization_code", "refresh_token"}, 91 114 Scope: scope, 92 115 ResponseTypes: []string{"code"}, 93 - RedirectURIs: []string{c.CallbackURL}, 116 + RedirectURIs: []string{config.CallbackURL}, 94 117 DpopBoundAccessTokens: true, 95 118 } 96 - if c.IsConfidential() { 119 + if config.IsConfidential() { 97 120 m.TokenEndpointAuthMethod = strPtr("private_key_jwt") 98 121 m.TokenEndpointAuthSigningAlg = strPtr("ES256") // XXX 99 122 // TODO: what is the correct format for in-line JWKS? 100 - //m.JWKS = c.JWKS() 123 + //m.JWKS = config.JWKS() 101 124 } 102 125 return m 103 126 } 104 127 105 - func (c *ClientApp) ResumeSession(ctx context.Context, did syntax.DID) (*Session, error) { 128 + func (app *ClientApp) ResumeSession(ctx context.Context, did syntax.DID) (*ClientSession, error) { 106 129 107 - sd, err := c.Store.GetSession(ctx, did) 130 + sd, err := app.Store.GetSession(ctx, did) 108 131 if err != nil { 109 132 return nil, err 110 133 } 111 134 112 - sess := Session{ 113 - Client: c.Client, 114 - Config: c.Config, 135 + sess := ClientSession{ 136 + Client: app.Client, 137 + Config: app.Config, 115 138 Data: sd, 116 139 } 117 140 // XXX: configure token refresh callback ··· 143 166 Nonce *string `json:"nonce,omitempty"` 144 167 } 145 168 146 - func (cfg *ClientConfig) NewAssertionJWT(authURL string) (string, error) { 169 + func (cfg *ClientConfig) NewClientAssertion(authURL string) (string, error) { 147 170 if !cfg.IsConfidential() { 148 171 return "", fmt.Errorf("non-confidential client") 149 172 } ··· 167 190 return token.SignedString(cfg.PrivateKey) 168 191 } 169 192 170 - func NewDPoPJWT(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) { 193 + // 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'). 194 + // 195 + // 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. 196 + func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) { 171 197 172 198 claims := dpopClaims{ 173 199 HTTPMethod: httpMethod, ··· 187 213 return "", err 188 214 } 189 215 190 - // TODO: parse/cache this elsewhere 216 + // TODO: parse/cache this public JWK, for efficiency 191 217 pub, err := privKey.PublicKey() 192 218 if err != nil { 193 219 return "", err ··· 204 230 } 205 231 206 232 // Sends PAR request to auth server 207 - func (c *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) { 233 + func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) { 208 234 // TODO: pass as argument? 209 235 httpClient := http.DefaultClient 210 236 ··· 217 243 218 244 // self-signed JWT using private key in client metadata (confidential client) 219 245 // TODO: make "confidential client" mode optional 220 - assertionJWT, err := c.Config.NewAssertionJWT(authMeta.Issuer) 246 + assertionJWT, err := app.Config.NewClientAssertion(authMeta.Issuer) 221 247 if err != nil { 222 248 return nil, err 223 249 } 224 250 225 251 body := PushedAuthRequest{ 226 - ClientID: c.Config.ClientID, 252 + ClientID: app.Config.ClientID, 227 253 State: state, 228 - RedirectURI: c.Config.CallbackURL, 254 + RedirectURI: app.Config.CallbackURL, 229 255 Scope: scope, 230 256 ResponseType: "code", 231 257 ClientAssertionType: CLIENT_ASSERTION_JWT_BEARER, ··· 250 276 return nil, err 251 277 } 252 278 253 - slog.Info("sending auth request", "scope", scope, "state", state, "redirectURI", c.Config.CallbackURL) 279 + slog.Info("sending auth request", "scope", scope, "state", state, "redirectURI", app.Config.CallbackURL) 254 280 255 281 var resp *http.Response 256 282 for range 2 { 257 - dpopJWT, err := NewDPoPJWT("POST", parURL, dpopServerNonce, dpopPrivKey) 283 + dpopJWT, err := NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey) 258 284 if err != nil { 259 285 return nil, err 260 286 } ··· 319 345 return &parInfo, nil 320 346 } 321 347 322 - func (c *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) { 348 + func (app *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) { 323 349 324 - clientAssertion, err := c.Config.NewAssertionJWT(info.AuthServerURL) 350 + clientAssertion, err := app.Config.NewClientAssertion(info.AuthServerURL) 325 351 if err != nil { 326 352 return nil, err 327 353 } 328 354 329 355 // TODO: don't re-fetch? caching? 330 - authServerMeta, err := c.Resolver.ResolveAuthServerMetadata(ctx, info.AuthServerURL) 356 + authServerMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, info.AuthServerURL) 331 357 if err != nil { 332 358 return nil, err 333 359 } 334 360 335 361 body := InitialTokenRequest{ 336 - ClientID: c.Config.ClientID, 337 - RedirectURI: c.Config.CallbackURL, 362 + ClientID: app.Config.ClientID, 363 + RedirectURI: app.Config.CallbackURL, 338 364 GrantType: "authorization_code", 339 365 Code: authCode, 340 366 CodeVerifier: info.PKCEVerifier, ··· 357 383 358 384 var resp *http.Response 359 385 for range 2 { 360 - dpopJWT, err := NewDPoPJWT("POST", authServerMeta.TokenEndpoint, dpopServerNonce, dpopPrivKey) 386 + dpopJWT, err := NewAuthDPoP("POST", authServerMeta.TokenEndpoint, dpopServerNonce, dpopPrivKey) 361 387 if err != nil { 362 388 return nil, err 363 389 } ··· 369 395 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 370 396 req.Header.Set("DPoP", dpopJWT) 371 397 372 - resp, err = c.Client.Do(req) 398 + resp, err = app.Client.Do(req) 373 399 if err != nil { 374 400 return nil, err 375 401 } ··· 411 437 412 438 return &tokenResp, nil 413 439 } 440 + 441 + func (app *ClientApp) StartAuthFlow(ctx context.Context, username string) (string, error) { 442 + // TODO: auth server URL support 443 + atid, err := syntax.ParseAtIdentifier(username) 444 + if err != nil { 445 + return "", fmt.Errorf("not a valid account identifier (%s): %w", username, err) 446 + } 447 + ident, err := app.Dir.Lookup(ctx, *atid) 448 + if err != nil { 449 + return "", fmt.Errorf("failed to resolve username (%s): %w", username, err) 450 + } 451 + host := ident.PDSEndpoint() 452 + if host == "" { 453 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 454 + } 455 + 456 + logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host) 457 + logger.Info("resolving to auth server metadata") 458 + authserverURL, err := app.Resolver.ResolveAuthServerURL(ctx, host) 459 + if err != nil { 460 + return "", fmt.Errorf("resolving auth server: %w", err) 461 + } 462 + authserverMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 463 + if err != nil { 464 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 465 + } 466 + 467 + callbackURL, err := url.Parse(app.Config.ClientID) 468 + if err != nil { 469 + return "", fmt.Errorf("invalid client_id URL: %w", err) 470 + } 471 + callbackURL.Path = "/oauth/callback" 472 + app.Config.CallbackURL = callbackURL.String() 473 + 474 + scope := "atproto transition:generic" 475 + info, err := app.SendAuthRequest(ctx, authserverMeta, username, scope) 476 + if err != nil { 477 + return "", fmt.Errorf("auth request failed: %w", err) 478 + } 479 + 480 + // XXX: 481 + info.AccountDID = &ident.DID 482 + 483 + // persist auth request info 484 + app.Store.SaveAuthRequestInfo(ctx, *info) 485 + 486 + params := url.Values{} 487 + params.Set("client_id", app.Config.ClientID) 488 + params.Set("request_uri", info.RequestURI) 489 + // TODO: check that 'authorization_endpoint' is "safe" (?) 490 + redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 491 + return redirectURL, nil 492 + } 493 + 494 + func (app *ClientApp) ProcessCallback(ctx context.Context, params url.Values) (*ClientSessionData, error) { 495 + 496 + state := params.Get("state") 497 + authserverURL := params.Get("iss") 498 + authCode := params.Get("code") 499 + if state == "" || authserverURL == "" || authCode == "" { 500 + return nil, fmt.Errorf("missing required query param") 501 + } 502 + 503 + info, err := app.Store.GetAuthRequestInfo(ctx, state) 504 + if err != nil { 505 + return nil, fmt.Errorf("loading auth request info: %w", err) 506 + } 507 + 508 + if info.State != state || info.AuthServerURL != authserverURL { 509 + return nil, fmt.Errorf("callback params don't match request info") 510 + } 511 + 512 + tokenResp, err := app.SendInitialTokenRequest(ctx, authCode, *info) 513 + if err != nil { 514 + return nil, fmt.Errorf("initial token request: %w", err) 515 + } 516 + 517 + // XXX: verify against initial request info (DID, handle, etc) 518 + // - account identifier (if started with that) 519 + // - if started with PDS URL, resolve identity, and then resolve PDS to auth server, and check it all matches 520 + if info.AccountDID == nil || tokenResp.Subject != info.AccountDID.String() { 521 + return nil, fmt.Errorf("token subject didn't match original DID") 522 + } 523 + 524 + // TODO: could be flexible instead of considering this a hard failure? 525 + if tokenResp.Scope != info.Scope { 526 + return nil, fmt.Errorf("token scope didn't match original request") 527 + } 528 + 529 + sessData := ClientSessionData{ 530 + AccountDID: *info.AccountDID, // nil checked above 531 + HostURL: info.AuthServerURL, // XXX 532 + AuthServerURL: info.AuthServerURL, 533 + AccessToken: tokenResp.AccessToken, 534 + RefreshToken: tokenResp.RefreshToken, 535 + DpopAuthServerNonce: info.DpopAuthServerNonce, 536 + DpopHostNonce: info.DpopAuthServerNonce, // XXX 537 + DpopPrivateKeyMultibase: info.DpopPrivateKeyMultibase, 538 + } 539 + app.Store.SaveSession(ctx, sessData) 540 + return &sessData, nil 541 + }
+125 -31
atproto/auth/oauth/session.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 + "net/url" 11 + "strings" 10 12 "sync" 11 13 "time" 12 14 15 + "github.com/bluesky-social/indigo/atproto/client" 13 16 "github.com/bluesky-social/indigo/atproto/crypto" 14 17 "github.com/bluesky-social/indigo/atproto/syntax" 15 18 ··· 17 20 "github.com/google/go-querystring/query" 18 21 ) 19 22 20 - type RefreshCallback = func(ctx context.Context, data SessionData) 23 + type RefreshCallback = func(ctx context.Context, data ClientSessionData) 24 + 25 + // Persisted information about an OAuth session. Used to resume an active session. 26 + type ClientSessionData struct { 27 + // 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. 28 + AccountDID syntax.DID `json:"account_did"` 29 + 30 + // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info. 31 + HostURL string `json:"host_url"` 32 + 33 + // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info. 34 + AuthServerURL string `json:"authserver_url"` 35 + 36 + // Full token endpoint 37 + AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 38 + 39 + // Token which can be used directly against host ("resource server", eg PDS) 40 + AccessToken string `json:"access_token"` 41 + 42 + // Token which can be sent to auth server (eg, PDS or entryway) to get a new access token 43 + RefreshToken string `json:"refresh_token"` 44 + 45 + // Current auth server DPoP nonce 46 + DpopAuthServerNonce string `json:"dpop_authserver_nonce"` 47 + 48 + // Current host ("resource server", eg PDS) DPoP nonce 49 + DpopHostNonce string `json:"dpop_host_nonce"` 50 + 51 + // The secret cryptographic key generated by the client for this specific OAuth session 52 + DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"` 53 + 54 + // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT 55 + } 21 56 22 - type Session struct { 57 + type ClientSession struct { 23 58 // HTTP client used for token refresh requests 24 59 Client *http.Client 25 60 26 61 Config *ClientConfig 27 - Data *SessionData 62 + Data *ClientSessionData 28 63 DpopPrivateKey crypto.PrivateKey 29 64 30 65 RefreshCallback RefreshCallback ··· 33 68 lk sync.RWMutex 34 69 } 35 70 36 - func (sess *Session) RefreshTokens(ctx context.Context) error { 71 + func (sess *ClientSession) RefreshTokens(ctx context.Context) error { 37 72 38 73 // TODO: assuming confidential client 39 - clientAssertion, err := sess.Config.NewAssertionJWT(sess.Data.AuthServerURL) 74 + clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL) 40 75 if err != nil { 41 76 return err 42 77 } ··· 61 96 62 97 var resp *http.Response 63 98 for range 2 { 64 - dpopJWT, err := NewDPoPJWT("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey) 99 + dpopJWT, err := NewAuthDPoP("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey) 65 100 if err != nil { 66 101 return err 67 102 } ··· 120 155 return nil 121 156 } 122 157 123 - func (sess *Session) NewAccessDPoP(method, reqURL string) (string, error) { 158 + func (sess *ClientSession) NewAccessDPoP(method, reqURL string) (string, error) { 124 159 125 160 ath := S256CodeChallenge(sess.Data.AccessToken) 126 161 claims := dpopClaims{ ··· 143 178 return "", err 144 179 } 145 180 146 - // TODO: parse/cache this elsewhere 181 + // TODO: store a copy of this JWK on the ClientSession as a private field, for efficiency 147 182 pub, err := sess.DpopPrivateKey.PublicKey() 148 183 if err != nil { 149 184 return "", err ··· 159 194 return token.SignedString(sess.DpopPrivateKey) 160 195 } 161 196 162 - func (sess *Session) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 197 + // copy a request URL and strip query params and fragment, for DPoP 198 + func dpopURL(u *url.URL) string { 199 + u2 := *u 200 + u2.RawQuery = "" 201 + u2.ForceQuery = false 202 + u2.Fragment = "" 203 + u2.RawFragment = "" 204 + return u2.String() 205 + } 206 + 207 + func (sess *ClientSession) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 163 208 164 - // XXX: copy URL and strip query params 165 - u := req.URL.String() 209 + durl := dpopURL(req.URL) 210 + 211 + // XXX: fetch with mutex lock 212 + accessToken := sess.Data.AccessToken 213 + originalNonce := sess.Data.DpopHostNonce 214 + dpopNonce := originalNonce 166 215 167 - dpopServerNonce := sess.Data.DpopHostNonce 168 216 var resp *http.Response 169 - for range 2 { 170 - dpopJWT, err := sess.NewAccessDPoP(req.Method, u) 217 + for range 3 { 218 + dpopJWT, err := sess.NewAccessDPoP(req.Method, durl) 171 219 if err != nil { 172 220 return nil, err 173 221 } 174 - req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", sess.Data.AccessToken)) 222 + req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", accessToken)) 175 223 req.Header.Set("DPoP", dpopJWT) 176 224 177 225 resp, err = c.Do(req) ··· 179 227 return nil, err 180 228 } 181 229 182 - // check if a nonce was provided 183 - dpopServerNonce = resp.Header.Get("DPoP-Nonce") 184 - if resp.StatusCode == 400 && dpopServerNonce != "" { 185 - // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce' 186 - var errResp map[string]any 187 - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 188 - slog.Warn("authorized request failed", "url", u, "err", err, "statusCode", resp.StatusCode) 189 - } else { 190 - slog.Warn("authorized request failed", "url", u, "resp", errResp, "statusCode", resp.StatusCode) 230 + // on success, or most errors, just return HTTP response 231 + if resp.StatusCode != http.StatusBadRequest || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { 232 + return resp, nil 233 + } 234 + 235 + // parse the error response body (JSON) and check the error name 236 + defer resp.Body.Close() 237 + var eb client.ErrorBody 238 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 239 + return nil, &client.APIError{StatusCode: resp.StatusCode} 240 + } 241 + 242 + // if DPoP nonce was stale, simply retry 243 + if eb.Name == "use_dpop_nonce" && resp.Header.Get("DPoP-Nonce") != "" { 244 + dpopNonce = resp.Header.Get("DPoP-Nonce") 245 + if dpopNonce != originalNonce { 246 + // XXX: persist new nonce value via callback 191 247 } 192 248 193 - // XXX: doesn't really work, body might be drained second time 194 - // loop around try again 195 - resp.Body.Close() 249 + retry := req.Clone(req.Context()) 250 + if req.GetBody != nil { 251 + retry.Body, err = req.GetBody() 252 + if err != nil { 253 + return nil, fmt.Errorf("API request retry GetBody failed: %w", err) 254 + } 255 + } 256 + req = retry 196 257 continue 197 258 } 198 - // otherwise process result 199 - break 259 + 260 + // if this is anything other than an expired token, bail out now 261 + if eb.Name != "ExpiredToken" { 262 + return nil, eb.APIError(resp.StatusCode) 263 + } 264 + 265 + if err := sess.RefreshTokens(req.Context()); err != nil { 266 + return nil, err 267 + } 268 + 269 + // XXX: fetch with mutex lock 270 + accessToken = sess.Data.AccessToken 271 + 272 + retry := req.Clone(req.Context()) 273 + if req.GetBody != nil { 274 + retry.Body, err = req.GetBody() 275 + if err != nil { 276 + return nil, fmt.Errorf("API request retry GetBody failed: %w", err) 277 + } 278 + } 279 + req = retry 280 + continue 200 281 } 201 - // TODO: check for auth-specific errors, and return them as err 202 - return resp, nil 282 + 283 + return nil, fmt.Errorf("OAuth client ran out of retries") 284 + } 285 + 286 + func (sess *ClientSession) APIClient() *client.APIClient { 287 + c := client.APIClient{ 288 + Client: sess.Client, 289 + Host: sess.Data.HostURL, 290 + Auth: sess, 291 + AccountDID: &sess.Data.AccountDID, 292 + } 293 + if sess.Config.UserAgent != "" { 294 + c.Headers.Set("User-Agent", sess.Config.UserAgent) 295 + } 296 + return &c 203 297 }
+7 -3
atproto/auth/oauth/store.go
··· 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 7 ) 8 8 9 - type OAuthStore interface { 10 - GetSession(ctx context.Context, did syntax.DID) (*SessionData, error) 11 - SaveSession(ctx context.Context, sess SessionData) error 9 + // Interface for persisting session data and auth request data, required as part of an OAuth client app. 10 + // 11 + // Implementations should allow for concurrent access. 12 + type ClientAuthStore interface { 13 + GetSession(ctx context.Context, did syntax.DID) (*ClientSessionData, error) 14 + SaveSession(ctx context.Context, sess ClientSessionData) error 12 15 DeleteSession(ctx context.Context, did syntax.DID) error 16 + 13 17 GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error) 14 18 SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error 15 19 DeleteAuthRequestInfo(ctx context.Context, state string) error
-31
atproto/auth/oauth/types.go
··· 329 329 DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"` 330 330 } 331 331 332 - // Persisted information about an OAuth session. Used to resume an active session. 333 - type SessionData struct { 334 - // 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. 335 - AccountDID syntax.DID `json:"account_did"` 336 - 337 - // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info. 338 - HostURL string `json:"host_url"` 339 - 340 - // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info. 341 - AuthServerURL string `json:"authserver_url"` 342 - 343 - AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 344 - 345 - // Token which can be used directly against host ("resource server", eg PDS) 346 - AccessToken string `json:"access_token"` 347 - 348 - // Token which can be sent to auth server (eg, PDS or entryway) to get a new access token 349 - RefreshToken string `json:"refresh_token"` 350 - 351 - // Current auth server DPoP nonce 352 - DpopAuthServerNonce string `json:"dpop_authserver_nonce"` 353 - 354 - // Current host ("resource server", eg PDS) DPoP nonce 355 - DpopHostNonce string `json:"dpop_host_nonce"` 356 - 357 - // The secret cryptographic key generated by the client for this specific OAuth session 358 - DpopPrivateKeyMultibase string `json:"dpop_privatekey_multibase"` 359 - 360 - // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT 361 - } 362 - 363 332 // 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. 364 333 type InitialTokenRequest struct { 365 334 // Client ID, aka client metadata URL