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}