···
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
// 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 {
+
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{
+
// Check if the store implements MobileOAuthStore for server-side CSRF
+
if mobileStore, ok := store.(MobileOAuthStore); ok {
+
handler.mobileStore = mobileStore
+
// 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()
+
// For confidential clients in production, set JWKS URI based on request host
+
if h.client.IsConfidential() && !h.client.Config.DevMode {
+
jwksURI := fmt.Sprintf("https://%s/oauth/jwks.json", r.Host)
+
metadata.JWKSURI = &jwksURI
+
// 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)
+
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)
+
// HandleJWKS serves the public JWKS for confidential clients
+
// GET /oauth/jwks.json
+
func (h *OAuthHandler) HandleJWKS(w http.ResponseWriter, r *http.Request) {
+
jwks := h.client.PublicJWKS()
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(jwks); err != nil {
+
slog.Error("failed to encode JWKS", "error", err)
+
http.Error(w, "internal server error", http.StatusInternalServerError)
+
// HandleLogin starts the OAuth flow (web version)
+
// GET /oauth/login?handle=user.bsky.social
+
func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
+
// Get handle or DID from query params
+
identifier := r.URL.Query().Get("handle")
+
identifier = r.URL.Query().Get("did")
+
http.Error(w, "missing handle or did parameter", http.StatusBadRequest)
+
redirectURL, err := h.client.ClientApp.StartAuthFlow(ctx, identifier)
+
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)
+
// Log OAuth flow initiation (sanitized - no full URL to avoid leaking state)
+
slog.Info("redirecting to PDS for OAuth", "identifier", identifier)
+
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) {
+
// Get handle or DID from query params
+
identifier := r.URL.Query().Get("handle")
+
identifier = r.URL.Query().Get("did")
+
http.Error(w, "missing handle or did parameter", http.StatusBadRequest)
+
// Get mobile redirect URI (deep link)
+
mobileRedirectURI := r.URL.Query().Get("redirect_uri")
+
if mobileRedirectURI == "" {
+
http.Error(w, "missing redirect_uri parameter", http.StatusBadRequest)
+
// 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)
+
// 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)
+
// SECURITY FIX 2: Generate CSRF token
+
csrfToken, err := generateCSRFToken()
+
http.Error(w, "internal server error", http.StatusInternalServerError)
+
// 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{
+
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)
+
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)
+
// 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{
+
MaxAge: 600, // 10 minutes
+
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),
+
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",
+
Secure: !h.client.Config.DevMode,
+
SameSite: http.SameSiteLaxMode,
+
MaxAge: 600, // 10 minutes
+
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) {
+
// 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())
+
slog.Error("failed to process OAuth callback", "error", err)
+
http.Error(w, fmt.Sprintf("OAuth callback failed: %v", err), http.StatusBadRequest)
+
// Ensure sessData is not nil before using it
+
slog.Error("OAuth callback returned nil session data")
+
http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError)
+
// 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)
+
// 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)
+
// 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)
+
// Success: handle is valid and bidirectionally verified
+
slog.Info("OAuth callback successful", "did", sessData.AccountDID, "handle", ident.Handle)
+
// 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")
+
slog.Warn("mobile callback missing CSRF token")
+
http.Error(w, "invalid request: missing CSRF token", http.StatusForbidden)
+
// 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")
+
slog.Warn("mobile callback missing redirect binding - possible attack attempt")
+
http.Error(w, "invalid request: missing redirect binding", http.StatusForbidden)
+
// Decode the mobile redirect URI to validate binding
+
mobileRedirectURI, err := url.QueryUnescape(mobileRedirect.Value)
+
slog.Error("failed to decode mobile redirect URI", "error", err)
+
http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest)
+
// 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))
+
// Fail closed: treat as web flow instead of mobile
+
h.handleWebCallback(w, r, sessData)
+
// 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)
+
h.handleWebCallback(w, r, sessData)
+
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)
+
h.handleWebCallback(w, r, sessData)
+
// 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",
+
h.handleWebCallback(w, r, sessData)
+
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))
+
h.handleWebCallback(w, r, sessData)
+
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")
+
h.handleWebCallback(w, r, sessData)
+
// 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)
+
// 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(),
+
h.client.Config.SealedTokenTTL,
+
slog.Error("failed to seal session for web", "error", err)
+
http.Error(w, "failed to create session", http.StatusInternalServerError)
+
http.SetCookie(w, &http.Cookie{
+
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)
+
// Redirect to home or app
+
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)
+
slog.Error("failed to decode mobile redirect URI", "error", err)
+
http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest)
+
// 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)
+
// Seal the session data for mobile
+
sealedToken, err := h.client.SealSession(
+
sessData.AccountDID.String(),
+
h.client.Config.SealedTokenTTL,
+
slog.Error("failed to seal session data", "error", err)
+
http.Error(w, "failed to create session token", http.StatusInternalServerError)
+
// Get account handle for convenience
+
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)
+
// Build deep link with sealed token
+
deepLink := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s",
+
url.QueryEscape(sealedToken),
+
url.QueryEscape(sessData.AccountDID.String()),
+
url.QueryEscape(sessData.SessionID),
+
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)
+
// Redirect to mobile app deep link
+
http.Redirect(w, r, deepLink, http.StatusFound)
+
// HandleLogout revokes the session and clears cookies
+
func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
+
// Get session from cookie (now sealed)
+
cookie, err := r.Cookie("coves_session")
+
// No session, just return success
+
w.WriteHeader(http.StatusOK)
+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"})
+
// Unseal the session token
+
sealed, err := h.client.UnsealSession(cookie.Value)
+
// Invalid session, clear cookie and return
+
h.clearSessionCookie(w)
+
w.WriteHeader(http.StatusOK)
+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"})
+
did, err := syntax.ParseDID(sealed.DID)
+
// Invalid DID, clear cookie and return
+
h.clearSessionCookie(w)
+
w.WriteHeader(http.StatusOK)
+
_ = json.NewEncoder(w).Encode(map[string]string{"status": "logged_out"})
+
// 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)
+
// Body: {"did": "did:plc:...", "session_id": "...", "sealed_token": "..."}
+
func (h *OAuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) {
+
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)
+
// 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)
+
// SECURITY: Unseal and validate the token
+
unsealed, err := h.client.UnsealSession(req.SealedToken)
+
slog.Warn("refresh: invalid sealed token", "error", err)
+
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
+
// 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)
+
// 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)
+
// Parse DID after validation
+
did, err := syntax.ParseDID(req.DID)
+
http.Error(w, "invalid DID", http.StatusBadRequest)
+
// Resume session (now authenticated via sealed token)
+
sess, err := h.client.ClientApp.ResumeSession(ctx, did, req.SessionID)
+
slog.Error("failed to resume session", "error", err, "did", did, "session_id", req.SessionID)
+
http.Error(w, "session not found", http.StatusUnauthorized)
+
newAccessToken, err := sess.RefreshTokens(ctx)
+
slog.Error("failed to refresh tokens", "error", err, "did", did)
+
http.Error(w, "failed to refresh tokens", http.StatusUnauthorized)
+
// Create new sealed token for mobile
+
sealedToken, err := h.client.SealSession(
+
sess.Data.AccountDID.String(),
+
h.client.Config.SealedTokenTTL,
+
slog.Error("failed to seal new session data", "error", err)
+
http.Error(w, "failed to create session token", http.StatusInternalServerError)
+
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{
+
// 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)
+
did, err := syntax.ParseDID(sealed.DID)
+
return h.store.GetSession(r.Context(), did, sealed.SessionID)
+
// Try to get session from Authorization header (mobile)
+
authHeader := r.Header.Get("Authorization")
+
// Expected format: "Bearer <sealed_token>"
+
const prefix = "Bearer "
+
if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
+
sealedToken := authHeader[len(prefix):]
+
sealed, err := h.client.UnsealSession(sealedToken)
+
return nil, fmt.Errorf("invalid sealed token: %w", err)
+
did, err := syntax.ParseDID(sealed.DID)
+
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)