···
11
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
// MobileOAuthStore interface for mobile-specific OAuth operations
16
+
// This extends the base OAuth store with mobile CSRF tracking
17
+
type MobileOAuthStore interface {
18
+
SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error
19
+
GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error)
22
+
// OAuthHandler handles OAuth-related HTTP endpoints
23
+
type OAuthHandler struct {
25
+
store oauth.ClientAuthStore
26
+
mobileStore MobileOAuthStore // For server-side CSRF validation
29
+
// NewOAuthHandler creates a new OAuth handler
30
+
func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore) *OAuthHandler {
31
+
handler := &OAuthHandler{
36
+
// Check if the store implements MobileOAuthStore for server-side CSRF
37
+
if mobileStore, ok := store.(MobileOAuthStore); ok {
38
+
handler.mobileStore = mobileStore
44
+
// HandleClientMetadata serves the OAuth client metadata document
45
+
// GET /oauth/client-metadata.json
46
+
func (h *OAuthHandler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
47
+
metadata := h.client.ClientMetadata()
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
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)
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)
72
+
// HandleJWKS serves the public JWKS for confidential clients
73
+
// GET /oauth/jwks.json
74
+
func (h *OAuthHandler) HandleJWKS(w http.ResponseWriter, r *http.Request) {
75
+
jwks := h.client.PublicJWKS()
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)
85
+
// HandleLogin starts the OAuth flow (web version)
86
+
// GET /oauth/login?handle=user.bsky.social
87
+
func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
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")
95
+
if identifier == "" {
96
+
http.Error(w, "missing handle or did parameter", http.StatusBadRequest)
100
+
// Start OAuth flow
101
+
redirectURL, err := h.client.ClientApp.StartAuthFlow(ctx, identifier)
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)
108
+
// Log OAuth flow initiation (sanitized - no full URL to avoid leaking state)
109
+
slog.Info("redirecting to PDS for OAuth", "identifier", identifier)
112
+
http.Redirect(w, r, redirectURL, http.StatusFound)
115
+
// HandleMobileLogin starts the OAuth flow for mobile apps
116
+
// GET /oauth/mobile/login?handle=user.bsky.social&redirect_uri=coves-app://callback
117
+
func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) {
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")
125
+
if identifier == "" {
126
+
http.Error(w, "missing handle or did parameter", http.StatusBadRequest)
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)
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)
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)
156
+
// SECURITY FIX 2: Generate CSRF token
157
+
csrfToken, err := generateCSRFToken()
159
+
http.Error(w, "internal server error", http.StatusInternalServerError)
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,
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)
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)
181
+
// Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params)
182
+
slog.Info("redirecting to PDS for mobile OAuth", "identifier", identifier)
184
+
// SECURITY FIX 2: Store CSRF token in cookie
185
+
http.SetCookie(w, &http.Cookie{
186
+
Name: "oauth_csrf",
189
+
MaxAge: 600, // 10 minutes
191
+
Secure: !h.client.Config.DevMode,
192
+
SameSite: http.SameSiteLaxMode,
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)
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),
207
+
Secure: !h.client.Config.DevMode,
208
+
SameSite: http.SameSiteLaxMode,
209
+
MaxAge: 600, // 10 minutes
212
+
// Set binding cookie to validate mobile redirect in callback
213
+
http.SetCookie(w, &http.Cookie{
214
+
Name: "mobile_redirect_binding",
215
+
Value: mobileBinding,
218
+
Secure: !h.client.Config.DevMode,
219
+
SameSite: http.SameSiteLaxMode,
220
+
MaxAge: 600, // 10 minutes
224
+
http.Redirect(w, r, redirectURL, http.StatusFound)
227
+
// HandleCallback handles the OAuth callback from the PDS
228
+
// GET /oauth/callback?code=...&state=...&iss=...
229
+
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
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")
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 != ""
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
250
+
// Process the callback (this deletes the oauth_requests row)
251
+
sessData, err := h.client.ClientApp.ProcessCallback(ctx, r.URL.Query())
253
+
slog.Error("failed to process OAuth callback", "error", err)
254
+
http.Error(w, fmt.Sprintf("OAuth callback failed: %v", err), http.StatusBadRequest)
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)
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)
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)
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)
289
+
// Success: handle is valid and bidirectionally verified
290
+
slog.Info("OAuth callback successful", "did", sessData.AccountDID, "handle", ident.Handle)
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)
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")
305
+
slog.Warn("mobile callback missing CSRF token")
306
+
clearMobileCookies(w)
307
+
http.Error(w, "invalid request: missing CSRF token", http.StatusForbidden)
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")
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)
322
+
// Decode the mobile redirect URI to validate binding
323
+
mobileRedirectURI, err := url.QueryUnescape(mobileRedirect.Value)
325
+
slog.Error("failed to decode mobile redirect URI", "error", err)
326
+
clearMobileCookies(w)
327
+
http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest)
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)
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.
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.
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)
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)
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)
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)
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)
402
+
// Note: if h.mobileStore is nil (e.g., in tests), we fall back to cookie-only validation
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)
410
+
// Web flow: set session cookie
411
+
h.handleWebCallback(w, r, sessData)
414
+
// handleWebCallback handles the web OAuth callback flow
415
+
func (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."
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,
427
+
slog.Error("failed to seal session for web", "error", err)
428
+
http.Error(w, "failed to create session", http.StatusInternalServerError)
432
+
http.SetCookie(w, &http.Cookie{
433
+
Name: "coves_session",
434
+
Value: sealedToken,
437
+
Secure: !h.client.Config.DevMode,
438
+
SameSite: http.SameSiteLaxMode,
439
+
MaxAge: int(h.client.Config.SealedTokenTTL.Seconds()),
442
+
// Clear all mobile cookies if they exist (defense in depth)
443
+
clearMobileCookies(w)
445
+
// Redirect to home or app
447
+
if !h.client.Config.DevMode {
448
+
redirectURL = h.client.Config.PublicURL + "/"
450
+
http.Redirect(w, r, redirectURL, http.StatusFound)
453
+
// handleMobileCallback handles the mobile OAuth callback flow
454
+
func (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)
458
+
slog.Error("failed to decode mobile redirect URI", "error", err)
459
+
http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest)
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)
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,
477
+
slog.Error("failed to seal session data", "error", err)
478
+
http.Error(w, "failed to create session token", http.StatusInternalServerError)
482
+
// Get account handle for convenience
484
+
if ident, err := h.client.ClientApp.Dir.LookupDID(r.Context(), sessData.AccountDID); err == nil {
485
+
handle = ident.Handle.String()
488
+
// Clear all mobile cookies to prevent reuse (defense in depth)
489
+
clearMobileCookies(w)
491
+
// Build deep link with sealed token
492
+
deepLink := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s",
494
+
url.QueryEscape(sealedToken),
495
+
url.QueryEscape(sessData.AccountDID.String()),
496
+
url.QueryEscape(sessData.SessionID),
499
+
deepLink += "&handle=" + url.QueryEscape(handle)
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)
505
+
// Redirect to mobile app deep link
506
+
http.Redirect(w, r, deepLink, http.StatusFound)
509
+
// HandleLogout revokes the session and clears cookies
510
+
// POST /oauth/logout
511
+
func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
514
+
// Get session from cookie (now sealed)
515
+
cookie, err := r.Cookie("coves_session")
517
+
// No session, just return success
518
+
w.WriteHeader(http.StatusOK)
519
+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"})
523
+
// Unseal the session token
524
+
sealed, err := h.client.UnsealSession(cookie.Value)
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"})
534
+
did, err := syntax.ParseDID(sealed.DID)
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"})
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
549
+
// Clear session cookie
550
+
h.clearSessionCookie(w)
552
+
w.Header().Set("Content-Type", "application/json")
553
+
w.WriteHeader(http.StatusOK)
554
+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"})
557
+
// HandleRefresh refreshes the session token (for mobile)
558
+
// POST /oauth/refresh
559
+
// Body: {"did": "did:plc:...", "session_id": "...", "sealed_token": "..."}
560
+
func (h *OAuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) {
564
+
DID string `json:"did"`
565
+
SessionID string `json:"session_id"`
566
+
SealedToken string `json:"sealed_token,omitempty"`
568
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
569
+
http.Error(w, "invalid request body", http.StatusBadRequest)
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)
581
+
// SECURITY: Unseal and validate the token
582
+
unsealed, err := h.client.UnsealSession(req.SealedToken)
584
+
slog.Warn("refresh: invalid sealed token", "error", err)
585
+
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
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)
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)
603
+
// Parse DID after validation
604
+
did, err := syntax.ParseDID(req.DID)
606
+
http.Error(w, "invalid DID", http.StatusBadRequest)
610
+
// Resume session (now authenticated via sealed token)
611
+
sess, err := h.client.ClientApp.ResumeSession(ctx, did, req.SessionID)
613
+
slog.Error("failed to resume session", "error", err, "did", did, "session_id", req.SessionID)
614
+
http.Error(w, "session not found", http.StatusUnauthorized)
619
+
newAccessToken, err := sess.RefreshTokens(ctx)
621
+
slog.Error("failed to refresh tokens", "error", err, "did", did)
622
+
http.Error(w, "failed to refresh tokens", http.StatusUnauthorized)
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,
633
+
slog.Error("failed to seal new session data", "error", err)
634
+
http.Error(w, "failed to create session token", http.StatusInternalServerError)
638
+
w.Header().Set("Content-Type", "application/json")
639
+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
640
+
"access_token": newAccessToken,
641
+
"sealed_token": sealedToken,
645
+
// clearSessionCookie clears the session cookie
646
+
func (h *OAuthHandler) clearSessionCookie(w http.ResponseWriter) {
647
+
http.SetCookie(w, &http.Cookie{
648
+
Name: "coves_session",
655
+
// GetSessionFromRequest extracts session data from an HTTP request
656
+
func (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)
663
+
did, err := syntax.ParseDID(sealed.DID)
665
+
return h.store.GetSession(r.Context(), did, sealed.SessionID)
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)
679
+
return nil, fmt.Errorf("invalid sealed token: %w", err)
681
+
did, err := syntax.ParseDID(sealed.DID)
683
+
return nil, fmt.Errorf("invalid DID in sealed token: %w", err)
685
+
return h.store.GetSession(r.Context(), did, sealed.SessionID)
689
+
return nil, fmt.Errorf("no session found")
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
696
+
func (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"},
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)