Files
Evan JarrettandClaude Fable 5.1 9dbc53b670 appview: stop serving service tokens past their expiry, and challenge the client when the hold rejects one
Seen in production on 2026-09-11: three cold pulls of a 22-layer image failed
with BLOB_UNKNOWN for layers that exist. The hold had answered 403 "service
token authentication failed: token has expired", and the same blobs served
fine a minute later.

Three things lined up. The registry middleware's validation cache kept a
fetched service token for a flat 45 seconds regardless of its real remaining
life, so a token fetched with 12 seconds left was still handed to the hold
half a minute after it died. The registry JWT is stamped from the auth cache's
expiry, which trailed the real exp by only 10 seconds, while distribution
accepts a JWT for 60 seconds past its exp, so a client could hold an accepted
JWT for most of a minute after the credential behind it was gone. And the
hold's 403 was flattened to BLOB_UNKNOWN, so the client failed instead of
re-authenticating.

Now the validation cache bounds an entry by the token's exp minus a shared
ServiceTokenSafetyMargin of 60 seconds, the same margin the auth cache and the
JWT stamp use, chosen to equal distribution's leeway so the last instant a JWT
is accepted is the service token's real exp. A PDS that grants less than the
margin gets half its remaining life instead of an already-past deadline. When
the hold rejects the service token as expired or missing, the appview drops
both cached copies and returns a 401 challenge so Docker and crane re-run the
token dance and retry; a genuine permission denial stays a 403, and a hold
that is down still maps to blob unknown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvFJr4Dwz8p2NDAeXmgmBt
2026-09-11 19:26:51 -05:00

102 lines
3.6 KiB
Go

package authgate
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
)
// ServiceAuthFetcher pre-mints the AppView↔hold service-auth at /auth/token
// time so the registry JWT can be bound to its lifetime.
//
// Flow: token handler calls Fetch with the requester's DID and chosen auth
// method, we resolve their hold, call the appropriate service-token fetcher
// (which caches the result with the PDS-granted exp), and read the cached
// expiry back. The token handler stamps the JWT's exp from that value, so
// the JWT and service-auth expire concurrently.
type ServiceAuthFetcher struct {
holdResolver
refresher *oauth.Refresher
cache *auth.Cache
}
// ServiceAuthOption configures a ServiceAuthFetcher.
type ServiceAuthOption func(*ServiceAuthFetcher)
// WithCache routes expiry read-back through the supplied cache instead of
// the package default. GetOrFetchServiceToken* still writes to the default
// cache today; injecting a fresh Cache is currently only useful for tests
// that pre-seed entries and want to verify the read-back path.
func WithCache(c *auth.Cache) ServiceAuthOption {
return func(f *ServiceAuthFetcher) { f.cache = c }
}
// NewServiceAuthFetcher constructs a ServiceAuthFetcher. defaultHoldDID is
// the AppView fallback used when a user has no sailor profile yet. The
// refresher is required for OAuth-flow service-token minting; pass nil if
// only the app-password flow needs to work.
func NewServiceAuthFetcher(db *sql.DB, refresher *oauth.Refresher, defaultHoldDID string, opts ...ServiceAuthOption) *ServiceAuthFetcher {
f := &ServiceAuthFetcher{
holdResolver: holdResolver{db: db, defaultHoldDID: defaultHoldDID},
refresher: refresher,
cache: auth.DefaultCache(),
}
for _, opt := range opts {
opt(f)
}
return f
}
// Fetch satisfies token.ServiceAuthFetcher. Returns the granted expiry of
// the cached service-auth, or zero time when the user has no hold (caller
// falls back to the issuer's default).
func (f *ServiceAuthFetcher) Fetch(ctx context.Context, did, authMethod string) (time.Time, error) {
holdDID, err := f.resolveHoldDID(ctx, did)
if err != nil {
return time.Time{}, err
}
if holdDID == "" {
// No hold configured anywhere — graceful degradation.
return time.Time{}, nil
}
_, _, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
return time.Time{}, fmt.Errorf("resolve PDS for %s: %w", did, err)
}
switch authMethod {
case token.AuthMethodOAuth:
if f.refresher == nil {
return time.Time{}, errors.New("OAuth flow requires a refresher")
}
if _, err := auth.GetOrFetchServiceToken(ctx, f.refresher, did, holdDID, pdsEndpoint); err != nil {
return time.Time{}, fmt.Errorf("oauth service-auth fetch: %w", err)
}
case token.AuthMethodAppPassword:
if _, err := auth.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint); err != nil {
return time.Time{}, fmt.Errorf("app-password service-auth fetch: %w", err)
}
default:
return time.Time{}, fmt.Errorf("unknown auth method: %q", authMethod)
}
// GetOrFetchServiceToken* caches as a side effect — read the granted
// expiry back. The cached value already has auth.ServiceTokenSafetyMargin
// subtracted from the PDS-granted exp (pkg/auth/cache.go), and the token
// handler stamps the JWT's exp from exactly this value, so the JWT cannot
// outlive the service token behind it.
_, expiresAt := f.cache.Get(did, holdDID)
if expiresAt.IsZero() {
return time.Time{}, errors.New("service-auth fetched but cache miss")
}
return expiresAt, nil
}