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
This commit is contained in:
Evan Jarrett
2026-09-11 19:26:51 -05:00
co-authored by Claude Fable 5.1
parent dcad0f8626
commit 9dbc53b670
11 changed files with 867 additions and 49 deletions
+60 -7
View File
@@ -1,7 +1,9 @@
// Package auth provides service token caching and management for AppView.
// Service tokens are JWTs issued by a user's PDS to authorize AppView to
// act on their behalf when communicating with hold services. Tokens are
// cached with automatic expiry parsing and 10-second safety margins.
// cached with automatic expiry parsing and a safety margin
// (ServiceTokenSafetyMargin) so the cache stops serving a token before the
// hold would reject it.
package auth
import (
@@ -14,6 +16,28 @@ import (
"time"
)
// ServiceTokenSafetyMargin is how far ahead of a service token's real exp the
// AppView stops treating it as usable. Every cache that holds a service token
// (this one, and the registry middleware's per-process validation cache)
// subtracts it, and the registry JWT's exp is stamped from the value this
// package returns, so the JWT never outlives the credential behind it.
//
// It is 60s because that is exactly distribution's token.Leeway: the registry
// auth package accepts a registry JWT for 60s past its exp. With a 60s margin
// the JWT is stamped at (service token exp - 60s), so the last moment
// distribution will accept it is the service token's real exp. Shrinking this
// below distribution's leeway reopens the window this constant closes: a client
// would hold an accepted JWT while the service token behind it is already dead,
// the hold would answer 403 "token has expired", and the pull would fail
// instead of re-authenticating.
const ServiceTokenSafetyMargin = 60 * time.Second
// unparsableTokenTTL is how long a token whose exp claim could not be read is
// cached. PDS-granted service tokens are requested with a 5 minute expiry (see
// servicetoken.go), so a fixed 50s is comfortably inside any plausible real
// lifetime and the next request re-mints.
const unparsableTokenTTL = 50 * time.Second
// serviceTokenEntry represents a cached service token.
type serviceTokenEntry struct {
token string
@@ -63,17 +87,34 @@ func (c *Cache) Get(did, holdDID string) (string, time.Time) {
}
// Set stores token for (did, holdDID), parsing its JWT exp claim and
// applying a 10s safety margin so the cache expires before the real
// token does. Falls back to a 50s TTL if the JWT can't be parsed.
// applying ServiceTokenSafetyMargin so the cache expires before the real
// token does. Falls back to unparsableTokenTTL if the JWT can't be parsed.
//
// A PDS is free to grant less than the margin (ATCR asks for 5 minutes;
// reference PDSes grant up to an hour, others may grant less). Subtracting a
// 60s margin from a 30s token would store an entry that is already expired,
// which Get would evict on sight, so every single request would re-mint: a
// refetch storm against the user's PDS. In that case the entry is kept for half
// of whatever life the token actually has instead, which is always positive
// while the token is alive and still leaves headroom proportional to it. A
// token that arrives already expired gets a past expiry, which is correct: it
// is unusable and the next call must mint a new one.
func (c *Cache) Set(did, holdDID, token string) error {
cacheKey := did + ":" + holdDID
expiry, err := parseJWTExpiry(token)
if err != nil {
slog.Warn("Failed to parse JWT expiry, using default 50s", "error", err, "cacheKey", cacheKey)
expiry = time.Now().Add(50 * time.Second)
slog.Warn("Failed to parse JWT expiry, using fallback TTL",
"error", err, "cacheKey", cacheKey, "ttl", unparsableTokenTTL)
expiry = time.Now().Add(unparsableTokenTTL)
} else if remaining := time.Until(expiry); remaining <= ServiceTokenSafetyMargin {
slog.Warn("PDS granted a service token shorter than the safety margin",
"cacheKey", cacheKey,
"grantedLife", remaining.Round(time.Second),
"margin", ServiceTokenSafetyMargin)
expiry = time.Now().Add(remaining / 2)
} else {
expiry = expiry.Add(-10 * time.Second)
expiry = expiry.Add(-ServiceTokenSafetyMargin)
}
c.mu.Lock()
@@ -163,7 +204,7 @@ func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) {
}
// SetServiceToken stores token under (did, holdDID) in the default cache,
// applying the standard 10s safety margin against the JWT's exp claim.
// applying ServiceTokenSafetyMargin against the JWT's exp claim.
func SetServiceToken(did, holdDID, token string) error {
return defaultCache.Set(did, holdDID, token)
}
@@ -191,6 +232,18 @@ func DefaultCache() *Cache {
return defaultCache
}
// ServiceTokenExpiry reports the exp claim of a PDS-issued service token,
// without verifying its signature (we trust tokens minted by the user's PDS,
// and the hold verifies them anyway).
//
// Exported for callers that hold a service token outside this cache and must
// not keep it past its real life. The registry middleware's validation cache is
// the one that matters: it used to pin every token for a flat 45s, so a token
// with 12s left was handed to the hold for another 33s after it died.
func ServiceTokenExpiry(token string) (time.Time, error) {
return parseJWTExpiry(token)
}
// parseJWTExpiry extracts the exp claim from a JWT without verifying its
// signature. We trust tokens from the user's PDS, so signature
// verification isn't needed here.