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