mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-18 00:04:16 +00:00
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
102 lines
3.6 KiB
Go
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
|
|
}
|