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}