package oauth import ( "context" "encoding/json" "fmt" "html/template" "log/slog" "net/http" "net/url" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" ) // mobileCallbackTemplate is the intermediate page shown after OAuth completes // before redirecting to the mobile app via custom scheme. // This prevents the browser from showing a stale PDS page after the redirect. var mobileCallbackTemplate = template.Must(template.New("mobile_callback").Parse(` Login Complete - Coves

Login Complete

Returning to Coves...

{{if .Handle}}
@{{.Handle}}
{{end}}

If the app doesn't open automatically,
you can close this window.

`)) // MobileOAuthStore interface for mobile-specific OAuth operations // This extends the base OAuth store with mobile CSRF tracking type MobileOAuthStore interface { SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) } // OAuthHandler handles OAuth-related HTTP endpoints type OAuthHandler struct { client *OAuthClient store oauth.ClientAuthStore mobileStore MobileOAuthStore // For server-side CSRF validation } // NewOAuthHandler creates a new OAuth handler func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore) *OAuthHandler { handler := &OAuthHandler{ client: client, store: store, } // Check if the store implements MobileOAuthStore for server-side CSRF if mobileStore, ok := store.(MobileOAuthStore); ok { handler.mobileStore = mobileStore } return handler } // HandleClientMetadata serves the OAuth client metadata document // GET /oauth/client-metadata.json func (h *OAuthHandler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { metadata := h.client.ClientMetadata() // Validate metadata before returning (skip in dev mode - localhost doesn't need https validation) if !h.client.Config.DevMode { if err := metadata.Validate(h.client.ClientApp.Config.ClientID); err != nil { slog.Error("client metadata validation failed", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(metadata); err != nil { slog.Error("failed to encode client metadata", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // HandleLogin starts the OAuth flow (web version) // GET /oauth/login?handle=user.bsky.social func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get handle or DID from query params identifier := r.URL.Query().Get("handle") if identifier == "" { identifier = r.URL.Query().Get("did") } if identifier == "" { http.Error(w, "missing handle or did parameter", http.StatusBadRequest) return } // Start OAuth flow redirectURL, err := h.client.ClientApp.StartAuthFlow(ctx, identifier) if err != nil { slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) return } // Log OAuth flow initiation (sanitized - no full URL to avoid leaking state) slog.Info("redirecting to PDS for OAuth", "identifier", identifier) // Redirect to PDS http.Redirect(w, r, redirectURL, http.StatusFound) } // HandleMobileLogin starts the OAuth flow for mobile apps // GET /oauth/mobile/login?handle=user.bsky.social&redirect_uri=coves-app://callback func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get handle or DID from query params identifier := r.URL.Query().Get("handle") if identifier == "" { identifier = r.URL.Query().Get("did") } if identifier == "" { http.Error(w, "missing handle or did parameter", http.StatusBadRequest) return } // Get mobile redirect URI (deep link) mobileRedirectURI := r.URL.Query().Get("redirect_uri") if mobileRedirectURI == "" { http.Error(w, "missing redirect_uri parameter", http.StatusBadRequest) return } // SECURITY FIX 1: Validate redirect_uri against allowlist if !isAllowedMobileRedirectURI(mobileRedirectURI) { slog.Warn("rejected unauthorized mobile redirect URI", "scheme", extractScheme(mobileRedirectURI)) http.Error(w, "invalid redirect_uri: scheme not allowed", http.StatusBadRequest) return } // SECURITY: Verify store is properly configured for mobile OAuth // A plain PostgresOAuthStore implements MobileOAuthStore (has Save/GetMobileOAuthData), // but without the MobileAwareStoreWrapper, SaveMobileOAuthData is never called during // StartAuthFlow, so server-side CSRF data is never stored. This causes mobile callbacks // to silently fall back to web flow. Fail fast here instead of silent breakage. if _, ok := h.store.(MobileAwareClientStore); !ok { slog.Error("mobile OAuth not supported: store is not wrapped with MobileAwareStoreWrapper", "store_type", fmt.Sprintf("%T", h.store)) http.Error(w, "mobile OAuth not configured on this server", http.StatusInternalServerError) return } // SECURITY FIX 2: Generate CSRF token csrfToken, err := generateCSRFToken() if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError) return } // SECURITY FIX 4: Store CSRF server-side tied to OAuth state // Add mobile data to context so the store wrapper can capture it when // SaveAuthRequestInfo is called by indigo's StartAuthFlow. // This is necessary because PAR redirects don't include the state in the URL, // so we can't extract it after StartAuthFlow returns. mobileCtx := ContextWithMobileFlowData(ctx, MobileOAuthData{ CSRFToken: csrfToken, RedirectURI: mobileRedirectURI, }) // Start OAuth flow (the store wrapper will save mobile data when auth request is saved) redirectURL, err := h.client.ClientApp.StartAuthFlow(mobileCtx, identifier) if err != nil { slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) return } // Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params) slog.Info("redirecting to PDS for mobile OAuth", "identifier", identifier) // SECURITY FIX 2: Store CSRF token in cookie http.SetCookie(w, &http.Cookie{ Name: "oauth_csrf", Value: csrfToken, Path: "/oauth", MaxAge: 600, // 10 minutes HttpOnly: true, Secure: !h.client.Config.DevMode, SameSite: http.SameSiteLaxMode, }) // SECURITY FIX 3: Generate binding token to tie CSRF token + mobile redirect to this OAuth flow // This prevents session fixation attacks where an attacker plants a mobile_redirect_uri // cookie, then the user does a web login, and credentials get sent to attacker's deep link. // The binding includes the CSRF token so we validate its VALUE (not just presence) on callback. mobileBinding := generateMobileRedirectBinding(csrfToken, mobileRedirectURI) // Set cookie with mobile redirect URI for use in callback http.SetCookie(w, &http.Cookie{ Name: "mobile_redirect_uri", Value: url.QueryEscape(mobileRedirectURI), Path: "/oauth", HttpOnly: true, Secure: !h.client.Config.DevMode, SameSite: http.SameSiteLaxMode, MaxAge: 600, // 10 minutes }) // Set binding cookie to validate mobile redirect in callback http.SetCookie(w, &http.Cookie{ Name: "mobile_redirect_binding", Value: mobileBinding, Path: "/oauth", HttpOnly: true, Secure: !h.client.Config.DevMode, SameSite: http.SameSiteLaxMode, MaxAge: 600, // 10 minutes }) // Redirect to PDS http.Redirect(w, r, redirectURL, http.StatusFound) } // HandleCallback handles the OAuth callback from the PDS // GET /oauth/callback?code=...&state=...&iss=... func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // IMPORTANT: Look up mobile CSRF data BEFORE ProcessCallback // ProcessCallback deletes the oauth_requests row, so we must retrieve mobile data first. // We store it in a local variable for validation after ProcessCallback completes. var serverMobileData *MobileOAuthData var mobileDataLookupErr error oauthState := r.URL.Query().Get("state") // Check if this might be a mobile callback (mobile_redirect_uri cookie present) // We do a preliminary check here to decide if we need to fetch mobile data mobileRedirectCookie, _ := r.Cookie("mobile_redirect_uri") isMobileFlow := mobileRedirectCookie != nil && mobileRedirectCookie.Value != "" if isMobileFlow && h.mobileStore != nil && oauthState != "" { // Fetch mobile data BEFORE ProcessCallback deletes the row serverMobileData, mobileDataLookupErr = h.mobileStore.GetMobileOAuthData(ctx, oauthState) // We'll handle errors after ProcessCallback - for now just capture the result } // Process the callback (this deletes the oauth_requests row) sessData, err := h.client.ClientApp.ProcessCallback(ctx, r.URL.Query()) if err != nil { slog.Error("failed to process OAuth callback", "error", err) http.Error(w, fmt.Sprintf("OAuth callback failed: %v", err), http.StatusBadRequest) return } // Ensure sessData is not nil before using it if sessData == nil { slog.Error("OAuth callback returned nil session data") http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError) return } // Bidirectional handle verification: ensure the DID actually controls a valid handle // This prevents impersonation via compromised PDS that issues tokens with invalid handle mappings // Per AT Protocol spec: "Bidirectional verification required; confirm DID document claims handle" if h.client.ClientApp.Dir != nil { ident, err := h.client.ClientApp.Dir.LookupDID(ctx, sessData.AccountDID) if err != nil { // Directory lookup failed - this is a hard error for security slog.Error("OAuth callback: DID lookup failed during handle verification", "did", sessData.AccountDID, "error", err) http.Error(w, "Handle verification failed", http.StatusUnauthorized) return } // Check if the handle is the special "handle.invalid" value // This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed) if ident.Handle.String() == "handle.invalid" { slog.Warn("OAuth callback: bidirectional handle verification failed", "did", sessData.AccountDID, "handle", "handle.invalid", "reason", "DID document claims a handle that doesn't resolve back to this DID") http.Error(w, "Handle verification failed: DID/handle mismatch", http.StatusUnauthorized) return } // Success: handle is valid and bidirectionally verified slog.Info("OAuth callback successful", "did", sessData.AccountDID, "handle", ident.Handle) } else { // No directory client available - log warning but proceed // This should only happen in testing scenarios slog.Warn("OAuth callback: directory client not available, skipping handle verification", "did", sessData.AccountDID) slog.Info("OAuth callback successful (no handle verification)", "did", sessData.AccountDID) } // Check if this is a mobile callback (check for mobile_redirect_uri cookie) mobileRedirect, err := r.Cookie("mobile_redirect_uri") if err == nil && mobileRedirect.Value != "" { // SECURITY FIX 2: Validate CSRF token for mobile callback csrfCookie, err := r.Cookie("oauth_csrf") if err != nil { slog.Warn("mobile callback missing CSRF token") clearMobileCookies(w) http.Error(w, "invalid request: missing CSRF token", http.StatusForbidden) return } // SECURITY FIX 3: Validate mobile redirect binding // This prevents session fixation attacks where an attacker plants a mobile_redirect_uri // cookie, then the user does a web login, and credentials get sent to attacker's deep link bindingCookie, err := r.Cookie("mobile_redirect_binding") if err != nil { slog.Warn("mobile callback missing redirect binding - possible attack attempt") clearMobileCookies(w) http.Error(w, "invalid request: missing redirect binding", http.StatusForbidden) return } // Decode the mobile redirect URI to validate binding mobileRedirectURI, err := url.QueryUnescape(mobileRedirect.Value) if err != nil { slog.Error("failed to decode mobile redirect URI", "error", err) clearMobileCookies(w) http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) return } // Validate that the binding matches both the CSRF token AND redirect URI // This is the actual CSRF validation - we verify the token VALUE by checking // that hash(csrfToken + redirectURI) == binding. This prevents: // 1. CSRF attacks: attacker can't forge binding without knowing CSRF token // 2. Session fixation: cookies must all originate from the same /oauth/mobile/login request if !validateMobileRedirectBinding(csrfCookie.Value, mobileRedirectURI, bindingCookie.Value) { slog.Warn("mobile redirect binding/CSRF validation failed - possible attack attempt", "expected_scheme", extractScheme(mobileRedirectURI)) clearMobileCookies(w) // Fail closed: treat as web flow instead of mobile h.handleWebCallback(w, r, sessData) return } // SECURITY FIX 4: Validate CSRF cookie against server-side state // This compares the cookie CSRF against a value tied to the OAuth state parameter // (which comes back through the OAuth response), satisfying the requirement to // validate against server-side state rather than only against other cookies. // // CRITICAL: If mobile cookies are present but server-side mobile data is MISSING, // this indicates a potential attack where: // 1. Attacker did a WEB OAuth flow (no mobile data stored) // 2. Attacker planted mobile cookies via cross-site /oauth/mobile/login // 3. Attacker sends victim to callback with attacker's web-flow state/code // We MUST fail closed and use web flow when server-side mobile data is missing. // // NOTE: serverMobileData was fetched BEFORE ProcessCallback (which deletes the row) // at the top of this function. We use the pre-fetched result here. if h.mobileStore != nil && oauthState != "" { if mobileDataLookupErr != nil { // Database error - fail closed, use web flow slog.Warn("failed to retrieve server-side mobile OAuth data - using web flow", "error", mobileDataLookupErr, "state", oauthState) clearMobileCookies(w) h.handleWebCallback(w, r, sessData) return } if serverMobileData == nil { // No server-side mobile data for this state - this OAuth flow was NOT started // via /oauth/mobile/login. Mobile cookies are likely attacker-planted. // Fail closed: clear cookies and use web flow. slog.Warn("mobile cookies present but no server-side mobile data for OAuth state - "+ "possible cross-flow attack, using web flow", "state", oauthState) clearMobileCookies(w) h.handleWebCallback(w, r, sessData) return } // Server-side mobile data exists - validate it matches cookies if !constantTimeCompare(csrfCookie.Value, serverMobileData.CSRFToken) { slog.Warn("mobile callback CSRF mismatch: cookie differs from server-side state", "state", oauthState) clearMobileCookies(w) h.handleWebCallback(w, r, sessData) return } if serverMobileData.RedirectURI != mobileRedirectURI { slog.Warn("mobile callback redirect URI mismatch: cookie differs from server-side state", "cookie_uri", extractScheme(mobileRedirectURI), "server_uri", extractScheme(serverMobileData.RedirectURI)) clearMobileCookies(w) h.handleWebCallback(w, r, sessData) return } slog.Debug("server-side CSRF validation passed", "state", oauthState) } else if h.mobileStore != nil { // mobileStore exists but no state in query - shouldn't happen with valid OAuth slog.Warn("mobile cookies present but no OAuth state in callback - using web flow") clearMobileCookies(w) h.handleWebCallback(w, r, sessData) return } // Note: if h.mobileStore is nil (e.g., in tests), we fall back to cookie-only validation // All security checks passed - proceed with mobile flow // Mobile flow: seal the session and redirect to deep link h.handleMobileCallback(w, r, sessData, mobileRedirect.Value, csrfCookie.Value) return } // Web flow: set session cookie h.handleWebCallback(w, r, sessData) } // handleWebCallback handles the web OAuth callback flow func (h *OAuthHandler) handleWebCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) { // Use sealed tokens for web flow (same as mobile) per atProto OAuth spec: // "Access and refresh tokens should never be copied or shared across end devices. // They should not be stored in session cookies." // Seal the session data using AES-GCM encryption sealedToken, err := h.client.SealSession( sessData.AccountDID.String(), sessData.SessionID, h.client.Config.SealedTokenTTL, ) if err != nil { slog.Error("failed to seal session for web", "error", err) http.Error(w, "failed to create session", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "coves_session", Value: sealedToken, Path: "/", HttpOnly: true, Secure: !h.client.Config.DevMode, SameSite: http.SameSiteLaxMode, MaxAge: int(h.client.Config.SealedTokenTTL.Seconds()), }) // Clear all mobile cookies if they exist (defense in depth) clearMobileCookies(w) // Redirect to home or app redirectURL := "/" if !h.client.Config.DevMode { redirectURL = h.client.Config.PublicURL + "/" } http.Redirect(w, r, redirectURL, http.StatusFound) } // handleMobileCallback handles the mobile OAuth callback flow func (h *OAuthHandler) handleMobileCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData, mobileRedirectURIEncoded, csrfToken string) { // Decode the mobile redirect URI mobileRedirectURI, err := url.QueryUnescape(mobileRedirectURIEncoded) if err != nil { slog.Error("failed to decode mobile redirect URI", "error", err) http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) return } // SECURITY FIX 1: Re-validate redirect URI against allowlist if !isAllowedMobileRedirectURI(mobileRedirectURI) { slog.Error("mobile callback attempted with unauthorized redirect URI", "scheme", extractScheme(mobileRedirectURI)) http.Error(w, "invalid redirect URI", http.StatusBadRequest) return } // Seal the session data for mobile sealedToken, err := h.client.SealSession( sessData.AccountDID.String(), sessData.SessionID, h.client.Config.SealedTokenTTL, ) if err != nil { slog.Error("failed to seal session data", "error", err) http.Error(w, "failed to create session token", http.StatusInternalServerError) return } // Get account handle for convenience handle := "" if ident, err := h.client.ClientApp.Dir.LookupDID(r.Context(), sessData.AccountDID); err == nil { handle = ident.Handle.String() } // Clear all mobile cookies to prevent reuse (defense in depth) clearMobileCookies(w) // Build deep link with sealed token deepLink := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s", mobileRedirectURI, url.QueryEscape(sealedToken), url.QueryEscape(sessData.AccountDID.String()), url.QueryEscape(sessData.SessionID), ) if handle != "" { deepLink += "&handle=" + url.QueryEscape(handle) } // Log mobile redirect (sanitized - no token or session ID to avoid leaking credentials) slog.Info("redirecting to mobile app", "did", sessData.AccountDID, "handle", handle) // Serve intermediate page that redirects to the app // This prevents the browser from showing a stale PDS page after the custom scheme redirect w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") data := struct { DeepLink string Handle string }{ DeepLink: deepLink, Handle: handle, } if err := mobileCallbackTemplate.Execute(w, data); err != nil { slog.Error("failed to render mobile callback template", "error", err) // Fallback to direct redirect if template fails http.Redirect(w, r, deepLink, http.StatusFound) } } // HandleLogout revokes the session and clears cookies // POST /oauth/logout func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get session from cookie (now sealed) cookie, err := r.Cookie("coves_session") if err != nil { // No session, just return success w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) return } // Unseal the session token sealed, err := h.client.UnsealSession(cookie.Value) if err != nil { // Invalid session, clear cookie and return h.clearSessionCookie(w) w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) return } // Parse DID did, err := syntax.ParseDID(sealed.DID) if err != nil { // Invalid DID, clear cookie and return h.clearSessionCookie(w) w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) return } // Revoke session on auth server if err := h.client.ClientApp.Logout(ctx, did, sealed.SessionID); err != nil { slog.Error("failed to revoke session on auth server", "error", err, "did", did) // Continue anyway to clear local session } // Clear session cookie h.clearSessionCookie(w) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"}) } // HandleRefresh refreshes the session token (for mobile) // POST /oauth/refresh // Body: {"did": "did:plc:...", "session_id": "...", "sealed_token": "..."} func (h *OAuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req struct { DID string `json:"did"` SessionID string `json:"session_id"` SealedToken string `json:"sealed_token,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } // SECURITY: Require sealed_token for proof of possession // Without this, anyone who knows a DID + session_id can steal credentials if req.SealedToken == "" { slog.Warn("refresh: missing sealed_token", "did", req.DID) http.Error(w, "sealed_token required for refresh", http.StatusUnauthorized) return } // SECURITY: Unseal and validate the token unsealed, err := h.client.UnsealSession(req.SealedToken) if err != nil { slog.Warn("refresh: invalid sealed token", "error", err) http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } // SECURITY: Verify the unsealed token matches the claimed DID if unsealed.DID != req.DID { slog.Warn("refresh: DID mismatch", "token_did", unsealed.DID, "claimed_did", req.DID) http.Error(w, "Token DID mismatch", http.StatusUnauthorized) return } // SECURITY: Verify the unsealed token matches the claimed session_id if unsealed.SessionID != req.SessionID { slog.Warn("refresh: session_id mismatch", "token_session", unsealed.SessionID, "claimed_session", req.SessionID) http.Error(w, "Token session mismatch", http.StatusUnauthorized) return } // Parse DID after validation did, err := syntax.ParseDID(req.DID) if err != nil { http.Error(w, "invalid DID", http.StatusBadRequest) return } // Resume session (now authenticated via sealed token) sess, err := h.client.ClientApp.ResumeSession(ctx, did, req.SessionID) if err != nil { slog.Error("failed to resume session", "error", err, "did", did, "session_id", req.SessionID) http.Error(w, "session not found", http.StatusUnauthorized) return } // Refresh tokens newAccessToken, err := sess.RefreshTokens(ctx) if err != nil { slog.Error("failed to refresh tokens", "error", err, "did", did) http.Error(w, "failed to refresh tokens", http.StatusUnauthorized) return } // Create new sealed token for mobile sealedToken, err := h.client.SealSession( sess.Data.AccountDID.String(), sess.Data.SessionID, h.client.Config.SealedTokenTTL, ) if err != nil { slog.Error("failed to seal new session data", "error", err) http.Error(w, "failed to create session token", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "access_token": newAccessToken, "sealed_token": sealedToken, }) } // clearSessionCookie clears the session cookie func (h *OAuthHandler) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: "coves_session", Value: "", Path: "/", MaxAge: -1, }) } // GetSessionFromRequest extracts session data from an HTTP request func (h *OAuthHandler) GetSessionFromRequest(r *http.Request) (*oauth.ClientSessionData, error) { // Try to get session from cookie (web) - now using sealed tokens cookie, err := r.Cookie("coves_session") if err == nil && cookie.Value != "" { // Unseal the token to get DID and session ID sealed, err := h.client.UnsealSession(cookie.Value) if err == nil { did, err := syntax.ParseDID(sealed.DID) if err == nil { return h.store.GetSession(r.Context(), did, sealed.SessionID) } } } // Try to get session from Authorization header (mobile) authHeader := r.Header.Get("Authorization") if authHeader != "" { // Expected format: "Bearer " const prefix = "Bearer " if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix { sealedToken := authHeader[len(prefix):] sealed, err := h.client.UnsealSession(sealedToken) if err != nil { return nil, fmt.Errorf("invalid sealed token: %w", err) } did, err := syntax.ParseDID(sealed.DID) if err != nil { return nil, fmt.Errorf("invalid DID in sealed token: %w", err) } return h.store.GetSession(r.Context(), did, sealed.SessionID) } } return nil, fmt.Errorf("no session found") } // HandleProtectedResourceMetadata returns OAuth protected resource metadata // per RFC 9449 and atproto OAuth spec. This endpoint allows third-party OAuth // clients to discover which authorization server to use for this resource. // Spec: https://datatracker.ietf.org/doc/html/rfc9449#section-5 func (h *OAuthHandler) HandleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { metadata := map[string]interface{}{ "resource": h.client.Config.PublicURL, "authorization_servers": []string{"https://bsky.social"}, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") if err := json.NewEncoder(w).Encode(metadata); err != nil { slog.Error("failed to encode protected resource metadata", "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // HandleMobileDeepLinkFallback handles requests to /app/oauth/callback when // Universal Links fail to intercept the redirect. // // If this handler is reached, it means the mobile app did NOT intercept the // Universal Link redirect. The OAuth flow succeeded server-side, but the // credentials couldn't be delivered to the app. func (h *OAuthHandler) HandleMobileDeepLinkFallback(w http.ResponseWriter, r *http.Request) { // Log the failure for debugging slog.Warn("Universal Link not intercepted - mobile app did not handle redirect", "path", r.URL.Path, "has_token", r.URL.Query().Get("token") != "", "has_did", r.URL.Query().Get("did") != "", ) http.Error(w, "Universal Link not intercepted: The mobile app should have opened this URL. "+ "Check that Universal Links (iOS) or App Links (Android) are properly configured.", http.StatusBadRequest) }