A community based topic aggregation platform built on atproto
1package oauth 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" 9 "net/url" 10 11 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13) 14 15// MobileOAuthStore interface for mobile-specific OAuth operations 16// This extends the base OAuth store with mobile CSRF tracking 17type MobileOAuthStore interface { 18 SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error 19 GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) 20} 21 22// OAuthHandler handles OAuth-related HTTP endpoints 23type OAuthHandler struct { 24 client *OAuthClient 25 store oauth.ClientAuthStore 26 mobileStore MobileOAuthStore // For server-side CSRF validation 27} 28 29// NewOAuthHandler creates a new OAuth handler 30func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore) *OAuthHandler { 31 handler := &OAuthHandler{ 32 client: client, 33 store: store, 34 } 35 36 // Check if the store implements MobileOAuthStore for server-side CSRF 37 if mobileStore, ok := store.(MobileOAuthStore); ok { 38 handler.mobileStore = mobileStore 39 } 40 41 return handler 42} 43 44// HandleClientMetadata serves the OAuth client metadata document 45// GET /oauth/client-metadata.json 46func (h *OAuthHandler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 47 metadata := h.client.ClientMetadata() 48 49 // For confidential clients in production, set JWKS URI based on request host 50 if h.client.IsConfidential() && !h.client.Config.DevMode { 51 jwksURI := fmt.Sprintf("https://%s/oauth/jwks.json", r.Host) 52 metadata.JWKSURI = &jwksURI 53 } 54 55 // Validate metadata before returning (skip in dev mode - localhost doesn't need https validation) 56 if !h.client.Config.DevMode { 57 if err := metadata.Validate(h.client.ClientApp.Config.ClientID); err != nil { 58 slog.Error("client metadata validation failed", "error", err) 59 http.Error(w, "internal server error", http.StatusInternalServerError) 60 return 61 } 62 } 63 64 w.Header().Set("Content-Type", "application/json") 65 if err := json.NewEncoder(w).Encode(metadata); err != nil { 66 slog.Error("failed to encode client metadata", "error", err) 67 http.Error(w, "internal server error", http.StatusInternalServerError) 68 return 69 } 70} 71 72// HandleJWKS serves the public JWKS for confidential clients 73// GET /oauth/jwks.json 74func (h *OAuthHandler) HandleJWKS(w http.ResponseWriter, r *http.Request) { 75 jwks := h.client.PublicJWKS() 76 77 w.Header().Set("Content-Type", "application/json") 78 if err := json.NewEncoder(w).Encode(jwks); err != nil { 79 slog.Error("failed to encode JWKS", "error", err) 80 http.Error(w, "internal server error", http.StatusInternalServerError) 81 return 82 } 83} 84 85// HandleLogin starts the OAuth flow (web version) 86// GET /oauth/login?handle=user.bsky.social 87func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { 88 ctx := r.Context() 89 90 // Get handle or DID from query params 91 identifier := r.URL.Query().Get("handle") 92 if identifier == "" { 93 identifier = r.URL.Query().Get("did") 94 } 95 if identifier == "" { 96 http.Error(w, "missing handle or did parameter", http.StatusBadRequest) 97 return 98 } 99 100 // Start OAuth flow 101 redirectURL, err := h.client.ClientApp.StartAuthFlow(ctx, identifier) 102 if err != nil { 103 slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 104 http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 105 return 106 } 107 108 // Log OAuth flow initiation (sanitized - no full URL to avoid leaking state) 109 slog.Info("redirecting to PDS for OAuth", "identifier", identifier) 110 111 // Redirect to PDS 112 http.Redirect(w, r, redirectURL, http.StatusFound) 113} 114 115// HandleMobileLogin starts the OAuth flow for mobile apps 116// GET /oauth/mobile/login?handle=user.bsky.social&redirect_uri=coves-app://callback 117func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) { 118 ctx := r.Context() 119 120 // Get handle or DID from query params 121 identifier := r.URL.Query().Get("handle") 122 if identifier == "" { 123 identifier = r.URL.Query().Get("did") 124 } 125 if identifier == "" { 126 http.Error(w, "missing handle or did parameter", http.StatusBadRequest) 127 return 128 } 129 130 // Get mobile redirect URI (deep link) 131 mobileRedirectURI := r.URL.Query().Get("redirect_uri") 132 if mobileRedirectURI == "" { 133 http.Error(w, "missing redirect_uri parameter", http.StatusBadRequest) 134 return 135 } 136 137 // SECURITY FIX 1: Validate redirect_uri against allowlist 138 if !isAllowedMobileRedirectURI(mobileRedirectURI) { 139 slog.Warn("rejected unauthorized mobile redirect URI", "scheme", extractScheme(mobileRedirectURI)) 140 http.Error(w, "invalid redirect_uri: scheme not allowed", http.StatusBadRequest) 141 return 142 } 143 144 // SECURITY: Verify store is properly configured for mobile OAuth 145 // A plain PostgresOAuthStore implements MobileOAuthStore (has Save/GetMobileOAuthData), 146 // but without the MobileAwareStoreWrapper, SaveMobileOAuthData is never called during 147 // StartAuthFlow, so server-side CSRF data is never stored. This causes mobile callbacks 148 // to silently fall back to web flow. Fail fast here instead of silent breakage. 149 if _, ok := h.store.(MobileAwareClientStore); !ok { 150 slog.Error("mobile OAuth not supported: store is not wrapped with MobileAwareStoreWrapper", 151 "store_type", fmt.Sprintf("%T", h.store)) 152 http.Error(w, "mobile OAuth not configured on this server", http.StatusInternalServerError) 153 return 154 } 155 156 // SECURITY FIX 2: Generate CSRF token 157 csrfToken, err := generateCSRFToken() 158 if err != nil { 159 http.Error(w, "internal server error", http.StatusInternalServerError) 160 return 161 } 162 163 // SECURITY FIX 4: Store CSRF server-side tied to OAuth state 164 // Add mobile data to context so the store wrapper can capture it when 165 // SaveAuthRequestInfo is called by indigo's StartAuthFlow. 166 // This is necessary because PAR redirects don't include the state in the URL, 167 // so we can't extract it after StartAuthFlow returns. 168 mobileCtx := ContextWithMobileFlowData(ctx, MobileOAuthData{ 169 CSRFToken: csrfToken, 170 RedirectURI: mobileRedirectURI, 171 }) 172 173 // Start OAuth flow (the store wrapper will save mobile data when auth request is saved) 174 redirectURL, err := h.client.ClientApp.StartAuthFlow(mobileCtx, identifier) 175 if err != nil { 176 slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 177 http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 178 return 179 } 180 181 // Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params) 182 slog.Info("redirecting to PDS for mobile OAuth", "identifier", identifier) 183 184 // SECURITY FIX 2: Store CSRF token in cookie 185 http.SetCookie(w, &http.Cookie{ 186 Name: "oauth_csrf", 187 Value: csrfToken, 188 Path: "/oauth", 189 MaxAge: 600, // 10 minutes 190 HttpOnly: true, 191 Secure: !h.client.Config.DevMode, 192 SameSite: http.SameSiteLaxMode, 193 }) 194 195 // SECURITY FIX 3: Generate binding token to tie CSRF token + mobile redirect to this OAuth flow 196 // This prevents session fixation attacks where an attacker plants a mobile_redirect_uri 197 // cookie, then the user does a web login, and credentials get sent to attacker's deep link. 198 // The binding includes the CSRF token so we validate its VALUE (not just presence) on callback. 199 mobileBinding := generateMobileRedirectBinding(csrfToken, mobileRedirectURI) 200 201 // Set cookie with mobile redirect URI for use in callback 202 http.SetCookie(w, &http.Cookie{ 203 Name: "mobile_redirect_uri", 204 Value: url.QueryEscape(mobileRedirectURI), 205 Path: "/oauth", 206 HttpOnly: true, 207 Secure: !h.client.Config.DevMode, 208 SameSite: http.SameSiteLaxMode, 209 MaxAge: 600, // 10 minutes 210 }) 211 212 // Set binding cookie to validate mobile redirect in callback 213 http.SetCookie(w, &http.Cookie{ 214 Name: "mobile_redirect_binding", 215 Value: mobileBinding, 216 Path: "/oauth", 217 HttpOnly: true, 218 Secure: !h.client.Config.DevMode, 219 SameSite: http.SameSiteLaxMode, 220 MaxAge: 600, // 10 minutes 221 }) 222 223 // Redirect to PDS 224 http.Redirect(w, r, redirectURL, http.StatusFound) 225} 226 227// HandleCallback handles the OAuth callback from the PDS 228// GET /oauth/callback?code=...&state=...&iss=... 229func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { 230 ctx := r.Context() 231 232 // IMPORTANT: Look up mobile CSRF data BEFORE ProcessCallback 233 // ProcessCallback deletes the oauth_requests row, so we must retrieve mobile data first. 234 // We store it in a local variable for validation after ProcessCallback completes. 235 var serverMobileData *MobileOAuthData 236 var mobileDataLookupErr error 237 oauthState := r.URL.Query().Get("state") 238 239 // Check if this might be a mobile callback (mobile_redirect_uri cookie present) 240 // We do a preliminary check here to decide if we need to fetch mobile data 241 mobileRedirectCookie, _ := r.Cookie("mobile_redirect_uri") 242 isMobileFlow := mobileRedirectCookie != nil && mobileRedirectCookie.Value != "" 243 244 if isMobileFlow && h.mobileStore != nil && oauthState != "" { 245 // Fetch mobile data BEFORE ProcessCallback deletes the row 246 serverMobileData, mobileDataLookupErr = h.mobileStore.GetMobileOAuthData(ctx, oauthState) 247 // We'll handle errors after ProcessCallback - for now just capture the result 248 } 249 250 // Process the callback (this deletes the oauth_requests row) 251 sessData, err := h.client.ClientApp.ProcessCallback(ctx, r.URL.Query()) 252 if err != nil { 253 slog.Error("failed to process OAuth callback", "error", err) 254 http.Error(w, fmt.Sprintf("OAuth callback failed: %v", err), http.StatusBadRequest) 255 return 256 } 257 258 // Ensure sessData is not nil before using it 259 if sessData == nil { 260 slog.Error("OAuth callback returned nil session data") 261 http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError) 262 return 263 } 264 265 // Bidirectional handle verification: ensure the DID actually controls a valid handle 266 // This prevents impersonation via compromised PDS that issues tokens with invalid handle mappings 267 // Per AT Protocol spec: "Bidirectional verification required; confirm DID document claims handle" 268 if h.client.ClientApp.Dir != nil { 269 ident, err := h.client.ClientApp.Dir.LookupDID(ctx, sessData.AccountDID) 270 if err != nil { 271 // Directory lookup failed - this is a hard error for security 272 slog.Error("OAuth callback: DID lookup failed during handle verification", 273 "did", sessData.AccountDID, "error", err) 274 http.Error(w, "Handle verification failed", http.StatusUnauthorized) 275 return 276 } 277 278 // Check if the handle is the special "handle.invalid" value 279 // This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed) 280 if ident.Handle.String() == "handle.invalid" { 281 slog.Warn("OAuth callback: bidirectional handle verification failed", 282 "did", sessData.AccountDID, 283 "handle", "handle.invalid", 284 "reason", "DID document claims a handle that doesn't resolve back to this DID") 285 http.Error(w, "Handle verification failed: DID/handle mismatch", http.StatusUnauthorized) 286 return 287 } 288 289 // Success: handle is valid and bidirectionally verified 290 slog.Info("OAuth callback successful", "did", sessData.AccountDID, "handle", ident.Handle) 291 } else { 292 // No directory client available - log warning but proceed 293 // This should only happen in testing scenarios 294 slog.Warn("OAuth callback: directory client not available, skipping handle verification", 295 "did", sessData.AccountDID) 296 slog.Info("OAuth callback successful (no handle verification)", "did", sessData.AccountDID) 297 } 298 299 // Check if this is a mobile callback (check for mobile_redirect_uri cookie) 300 mobileRedirect, err := r.Cookie("mobile_redirect_uri") 301 if err == nil && mobileRedirect.Value != "" { 302 // SECURITY FIX 2: Validate CSRF token for mobile callback 303 csrfCookie, err := r.Cookie("oauth_csrf") 304 if err != nil { 305 slog.Warn("mobile callback missing CSRF token") 306 clearMobileCookies(w) 307 http.Error(w, "invalid request: missing CSRF token", http.StatusForbidden) 308 return 309 } 310 311 // SECURITY FIX 3: Validate mobile redirect binding 312 // This prevents session fixation attacks where an attacker plants a mobile_redirect_uri 313 // cookie, then the user does a web login, and credentials get sent to attacker's deep link 314 bindingCookie, err := r.Cookie("mobile_redirect_binding") 315 if err != nil { 316 slog.Warn("mobile callback missing redirect binding - possible attack attempt") 317 clearMobileCookies(w) 318 http.Error(w, "invalid request: missing redirect binding", http.StatusForbidden) 319 return 320 } 321 322 // Decode the mobile redirect URI to validate binding 323 mobileRedirectURI, err := url.QueryUnescape(mobileRedirect.Value) 324 if err != nil { 325 slog.Error("failed to decode mobile redirect URI", "error", err) 326 clearMobileCookies(w) 327 http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) 328 return 329 } 330 331 // Validate that the binding matches both the CSRF token AND redirect URI 332 // This is the actual CSRF validation - we verify the token VALUE by checking 333 // that hash(csrfToken + redirectURI) == binding. This prevents: 334 // 1. CSRF attacks: attacker can't forge binding without knowing CSRF token 335 // 2. Session fixation: cookies must all originate from the same /oauth/mobile/login request 336 if !validateMobileRedirectBinding(csrfCookie.Value, mobileRedirectURI, bindingCookie.Value) { 337 slog.Warn("mobile redirect binding/CSRF validation failed - possible attack attempt", 338 "expected_scheme", extractScheme(mobileRedirectURI)) 339 clearMobileCookies(w) 340 // Fail closed: treat as web flow instead of mobile 341 h.handleWebCallback(w, r, sessData) 342 return 343 } 344 345 // SECURITY FIX 4: Validate CSRF cookie against server-side state 346 // This compares the cookie CSRF against a value tied to the OAuth state parameter 347 // (which comes back through the OAuth response), satisfying the requirement to 348 // validate against server-side state rather than only against other cookies. 349 // 350 // CRITICAL: If mobile cookies are present but server-side mobile data is MISSING, 351 // this indicates a potential attack where: 352 // 1. Attacker did a WEB OAuth flow (no mobile data stored) 353 // 2. Attacker planted mobile cookies via cross-site /oauth/mobile/login 354 // 3. Attacker sends victim to callback with attacker's web-flow state/code 355 // We MUST fail closed and use web flow when server-side mobile data is missing. 356 // 357 // NOTE: serverMobileData was fetched BEFORE ProcessCallback (which deletes the row) 358 // at the top of this function. We use the pre-fetched result here. 359 if h.mobileStore != nil && oauthState != "" { 360 if mobileDataLookupErr != nil { 361 // Database error - fail closed, use web flow 362 slog.Warn("failed to retrieve server-side mobile OAuth data - using web flow", 363 "error", mobileDataLookupErr, "state", oauthState) 364 clearMobileCookies(w) 365 h.handleWebCallback(w, r, sessData) 366 return 367 } 368 if serverMobileData == nil { 369 // No server-side mobile data for this state - this OAuth flow was NOT started 370 // via /oauth/mobile/login. Mobile cookies are likely attacker-planted. 371 // Fail closed: clear cookies and use web flow. 372 slog.Warn("mobile cookies present but no server-side mobile data for OAuth state - "+ 373 "possible cross-flow attack, using web flow", "state", oauthState) 374 clearMobileCookies(w) 375 h.handleWebCallback(w, r, sessData) 376 return 377 } 378 // Server-side mobile data exists - validate it matches cookies 379 if !constantTimeCompare(csrfCookie.Value, serverMobileData.CSRFToken) { 380 slog.Warn("mobile callback CSRF mismatch: cookie differs from server-side state", 381 "state", oauthState) 382 clearMobileCookies(w) 383 h.handleWebCallback(w, r, sessData) 384 return 385 } 386 if serverMobileData.RedirectURI != mobileRedirectURI { 387 slog.Warn("mobile callback redirect URI mismatch: cookie differs from server-side state", 388 "cookie_uri", extractScheme(mobileRedirectURI), 389 "server_uri", extractScheme(serverMobileData.RedirectURI)) 390 clearMobileCookies(w) 391 h.handleWebCallback(w, r, sessData) 392 return 393 } 394 slog.Debug("server-side CSRF validation passed", "state", oauthState) 395 } else if h.mobileStore != nil { 396 // mobileStore exists but no state in query - shouldn't happen with valid OAuth 397 slog.Warn("mobile cookies present but no OAuth state in callback - using web flow") 398 clearMobileCookies(w) 399 h.handleWebCallback(w, r, sessData) 400 return 401 } 402 // Note: if h.mobileStore is nil (e.g., in tests), we fall back to cookie-only validation 403 404 // All security checks passed - proceed with mobile flow 405 // Mobile flow: seal the session and redirect to deep link 406 h.handleMobileCallback(w, r, sessData, mobileRedirect.Value, csrfCookie.Value) 407 return 408 } 409 410 // Web flow: set session cookie 411 h.handleWebCallback(w, r, sessData) 412} 413 414// handleWebCallback handles the web OAuth callback flow 415func (h *OAuthHandler) handleWebCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) { 416 // Use sealed tokens for web flow (same as mobile) per atProto OAuth spec: 417 // "Access and refresh tokens should never be copied or shared across end devices. 418 // They should not be stored in session cookies." 419 420 // Seal the session data using AES-GCM encryption 421 sealedToken, err := h.client.SealSession( 422 sessData.AccountDID.String(), 423 sessData.SessionID, 424 h.client.Config.SealedTokenTTL, 425 ) 426 if err != nil { 427 slog.Error("failed to seal session for web", "error", err) 428 http.Error(w, "failed to create session", http.StatusInternalServerError) 429 return 430 } 431 432 http.SetCookie(w, &http.Cookie{ 433 Name: "coves_session", 434 Value: sealedToken, 435 Path: "/", 436 HttpOnly: true, 437 Secure: !h.client.Config.DevMode, 438 SameSite: http.SameSiteLaxMode, 439 MaxAge: int(h.client.Config.SealedTokenTTL.Seconds()), 440 }) 441 442 // Clear all mobile cookies if they exist (defense in depth) 443 clearMobileCookies(w) 444 445 // Redirect to home or app 446 redirectURL := "/" 447 if !h.client.Config.DevMode { 448 redirectURL = h.client.Config.PublicURL + "/" 449 } 450 http.Redirect(w, r, redirectURL, http.StatusFound) 451} 452 453// handleMobileCallback handles the mobile OAuth callback flow 454func (h *OAuthHandler) handleMobileCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData, mobileRedirectURIEncoded, csrfToken string) { 455 // Decode the mobile redirect URI 456 mobileRedirectURI, err := url.QueryUnescape(mobileRedirectURIEncoded) 457 if err != nil { 458 slog.Error("failed to decode mobile redirect URI", "error", err) 459 http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) 460 return 461 } 462 463 // SECURITY FIX 1: Re-validate redirect URI against allowlist 464 if !isAllowedMobileRedirectURI(mobileRedirectURI) { 465 slog.Error("mobile callback attempted with unauthorized redirect URI", "scheme", extractScheme(mobileRedirectURI)) 466 http.Error(w, "invalid redirect URI", http.StatusBadRequest) 467 return 468 } 469 470 // Seal the session data for mobile 471 sealedToken, err := h.client.SealSession( 472 sessData.AccountDID.String(), 473 sessData.SessionID, 474 h.client.Config.SealedTokenTTL, 475 ) 476 if err != nil { 477 slog.Error("failed to seal session data", "error", err) 478 http.Error(w, "failed to create session token", http.StatusInternalServerError) 479 return 480 } 481 482 // Get account handle for convenience 483 handle := "" 484 if ident, err := h.client.ClientApp.Dir.LookupDID(r.Context(), sessData.AccountDID); err == nil { 485 handle = ident.Handle.String() 486 } 487 488 // Clear all mobile cookies to prevent reuse (defense in depth) 489 clearMobileCookies(w) 490 491 // Build deep link with sealed token 492 deepLink := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s", 493 mobileRedirectURI, 494 url.QueryEscape(sealedToken), 495 url.QueryEscape(sessData.AccountDID.String()), 496 url.QueryEscape(sessData.SessionID), 497 ) 498 if handle != "" { 499 deepLink += "&handle=" + url.QueryEscape(handle) 500 } 501 502 // Log mobile redirect (sanitized - no token or session ID to avoid leaking credentials) 503 slog.Info("redirecting to mobile app", "did", sessData.AccountDID, "handle", handle) 504 505 // Redirect to mobile app deep link 506 http.Redirect(w, r, deepLink, http.StatusFound) 507} 508 509// HandleLogout revokes the session and clears cookies 510// POST /oauth/logout 511func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { 512 ctx := r.Context() 513 514 // Get session from cookie (now sealed) 515 cookie, err := r.Cookie("coves_session") 516 if err != nil { 517 // No session, just return success 518 w.WriteHeader(http.StatusOK) 519 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 520 return 521 } 522 523 // Unseal the session token 524 sealed, err := h.client.UnsealSession(cookie.Value) 525 if err != nil { 526 // Invalid session, clear cookie and return 527 h.clearSessionCookie(w) 528 w.WriteHeader(http.StatusOK) 529 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 530 return 531 } 532 533 // Parse DID 534 did, err := syntax.ParseDID(sealed.DID) 535 if err != nil { 536 // Invalid DID, clear cookie and return 537 h.clearSessionCookie(w) 538 w.WriteHeader(http.StatusOK) 539 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 540 return 541 } 542 543 // Revoke session on auth server 544 if err := h.client.ClientApp.Logout(ctx, did, sealed.SessionID); err != nil { 545 slog.Error("failed to revoke session on auth server", "error", err, "did", did) 546 // Continue anyway to clear local session 547 } 548 549 // Clear session cookie 550 h.clearSessionCookie(w) 551 552 w.Header().Set("Content-Type", "application/json") 553 w.WriteHeader(http.StatusOK) 554 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 555} 556 557// HandleRefresh refreshes the session token (for mobile) 558// POST /oauth/refresh 559// Body: {"did": "did:plc:...", "session_id": "...", "sealed_token": "..."} 560func (h *OAuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) { 561 ctx := r.Context() 562 563 var req struct { 564 DID string `json:"did"` 565 SessionID string `json:"session_id"` 566 SealedToken string `json:"sealed_token,omitempty"` 567 } 568 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 569 http.Error(w, "invalid request body", http.StatusBadRequest) 570 return 571 } 572 573 // SECURITY: Require sealed_token for proof of possession 574 // Without this, anyone who knows a DID + session_id can steal credentials 575 if req.SealedToken == "" { 576 slog.Warn("refresh: missing sealed_token", "did", req.DID) 577 http.Error(w, "sealed_token required for refresh", http.StatusUnauthorized) 578 return 579 } 580 581 // SECURITY: Unseal and validate the token 582 unsealed, err := h.client.UnsealSession(req.SealedToken) 583 if err != nil { 584 slog.Warn("refresh: invalid sealed token", "error", err) 585 http.Error(w, "Invalid or expired token", http.StatusUnauthorized) 586 return 587 } 588 589 // SECURITY: Verify the unsealed token matches the claimed DID 590 if unsealed.DID != req.DID { 591 slog.Warn("refresh: DID mismatch", "token_did", unsealed.DID, "claimed_did", req.DID) 592 http.Error(w, "Token DID mismatch", http.StatusUnauthorized) 593 return 594 } 595 596 // SECURITY: Verify the unsealed token matches the claimed session_id 597 if unsealed.SessionID != req.SessionID { 598 slog.Warn("refresh: session_id mismatch", "token_session", unsealed.SessionID, "claimed_session", req.SessionID) 599 http.Error(w, "Token session mismatch", http.StatusUnauthorized) 600 return 601 } 602 603 // Parse DID after validation 604 did, err := syntax.ParseDID(req.DID) 605 if err != nil { 606 http.Error(w, "invalid DID", http.StatusBadRequest) 607 return 608 } 609 610 // Resume session (now authenticated via sealed token) 611 sess, err := h.client.ClientApp.ResumeSession(ctx, did, req.SessionID) 612 if err != nil { 613 slog.Error("failed to resume session", "error", err, "did", did, "session_id", req.SessionID) 614 http.Error(w, "session not found", http.StatusUnauthorized) 615 return 616 } 617 618 // Refresh tokens 619 newAccessToken, err := sess.RefreshTokens(ctx) 620 if err != nil { 621 slog.Error("failed to refresh tokens", "error", err, "did", did) 622 http.Error(w, "failed to refresh tokens", http.StatusUnauthorized) 623 return 624 } 625 626 // Create new sealed token for mobile 627 sealedToken, err := h.client.SealSession( 628 sess.Data.AccountDID.String(), 629 sess.Data.SessionID, 630 h.client.Config.SealedTokenTTL, 631 ) 632 if err != nil { 633 slog.Error("failed to seal new session data", "error", err) 634 http.Error(w, "failed to create session token", http.StatusInternalServerError) 635 return 636 } 637 638 w.Header().Set("Content-Type", "application/json") 639 _ = json.NewEncoder(w).Encode(map[string]interface{}{ 640 "access_token": newAccessToken, 641 "sealed_token": sealedToken, 642 }) 643} 644 645// clearSessionCookie clears the session cookie 646func (h *OAuthHandler) clearSessionCookie(w http.ResponseWriter) { 647 http.SetCookie(w, &http.Cookie{ 648 Name: "coves_session", 649 Value: "", 650 Path: "/", 651 MaxAge: -1, 652 }) 653} 654 655// GetSessionFromRequest extracts session data from an HTTP request 656func (h *OAuthHandler) GetSessionFromRequest(r *http.Request) (*oauth.ClientSessionData, error) { 657 // Try to get session from cookie (web) - now using sealed tokens 658 cookie, err := r.Cookie("coves_session") 659 if err == nil && cookie.Value != "" { 660 // Unseal the token to get DID and session ID 661 sealed, err := h.client.UnsealSession(cookie.Value) 662 if err == nil { 663 did, err := syntax.ParseDID(sealed.DID) 664 if err == nil { 665 return h.store.GetSession(r.Context(), did, sealed.SessionID) 666 } 667 } 668 } 669 670 // Try to get session from Authorization header (mobile) 671 authHeader := r.Header.Get("Authorization") 672 if authHeader != "" { 673 // Expected format: "Bearer <sealed_token>" 674 const prefix = "Bearer " 675 if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix { 676 sealedToken := authHeader[len(prefix):] 677 sealed, err := h.client.UnsealSession(sealedToken) 678 if err != nil { 679 return nil, fmt.Errorf("invalid sealed token: %w", err) 680 } 681 did, err := syntax.ParseDID(sealed.DID) 682 if err != nil { 683 return nil, fmt.Errorf("invalid DID in sealed token: %w", err) 684 } 685 return h.store.GetSession(r.Context(), did, sealed.SessionID) 686 } 687 } 688 689 return nil, fmt.Errorf("no session found") 690} 691 692// HandleProtectedResourceMetadata returns OAuth protected resource metadata 693// per RFC 9449 and atproto OAuth spec. This endpoint allows third-party OAuth 694// clients to discover which authorization server to use for this resource. 695// Spec: https://datatracker.ietf.org/doc/html/rfc9449#section-5 696func (h *OAuthHandler) HandleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { 697 metadata := map[string]interface{}{ 698 "resource": h.client.Config.PublicURL, 699 "authorization_servers": []string{"https://bsky.social"}, 700 } 701 702 w.Header().Set("Content-Type", "application/json") 703 w.Header().Set("Cache-Control", "public, max-age=3600") 704 if err := json.NewEncoder(w).Encode(metadata); err != nil { 705 slog.Error("failed to encode protected resource metadata", "error", err) 706 http.Error(w, "internal server error", http.StatusInternalServerError) 707 return 708 } 709}