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 "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}