this repo has no description
0
fork

Configure Feed

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

refactor in progress

+432 -377
+82 -84
atproto/auth/cmd/atp-oauth-demo/main.go
··· 33 33 EnvVars: []string{"SESSION_SECRET"}, 34 34 }, 35 35 &cli.StringFlag{ 36 - Name: "client-secret-key", 37 - Usage: "confidential client secret key. should be P-256 private key in multibase encoding", 38 - Required: true, 39 - EnvVars: []string{"CLIENT_SECRET_KEY"}, 36 + Name: "hostname", 37 + Usage: "public host name for this client (if not localhost dev mode)", 38 + EnvVars: []string{"CLIENT_HOSTNAME"}, 39 + }, 40 + &cli.StringFlag{ 41 + Name: "client-secret-key", 42 + Usage: "confidential client secret key. should be P-256 private key in multibase encoding", 43 + EnvVars: []string{"CLIENT_SECRET_KEY"}, 44 + }, 45 + &cli.StringFlag{ 46 + Name: "client-secret-key-id", 47 + Usage: "key id for client-secret-key", 48 + Value: "primary", 49 + EnvVars: []string{"CLIENT_SECRET_KEY_ID"}, 40 50 }, 41 51 }, 42 52 } ··· 47 57 48 58 type Server struct { 49 59 CookieStore *sessions.CookieStore 50 - AuthStore AuthMemStore 51 60 Dir identity.Directory 52 - Config oauth.ClientConfig 53 - Resolver *oauth.Resolver 61 + OAuth oauth.ClientApp 54 62 } 55 63 56 64 //go:embed "base.html" ··· 74 82 75 83 func runServer(cctx *cli.Context) error { 76 84 77 - priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 78 - if err != nil { 79 - return err 85 + // XXX: localhost dev mode if hostname is empty 86 + 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 + 92 + if cctx.String("client-secret-key") != "" { 93 + priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 94 + if err != nil { 95 + return err 96 + } 97 + conf.PrivateKey = priv 98 + conf.KeyID = strPtr(cctx.String("client-secret-key-id")) 80 99 } 81 - // NOTE: initializing with empty callback and client ID; will initialize from request hostname 82 - conf := oauth.NewClientConfig("", "") 83 - conf.PrivateKey = priv 84 100 srv := Server{ 85 101 CookieStore: sessions.NewCookieStore([]byte(cctx.String("session-secret"))), 86 - AuthStore: NewAuthMemStore(), 87 102 Dir: identity.DefaultDirectory(), 88 - Config: conf, 89 - Resolver: oauth.NewResolver(), 103 + OAuth: oauth.ClientApp{ 104 + Client: http.DefaultClient, 105 + Config: &conf, 106 + Resolver: oauth.NewResolver(), 107 + Store: oauth.NewMemStore(), 108 + }, 90 109 } 91 110 92 111 http.HandleFunc("GET /", srv.Homepage) ··· 108 127 return nil 109 128 } 110 129 130 + func (s *Server) loadOAuthSession(r *http.Request) (*oauth.Session, error) { 131 + ctx := r.Context() 132 + 133 + sess, _ := s.CookieStore.Get(r, "oauth-demo") 134 + accountDID, ok := sess.Values["account_did"].(string) 135 + if !ok || accountDID == "" { 136 + return nil, fmt.Errorf("not authenticated") 137 + } 138 + did, err := syntax.ParseDID(accountDID) 139 + if err != nil { 140 + return nil, err 141 + } 142 + 143 + return s.OAuth.ResumeSession(ctx, did) 144 + } 145 + 111 146 func strPtr(raw string) *string { 112 147 return &raw 113 148 } ··· 117 152 if host == "" { 118 153 return 119 154 } 120 - if s.Config.ClientID == "" { 121 - s.Config.ClientID = fmt.Sprintf("https://%s/oauth/client-metadata.json", host) 122 - s.Config.CallbackURL = fmt.Sprintf("https://%s/oauth/callback", host) 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) 123 158 } 124 159 return 125 160 } ··· 129 164 s.finishConfig(r) 130 165 131 166 scope := "atproto transition:generic" 132 - meta := s.Config.ClientMetadata(scope) 167 + meta := s.OAuth.Config.ClientMetadata(scope) 133 168 meta.JWKSUri = strPtr(fmt.Sprintf("https://%s/oauth/jwks.json", r.Host)) 134 169 meta.ClientName = strPtr("indigo atp-oauth-demo") 135 170 meta.ClientURI = strPtr(fmt.Sprintf("https://%s", r.Host)) 136 171 137 172 // internal consistency check 138 - if err := meta.Validate(s.Config.ClientID); err != nil { 173 + if err := meta.Validate(s.OAuth.Config.ClientID); err != nil { 139 174 slog.Error("validating client metadata", "err", err) 140 175 http.Error(w, err.Error(), http.StatusInternalServerError) 141 176 return ··· 151 186 152 187 func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) { 153 188 w.Header().Set("Content-Type", "application/json") 154 - body := s.Config.PublicJWKS() 189 + body := s.OAuth.Config.PublicJWKS() 155 190 if err := json.NewEncoder(w).Encode(body); err != nil { 156 191 http.Error(w, err.Error(), http.StatusInternalServerError) 157 192 return ··· 192 227 193 228 logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host) 194 229 logger.Info("resolving to auth server metadata") 195 - authserverURL, err := s.Resolver.ResolveAuthServerURL(ctx, host) 230 + authserverURL, err := s.OAuth.Resolver.ResolveAuthServerURL(ctx, host) 196 231 if err != nil { 197 232 http.Error(w, fmt.Errorf("resolving auth server: %w", err).Error(), http.StatusBadRequest) 198 233 return 199 234 } 200 - authserverMeta, err := s.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 235 + authserverMeta, err := s.OAuth.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 201 236 if err != nil { 202 237 http.Error(w, fmt.Errorf("fetching auth server metadata: %w", err).Error(), http.StatusBadRequest) 203 238 return 204 239 } 205 240 206 - callbackURL, err := url.Parse(s.Config.ClientID) 241 + callbackURL, err := url.Parse(s.OAuth.Config.ClientID) 207 242 if err != nil { 208 243 http.Error(w, err.Error(), http.StatusInternalServerError) 209 244 return 210 245 } 211 246 callbackURL.Path = "/oauth/callback" 212 - s.Config.CallbackURL = callbackURL.String() 247 + s.OAuth.Config.CallbackURL = callbackURL.String() 213 248 214 249 scope := "atproto transition:generic" 215 - info, err := s.Config.SendAuthRequest(ctx, authserverMeta, username, scope) 250 + info, err := s.OAuth.SendAuthRequest(ctx, authserverMeta, username, scope) 216 251 if err != nil { 217 252 http.Error(w, fmt.Errorf("auth request failed: %w", err).Error(), http.StatusBadRequest) 218 253 return ··· 222 257 info.AccountDID = &ident.DID 223 258 224 259 // persist auth request info 225 - s.AuthStore.SaveAuthRequestInfo(*info) 260 + s.OAuth.Store.SaveAuthRequestInfo(ctx, *info) 226 261 227 262 params := url.Values{} 228 - params.Set("client_id", s.Config.ClientID) 263 + params.Set("client_id", s.OAuth.Config.ClientID) 229 264 params.Set("request_uri", info.RequestURI) 230 265 // TODO: check that 'authorization_endpoint' is "safe" (?) 231 266 redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) ··· 246 281 return 247 282 } 248 283 249 - info, err := s.AuthStore.GetAuthRequestInfo(state) 284 + info, err := s.OAuth.Store.GetAuthRequestInfo(ctx, state) 250 285 if err != nil { 251 286 http.Error(w, fmt.Errorf("loading auth request info: %w", err).Error(), http.StatusNotFound) 252 287 return ··· 257 292 return 258 293 } 259 294 260 - tokenResp, err := s.Config.SendInitialTokenRequest(ctx, authCode, *info) 295 + tokenResp, err := s.OAuth.SendInitialTokenRequest(ctx, authCode, *info) 261 296 if err != nil { 262 297 http.Error(w, fmt.Errorf("initial token request: %w", err).Error(), http.StatusInternalServerError) 263 298 return ··· 278 313 } 279 314 280 315 authSess := oauth.SessionData{ 281 - AccountDID: *info.AccountDID, // nil checked above 282 - HostURL: info.AuthServerURL, // XXX 283 - AuthServerURL: info.AuthServerURL, 284 - AccessToken: tokenResp.AccessToken, 285 - RefreshToken: tokenResp.RefreshToken, 286 - DpopAuthServerNonce: info.DpopAuthServerNonce, 287 - DpopHostNonce: info.DpopAuthServerNonce, // XXX 288 - DpopPrivateKeyMultibase: info.DpopPrivateKeyMultibase, 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, 289 324 } 290 - s.AuthStore.SaveSession(authSess) 325 + s.OAuth.Store.SaveSession(ctx, authSess) 291 326 292 327 // create signed cookie session, indicating account DID 293 328 sess, _ := s.CookieStore.Get(r, "oauth-demo") ··· 304 339 func (s *Server) OAuthRefresh(w http.ResponseWriter, r *http.Request) { 305 340 ctx := r.Context() 306 341 307 - sess, _ := s.CookieStore.Get(r, "oauth-demo") 308 - accountDID, ok := sess.Values["account_did"].(string) 309 - if !ok || accountDID == "" { 310 - // TODO: suppowed to set a WWW header; and could redirect? 311 - http.Error(w, "not authenticated", http.StatusUnauthorized) 312 - return 313 - } 314 - did, err := syntax.ParseDID(accountDID) 342 + oauthSess, err := s.loadOAuthSession(r) 315 343 if err != nil { 344 + // TODO: suppowed to set a WWW header; and could redirect? 316 345 http.Error(w, "not authenticated", http.StatusUnauthorized) 317 346 return 318 347 } 319 348 320 - sessData, err := s.AuthStore.GetSession(did) 321 - if err != nil { 322 - http.Error(w, err.Error(), http.StatusBadRequest) 323 - return 324 - } 325 - 326 - oauthSess, err := oauth.ResumeSession(&s.Config, sessData) 327 - if err != nil { 328 - http.Error(w, err.Error(), http.StatusBadRequest) 329 - return 330 - } 331 - 332 349 if err := oauthSess.RefreshTokens(ctx); err != nil { 333 350 http.Error(w, err.Error(), http.StatusBadRequest) 334 351 return 335 352 } 336 - s.AuthStore.SaveSession(*oauthSess.Data) 353 + s.OAuth.Store.SaveSession(ctx, *oauthSess.Data) 337 354 slog.Info("refreshed tokens") 338 355 http.Redirect(w, r, "/", http.StatusFound) 339 356 } ··· 358 375 359 376 slog.Info("in post handler") 360 377 361 - sess, _ := s.CookieStore.Get(r, "oauth-demo") 362 - accountDID, ok := sess.Values["account_did"].(string) 363 - if !ok || accountDID == "" { 364 - // TODO: suppowed to set a WWW header; and could redirect? 365 - http.Error(w, "not authenticated", http.StatusUnauthorized) 366 - return 367 - } 368 - did, err := syntax.ParseDID(accountDID) 369 - if err != nil { 370 - http.Error(w, "not authenticated", http.StatusUnauthorized) 378 + if r.Method != "POST" { 379 + tmplPost.Execute(w, nil) 371 380 return 372 381 } 373 382 374 - sessData, err := s.AuthStore.GetSession(did) 383 + oauthSess, err := s.loadOAuthSession(r) 375 384 if err != nil { 376 - http.Error(w, err.Error(), http.StatusBadRequest) 377 - return 378 - } 379 - 380 - if r.Method != "POST" { 381 - tmplPost.Execute(w, nil) 385 + http.Error(w, "not authenticated", http.StatusUnauthorized) 382 386 return 383 387 } 384 388 ··· 387 391 return 388 392 } 389 393 text := r.PostFormValue("post_text") 390 - 391 - oauthSess, err := oauth.ResumeSession(&s.Config, sessData) 392 - if err != nil { 393 - http.Error(w, err.Error(), http.StatusBadRequest) 394 - return 395 - } 396 394 397 395 c := client.NewAPIClient(oauthSess.Data.HostURL) 398 396 c.Auth = oauthSess
-61
atproto/auth/cmd/atp-oauth-demo/store.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "sync" 6 - 7 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - ) 10 - 11 - // simple in-process database for sessions and auth requests 12 - type AuthMemStore struct { 13 - requests map[string]oauth.AuthRequestData 14 - sessions map[string]oauth.SessionData 15 - 16 - lk sync.Mutex 17 - } 18 - 19 - func NewAuthMemStore() AuthMemStore { 20 - return AuthMemStore{ 21 - requests: make(map[string]oauth.AuthRequestData), 22 - sessions: make(map[string]oauth.SessionData), 23 - } 24 - } 25 - 26 - func (m *AuthMemStore) GetSession(did syntax.DID) (*oauth.SessionData, error) { 27 - m.lk.Lock() 28 - defer m.lk.Unlock() 29 - 30 - sess, ok := m.sessions[did.String()] 31 - if !ok { 32 - return nil, fmt.Errorf("session not found: %s", did) 33 - } 34 - return &sess, nil 35 - } 36 - 37 - func (m *AuthMemStore) GetAuthRequestInfo(state string) (*oauth.AuthRequestData, error) { 38 - m.lk.Lock() 39 - defer m.lk.Unlock() 40 - 41 - req, ok := m.requests[state] 42 - if !ok { 43 - return nil, fmt.Errorf("request info not found: %s", state) 44 - } 45 - // TODO: delete? should only ever fetch once 46 - return &req, nil 47 - } 48 - 49 - func (m *AuthMemStore) SaveSession(sess oauth.SessionData) { 50 - m.lk.Lock() 51 - defer m.lk.Unlock() 52 - 53 - m.sessions[sess.AccountDID.String()] = sess 54 - } 55 - 56 - func (m *AuthMemStore) SaveAuthRequestInfo(info oauth.AuthRequestData) { 57 - m.lk.Lock() 58 - defer m.lk.Unlock() 59 - 60 - m.requests[info.State] = info 61 - }
+81
atproto/auth/oauth/memstore.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + // Simple in-memory implementation of OAuthStore 12 + type MemStore struct { 13 + requests map[string]AuthRequestData 14 + sessions map[string]SessionData 15 + 16 + lk sync.Mutex 17 + } 18 + 19 + var _ OAuthStore = &MemStore{} 20 + 21 + func NewMemStore() *MemStore { 22 + return &MemStore{ 23 + requests: make(map[string]AuthRequestData), 24 + sessions: make(map[string]SessionData), 25 + } 26 + } 27 + 28 + func (m *MemStore) GetSession(ctx context.Context, did syntax.DID) (*SessionData, error) { 29 + m.lk.Lock() 30 + defer m.lk.Unlock() 31 + 32 + sess, ok := m.sessions[did.String()] 33 + if !ok { 34 + return nil, fmt.Errorf("session not found: %s", did) 35 + } 36 + return &sess, nil 37 + } 38 + 39 + func (m *MemStore) SaveSession(ctx context.Context, sess SessionData) error { 40 + m.lk.Lock() 41 + defer m.lk.Unlock() 42 + 43 + m.sessions[sess.AccountDID.String()] = sess 44 + return nil 45 + } 46 + 47 + func (m *MemStore) DeleteSession(ctx context.Context, did syntax.DID) error { 48 + m.lk.Lock() 49 + defer m.lk.Unlock() 50 + 51 + delete(m.sessions, did.String()) 52 + return nil 53 + } 54 + 55 + func (m *MemStore) GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error) { 56 + m.lk.Lock() 57 + defer m.lk.Unlock() 58 + 59 + req, ok := m.requests[state] 60 + if !ok { 61 + return nil, fmt.Errorf("request info not found: %s", state) 62 + } 63 + // TODO: delete? should only ever fetch once 64 + return &req, nil 65 + } 66 + 67 + func (m *MemStore) SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error { 68 + m.lk.Lock() 69 + defer m.lk.Unlock() 70 + 71 + m.requests[info.State] = info 72 + return nil 73 + } 74 + 75 + func (m *MemStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 76 + m.lk.Lock() 77 + defer m.lk.Unlock() 78 + 79 + delete(m.requests, state) 80 + return nil 81 + }
+47 -230
atproto/auth/oauth/oauth.go
··· 19 19 var JWT_EXPIRATION_DURATION = 30 * time.Second 20 20 21 21 // Service-level client. Used to establish and refrsh OAuth sessions, but is not itself account or session specific, and can not be used directly to make API calls on behalf of a user. 22 - type OAuthClient struct { 22 + type ClientApp struct { 23 23 Client *http.Client 24 24 Resolver *Resolver 25 25 Config *ClientConfig 26 + Store OAuthStore 26 27 } 27 28 28 29 type ClientConfig struct { ··· 38 39 KeyID *string 39 40 } 40 41 41 - func (conf *ClientConfig) IsConfidential() bool { 42 - return conf.PrivateKey != nil && conf.KeyID != nil 43 - } 44 - 45 - func NewClientConfig(clientID, callbackURL string) ClientConfig { 42 + func NewPublicConfig(clientID, callbackURL string) ClientConfig { 46 43 c := ClientConfig{ 47 44 ClientID: clientID, 48 45 CallbackURL: callbackURL, 46 + UserAgent: "indigo-sdk", 49 47 } 50 48 return c 51 49 } 52 50 53 - // Returns public JWK corresponding to the client's (private) attestation key. 54 - // 55 - // If the client does not have a key (eg, a non-confidential client), returns an error. 56 - func (c *ClientConfig) PublicJWK() (*crypto.JWK, error) { 57 - if c.PrivateKey == nil || c.KeyID == nil { 58 - return nil, fmt.Errorf("non-confidential client has no public JWK") 59 - } 60 - pub, err := c.PrivateKey.PublicKey() 61 - if err != nil { 62 - return nil, err 63 - } 64 - jwk, err := pub.JWK() 65 - if err != nil { 66 - return nil, err 67 - } 68 - jwk.KeyID = c.KeyID 69 - return jwk, nil 51 + func (conf *ClientConfig) IsConfidential() bool { 52 + return conf.PrivateKey != nil && conf.KeyID != nil 70 53 } 71 54 72 55 // Returns a "JWKS" representation of public keys for the client. This can be returned as JSON, as part of client metadata. 73 56 // 74 - // If the client does not have any keys, returns an empty set. 75 - func (c *ClientConfig) PublicJWKS() JWKS { 57 + // If the client does not have any keys (eg, public client), returns an empty set. 58 + func (conf *ClientConfig) PublicJWKS() JWKS { 76 59 // public client with no keys 77 - if c.PrivateKey == nil { 60 + if conf.PrivateKey == nil || conf.KeyID == nil { 78 61 return JWKS{} 79 62 } 80 - jwk, err := c.PublicJWK() 63 + 64 + pub, err := conf.PrivateKey.PublicKey() 65 + if err != nil { 66 + return JWKS{} 67 + } 68 + jwk, err := pub.JWK() 81 69 if err != nil { 82 70 return JWKS{} 83 71 } 72 + jwk.KeyID = conf.KeyID 73 + 84 74 jwks := JWKS{ 85 75 Keys: []crypto.JWK{*jwk}, 86 76 } ··· 112 102 return m 113 103 } 114 104 115 - type Session struct { 116 - // HTTP client used for token refresh requests 117 - Client *http.Client 105 + func (c *ClientApp) ResumeSession(ctx context.Context, did syntax.DID) (*Session, error) { 118 106 119 - Config *ClientConfig 120 - Data *SessionData 121 - DpopPrivateKey crypto.PrivateKey 122 - } 107 + sd, err := c.Store.GetSession(ctx, did) 108 + if err != nil { 109 + return nil, err 110 + } 123 111 124 - func ResumeSession(config *ClientConfig, data *SessionData) (*Session, error) { 125 112 sess := Session{ 126 - Client: http.DefaultClient, 127 - Config: config, 128 - Data: data, 113 + Client: c.Client, 114 + Config: c.Config, 115 + Data: sd, 129 116 } 130 - priv, err := crypto.ParsePrivateMultibase(data.DpopPrivateKeyMultibase) 117 + // XXX: configure token refresh callback 118 + 119 + // XXX: refactor this in to store layer? 120 + priv, err := crypto.ParsePrivateMultibase(sd.DpopPrivateKeyMultibase) 131 121 if err != nil { 132 122 return nil, err 133 123 } ··· 153 143 Nonce *string `json:"nonce,omitempty"` 154 144 } 155 145 156 - func (conf *ClientConfig) NewAssertionJWT(authURL string) (string, error) { 157 - if !conf.IsConfidential() { 146 + func (cfg *ClientConfig) NewAssertionJWT(authURL string) (string, error) { 147 + if !cfg.IsConfidential() { 158 148 return "", fmt.Errorf("non-confidential client") 159 149 } 160 150 claims := clientAssertionClaims{ 161 151 RegisteredClaims: jwt.RegisteredClaims{ 162 - Issuer: conf.ClientID, 163 - Subject: conf.ClientID, 152 + Issuer: cfg.ClientID, 153 + Subject: cfg.ClientID, 164 154 Audience: []string{authURL}, 165 155 ID: randomNonce(), 166 156 IssuedAt: jwt.NewNumericDate(time.Now()), 167 157 }, 168 158 } 169 159 170 - signingMethod, err := keySigningMethod(conf.PrivateKey) 160 + signingMethod, err := keySigningMethod(cfg.PrivateKey) 171 161 if err != nil { 172 162 return "", err 173 163 } 174 164 175 165 token := jwt.NewWithClaims(signingMethod, claims) 176 - token.Header["kid"] = conf.KeyID 177 - return token.SignedString(conf.PrivateKey) 166 + token.Header["kid"] = cfg.KeyID 167 + return token.SignedString(cfg.PrivateKey) 178 168 } 179 169 180 170 func NewDPoPJWT(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) { ··· 214 204 } 215 205 216 206 // Sends PAR request to auth server 217 - func (c *ClientConfig) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) { 207 + func (c *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, loginHint, scope string) (*AuthRequestData, error) { 218 208 // TODO: pass as argument? 219 209 httpClient := http.DefaultClient 220 210 ··· 227 217 228 218 // self-signed JWT using private key in client metadata (confidential client) 229 219 // TODO: make "confidential client" mode optional 230 - assertionJWT, err := c.NewAssertionJWT(authMeta.Issuer) 220 + assertionJWT, err := c.Config.NewAssertionJWT(authMeta.Issuer) 231 221 if err != nil { 232 222 return nil, err 233 223 } 234 224 235 225 body := PushedAuthRequest{ 236 - ClientID: c.ClientID, 226 + ClientID: c.Config.ClientID, 237 227 State: state, 238 - RedirectURI: c.CallbackURL, 228 + RedirectURI: c.Config.CallbackURL, 239 229 Scope: scope, 240 230 ResponseType: "code", 241 231 ClientAssertionType: CLIENT_ASSERTION_JWT_BEARER, ··· 260 250 return nil, err 261 251 } 262 252 263 - slog.Info("sending auth request", "scope", scope, "state", state, "redirectURI", c.CallbackURL) 253 + slog.Info("sending auth request", "scope", scope, "state", state, "redirectURI", c.Config.CallbackURL) 264 254 265 255 var resp *http.Response 266 256 for range 2 { ··· 329 319 return &parInfo, nil 330 320 } 331 321 332 - func (c *ClientConfig) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) { 322 + func (c *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) { 333 323 334 - clientAssertion, err := c.NewAssertionJWT(info.AuthServerURL) 324 + clientAssertion, err := c.Config.NewAssertionJWT(info.AuthServerURL) 335 325 if err != nil { 336 326 return nil, err 337 327 } 338 328 339 - // XXX: pass in? 340 - resolv := NewResolver() 341 - httpClient := http.DefaultClient 342 - 343 329 // TODO: don't re-fetch? caching? 344 - authServerMeta, err := resolv.ResolveAuthServerMetadata(ctx, info.AuthServerURL) 330 + authServerMeta, err := c.Resolver.ResolveAuthServerMetadata(ctx, info.AuthServerURL) 345 331 if err != nil { 346 332 return nil, err 347 333 } 348 334 349 335 body := InitialTokenRequest{ 350 - ClientID: c.ClientID, 351 - RedirectURI: c.CallbackURL, 336 + ClientID: c.Config.ClientID, 337 + RedirectURI: c.Config.CallbackURL, 352 338 GrantType: "authorization_code", 353 339 Code: authCode, 354 340 CodeVerifier: info.PKCEVerifier, ··· 383 369 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 384 370 req.Header.Set("DPoP", dpopJWT) 385 371 386 - resp, err = httpClient.Do(req) 372 + resp, err = c.Client.Do(req) 387 373 if err != nil { 388 374 return nil, err 389 375 } ··· 425 411 426 412 return &tokenResp, nil 427 413 } 428 - 429 - func (sess *Session) RefreshTokens(ctx context.Context) error { 430 - 431 - // TODO: assuming confidential client 432 - clientAssertion, err := sess.Config.NewAssertionJWT(sess.Data.AuthServerURL) 433 - if err != nil { 434 - return err 435 - } 436 - 437 - body := RefreshTokenRequest{ 438 - ClientID: sess.Config.ClientID, 439 - GrantType: "authorization_code", 440 - RefreshToken: sess.Data.RefreshToken, 441 - ClientAssertionType: &CLIENT_ASSERTION_JWT_BEARER, 442 - ClientAssertion: &clientAssertion, 443 - } 444 - 445 - vals, err := query.Values(body) 446 - if err != nil { 447 - return err 448 - } 449 - bodyBytes := []byte(vals.Encode()) 450 - 451 - // XXX: persist this back to the data? 452 - dpopServerNonce := sess.Data.DpopAuthServerNonce 453 - tokenURL := sess.Data.AuthServerTokenEndpoint 454 - 455 - var resp *http.Response 456 - for range 2 { 457 - dpopJWT, err := NewDPoPJWT("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey) 458 - if err != nil { 459 - return err 460 - } 461 - 462 - req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes)) 463 - if err != nil { 464 - return err 465 - } 466 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 467 - req.Header.Set("DPoP", dpopJWT) 468 - 469 - resp, err = sess.Client.Do(req) 470 - if err != nil { 471 - return err 472 - } 473 - 474 - // check if a nonce was provided 475 - dpopServerNonce = resp.Header.Get("DPoP-Nonce") 476 - if resp.StatusCode == 400 && dpopServerNonce != "" { 477 - // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce' 478 - var errResp map[string]any 479 - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 480 - slog.Warn("initial token request failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode) 481 - } else { 482 - slog.Warn("initial token request failed", "authServer", tokenURL, "resp", errResp, "statusCode", resp.StatusCode) 483 - } 484 - 485 - // loop around try again 486 - resp.Body.Close() 487 - continue 488 - } 489 - // otherwise process result 490 - break 491 - } 492 - 493 - defer resp.Body.Close() 494 - if resp.StatusCode != 200 { 495 - var errResp map[string]any 496 - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 497 - slog.Warn("initial token request failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode) 498 - } else { 499 - slog.Warn("initial token request failed", "authServer", tokenURL, "resp", errResp, "statusCode", resp.StatusCode) 500 - } 501 - return fmt.Errorf("initial token request failed: HTTP %d", resp.StatusCode) 502 - } 503 - 504 - var tokenResp TokenResponse 505 - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 506 - return fmt.Errorf("token response failed to decode: %w", err) 507 - } 508 - // XXX: more validation of response? 509 - 510 - sess.Data.AccessToken = tokenResp.AccessToken 511 - sess.Data.RefreshToken = tokenResp.RefreshToken 512 - 513 - return nil 514 - } 515 - 516 - func (sess *Session) NewAccessDPoP(method, reqURL string) (string, error) { 517 - 518 - ath := S256CodeChallenge(sess.Data.AccessToken) 519 - claims := dpopClaims{ 520 - HTTPMethod: method, 521 - TargetURI: reqURL, 522 - AccessTokenHash: &ath, 523 - RegisteredClaims: jwt.RegisteredClaims{ 524 - Issuer: sess.Data.AuthServerURL, 525 - ID: randomNonce(), 526 - IssuedAt: jwt.NewNumericDate(time.Now()), 527 - ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT_EXPIRATION_DURATION)), 528 - }, 529 - } 530 - if sess.Data.DpopHostNonce != "" { 531 - claims.Nonce = &sess.Data.DpopHostNonce 532 - } 533 - 534 - keyMethod, err := keySigningMethod(sess.DpopPrivateKey) 535 - if err != nil { 536 - return "", err 537 - } 538 - 539 - // TODO: parse/cache this elsewhere 540 - pub, err := sess.DpopPrivateKey.PublicKey() 541 - if err != nil { 542 - return "", err 543 - } 544 - pubJWK, err := pub.JWK() 545 - if err != nil { 546 - return "", err 547 - } 548 - 549 - token := jwt.NewWithClaims(keyMethod, claims) 550 - token.Header["typ"] = "dpop+jwt" 551 - token.Header["jwk"] = pubJWK 552 - return token.SignedString(sess.DpopPrivateKey) 553 - } 554 - 555 - func (sess *Session) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 556 - 557 - // XXX: copy URL and strip query params 558 - u := req.URL.String() 559 - 560 - dpopServerNonce := sess.Data.DpopHostNonce 561 - var resp *http.Response 562 - for range 2 { 563 - dpopJWT, err := sess.NewAccessDPoP(req.Method, u) 564 - if err != nil { 565 - return nil, err 566 - } 567 - req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", sess.Data.AccessToken)) 568 - req.Header.Set("DPoP", dpopJWT) 569 - 570 - resp, err = c.Do(req) 571 - if err != nil { 572 - return nil, err 573 - } 574 - 575 - // check if a nonce was provided 576 - dpopServerNonce = resp.Header.Get("DPoP-Nonce") 577 - if resp.StatusCode == 400 && dpopServerNonce != "" { 578 - // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce' 579 - var errResp map[string]any 580 - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 581 - slog.Warn("authorized request failed", "url", u, "err", err, "statusCode", resp.StatusCode) 582 - } else { 583 - slog.Warn("authorized request failed", "url", u, "resp", errResp, "statusCode", resp.StatusCode) 584 - } 585 - 586 - // XXX: doesn't really work, body might be drained second time 587 - // loop around try again 588 - resp.Body.Close() 589 - continue 590 - } 591 - // otherwise process result 592 - break 593 - } 594 - // TODO: check for auth-specific errors, and return them as err 595 - return resp, nil 596 - }
+2 -1
atproto/auth/oauth/oauth_types.go atproto/auth/oauth/types.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 ) 13 13 14 + var CLIENT_ASSERTION_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 15 + 14 16 var ( 15 17 ErrInvalidAuthServerMetadata = errors.New("invalid auth server metadata") 16 18 ErrInvalidClientMetadata = errors.New("invalid client metadata doc") 17 - CLIENT_ASSERTION_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 18 19 ) 19 20 20 21 type JWKS struct {
+1 -1
atproto/auth/oauth/resolver.go
··· 14 14 15 15 // Helper for resolving OAuth documents from the public web: client metadata, auth server metadata, etc. 16 16 // 17 - // NOTE: will probably want to add flexible caching to this interface, and that might mean turning it in to an interface. 17 + // NOTE: will probably want to add flexible caching to this interface, and that may mean turning it in to an interface. 18 18 type Resolver struct { 19 19 Client *http.Client 20 20 UserAgent string
+203
atproto/auth/oauth/session.go
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "sync" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/atproto/crypto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + 16 + "github.com/golang-jwt/jwt/v5" 17 + "github.com/google/go-querystring/query" 18 + ) 19 + 20 + type RefreshCallback = func(ctx context.Context, data SessionData) 21 + 22 + type Session struct { 23 + // HTTP client used for token refresh requests 24 + Client *http.Client 25 + 26 + Config *ClientConfig 27 + Data *SessionData 28 + DpopPrivateKey crypto.PrivateKey 29 + 30 + RefreshCallback RefreshCallback 31 + 32 + // Lock which protects concurrent access to session data (eg, access and refresh tokens) 33 + lk sync.RWMutex 34 + } 35 + 36 + func (sess *Session) RefreshTokens(ctx context.Context) error { 37 + 38 + // TODO: assuming confidential client 39 + clientAssertion, err := sess.Config.NewAssertionJWT(sess.Data.AuthServerURL) 40 + if err != nil { 41 + return err 42 + } 43 + 44 + body := RefreshTokenRequest{ 45 + ClientID: sess.Config.ClientID, 46 + GrantType: "authorization_code", 47 + RefreshToken: sess.Data.RefreshToken, 48 + ClientAssertionType: &CLIENT_ASSERTION_JWT_BEARER, 49 + ClientAssertion: &clientAssertion, 50 + } 51 + 52 + vals, err := query.Values(body) 53 + if err != nil { 54 + return err 55 + } 56 + bodyBytes := []byte(vals.Encode()) 57 + 58 + // XXX: persist this back to the data? 59 + dpopServerNonce := sess.Data.DpopAuthServerNonce 60 + tokenURL := sess.Data.AuthServerTokenEndpoint 61 + 62 + var resp *http.Response 63 + for range 2 { 64 + dpopJWT, err := NewDPoPJWT("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes)) 70 + if err != nil { 71 + return err 72 + } 73 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 74 + req.Header.Set("DPoP", dpopJWT) 75 + 76 + resp, err = sess.Client.Do(req) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + // check if a nonce was provided 82 + dpopServerNonce = resp.Header.Get("DPoP-Nonce") 83 + if resp.StatusCode == 400 && dpopServerNonce != "" { 84 + // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce' 85 + var errResp map[string]any 86 + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 87 + slog.Warn("initial token request failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode) 88 + } else { 89 + slog.Warn("initial token request failed", "authServer", tokenURL, "resp", errResp, "statusCode", resp.StatusCode) 90 + } 91 + 92 + // loop around try again 93 + resp.Body.Close() 94 + continue 95 + } 96 + // otherwise process result 97 + break 98 + } 99 + 100 + defer resp.Body.Close() 101 + if resp.StatusCode != 200 { 102 + var errResp map[string]any 103 + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 104 + slog.Warn("initial token request failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode) 105 + } else { 106 + slog.Warn("initial token request failed", "authServer", tokenURL, "resp", errResp, "statusCode", resp.StatusCode) 107 + } 108 + return fmt.Errorf("initial token request failed: HTTP %d", resp.StatusCode) 109 + } 110 + 111 + var tokenResp TokenResponse 112 + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 113 + return fmt.Errorf("token response failed to decode: %w", err) 114 + } 115 + // XXX: more validation of response? 116 + 117 + sess.Data.AccessToken = tokenResp.AccessToken 118 + sess.Data.RefreshToken = tokenResp.RefreshToken 119 + 120 + return nil 121 + } 122 + 123 + func (sess *Session) NewAccessDPoP(method, reqURL string) (string, error) { 124 + 125 + ath := S256CodeChallenge(sess.Data.AccessToken) 126 + claims := dpopClaims{ 127 + HTTPMethod: method, 128 + TargetURI: reqURL, 129 + AccessTokenHash: &ath, 130 + RegisteredClaims: jwt.RegisteredClaims{ 131 + Issuer: sess.Data.AuthServerURL, 132 + ID: randomNonce(), 133 + IssuedAt: jwt.NewNumericDate(time.Now()), 134 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT_EXPIRATION_DURATION)), 135 + }, 136 + } 137 + if sess.Data.DpopHostNonce != "" { 138 + claims.Nonce = &sess.Data.DpopHostNonce 139 + } 140 + 141 + keyMethod, err := keySigningMethod(sess.DpopPrivateKey) 142 + if err != nil { 143 + return "", err 144 + } 145 + 146 + // TODO: parse/cache this elsewhere 147 + pub, err := sess.DpopPrivateKey.PublicKey() 148 + if err != nil { 149 + return "", err 150 + } 151 + pubJWK, err := pub.JWK() 152 + if err != nil { 153 + return "", err 154 + } 155 + 156 + token := jwt.NewWithClaims(keyMethod, claims) 157 + token.Header["typ"] = "dpop+jwt" 158 + token.Header["jwk"] = pubJWK 159 + return token.SignedString(sess.DpopPrivateKey) 160 + } 161 + 162 + func (sess *Session) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 163 + 164 + // XXX: copy URL and strip query params 165 + u := req.URL.String() 166 + 167 + dpopServerNonce := sess.Data.DpopHostNonce 168 + var resp *http.Response 169 + for range 2 { 170 + dpopJWT, err := sess.NewAccessDPoP(req.Method, u) 171 + if err != nil { 172 + return nil, err 173 + } 174 + req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", sess.Data.AccessToken)) 175 + req.Header.Set("DPoP", dpopJWT) 176 + 177 + resp, err = c.Do(req) 178 + if err != nil { 179 + return nil, err 180 + } 181 + 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) 191 + } 192 + 193 + // XXX: doesn't really work, body might be drained second time 194 + // loop around try again 195 + resp.Body.Close() 196 + continue 197 + } 198 + // otherwise process result 199 + break 200 + } 201 + // TODO: check for auth-specific errors, and return them as err 202 + return resp, nil 203 + }
+16
atproto/auth/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type OAuthStore interface { 10 + GetSession(ctx context.Context, did syntax.DID) (*SessionData, error) 11 + SaveSession(ctx context.Context, sess SessionData) error 12 + DeleteSession(ctx context.Context, did syntax.DID) error 13 + GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error) 14 + SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error 15 + DeleteAuthRequestInfo(ctx context.Context, state string) error 16 + }