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