// Package oauth provides OAuth client configuration and helper functions for ATCR. // It provides helpers for setting up indigo's OAuth library with ATCR-specific // configuration, including default scopes, confidential client setup, and // interactive browser-based authentication flows. package oauth import ( "context" "errors" "fmt" "log/slog" "strings" "sync" "time" "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/xrpc" ) // SessionDeleteTimeout bounds a detached session delete. These deletes run on // context.WithoutCancel so a canceled inbound request cannot leave the cleanup // half-done — but that also strips the only deadline they had, so without a // replacement a wedged database write blocks the calling goroutine forever with // no way to shed it. Matches the bound on the detached persist callback. const SessionDeleteTimeout = 10 * time.Second // permissionSetExpansions maps lexicon IDs to their expanded scope format. // These must match the collections defined in lexicons/io/atcr/authFullApp.json // Collections are sorted alphabetically for consistent comparison with PDS-expanded scopes. var permissionSetExpansions = map[string]string{ "io.atcr.authFullApp": "repo?" + "collection=io.atcr.manifest&" + "collection=io.atcr.repo.page&" + "collection=io.atcr.sailor.profile&" + "collection=io.atcr.sailor.star&" + "collection=io.atcr.tag", } // ExpandIncludeScopes expands any "include:" prefixed scopes to their full form // by looking up the corresponding permission-set in the embedded lexicon files. // For example, "include:io.atcr.authFullApp" expands to "repo?collection=io.atcr.manifest&..." func ExpandIncludeScopes(scopes []string) []string { var expanded []string for _, scope := range scopes { if after, ok := strings.CutPrefix(scope, "include:"); ok { lexiconID := after if exp, ok := permissionSetExpansions[lexiconID]; ok { expanded = append(expanded, exp) } else { expanded = append(expanded, scope) // Keep original if unknown } } else { expanded = append(expanded, scope) } } return expanded } // NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration // Automatically configures confidential client for production deployments // keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost) // clientName is added to OAuth client metadata (currently unused, reserved for future) func NewClientApp(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*oauth.ClientApp, error) { var config oauth.ClientConfig redirectURI := RedirectURI(baseURL) // If production (not localhost), automatically set up confidential client if !isLocalhost(baseURL) { clientID := baseURL + "/oauth-client-metadata.json" config = oauth.NewPublicConfig(clientID, redirectURI, scopes) // Generate or load P-256 key privateKey, err := GenerateOrLoadClientKey(keyPath) if err != nil { return nil, fmt.Errorf("failed to load OAuth client key: %w", err) } // Generate key ID from public key keyID, err := GenerateKeyID(privateKey) if err != nil { return nil, fmt.Errorf("failed to generate key ID: %w", err) } // Upgrade to confidential client if err := config.SetClientSecret(privateKey, keyID); err != nil { return nil, fmt.Errorf("failed to configure confidential client: %w", err) } slog.Info("Configured confidential OAuth client", "key_id", keyID, "key_path", keyPath, ) } else { config = oauth.NewLocalhostConfig(redirectURI, scopes) slog.Info("Using public OAuth client (localhost development)") } clientApp := atproto.NewOAuthClientApp(&config, store) return clientApp, nil } // NewClientAppWithKey creates an indigo OAuth ClientApp with a pre-loaded P-256 key. // Used by AppView when loading keys from the database instead of disk. // For localhost development, privateKey is ignored (public client). func NewClientAppWithKey(baseURL string, store oauth.ClientAuthStore, scopes []string, privateKey *atcrypto.PrivateKeyP256, clientName string) (*oauth.ClientApp, error) { var config oauth.ClientConfig redirectURI := RedirectURI(baseURL) if !isLocalhost(baseURL) { clientID := baseURL + "/oauth-client-metadata.json" config = oauth.NewPublicConfig(clientID, redirectURI, scopes) keyID, err := GenerateKeyID(privateKey) if err != nil { return nil, fmt.Errorf("failed to generate key ID: %w", err) } if err := config.SetClientSecret(privateKey, keyID); err != nil { return nil, fmt.Errorf("failed to configure confidential client: %w", err) } slog.Info("Configured confidential OAuth client", "key_id", keyID) } else { config = oauth.NewLocalhostConfig(redirectURI, scopes) slog.Info("Using public OAuth client (localhost development)") } clientApp := atproto.NewOAuthClientApp(&config, store) return clientApp, nil } // RedirectURI returns the OAuth redirect URI for ATCR func RedirectURI(baseURL string) string { return baseURL + "/auth/oauth/callback" } // GetDefaultScopes returns the default OAuth scopes for ATCR registry operations. // Includes io.atcr.authFullApp permission-set plus individual scopes for PDS compatibility. // Blob scopes are listed explicitly (not supported in Lexicon permission-sets). func GetDefaultScopes(did string) []string { return []string{ "atproto", // Permission-set // See lexicons/io/atcr/authFullApp.json for definition "include:io.atcr.authFullApp", // com.atproto scopes must be separate (permission-sets are namespace-limited) "rpc:com.atproto.repo.getRecord?aud=*", // Blob scopes (not supported in Lexicon permission-sets) // Image manifest types (single-arch) "blob:application/vnd.oci.image.manifest.v1+json", "blob:application/vnd.docker.distribution.manifest.v2+json", // Manifest list/index types (multi-arch) "blob:application/vnd.oci.image.index.v1+json", "blob:application/vnd.docker.distribution.manifest.list.v2+json", // OCI artifact manifests (for cosign signatures, SBOMs, attestations) "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", // Helm chart support "blob:application/vnd.cncf.helm.config.v1+json", "blob:application/vnd.cncf.helm.chart.content.v1.tar+gzip", // Image avatars "blob:image/*", } } // ScopesMatch checks if two scope lists are equivalent (order-independent) // Returns true if both lists contain the same scopes, regardless of order. // Expands any "include:" prefixed scopes in the desired list before comparing, // since the PDS returns expanded scopes in the stored session. func ScopesMatch(stored, desired []string) bool { // Expand any include: scopes in desired before comparing expandedDesired := ExpandIncludeScopes(desired) // Handle nil/empty cases if len(stored) == 0 && len(expandedDesired) == 0 { return true } if len(stored) != len(expandedDesired) { return false } // Build map of desired scopes for O(1) lookup desiredMap := make(map[string]bool, len(expandedDesired)) for _, scope := range expandedDesired { desiredMap[scope] = true } // Check if all stored scopes exist in desired for _, scope := range stored { if !desiredMap[scope] { return false } } return true } // isLocalhost checks if a base URL is a localhost address func isLocalhost(baseURL string) bool { return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") } // ---------------------------------------------------------------------------- // Session Management // ---------------------------------------------------------------------------- // SessionCache represents a cached OAuth session type SessionCache struct { Session *oauth.ClientSession SessionID string } // UISessionStore interface for managing UI sessions // Shared between refresher and server type UISessionStore interface { Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) DeleteByDID(did string) } // Refresher manages OAuth sessions and token refresh for AppView // Sessions are loaded fresh from database on every request (database is source of truth) type Refresher struct { clientApp *oauth.ClientApp uiSessionStore UISessionStore // For invalidating UI sessions on OAuth failures didLocks sync.Map // Per-DID mutexes to prevent concurrent DPoP nonce races } // NewRefresher creates a new session refresher func NewRefresher(clientApp *oauth.ClientApp) *Refresher { return &Refresher{ clientApp: clientApp, } } // SetUISessionStore sets the UI session store for invalidating sessions on OAuth failures func (r *Refresher) SetUISessionStore(store UISessionStore) { r.uiSessionStore = store } // ErrSessionRevConflict is returned by a session store's SaveSession when the // stored session has been written by someone else since this process last read // it. // // It lives here rather than next to the SQLite store because pkg/appview/db // already imports this package; defining it there and referring to it here would // be an import cycle. // // It is not a failure so much as a report: the caller's copy is stale and must // not be written over the newer one. Callers should re-read rather than retry // blindly, and must not treat it as evidence that the session is broken. var ErrSessionRevConflict = errors.New("oauth: session was modified concurrently") // staleSessionError marks an auth failure that is explained by another instance // having refreshed the same session concurrently, rather than by the session // being dead. DoWithSession retries these once against the newer tokens. type staleSessionError struct { did string cause error } func (e *staleSessionError) Error() string { return fmt.Sprintf("oauth session for %s was refreshed concurrently: %v", e.did, e.cause) } func (e *staleSessionError) Unwrap() error { return e.cause } // sessionRevChecker is implemented by stores that version their sessions. The // SQLite store does; a store that does not simply keeps the old behavior of // deleting on any auth error. type sessionRevChecker interface { KnownRev(did, sessionID string) (int64, bool) GetSessionRev(ctx context.Context, did, sessionID string) (int64, bool, error) } // sessionRevisionAdvanced reports whether the stored session has been written by // someone else since this process last read it. // // Deliberately conservative: anything it cannot determine (a store without // revisions, no revision recorded, a failed lookup, a session that is simply // gone) returns false and leaves the caller with the previous delete-on-error // behavior. Wrongly answering true would keep a genuinely dead session alive and // leave the user stuck; wrongly answering false only costs a re-login. func (r *Refresher) sessionRevisionAdvanced(ctx context.Context, did, sessionID string) bool { checker, ok := r.clientApp.Store.(sessionRevChecker) if !ok { return false } knownRev, ok := checker.KnownRev(did, sessionID) if !ok { return false } // Detached: we are deciding whether to destroy a session, so an inbound // request that was canceled mid-flight must not push us into deleting it. checkCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SessionDeleteTimeout) defer cancel() currentRev, exists, err := checker.GetSessionRev(checkCtx, did, sessionID) if err != nil || !exists { return false } return currentRev > knownRev } // DoWithSession executes a function with a locked OAuth session. // // If the session turns out to have been refreshed by another instance while we // were working, it is retried once against the newer tokens. Exactly once: a // second failure means the trouble is not staleness, and looping would just hold // the per-DID lock while getting the same answer. func (r *Refresher) DoWithSession(ctx context.Context, did string, fn func(session *oauth.ClientSession) error) error { err := r.doWithSessionOnce(ctx, did, fn) var stale *staleSessionError if errors.As(err, &stale) { slog.Info("Retrying with the session another instance refreshed", "component", "oauth/refresher", "did", did) err = r.doWithSessionOnce(ctx, did, fn) // Still stale on the retry: stop unwrapping and report the underlying // auth failure, so callers see a normal auth error rather than an // internal marker type. if errors.As(err, &stale) { return stale.cause } } return err } // doWithSessionOnce is one attempt of DoWithSession. // The lock is held for the entire duration of the function, preventing DPoP nonce races. // // This is the preferred way to make PDS requests that require OAuth/DPoP authentication. // The lock is held through the entire PDS interaction, ensuring that: // 1. Only one goroutine at a time can negotiate DPoP nonces with the PDS for a given DID // 2. The session's PersistSessionCallback saves the updated nonce before other goroutines load // 3. Concurrent layer uploads don't race on stale nonces // // Why locking is critical: // During docker push, multiple layers upload concurrently. Each layer creates a new // ClientSession by loading from database. Without locking, this race condition occurs: // 1. Layer A loads session with stale DPoP nonce from DB // 2. Layer B loads session with same stale nonce (A hasn't updated DB yet) // 3. Layer A makes request → 401 "use_dpop_nonce" → gets fresh nonce → saves to DB // 4. Layer B makes request → 401 "use_dpop_nonce" (using stale nonce from step 2) // 5. DPoP nonce thrashing continues, eventually causing 500 errors // // With per-DID locking: // 1. Layer A acquires lock, loads session, handles nonce negotiation, saves, releases lock // 2. Layer B acquires lock AFTER A releases, loads fresh nonce from DB, succeeds // // Example usage: // // var result MyResult // err := refresher.DoWithSession(ctx, did, func(session *oauth.ClientSession) error { // resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth") // if err != nil { // return err // } // // Parse response into result... // return nil // }) func (r *Refresher) doWithSessionOnce(ctx context.Context, did string, fn func(session *oauth.ClientSession) error) error { // Get or create a mutex for this DID mutexInterface, _ := r.didLocks.LoadOrStore(did, &sync.Mutex{}) mutex := mutexInterface.(*sync.Mutex) // Hold the lock for the ENTIRE operation (load + PDS request + nonce save) lockStart := time.Now() mutex.Lock() defer mutex.Unlock() lockWait := time.Since(lockStart) slog.Debug("Acquired session lock for DoWithSession", "component", "oauth/refresher", "did", did, "lockWait", lockWait.Round(time.Millisecond)) if lockWait > 5*time.Second { slog.Warn("Slow per-DID session lock acquisition", "component", "oauth/refresher", "did", did, "lockWait", lockWait.Round(time.Millisecond)) } // Load session while holding lock resumeStart := time.Now() session, err := r.resumeSession(ctx, did) if err != nil { return err } if resumeDur := time.Since(resumeStart); resumeDur > 5*time.Second { slog.Warn("Slow OAuth session resume", "component", "oauth/refresher", "did", did, "duration", resumeDur.Round(time.Millisecond)) } // Execute the function (PDS request) while still holding lock // The session's PersistSessionCallback will save nonce updates to DB err = fn(session) // If request failed with auth error, delete session to force re-auth if err != nil && isAuthError(err) { // ...unless another instance refreshed this session while we were // working. The mutex above is process-local, so with more than one // AppView instance two of them can refresh the same account at once; // refresh tokens rotate on use, so the slower one gets invalid_grant on // a token that is merely superseded rather than revoked. Deleting then // signs the user out mid-push and destroys the session the other // instance just legitimately refreshed. // // A revision that has moved since we read it is exactly that case. if r.sessionRevisionAdvanced(ctx, did, session.Data.SessionID) { slog.Info("Auth error on a session another instance has since refreshed; keeping it and retrying", "component", "oauth/refresher", "did", did, "error", err) return &staleSessionError{did: did, cause: err} } slog.Warn("Auth error detected, deleting session to force re-auth", "component", "oauth/refresher", "did", did, "error", err) // Don't hold the lock while deleting - release first. Detached // context: once we decide to delete, the cleanup must finish even if // the inbound request is canceled mid-way. mutex.Unlock() delCtx, cancelDel := context.WithTimeout(context.WithoutCancel(ctx), SessionDeleteTimeout) _ = r.DeleteSession(delCtx, did) cancelDel() mutex.Lock() // Re-acquire for the deferred unlock } slog.Debug("Released session lock for DoWithSession", "component", "oauth/refresher", "did", did, "success", err == nil) return err } // IsSessionInvalidError reports whether err indicates the OAuth session // itself is invalid or revoked, i.e. deleting it (and signing the user out) // is the right response. It is deliberately false for cancellation, deadline, // and transport errors: deleting a session over a transient failure signs the // user out everywhere for nothing. func IsSessionInvalidError(err error) bool { if err == nil { return false } // A canceled or timed-out request says nothing about session validity. // Checked first so wrapped chains never fall through to string matching. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } var xrpcErr *xrpc.Error if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 401 { return true } var apiErr *atclient.APIError if errors.As(err, &apiErr) { if apiErr.StatusCode == 401 { return true } // atproto XRPC error names, which are camel-case. The string match // below handles a *different* family (OAuth 2.0 token-endpoint codes, // snake_case) and cannot catch these — the mismatch is the underscore, // not the case, so lowercasing "InvalidToken" still never reaches // "invalid_token". Every XRPC name meaning "this session is dead" has // to be listed right here. // Deliberately absent: ExpiredToken. It means "refresh me", not "this // session is revoked" — deleting on it signs the user out of every UI // session over an ordinary access-token expiry that a refresh would // have fixed. A genuinely dead session still gets caught by the 401 // status check above. isAuthError (below) omits it for the same reason; // the two classifiers must agree about the same condition. switch apiErr.Name { case "InvalidToken", "InvalidGrant", "InsufficientScope": return true } } // OAuth 2.0 token-endpoint failures, which are a separate error family from // the XRPC names above rather than a looser spelling of them. indigo's // RefreshTokens returns them as a bare fmt.Errorf carrying the auth // server's error code verbatim ("token refresh failed (HTTP 400): // invalid_grant") — never an APIError, never a wrapped typed error — so // matching the string is the only way to classify them. RFC 6749 defines // these codes as snake_case, hence the different spellings; ToLower is only // belt-and-braces for a server that deviates. The substrings are // auth-specific and won't appear in digests or URIs. errStr := strings.ToLower(err.Error()) return strings.Contains(errStr, "invalid_grant") || strings.Contains(errStr, "invalid_token") || strings.Contains(errStr, "insufficient_scope") || strings.Contains(errStr, "token expired") } // isAuthError checks if an error looks like an OAuth/auth failure // Uses structured error types to avoid false positives from substring matching // (e.g., a digest hash containing "401" in a RecordNotFound error) func isAuthError(err error) bool { if err == nil { return false } // Never treat cancellation/deadline as an auth failure, no matter what // the wrapped chain's text looks like. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } // Check structured error types first var xrpcErr *xrpc.Error if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 401 { return true } var apiErr *atclient.APIError if errors.As(err, &apiErr) { if apiErr.StatusCode == 401 { return true } if apiErr.Name == "InvalidToken" || apiErr.Name == "InsufficientScope" { return true } } // Fallback: check for known auth-specific error strings that won't // appear in digests or URIs errStr := strings.ToLower(err.Error()) return strings.Contains(errStr, "invalid_token") || strings.Contains(errStr, "insufficient_scope") || strings.Contains(errStr, "token expired") } // resumeSession loads a session from storage func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) { // Parse DID accountDID, err := syntax.ParseDID(did) if err != nil { return nil, fmt.Errorf("failed to parse DID: %w", err) } // Get the latest session for this DID from SQLite store // The store must implement GetLatestSessionForDID (returns newest by updated_at) type sessionGetter interface { GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) } getter, ok := r.clientApp.Store.(sessionGetter) if !ok { return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)") } sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did) if err != nil { return nil, fmt.Errorf("no session found for DID: %s", did) } // Log scope differences for debugging, but don't delete session // The PDS will reject requests if scopes are insufficient // (Permission-sets get expanded by PDS, so exact matching doesn't work) desiredScopes := r.clientApp.Config.Scopes if !ScopesMatch(sessionData.Scopes, desiredScopes) { slog.Debug("Session scopes differ from desired (may be permission-set expansion)", "did", did, "storedScopes", sessionData.Scopes, "desiredScopes", desiredScopes) } // Resume session session, err := r.clientApp.ResumeSession(ctx, accountDID, sessionID) if err != nil { return nil, fmt.Errorf("failed to resume session: %w", err) } // Token-refresh POSTs rotate the refresh token on the auth server; an // inbound request cancellation must never abort one mid-flight, or the // rotated token is stranded server-side and the session dies with // invalid_grant "Refresh token replayed" on the next refresh. session.Client = newRefreshDetachClient(session.Client, session.Data.AuthServerTokenEndpoint) // Set up callback to persist token updates to SQLite // This ensures that when indigo automatically refreshes tokens or updates DPoP nonces, // the new state is saved to the database immediately session.PersistSessionCallback = func(callbackCtx context.Context, updatedData *oauth.ClientSessionData) { // Indigo invokes this with the context of whatever request triggered // the refresh — possibly already canceled. Once tokens have rotated, // this save must complete or the session is bricked. saveCtx, cancel := context.WithTimeout(context.WithoutCancel(callbackCtx), 10*time.Second) defer cancel() if err := r.clientApp.Store.SaveSession(saveCtx, *updatedData); err != nil { // A revision conflict is not a failure. Another instance persisted a // newer state for this session, so ours is stale and overwriting it // would replace live tokens with superseded ones. Leaving the newer // state alone is the correct outcome. if errors.Is(err, ErrSessionRevConflict) { slog.Info("Skipped persisting a stale OAuth session update; another instance wrote first", "component", "oauth/refresher", "did", did, "sessionID", sessionID) return } slog.Error("Failed to persist OAuth session update", "component", "oauth/refresher", "did", did, "sessionID", sessionID, "error", err) } else { // Log session updates (token refresh, DPoP nonce updates, etc.) // Note: updatedData contains the full session state including DPoP nonce, // but we don't log sensitive data like tokens or nonces themselves slog.Debug("Persisted OAuth session update to database", "component", "oauth/refresher", "did", did, "sessionID", sessionID, "hint", "This includes token refresh and DPoP nonce updates") } } return session, nil } // DeleteSession removes an OAuth session from storage and optionally invalidates the UI session // This is called when OAuth authentication fails to force re-authentication func (r *Refresher) DeleteSession(ctx context.Context, did string) error { // Parse DID accountDID, err := syntax.ParseDID(did) if err != nil { return fmt.Errorf("failed to parse DID: %w", err) } // Get the session ID before deleting (for logging) type sessionGetter interface { GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) } getter, ok := r.clientApp.Store.(sessionGetter) if !ok { return fmt.Errorf("store must implement GetLatestSessionForDID") } _, sessionID, err := getter.GetLatestSessionForDID(ctx, did) if err != nil { // No session to delete - this is fine slog.Debug("No OAuth session to delete", "did", did) return nil } // Delete OAuth session from database if err := r.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil { slog.Warn("Failed to delete OAuth session", "did", did, "sessionID", sessionID, "error", err) return fmt.Errorf("failed to delete OAuth session: %w", err) } slog.Info("Deleted stale OAuth session", "component", "oauth/refresher", "did", did, "sessionID", sessionID, "reason", "OAuth authentication failed") // Also invalidate the UI session if store is configured if r.uiSessionStore != nil { r.uiSessionStore.DeleteByDID(did) slog.Info("Invalidated UI session for DID", "component", "oauth/refresher", "did", did, "reason", "OAuth session deleted") } return nil } // ValidateSession checks if an OAuth session is usable by attempting to load it. // This triggers token refresh if needed (via indigo's auto-refresh in DoWithSession). // Returns nil if session is valid, error if session is invalid/expired/needs re-auth. // // This is used by the token handler to validate OAuth sessions before issuing JWTs, // preventing the flood of errors that occurs when a stale session is discovered // during parallel layer uploads. func (r *Refresher) ValidateSession(ctx context.Context, did string) error { return r.DoWithSession(ctx, did, func(session *oauth.ClientSession) error { // Session loaded and refreshed successfully // DoWithSession already handles token refresh if needed slog.Debug("OAuth session validated successfully", "component", "oauth/refresher", "did", did) return nil }) }