A community based topic aggregation platform built on atproto
at main 35 kB view raw
1package oauth 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "html/template" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "strings" 12 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15) 16 17// mobileCallbackTemplate is the intermediate page shown after OAuth completes 18// before redirecting to the mobile app via custom scheme. 19// This prevents the browser from showing a stale PDS page after the redirect. 20var mobileCallbackTemplate = template.Must(template.New("mobile_callback").Parse(`<!DOCTYPE html> 21<html lang="en"> 22<head> 23 <meta charset="utf-8"> 24 <meta name="viewport" content="width=device-width, initial-scale=1"> 25 <title>Login Complete - Coves</title> 26 <meta http-equiv="refresh" content="1;url={{.DeepLink}}"> 27 <style> 28 * { box-sizing: border-box; margin: 0; padding: 0; } 29 body { 30 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 31 background: #0B0F14; 32 color: #e4e6e7; 33 min-height: 100vh; 34 display: flex; 35 justify-content: center; 36 align-items: center; 37 padding: 24px; 38 } 39 .card { 40 text-align: center; 41 max-width: 320px; 42 } 43 .logo { 44 width: 80px; 45 height: 80px; 46 margin: 0 auto 16px; 47 } 48 .checkmark { 49 width: 64px; 50 height: 64px; 51 margin: 0 auto 24px; 52 background: #FF6B35; 53 border-radius: 50%; 54 display: flex; 55 align-items: center; 56 justify-content: center; 57 animation: scale-in 0.3s ease-out; 58 } 59 .checkmark svg { 60 width: 32px; 61 height: 32px; 62 stroke: white; 63 stroke-width: 3; 64 fill: none; 65 } 66 @keyframes scale-in { 67 0% { transform: scale(0); } 68 50% { transform: scale(1.1); } 69 100% { transform: scale(1); } 70 } 71 h1 { 72 font-size: 24px; 73 font-weight: 600; 74 margin-bottom: 8px; 75 color: #e4e6e7; 76 } 77 .subtitle { 78 font-size: 16px; 79 color: #B6C2D2; 80 margin-bottom: 24px; 81 } 82 .handle { 83 font-size: 14px; 84 color: #7CB9E8; 85 background: #1A1F26; 86 padding: 8px 16px; 87 border-radius: 8px; 88 margin-bottom: 24px; 89 display: inline-block; 90 } 91 .hint { 92 font-size: 13px; 93 color: #6B7280; 94 line-height: 1.5; 95 } 96 .spinner { 97 width: 20px; 98 height: 20px; 99 border: 2px solid #2A2F36; 100 border-top-color: #FF6B35; 101 border-radius: 50%; 102 animation: spin 1s linear infinite; 103 display: inline-block; 104 vertical-align: middle; 105 margin-right: 8px; 106 } 107 @keyframes spin { 108 to { transform: rotate(360deg); } 109 } 110 </style> 111</head> 112<body> 113 <div class="card"> 114 <div class="checkmark"> 115 <svg viewBox="0 0 24 24"> 116 <polyline points="20 6 9 17 4 12"></polyline> 117 </svg> 118 </div> 119 <h1>Login Complete</h1> 120 <p class="subtitle"> 121 <span class="spinner"></span> 122 Returning to Coves... 123 </p> 124 {{if .Handle}} 125 <div class="handle">@{{.Handle}}</div> 126 {{end}} 127 <p class="hint">If the app doesn't open automatically,<br>you can close this window.</p> 128 </div> 129 <script> 130 // Redirect to app immediately 131 window.location.href = {{.DeepLink}}; 132 // Try to close window after a delay 133 setTimeout(function() { 134 window.close(); 135 }, 1500); 136 </script> 137</body> 138</html> 139`)) 140 141// MobileOAuthStore interface for mobile-specific OAuth operations 142// This extends the base OAuth store with mobile CSRF tracking 143type MobileOAuthStore interface { 144 SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error 145 GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) 146} 147 148// OAuthHandler handles OAuth-related HTTP endpoints 149type OAuthHandler struct { 150 client *OAuthClient 151 store oauth.ClientAuthStore 152 mobileStore MobileOAuthStore // For server-side CSRF validation 153 devResolver *DevHandleResolver // For dev mode: resolve handles via local PDS 154 devAuthResolver *DevAuthResolver // For dev mode: bypass HTTPS validation for localhost OAuth 155} 156 157// NewOAuthHandler creates a new OAuth handler 158func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore) *OAuthHandler { 159 handler := &OAuthHandler{ 160 client: client, 161 store: store, 162 } 163 164 // Check if the store implements MobileOAuthStore for server-side CSRF 165 if mobileStore, ok := store.(MobileOAuthStore); ok { 166 handler.mobileStore = mobileStore 167 } 168 169 // In dev mode, create resolvers for local PDS/PLC 170 // This is needed because: 171 // 1. Local handles (e.g., user.local.coves.dev) can't be resolved via DNS/HTTP 172 // 2. Indigo's OAuth library requires HTTPS, which localhost doesn't have 173 if client.Config.DevMode { 174 if client.Config.PDSURL != "" { 175 handler.devResolver = NewDevHandleResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs) 176 slog.Info("dev mode: handle resolution via local PDS enabled", "pds_url", client.Config.PDSURL) 177 } 178 // Create dev auth resolver to bypass HTTPS validation (pass PDS URL for handle resolution) 179 handler.devAuthResolver = NewDevAuthResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs) 180 slog.Info("dev mode: localhost OAuth auth resolver enabled", "pds_url", client.Config.PDSURL) 181 } 182 183 return handler 184} 185 186// HandleClientMetadata serves the OAuth client metadata document 187// GET /oauth/client-metadata.json 188func (h *OAuthHandler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 189 metadata := h.client.ClientMetadata() 190 191 // Validate metadata before returning (skip in dev mode - localhost doesn't need https validation) 192 if !h.client.Config.DevMode { 193 if err := metadata.Validate(h.client.ClientApp.Config.ClientID); err != nil { 194 slog.Error("client metadata validation failed", "error", err) 195 http.Error(w, "internal server error", http.StatusInternalServerError) 196 return 197 } 198 } 199 200 w.Header().Set("Content-Type", "application/json") 201 if err := json.NewEncoder(w).Encode(metadata); err != nil { 202 slog.Error("failed to encode client metadata", "error", err) 203 http.Error(w, "internal server error", http.StatusInternalServerError) 204 return 205 } 206} 207 208// HandleLogin starts the OAuth flow (web version) 209// GET /oauth/login?handle=user.bsky.social 210func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { 211 ctx := r.Context() 212 213 // Get handle or DID from query params 214 identifier := r.URL.Query().Get("handle") 215 if identifier == "" { 216 identifier = r.URL.Query().Get("did") 217 } 218 if identifier == "" { 219 http.Error(w, "missing handle or did parameter", http.StatusBadRequest) 220 return 221 } 222 223 var redirectURL string 224 var err error 225 226 // DEV MODE: Use custom OAuth flow that bypasses HTTPS validation 227 // This is needed because: 228 // 1. Local handles can't be resolved via DNS/HTTP well-known 229 // 2. Indigo's OAuth library requires HTTPS for auth servers 230 if h.devAuthResolver != nil { 231 slog.Info("dev mode: using localhost OAuth flow", "identifier", identifier) 232 redirectURL, err = h.devAuthResolver.StartDevAuthFlow(ctx, h.client, identifier, h.client.ClientApp.Dir) 233 if err != nil { 234 slog.Error("dev mode: failed to start OAuth flow", "error", err, "identifier", identifier) 235 http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 236 return 237 } 238 } else { 239 // Production mode: use standard indigo OAuth flow 240 redirectURL, err = h.client.ClientApp.StartAuthFlow(ctx, identifier) 241 if err != nil { 242 slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 243 http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 244 return 245 } 246 } 247 248 // Log OAuth flow initiation (sanitized - no full URL to avoid leaking state) 249 slog.Info("redirecting to PDS for OAuth", "identifier", identifier) 250 251 // Redirect to PDS 252 http.Redirect(w, r, redirectURL, http.StatusFound) 253} 254 255// HandleMobileLogin starts the OAuth flow for mobile apps 256// GET /oauth/mobile/login?handle=user.bsky.social&redirect_uri=coves-app://callback 257func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) { 258 ctx := r.Context() 259 260 // DEV MODE: Redirect localhost to 127.0.0.1 for cookie consistency 261 // The OAuth callback URL uses 127.0.0.1 (per RFC 8252), so cookies must be set 262 // on 127.0.0.1. If user calls localhost, redirect to 127.0.0.1 first. 263 if h.client.Config.DevMode && strings.Contains(r.Host, "localhost") { 264 // Use the configured PublicURL host for consistency 265 redirectURL := h.client.Config.PublicURL + r.URL.RequestURI() 266 slog.Info("dev mode: redirecting localhost to PublicURL host for cookie consistency", 267 "from", r.Host, "to", h.client.Config.PublicURL) 268 http.Redirect(w, r, redirectURL, http.StatusFound) 269 return 270 } 271 272 // Get handle or DID from query params 273 identifier := r.URL.Query().Get("handle") 274 if identifier == "" { 275 identifier = r.URL.Query().Get("did") 276 } 277 if identifier == "" { 278 http.Error(w, "missing handle or did parameter", http.StatusBadRequest) 279 return 280 } 281 282 // Get mobile redirect URI (deep link) 283 mobileRedirectURI := r.URL.Query().Get("redirect_uri") 284 if mobileRedirectURI == "" { 285 http.Error(w, "missing redirect_uri parameter", http.StatusBadRequest) 286 return 287 } 288 289 // SECURITY FIX 1: Validate redirect_uri against allowlist 290 if !isAllowedMobileRedirectURI(mobileRedirectURI) { 291 slog.Warn("rejected unauthorized mobile redirect URI", "scheme", extractScheme(mobileRedirectURI)) 292 http.Error(w, "invalid redirect_uri: scheme not allowed", http.StatusBadRequest) 293 return 294 } 295 296 // SECURITY: Verify store is properly configured for mobile OAuth 297 // A plain PostgresOAuthStore implements MobileOAuthStore (has Save/GetMobileOAuthData), 298 // but without the MobileAwareStoreWrapper, SaveMobileOAuthData is never called during 299 // StartAuthFlow, so server-side CSRF data is never stored. This causes mobile callbacks 300 // to silently fall back to web flow. Fail fast here instead of silent breakage. 301 if _, ok := h.store.(MobileAwareClientStore); !ok { 302 slog.Error("mobile OAuth not supported: store is not wrapped with MobileAwareStoreWrapper", 303 "store_type", fmt.Sprintf("%T", h.store)) 304 http.Error(w, "mobile OAuth not configured on this server", http.StatusInternalServerError) 305 return 306 } 307 308 // SECURITY FIX 2: Generate CSRF token 309 csrfToken, err := generateCSRFToken() 310 if err != nil { 311 http.Error(w, "internal server error", http.StatusInternalServerError) 312 return 313 } 314 315 // SECURITY FIX 4: Store CSRF server-side tied to OAuth state 316 // Add mobile data to context so the store wrapper can capture it when 317 // SaveAuthRequestInfo is called by indigo's StartAuthFlow. 318 // This is necessary because PAR redirects don't include the state in the URL, 319 // so we can't extract it after StartAuthFlow returns. 320 mobileCtx := ContextWithMobileFlowData(ctx, MobileOAuthData{ 321 CSRFToken: csrfToken, 322 RedirectURI: mobileRedirectURI, 323 }) 324 325 var redirectURL string 326 327 // DEV MODE: Use custom OAuth flow that bypasses HTTPS validation 328 // This is needed because: 329 // 1. Local handles can't be resolved via DNS/HTTP well-known 330 // 2. Indigo's OAuth library requires HTTPS for auth servers 331 if h.devAuthResolver != nil { 332 slog.Info("dev mode: using localhost OAuth flow for mobile", "identifier", identifier) 333 redirectURL, err = h.devAuthResolver.StartDevAuthFlow(mobileCtx, h.client, identifier, h.client.ClientApp.Dir) 334 if err != nil { 335 slog.Error("dev mode: failed to start OAuth flow", "error", err, "identifier", identifier) 336 http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 337 return 338 } 339 } else { 340 // Production mode: use standard indigo OAuth flow 341 redirectURL, err = h.client.ClientApp.StartAuthFlow(mobileCtx, identifier) 342 if err != nil { 343 slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 344 http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 345 return 346 } 347 } 348 349 // Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params) 350 slog.Info("redirecting to PDS for mobile OAuth", "identifier", identifier) 351 352 // SECURITY FIX 2: Store CSRF token in cookie 353 http.SetCookie(w, &http.Cookie{ 354 Name: "oauth_csrf", 355 Value: csrfToken, 356 Path: "/oauth", 357 MaxAge: 600, // 10 minutes 358 HttpOnly: true, 359 Secure: !h.client.Config.DevMode, 360 SameSite: http.SameSiteLaxMode, 361 }) 362 363 // SECURITY FIX 3: Generate binding token to tie CSRF token + mobile redirect to this OAuth flow 364 // This prevents session fixation attacks where an attacker plants a mobile_redirect_uri 365 // cookie, then the user does a web login, and credentials get sent to attacker's deep link. 366 // The binding includes the CSRF token so we validate its VALUE (not just presence) on callback. 367 mobileBinding := generateMobileRedirectBinding(csrfToken, mobileRedirectURI) 368 369 // Set cookie with mobile redirect URI for use in callback 370 http.SetCookie(w, &http.Cookie{ 371 Name: "mobile_redirect_uri", 372 Value: url.QueryEscape(mobileRedirectURI), 373 Path: "/oauth", 374 HttpOnly: true, 375 Secure: !h.client.Config.DevMode, 376 SameSite: http.SameSiteLaxMode, 377 MaxAge: 600, // 10 minutes 378 }) 379 380 // Set binding cookie to validate mobile redirect in callback 381 http.SetCookie(w, &http.Cookie{ 382 Name: "mobile_redirect_binding", 383 Value: mobileBinding, 384 Path: "/oauth", 385 HttpOnly: true, 386 Secure: !h.client.Config.DevMode, 387 SameSite: http.SameSiteLaxMode, 388 MaxAge: 600, // 10 minutes 389 }) 390 391 // Redirect to PDS 392 http.Redirect(w, r, redirectURL, http.StatusFound) 393} 394 395// HandleCallback handles the OAuth callback from the PDS 396// GET /oauth/callback?code=...&state=...&iss=... 397func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { 398 ctx := r.Context() 399 400 // IMPORTANT: Look up mobile CSRF data BEFORE ProcessCallback 401 // ProcessCallback deletes the oauth_requests row, so we must retrieve mobile data first. 402 // We store it in a local variable for validation after ProcessCallback completes. 403 var serverMobileData *MobileOAuthData 404 var mobileDataLookupErr error 405 oauthState := r.URL.Query().Get("state") 406 407 // Check if this might be a mobile callback (mobile_redirect_uri cookie present) 408 // We do a preliminary check here to decide if we need to fetch mobile data 409 mobileRedirectCookie, _ := r.Cookie("mobile_redirect_uri") 410 isMobileFlow := mobileRedirectCookie != nil && mobileRedirectCookie.Value != "" 411 412 if isMobileFlow && h.mobileStore != nil && oauthState != "" { 413 // Fetch mobile data BEFORE ProcessCallback deletes the row 414 serverMobileData, mobileDataLookupErr = h.mobileStore.GetMobileOAuthData(ctx, oauthState) 415 // We'll handle errors after ProcessCallback - for now just capture the result 416 } 417 418 // Process the callback (this deletes the oauth_requests row) 419 sessData, err := h.client.ClientApp.ProcessCallback(ctx, r.URL.Query()) 420 if err != nil { 421 slog.Error("failed to process OAuth callback", "error", err) 422 http.Error(w, fmt.Sprintf("OAuth callback failed: %v", err), http.StatusBadRequest) 423 return 424 } 425 426 // Ensure sessData is not nil before using it 427 if sessData == nil { 428 slog.Error("OAuth callback returned nil session data") 429 http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError) 430 return 431 } 432 433 // Bidirectional handle verification: ensure the DID actually controls a valid handle 434 // This prevents impersonation via compromised PDS that issues tokens with invalid handle mappings 435 // Per AT Protocol spec: "Bidirectional verification required; confirm DID document claims handle" 436 if h.client.ClientApp.Dir != nil { 437 ident, err := h.client.ClientApp.Dir.LookupDID(ctx, sessData.AccountDID) 438 if err != nil { 439 // Directory lookup failed - this is a hard error for security 440 slog.Error("OAuth callback: DID lookup failed during handle verification", 441 "did", sessData.AccountDID, "error", err) 442 http.Error(w, "Handle verification failed", http.StatusUnauthorized) 443 return 444 } 445 446 // Check if the handle is the special "handle.invalid" value 447 // This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed) 448 if ident.Handle.String() == "handle.invalid" { 449 // DEV MODE: For local handles, verify via PDS instead of DNS/HTTP 450 // Local handles like "user.local.coves.dev" can't be resolved via DNS 451 if h.devResolver != nil { 452 // Get the handle from DID document (alsoKnownAs) 453 declaredHandle := "" 454 if len(ident.AlsoKnownAs) > 0 { 455 // Extract handle from at:// URI 456 for _, aka := range ident.AlsoKnownAs { 457 if len(aka) > 5 && aka[:5] == "at://" { 458 declaredHandle = aka[5:] 459 break 460 } 461 } 462 } 463 464 if declaredHandle != "" { 465 // Verify handle via PDS 466 resolvedDID, err := h.devResolver.ResolveHandle(ctx, declaredHandle) 467 if err == nil && resolvedDID == sessData.AccountDID.String() { 468 slog.Info("OAuth callback successful (dev mode: handle verified via PDS)", 469 "did", sessData.AccountDID, "handle", declaredHandle) 470 goto handleVerificationPassed 471 } 472 slog.Warn("dev mode: PDS handle verification failed", 473 "did", sessData.AccountDID, "handle", declaredHandle, 474 "resolved_did", resolvedDID, "error", err) 475 } 476 } 477 478 slog.Warn("OAuth callback: bidirectional handle verification failed", 479 "did", sessData.AccountDID, 480 "handle", "handle.invalid", 481 "reason", "DID document claims a handle that doesn't resolve back to this DID") 482 http.Error(w, "Handle verification failed: DID/handle mismatch", http.StatusUnauthorized) 483 return 484 } 485 486 // Success: handle is valid and bidirectionally verified 487 slog.Info("OAuth callback successful", "did", sessData.AccountDID, "handle", ident.Handle) 488 } else { 489 // No directory client available - log warning but proceed 490 // This should only happen in testing scenarios 491 slog.Warn("OAuth callback: directory client not available, skipping handle verification", 492 "did", sessData.AccountDID) 493 slog.Info("OAuth callback successful (no handle verification)", "did", sessData.AccountDID) 494 } 495handleVerificationPassed: 496 497 // Check if this is a mobile callback (check for mobile_redirect_uri cookie) 498 mobileRedirect, err := r.Cookie("mobile_redirect_uri") 499 if err == nil && mobileRedirect.Value != "" { 500 // SECURITY FIX 2: Validate CSRF token for mobile callback 501 csrfCookie, err := r.Cookie("oauth_csrf") 502 if err != nil { 503 slog.Warn("mobile callback missing CSRF token") 504 clearMobileCookies(w) 505 http.Error(w, "invalid request: missing CSRF token", http.StatusForbidden) 506 return 507 } 508 509 // SECURITY FIX 3: Validate mobile redirect binding 510 // This prevents session fixation attacks where an attacker plants a mobile_redirect_uri 511 // cookie, then the user does a web login, and credentials get sent to attacker's deep link 512 bindingCookie, err := r.Cookie("mobile_redirect_binding") 513 if err != nil { 514 slog.Warn("mobile callback missing redirect binding - possible attack attempt") 515 clearMobileCookies(w) 516 http.Error(w, "invalid request: missing redirect binding", http.StatusForbidden) 517 return 518 } 519 520 // Decode the mobile redirect URI to validate binding 521 mobileRedirectURI, err := url.QueryUnescape(mobileRedirect.Value) 522 if err != nil { 523 slog.Error("failed to decode mobile redirect URI", "error", err) 524 clearMobileCookies(w) 525 http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) 526 return 527 } 528 529 // Validate that the binding matches both the CSRF token AND redirect URI 530 // This is the actual CSRF validation - we verify the token VALUE by checking 531 // that hash(csrfToken + redirectURI) == binding. This prevents: 532 // 1. CSRF attacks: attacker can't forge binding without knowing CSRF token 533 // 2. Session fixation: cookies must all originate from the same /oauth/mobile/login request 534 if !validateMobileRedirectBinding(csrfCookie.Value, mobileRedirectURI, bindingCookie.Value) { 535 slog.Warn("mobile redirect binding/CSRF validation failed - possible attack attempt", 536 "expected_scheme", extractScheme(mobileRedirectURI)) 537 clearMobileCookies(w) 538 // Fail closed: treat as web flow instead of mobile 539 h.handleWebCallback(w, r, sessData) 540 return 541 } 542 543 // SECURITY FIX 4: Validate CSRF cookie against server-side state 544 // This compares the cookie CSRF against a value tied to the OAuth state parameter 545 // (which comes back through the OAuth response), satisfying the requirement to 546 // validate against server-side state rather than only against other cookies. 547 // 548 // CRITICAL: If mobile cookies are present but server-side mobile data is MISSING, 549 // this indicates a potential attack where: 550 // 1. Attacker did a WEB OAuth flow (no mobile data stored) 551 // 2. Attacker planted mobile cookies via cross-site /oauth/mobile/login 552 // 3. Attacker sends victim to callback with attacker's web-flow state/code 553 // We MUST fail closed and use web flow when server-side mobile data is missing. 554 // 555 // NOTE: serverMobileData was fetched BEFORE ProcessCallback (which deletes the row) 556 // at the top of this function. We use the pre-fetched result here. 557 if h.mobileStore != nil && oauthState != "" { 558 if mobileDataLookupErr != nil { 559 // Database error - fail closed, use web flow 560 slog.Warn("failed to retrieve server-side mobile OAuth data - using web flow", 561 "error", mobileDataLookupErr, "state", oauthState) 562 clearMobileCookies(w) 563 h.handleWebCallback(w, r, sessData) 564 return 565 } 566 if serverMobileData == nil { 567 // No server-side mobile data for this state - this OAuth flow was NOT started 568 // via /oauth/mobile/login. Mobile cookies are likely attacker-planted. 569 // Fail closed: clear cookies and use web flow. 570 slog.Warn("mobile cookies present but no server-side mobile data for OAuth state - "+ 571 "possible cross-flow attack, using web flow", "state", oauthState) 572 clearMobileCookies(w) 573 h.handleWebCallback(w, r, sessData) 574 return 575 } 576 // Server-side mobile data exists - validate it matches cookies 577 if !constantTimeCompare(csrfCookie.Value, serverMobileData.CSRFToken) { 578 slog.Warn("mobile callback CSRF mismatch: cookie differs from server-side state", 579 "state", oauthState) 580 clearMobileCookies(w) 581 h.handleWebCallback(w, r, sessData) 582 return 583 } 584 if serverMobileData.RedirectURI != mobileRedirectURI { 585 slog.Warn("mobile callback redirect URI mismatch: cookie differs from server-side state", 586 "cookie_uri", extractScheme(mobileRedirectURI), 587 "server_uri", extractScheme(serverMobileData.RedirectURI)) 588 clearMobileCookies(w) 589 h.handleWebCallback(w, r, sessData) 590 return 591 } 592 slog.Debug("server-side CSRF validation passed", "state", oauthState) 593 } else if h.mobileStore != nil { 594 // mobileStore exists but no state in query - shouldn't happen with valid OAuth 595 slog.Warn("mobile cookies present but no OAuth state in callback - using web flow") 596 clearMobileCookies(w) 597 h.handleWebCallback(w, r, sessData) 598 return 599 } 600 // Note: if h.mobileStore is nil (e.g., in tests), we fall back to cookie-only validation 601 602 // All security checks passed - proceed with mobile flow 603 // Mobile flow: seal the session and redirect to deep link 604 h.handleMobileCallback(w, r, sessData, mobileRedirect.Value, csrfCookie.Value) 605 return 606 } 607 608 // Web flow: set session cookie 609 h.handleWebCallback(w, r, sessData) 610} 611 612// handleWebCallback handles the web OAuth callback flow 613func (h *OAuthHandler) handleWebCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) { 614 // Use sealed tokens for web flow (same as mobile) per atProto OAuth spec: 615 // "Access and refresh tokens should never be copied or shared across end devices. 616 // They should not be stored in session cookies." 617 618 // Seal the session data using AES-GCM encryption 619 sealedToken, err := h.client.SealSession( 620 sessData.AccountDID.String(), 621 sessData.SessionID, 622 h.client.Config.SealedTokenTTL, 623 ) 624 if err != nil { 625 slog.Error("failed to seal session for web", "error", err) 626 http.Error(w, "failed to create session", http.StatusInternalServerError) 627 return 628 } 629 630 http.SetCookie(w, &http.Cookie{ 631 Name: "coves_session", 632 Value: sealedToken, 633 Path: "/", 634 HttpOnly: true, 635 Secure: !h.client.Config.DevMode, 636 SameSite: http.SameSiteLaxMode, 637 MaxAge: int(h.client.Config.SealedTokenTTL.Seconds()), 638 }) 639 640 // Clear all mobile cookies if they exist (defense in depth) 641 clearMobileCookies(w) 642 643 // Redirect to home or app 644 redirectURL := "/" 645 if !h.client.Config.DevMode { 646 redirectURL = h.client.Config.PublicURL + "/" 647 } 648 http.Redirect(w, r, redirectURL, http.StatusFound) 649} 650 651// handleMobileCallback handles the mobile OAuth callback flow 652func (h *OAuthHandler) handleMobileCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData, mobileRedirectURIEncoded, csrfToken string) { 653 // Decode the mobile redirect URI 654 mobileRedirectURI, err := url.QueryUnescape(mobileRedirectURIEncoded) 655 if err != nil { 656 slog.Error("failed to decode mobile redirect URI", "error", err) 657 http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) 658 return 659 } 660 661 // SECURITY FIX 1: Re-validate redirect URI against allowlist 662 if !isAllowedMobileRedirectURI(mobileRedirectURI) { 663 slog.Error("mobile callback attempted with unauthorized redirect URI", "scheme", extractScheme(mobileRedirectURI)) 664 http.Error(w, "invalid redirect URI", http.StatusBadRequest) 665 return 666 } 667 668 // Seal the session data for mobile 669 sealedToken, err := h.client.SealSession( 670 sessData.AccountDID.String(), 671 sessData.SessionID, 672 h.client.Config.SealedTokenTTL, 673 ) 674 if err != nil { 675 slog.Error("failed to seal session data", "error", err) 676 http.Error(w, "failed to create session token", http.StatusInternalServerError) 677 return 678 } 679 680 // Get account handle for convenience 681 handle := "" 682 if ident, err := h.client.ClientApp.Dir.LookupDID(r.Context(), sessData.AccountDID); err == nil { 683 handle = ident.Handle.String() 684 } 685 686 // Clear all mobile cookies to prevent reuse (defense in depth) 687 clearMobileCookies(w) 688 689 // Build deep link with sealed token 690 deepLink := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s", 691 mobileRedirectURI, 692 url.QueryEscape(sealedToken), 693 url.QueryEscape(sessData.AccountDID.String()), 694 url.QueryEscape(sessData.SessionID), 695 ) 696 if handle != "" { 697 deepLink += "&handle=" + url.QueryEscape(handle) 698 } 699 700 // Log mobile redirect (sanitized - no token or session ID to avoid leaking credentials) 701 slog.Info("redirecting to mobile app", "did", sessData.AccountDID, "handle", handle) 702 703 // Serve intermediate page that redirects to the app 704 // This prevents the browser from showing a stale PDS page after the custom scheme redirect 705 w.Header().Set("Content-Type", "text/html; charset=utf-8") 706 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") 707 708 data := struct { 709 DeepLink string 710 Handle string 711 }{ 712 DeepLink: deepLink, 713 Handle: handle, 714 } 715 716 if err := mobileCallbackTemplate.Execute(w, data); err != nil { 717 slog.Error("failed to render mobile callback template", "error", err) 718 // Fallback to direct redirect if template fails 719 http.Redirect(w, r, deepLink, http.StatusFound) 720 } 721} 722 723// HandleLogout revokes the session and clears cookies 724// POST /oauth/logout 725func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { 726 ctx := r.Context() 727 728 // Get session from cookie (now sealed) 729 cookie, err := r.Cookie("coves_session") 730 if err != nil { 731 // No session, just return success 732 w.WriteHeader(http.StatusOK) 733 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 734 return 735 } 736 737 // Unseal the session token 738 sealed, err := h.client.UnsealSession(cookie.Value) 739 if err != nil { 740 // Invalid session, clear cookie and return 741 h.clearSessionCookie(w) 742 w.WriteHeader(http.StatusOK) 743 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 744 return 745 } 746 747 // Parse DID 748 did, err := syntax.ParseDID(sealed.DID) 749 if err != nil { 750 // Invalid DID, clear cookie and return 751 h.clearSessionCookie(w) 752 w.WriteHeader(http.StatusOK) 753 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 754 return 755 } 756 757 // Revoke session on auth server 758 if err := h.client.ClientApp.Logout(ctx, did, sealed.SessionID); err != nil { 759 slog.Error("failed to revoke session on auth server", "error", err, "did", did) 760 // Continue anyway to clear local session 761 } 762 763 // Clear session cookie 764 h.clearSessionCookie(w) 765 766 w.Header().Set("Content-Type", "application/json") 767 w.WriteHeader(http.StatusOK) 768 _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) 769} 770 771// HandleRefresh refreshes the session token (for mobile) 772// POST /oauth/refresh 773// Body: {"did": "did:plc:...", "session_id": "...", "sealed_token": "..."} 774func (h *OAuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) { 775 ctx := r.Context() 776 777 var req struct { 778 DID string `json:"did"` 779 SessionID string `json:"session_id"` 780 SealedToken string `json:"sealed_token,omitempty"` 781 } 782 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 783 http.Error(w, "invalid request body", http.StatusBadRequest) 784 return 785 } 786 787 // SECURITY: Require sealed_token for proof of possession 788 // Without this, anyone who knows a DID + session_id can steal credentials 789 if req.SealedToken == "" { 790 slog.Warn("refresh: missing sealed_token", "did", req.DID) 791 http.Error(w, "sealed_token required for refresh", http.StatusUnauthorized) 792 return 793 } 794 795 // SECURITY: Unseal and validate the token 796 unsealed, err := h.client.UnsealSession(req.SealedToken) 797 if err != nil { 798 slog.Warn("refresh: invalid sealed token", "error", err) 799 http.Error(w, "Invalid or expired token", http.StatusUnauthorized) 800 return 801 } 802 803 // SECURITY: Verify the unsealed token matches the claimed DID 804 if unsealed.DID != req.DID { 805 slog.Warn("refresh: DID mismatch", "token_did", unsealed.DID, "claimed_did", req.DID) 806 http.Error(w, "Token DID mismatch", http.StatusUnauthorized) 807 return 808 } 809 810 // SECURITY: Verify the unsealed token matches the claimed session_id 811 if unsealed.SessionID != req.SessionID { 812 slog.Warn("refresh: session_id mismatch", "token_session", unsealed.SessionID, "claimed_session", req.SessionID) 813 http.Error(w, "Token session mismatch", http.StatusUnauthorized) 814 return 815 } 816 817 // Parse DID after validation 818 did, err := syntax.ParseDID(req.DID) 819 if err != nil { 820 http.Error(w, "invalid DID", http.StatusBadRequest) 821 return 822 } 823 824 // Resume session (now authenticated via sealed token) 825 sess, err := h.client.ClientApp.ResumeSession(ctx, did, req.SessionID) 826 if err != nil { 827 slog.Error("failed to resume session", "error", err, "did", did, "session_id", req.SessionID) 828 http.Error(w, "session not found", http.StatusUnauthorized) 829 return 830 } 831 832 // Refresh tokens 833 newAccessToken, err := sess.RefreshTokens(ctx) 834 if err != nil { 835 slog.Error("failed to refresh tokens", "error", err, "did", did) 836 http.Error(w, "failed to refresh tokens", http.StatusUnauthorized) 837 return 838 } 839 840 // Create new sealed token for mobile 841 sealedToken, err := h.client.SealSession( 842 sess.Data.AccountDID.String(), 843 sess.Data.SessionID, 844 h.client.Config.SealedTokenTTL, 845 ) 846 if err != nil { 847 slog.Error("failed to seal new session data", "error", err) 848 http.Error(w, "failed to create session token", http.StatusInternalServerError) 849 return 850 } 851 852 w.Header().Set("Content-Type", "application/json") 853 _ = json.NewEncoder(w).Encode(map[string]interface{}{ 854 "access_token": newAccessToken, 855 "sealed_token": sealedToken, 856 }) 857} 858 859// clearSessionCookie clears the session cookie 860func (h *OAuthHandler) clearSessionCookie(w http.ResponseWriter) { 861 http.SetCookie(w, &http.Cookie{ 862 Name: "coves_session", 863 Value: "", 864 Path: "/", 865 MaxAge: -1, 866 }) 867} 868 869// GetSessionFromRequest extracts session data from an HTTP request 870func (h *OAuthHandler) GetSessionFromRequest(r *http.Request) (*oauth.ClientSessionData, error) { 871 // Try to get session from cookie (web) - now using sealed tokens 872 cookie, err := r.Cookie("coves_session") 873 if err == nil && cookie.Value != "" { 874 // Unseal the token to get DID and session ID 875 sealed, err := h.client.UnsealSession(cookie.Value) 876 if err == nil { 877 did, err := syntax.ParseDID(sealed.DID) 878 if err == nil { 879 return h.store.GetSession(r.Context(), did, sealed.SessionID) 880 } 881 } 882 } 883 884 // Try to get session from Authorization header (mobile) 885 authHeader := r.Header.Get("Authorization") 886 if authHeader != "" { 887 // Expected format: "Bearer <sealed_token>" 888 const prefix = "Bearer " 889 if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix { 890 sealedToken := authHeader[len(prefix):] 891 sealed, err := h.client.UnsealSession(sealedToken) 892 if err != nil { 893 return nil, fmt.Errorf("invalid sealed token: %w", err) 894 } 895 did, err := syntax.ParseDID(sealed.DID) 896 if err != nil { 897 return nil, fmt.Errorf("invalid DID in sealed token: %w", err) 898 } 899 return h.store.GetSession(r.Context(), did, sealed.SessionID) 900 } 901 } 902 903 return nil, fmt.Errorf("no session found") 904} 905 906// HandleProtectedResourceMetadata returns OAuth protected resource metadata 907// per RFC 9449 and atproto OAuth spec. This endpoint allows third-party OAuth 908// clients to discover which authorization server to use for this resource. 909// Spec: https://datatracker.ietf.org/doc/html/rfc9449#section-5 910func (h *OAuthHandler) HandleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { 911 metadata := map[string]interface{}{ 912 "resource": h.client.Config.PublicURL, 913 "authorization_servers": []string{"https://bsky.social"}, 914 } 915 916 w.Header().Set("Content-Type", "application/json") 917 w.Header().Set("Cache-Control", "public, max-age=3600") 918 if err := json.NewEncoder(w).Encode(metadata); err != nil { 919 slog.Error("failed to encode protected resource metadata", "error", err) 920 http.Error(w, "internal server error", http.StatusInternalServerError) 921 return 922 } 923} 924 925// HandleMobileDeepLinkFallback handles requests to /app/oauth/callback when 926// Universal Links fail to intercept the redirect. 927// 928// If this handler is reached, it means the mobile app did NOT intercept the 929// Universal Link redirect. The OAuth flow succeeded server-side, but the 930// credentials couldn't be delivered to the app. 931func (h *OAuthHandler) HandleMobileDeepLinkFallback(w http.ResponseWriter, r *http.Request) { 932 // Log the failure for debugging 933 slog.Warn("Universal Link not intercepted - mobile app did not handle redirect", 934 "path", r.URL.Path, 935 "has_token", r.URL.Query().Get("token") != "", 936 "has_did", r.URL.Query().Get("did") != "", 937 ) 938 939 http.Error(w, "Universal Link not intercepted: The mobile app should have opened this URL. "+ 940 "Check that Universal Links (iOS) or App Links (Android) are properly configured.", http.StatusBadRequest) 941}