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