this repo has no description
0
fork

Configure Feed

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

public client, localhost dev client, and host URL flows

+200 -118
+26 -9
atproto/auth/oauth/cmd/oauth-web-demo/main.go
··· 82 82 83 83 func runServer(cctx *cli.Context) error { 84 84 85 + scope := "atproto transition:generic" 86 + bind := ":8080" 87 + 85 88 // TODO: localhost dev mode if hostname is empty 89 + var config oauth.ClientConfig 86 90 hostname := cctx.String("hostname") 87 - conf := oauth.NewPublicConfig( 88 - fmt.Sprintf("https://%s/oauth/client-metadata.json", hostname), 89 - fmt.Sprintf("https://%s/oauth/callback", hostname), 90 - ) 91 + if hostname == "" { 92 + config = oauth.NewLocalhostConfig( 93 + fmt.Sprintf("http://127.0.0.1%s/oauth/callback", bind), 94 + scope, 95 + ) 96 + slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL) 97 + } else { 98 + config = oauth.NewPublicConfig( 99 + fmt.Sprintf("https://%s/oauth/client-metadata.json", hostname), 100 + fmt.Sprintf("https://%s/oauth/callback", hostname), 101 + ) 102 + } 91 103 92 104 // If a client secret key is provided (as a multibase string), turn this in to a confidential client 93 - if cctx.String("client-secret-key") != "" { 105 + if cctx.String("client-secret-key") != "" && hostname != "" { 94 106 priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 95 107 if err != nil { 96 108 return err 97 109 } 98 - conf.AddClientSecret(priv, cctx.String("client-secret-key-id")) 110 + config.AddClientSecret(priv, cctx.String("client-secret-key-id")) 111 + slog.Info("configuring confidential OAuth client") 99 112 } 100 113 101 - oauthClient := oauth.NewClientApp(&conf, oauth.NewMemStore()) 114 + oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore()) 102 115 103 116 srv := Server{ 104 117 CookieStore: sessions.NewCookieStore([]byte(cctx.String("session-secret"))), ··· 117 130 http.HandleFunc("GET /bsky/post", srv.Post) 118 131 http.HandleFunc("POST /bsky/post", srv.Post) 119 132 120 - bind := ":8080" 121 133 slog.Info("starting http server", "bind", bind) 122 134 if err := http.ListenAndServe(bind, nil); err != nil { 123 135 slog.Error("http shutdown", "err", err) ··· 150 162 151 163 scope := "atproto transition:generic" 152 164 meta := s.OAuth.Config.ClientMetadata(scope) 153 - // TODO: meta.JWKSUri = strPtr(fmt.Sprintf("https://%s/oauth/jwks.json", r.Host)) 165 + if s.OAuth.Config.IsConfidential() { 166 + meta.JWKSUri = strPtr(fmt.Sprintf("https://%s/oauth/jwks.json", r.Host)) 167 + } 154 168 meta.ClientName = strPtr("indigo atp-oauth-demo") 155 169 meta.ClientURI = strPtr(fmt.Sprintf("https://%s", r.Host)) 156 170 ··· 192 206 193 207 username := r.PostFormValue("username") 194 208 209 + slog.Info("OAuthLogin", "client_id", s.OAuth.Config.ClientID, "callback_url", s.OAuth.Config.CallbackURL) 210 + 195 211 redirectURL, err := s.OAuth.StartAuthFlow(ctx, username) 196 212 if err != nil { 197 213 http.Error(w, fmt.Errorf("OAuth login failed: %w", err).Error(), http.StatusBadRequest) ··· 211 227 sessData, err := s.OAuth.ProcessCallback(ctx, r.URL.Query()) 212 228 if err != nil { 213 229 http.Error(w, fmt.Errorf("processing OAuth callback: %w", err).Error(), http.StatusBadRequest) 230 + return 214 231 } 215 232 216 233 // create signed cookie session, indicating account DID
+60 -52
atproto/auth/oauth/doc.go
··· 20 20 ``` 21 21 oauthScope := "atproto transition:generic" 22 22 config := oauth.NewPublicConfig( 23 - "https://app.example.com/client-metadata.json", 24 - "https://app.example.com/oauth/callback", 23 + 24 + "https://app.example.com/client-metadata.json", 25 + "https://app.example.com/oauth/callback", 26 + 25 27 ) 26 28 27 29 // 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 - } 30 + 31 + if CLIENT_SECRET_KEY != "" { 32 + priv, err := crypto.ParsePrivateMultibase(CLIENT_SECRET_KEY) 33 + if err != nil { 34 + return err 35 + } 36 + config.AddClientSecret(priv, "example1") 37 + } 35 38 36 39 oauthApp := oauth.NewClientApp(&config, oauth.NewMemStore()) 37 40 ``` ··· 43 46 ``` 44 47 http.HandleFunc("GET /client-metadata.json", HandleClientMetadata) 45 48 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 49 + func HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 50 + doc := oauthApp.Config.ClientMetadata(oauthScope) 51 + w.Header().Set("Content-Type", "application/json") 52 + if err := json.NewEncoder(w).Encode(doc); err != nil { 53 + http.Error(w, err.Error(), http.StatusInternalServerError) 54 + return 55 + } 52 56 } 53 - } 57 + 54 58 ``` 55 59 56 60 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: ··· 58 62 ``` 59 63 http.HandleFunc("GET /oauth/login", HandleLogin) 60 64 61 - func HandleLogin(w http.ResponseWriter, r *http.Request) { 62 - ctx := r.Context() 65 + func HandleLogin(w http.ResponseWriter, r *http.Request) { 66 + ctx := r.Context() 63 67 64 - // parse login identifier from the request 65 - identifier := "..." 68 + // parse login identifier from the request 69 + identifier := "..." 66 70 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 - } 71 + redirectURL, err := oauthApp.StartAuthFlow(ctx, identifier) 72 + if err != nil { 73 + http.Error(w, err.Error(), http.StatusInternalServerError) 74 + } 75 + http.Redirect(w, r, redirectURL, http.StatusFound) 76 + } 77 + 73 78 ``` 74 79 75 80 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. ··· 77 82 ``` 78 83 http.HandleFunc("GET /client-metadata.json", HandleClientMetadata) 79 84 80 - func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { 81 - ctx := r.Context() 85 + func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { 86 + ctx := r.Context() 82 87 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 88 + sessData, err := oauthApp.ProcessCallback(ctx, r.URL.Query()) 89 + if err != nil { 90 + http.Error(w, err.Error(), http.StatusInternalServerError) 91 + } 90 92 91 - http.Redirect(w, r, "/app", http.StatusFound) 92 - } 93 + // web services might record the DID in a secure session cookie 94 + _ = sessData.AccountDID 95 + 96 + http.Redirect(w, r, "/app", http.StatusFound) 97 + } 98 + 93 99 ``` 94 100 95 101 Finally, sessions can be resumed and used to make authenticated API calls to the user's host: ··· 99 105 did := syntax.DID("did:plc:abc123") 100 106 101 107 sess, err := oauthApp.ResumeSession(ctx, did) 102 - if err != nil { 103 - return err 104 - } 108 + 109 + if err != nil { 110 + return err 111 + } 105 112 106 113 c := sess.APIClient() 107 114 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 - } 115 + body := map[string]any{ 116 + "repo": *c.AccountDID, 117 + "collection": "app.bsky.feed.post", 118 + "record": map[string]any{ 119 + "$type": "app.bsky.feed.post", 120 + "text": "Hello World via OAuth!", 121 + "createdAt": syntax.DatetimeNow(), 122 + }, 123 + } 117 124 118 - if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil { 119 - return err 120 - } 125 + if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil { 126 + return err 127 + } 128 + 121 129 ``` 122 130 123 131 The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [OAuthStore].
+109 -53
atproto/auth/oauth/oauth.go
··· 8 8 "log/slog" 9 9 "net/http" 10 10 "net/url" 11 + "strings" 11 12 "time" 12 13 13 14 "github.com/bluesky-social/indigo/atproto/crypto" ··· 66 67 return c 67 68 } 68 69 70 + func NewLocalhostConfig(callbackURL, scope string) ClientConfig { 71 + slog.Info("NewLocalhostConfig", "callbackURL", callbackURL) 72 + params := make(url.Values) 73 + params.Set("redirect_uri", callbackURL) 74 + params.Set("scope", scope) 75 + c := ClientConfig{ 76 + ClientID: fmt.Sprintf("http://localhost?%s", params.Encode()), 77 + CallbackURL: callbackURL, 78 + UserAgent: "indigo-sdk", 79 + } 80 + slog.Info("DONE NewLocalhostConfig", "callbackURL", c.CallbackURL) 81 + return c 82 + } 83 + 69 84 func (config *ClientConfig) IsConfidential() bool { 70 85 return config.PrivateKey != nil && config.KeyID != nil 71 86 } ··· 109 124 scope = "atproto" 110 125 } 111 126 m := ClientMetadata{ 112 - ClientID: config.ClientID, 113 - ApplicationType: strPtr("web"), 114 - GrantTypes: []string{"authorization_code", "refresh_token"}, 115 - Scope: scope, 116 - ResponseTypes: []string{"code"}, 117 - RedirectURIs: []string{config.CallbackURL}, 118 - DpopBoundAccessTokens: true, 127 + ClientID: config.ClientID, 128 + ApplicationType: strPtr("web"), 129 + GrantTypes: []string{"authorization_code", "refresh_token"}, 130 + Scope: scope, 131 + ResponseTypes: []string{"code"}, 132 + RedirectURIs: []string{config.CallbackURL}, 133 + DpopBoundAccessTokens: true, 119 134 TokenEndpointAuthMethod: strPtr("none"), 120 135 } 121 136 if config.IsConfidential() { 122 137 m.TokenEndpointAuthMethod = strPtr("private_key_jwt") 123 138 m.TokenEndpointAuthSigningAlg = strPtr("ES256") // XXX 124 - jwks := config.PublicJWKS() 125 - m.JWKS = &jwks 139 + // TODO: need to include 'use' or 'key_ops' for JWKS in the client metadata doc? 140 + //jwks := config.PublicJWKS() 141 + //m.JWKS = &jwks 126 142 } 127 143 return m 128 144 } ··· 232 248 } 233 249 234 250 // Sends PAR request to auth server 235 - func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) { 251 + func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, scope, loginHint string) (*AuthRequestData, error) { 236 252 // TODO: pass as argument? 237 253 httpClient := http.DefaultClient 238 254 ··· 243 259 // generate PKCE code challenge for use in PAR request 244 260 codeChallenge := S256CodeChallenge(pkceVerifier) 245 261 262 + slog.Info("preparing PAR", "client_id", app.Config.ClientID, "callback_url", app.Config.CallbackURL) 246 263 body := PushedAuthRequest{ 247 264 ClientID: app.Config.ClientID, 248 265 State: state, ··· 358 375 } 359 376 360 377 body := InitialTokenRequest{ 361 - ClientID: app.Config.ClientID, 362 - RedirectURI: app.Config.CallbackURL, 363 - GrantType: "authorization_code", 364 - Code: authCode, 365 - CodeVerifier: info.PKCEVerifier, 378 + ClientID: app.Config.ClientID, 379 + RedirectURI: app.Config.CallbackURL, 380 + GrantType: "authorization_code", 381 + Code: authCode, 382 + CodeVerifier: info.PKCEVerifier, 366 383 } 367 384 368 385 if app.Config.IsConfidential() { ··· 445 462 } 446 463 447 464 func (app *ClientApp) StartAuthFlow(ctx context.Context, username string) (string, error) { 448 - // TODO: auth server URL support 449 - atid, err := syntax.ParseAtIdentifier(username) 450 - if err != nil { 451 - return "", fmt.Errorf("not a valid account identifier (%s): %w", username, err) 452 - } 453 - ident, err := app.Dir.Lookup(ctx, *atid) 454 - if err != nil { 455 - return "", fmt.Errorf("failed to resolve username (%s): %w", username, err) 456 - } 457 - host := ident.PDSEndpoint() 458 - if host == "" { 459 - return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 460 - } 461 465 462 - logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host) 463 - logger.Info("resolving to auth server metadata") 464 - authserverURL, err := app.Resolver.ResolveAuthServerURL(ctx, host) 465 - if err != nil { 466 - return "", fmt.Errorf("resolving auth server: %w", err) 466 + var authserverURL string 467 + var accountDID syntax.DID 468 + 469 + if strings.HasPrefix(username, "https://") { 470 + authserverURL = username 471 + username = "" 472 + } else { 473 + atid, err := syntax.ParseAtIdentifier(username) 474 + if err != nil { 475 + return "", fmt.Errorf("not a valid account identifier (%s): %w", username, err) 476 + } 477 + ident, err := app.Dir.Lookup(ctx, *atid) 478 + if err != nil { 479 + return "", fmt.Errorf("failed to resolve username (%s): %w", username, err) 480 + } 481 + host := ident.PDSEndpoint() 482 + if host == "" { 483 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 484 + } 485 + 486 + // TODO: logger on ClientApp? 487 + logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host) 488 + logger.Info("resolving to auth server metadata") 489 + authserverURL, err = app.Resolver.ResolveAuthServerURL(ctx, host) 490 + if err != nil { 491 + return "", fmt.Errorf("resolving auth server: %w", err) 492 + } 467 493 } 494 + 468 495 authserverMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 469 496 if err != nil { 470 497 return "", fmt.Errorf("fetching auth server metadata: %w", err) 471 498 } 472 499 473 - callbackURL, err := url.Parse(app.Config.ClientID) 474 - if err != nil { 475 - return "", fmt.Errorf("invalid client_id URL: %w", err) 476 - } 477 - callbackURL.Path = "/oauth/callback" 478 - app.Config.CallbackURL = callbackURL.String() 479 - 480 - // XXX: from config 500 + // XXX: scope from config 481 501 scope := "atproto transition:generic" 482 - info, err := app.SendAuthRequest(ctx, authserverMeta, username, scope) 502 + info, err := app.SendAuthRequest(ctx, authserverMeta, scope, username) 483 503 if err != nil { 484 504 return "", fmt.Errorf("auth request failed: %w", err) 485 505 } 486 506 487 - // XXX: 488 - info.AccountDID = &ident.DID 507 + if accountDID != "" { 508 + info.AccountDID = &accountDID 509 + } 489 510 490 511 // persist auth request info 491 512 app.Store.SaveAuthRequestInfo(ctx, *info) ··· 493 514 params := url.Values{} 494 515 params.Set("client_id", app.Config.ClientID) 495 516 params.Set("request_uri", info.RequestURI) 517 + 496 518 // TODO: check that 'authorization_endpoint' is "safe" (?) 497 519 redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 498 520 return redirectURL, nil ··· 521 543 return nil, fmt.Errorf("initial token request: %w", err) 522 544 } 523 545 524 - // XXX: verify against initial request info (DID, handle, etc) 525 - // - account identifier (if started with that) 526 - // - if started with PDS URL, resolve identity, and then resolve PDS to auth server, and check it all matches 527 - if info.AccountDID == nil || tokenResp.Subject != info.AccountDID.String() { 528 - return nil, fmt.Errorf("token subject didn't match original DID") 546 + // verify against account/server from start of login 547 + var accountDID syntax.DID 548 + var hostURL string 549 + if info.AccountDID != nil { 550 + // if we started with an account DID, verify it against the subject 551 + accountDID = *info.AccountDID 552 + if tokenResp.Subject != info.AccountDID.String() { 553 + return nil, fmt.Errorf("token subject didn't match original DID") 554 + } 555 + // identity lookup for PDS hostname; this should be cached 556 + ident, err := app.Dir.LookupDID(ctx, accountDID) 557 + if err != nil { 558 + return nil, err 559 + } 560 + hostURL = ident.PDSEndpoint() 561 + } else { 562 + // if we started with an auth server URL, resolve and verify the identity 563 + accountDID, err = syntax.ParseDID(tokenResp.Subject) 564 + if err != nil { 565 + return nil, err 566 + } 567 + ident, err := app.Dir.LookupDID(ctx, accountDID) 568 + if err != nil { 569 + return nil, err 570 + } 571 + hostURL = ident.PDSEndpoint() 572 + res, err := app.Resolver.ResolveAuthServerURL(ctx, hostURL) 573 + if err != nil { 574 + return nil, fmt.Errorf("resolving auth server: %w", err) 575 + } 576 + if res != authserverURL { 577 + return nil, fmt.Errorf("token subject auth server did not match original") 578 + } 529 579 } 530 580 531 581 // TODO: could be flexible instead of considering this a hard failure? ··· 534 584 } 535 585 536 586 sessData := ClientSessionData{ 537 - AccountDID: *info.AccountDID, // nil checked above 538 - HostURL: info.AuthServerURL, // XXX 587 + AccountDID: accountDID, 588 + HostURL: hostURL, 539 589 AuthServerURL: info.AuthServerURL, 540 590 AccessToken: tokenResp.AccessToken, 541 591 RefreshToken: tokenResp.RefreshToken, 542 592 DpopAuthServerNonce: info.DpopAuthServerNonce, 543 - DpopHostNonce: info.DpopAuthServerNonce, // XXX 593 + DpopHostNonce: info.DpopAuthServerNonce, // bootstrap host nonce from authserver 544 594 DpopPrivateKeyMultibase: info.DpopPrivateKeyMultibase, 545 595 } 546 - app.Store.SaveSession(ctx, sessData) 596 + if err := app.Store.SaveSession(ctx, sessData); err != nil { 597 + return nil, err 598 + } 599 + if err := app.Store.DeleteAuthRequestInfo(ctx, state); err != nil { 600 + // only log on failure to delete state info 601 + slog.Warn("failed to delete auth request info", "state", state, "did", accountDID, "authserver", info.AuthServerURL, "err", err) 602 + } 547 603 return &sessData, nil 548 604 }
+3 -3
atproto/auth/oauth/session.go
··· 71 71 func (sess *ClientSession) RefreshTokens(ctx context.Context) error { 72 72 73 73 body := RefreshTokenRequest{ 74 - ClientID: sess.Config.ClientID, 75 - GrantType: "authorization_code", 76 - RefreshToken: sess.Data.RefreshToken, 74 + ClientID: sess.Config.ClientID, 75 + GrantType: "authorization_code", 76 + RefreshToken: sess.Data.RefreshToken, 77 77 } 78 78 79 79 if sess.Config.IsConfidential() {
+2 -1
atproto/auth/oauth/types.go
··· 123 123 if err != nil { 124 124 return fmt.Errorf("%w: invalid web redirect_uris: %w", ErrInvalidClientMetadata, err) 125 125 } 126 - if u.Scheme != "https" { 126 + if u.Scheme != "https" && u.Hostname() != "127.0.0.1" { 127 + fmt.Printf("bad redirect_uris: %s\n", ru) 127 128 return fmt.Errorf("%w: web redirect_uris must have 'https' scheme", ErrInvalidClientMetadata) 128 129 } 129 130 }