mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
Follow-up to37bab32. That commit stopped deleting OAuth sessions on transient errors, which fixed spurious sign-outs but overshot on one path: a genuinely dead session stopped being evicted at all, turning a forced re-login into a permanent failure loop. GetOrFetchServiceToken flattened every non-200 from getServiceAuth into fmt.Errorf("service auth failed with status %d: %s"). IsSessionInvalidError then had nothing structured to inspect, and its string fallback could not help: it looks for the OAuth 2.0 code invalid_token, while atproto emits the XRPC name InvalidToken. The difference is the underscore, not the case, so lowercasing never bridged it. A revoked session came back 401 InvalidToken and was classified transient, so /auth/token returned 503 forever and the user was never prompted to re-authenticate. The non-200 branch now wraps an *atclient.APIError carrying the status and the parsed atproto error name, which is what the existing structured checks in IsSessionInvalidError already know how to read. Transient shapes stay transient: atprotoErrorName returns "" for a non-JSON body, so 500s with HTML, 502s, and 429s do not evict. ExpiredToken is deliberately not treated as a dead session. It means "refresh me", and deleting on it would sign the user out of every UI session over an ordinary access-token expiry a refresh would have fixed. isAuthError omits it for the same reason; the two classifiers have to agree about the same condition. The comment on the string fallback claimed it was a looser spelling of the structured check. It is not — it handles a different error family. indigo's RefreshTokens returns OAuth token-endpoint failures as a bare fmt.Errorf carrying the auth server's snake_case code verbatim ("token refresh failed (HTTP 400): invalid_grant"), never a typed error, so a string match is the only thing that can classify a refresh failure, which is the invalid_grant replay case37bab32exists to detect. Both comments now say which family they cover. Two hardening items on the same theme: use_dpop_nonce no longer counts as an auth error in the appview's isOAuthError. It is a routine handshake step indigo retries with the server-supplied nonce, and treating it as fatal signed users out over ordinary nonce rotation. It can still escape when a server sends that error with no DPoP-Nonce header, leaving indigo nothing to retry with; a stuck session there is preferable to signing everyone out in the common case, and the comment says so rather than claiming it cannot happen. Detached session deletes are bounded by SessionDeleteTimeout. They run on context.WithoutCancel so a canceled request cannot leave the cleanup half-done, which also stripped the only deadline they had — a wedged database write blocked the goroutine with no way to shed it. Matches the bound already on the detached persist callback. The unparseable-token-endpoint warning is now deduped per endpoint rather than once per process, since that path fails open by returning the client unwrapped, silently reinstating the refresh burn. The refreshDetachTimeout comment now notes the cap is per-POST: the DPoP-nonce retry means one refresh can issue two, holding the per-DID lock for up to twice the stated value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
583 lines
22 KiB
Go
583 lines
22 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
|
|
}
|
|
|
|
// DoWithSession executes a function with a locked OAuth session.
|
|
// 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) DoWithSession(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) {
|
|
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 {
|
|
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
|
|
})
|
|
}
|