mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
Refresh tokens rotate on use, and DoWithSession serializes refreshes per DID with an in-process mutex. That is the right mechanism and it protects nothing once there are two instances: both can refresh the same account at the same time, the slower one presents a refresh token the auth server has already superseded, gets invalid_grant, and isAuthError deletes the session. The user is signed out mid-push, and the session another instance had just legitimately refreshed is destroyed along with it. oauth_sessions gains a rev that increments on every write. A store that has read a session writes with a compare-and-swap against the revision it read and gets ErrSessionRevConflict if anyone wrote first, so a stale writer can no longer replace rotated tokens with invalidated ones. The persist callback treats that conflict as an ordinary outcome rather than an error, since leaving the newer state alone is exactly right. The delete path is now guarded by the same signal. An auth error on a session whose revision has moved since we read it means "someone else refreshed this", not "this session is dead", so it retries once against the newer tokens instead of deleting. Exactly once: a second failure means staleness was not the problem, and looping would hold the per-DID lock while getting the same answer. The guard is deliberately conservative. A store without revisions, no recorded revision, a failed lookup, a session that is simply gone: all answer "not advanced" and keep the previous delete-on-error behavior. Wrongly claiming a concurrent refresh would keep a genuinely dead session alive with no way out but waiting; wrongly missing one costs a re-login. The sentinel lives in pkg/auth/oauth rather than next to the SQLite store, because pkg/appview/db already imports pkg/auth/oauth and the other direction would be an import cycle. The db package re-exports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
701 lines
27 KiB
Go
701 lines
27 KiB
Go
// 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 := oauth.NewClientApp(&config, store)
|
|
clientApp.Dir = atproto.GetDirectory()
|
|
|
|
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 := oauth.NewClientApp(&config, store)
|
|
clientApp.Dir = atproto.GetDirectory()
|
|
|
|
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
|
|
})
|
|
}
|