mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-22 18:24:21 +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
874 lines
36 KiB
Go
874 lines
36 KiB
Go
package token
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
"github.com/go-chi/render"
|
|
)
|
|
|
|
// PostAuthCallback is called after successful Basic Auth authentication.
|
|
// Parameters: ctx, did, handle, pdsEndpoint, accessToken
|
|
// This allows AppView to perform business logic (profile creation, etc.)
|
|
// without coupling the token package to AppView-specific dependencies.
|
|
type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
|
|
|
|
// OAuthSessionValidator validates OAuth sessions before issuing tokens
|
|
// This interface allows the token handler to verify OAuth sessions are usable
|
|
// (not just that they exist) without depending directly on the OAuth implementation.
|
|
type OAuthSessionValidator interface {
|
|
// ValidateSession checks if OAuth session is usable by attempting to load/refresh it
|
|
// Returns nil if session is valid, error if session is invalid/expired/needs re-auth
|
|
ValidateSession(ctx context.Context, did string) error
|
|
}
|
|
|
|
// Authorizer gates issuance of registry JWTs at the auth phase.
|
|
// Implementations decide which sub-checks apply based on `access`:
|
|
// crew reconciliation runs for any token request (so first-time CLI users
|
|
// can pull from a private hold), while membership and quota enforcement
|
|
// only apply to non-wildcard push scopes. The Docker spec model is to
|
|
// embed authorization in the JWT and trust it for its short lifetime; this
|
|
// is the single point where those gates run.
|
|
type Authorizer interface {
|
|
// Authorize is called once per token issuance, after the requester's
|
|
// ATProto credentials have been validated. Returns nil if `access` is
|
|
// allowed, or an error describing the denial reason. The error message
|
|
// surfaces to the OCI client inside the distribution error JSON body.
|
|
//
|
|
// Implementations may narrow `access` in place (dropping actions from an
|
|
// entry) to grant a subset rather than deny the whole request, and the
|
|
// issued JWT carries whatever survives. They must not widen it: every
|
|
// entry has already cleared ValidateAccess. The handler reads `access`
|
|
// only after draining the gate, so the narrowing is ordered before the
|
|
// JWT is signed — keep it that way if this call site moves.
|
|
//
|
|
// authMethod is one of AuthMethodOAuth or AuthMethodAppPassword and lets
|
|
// the implementation pick the right service-token fetcher when it needs
|
|
// to talk to the hold (e.g. for crew reconciliation).
|
|
Authorize(ctx context.Context, did, authMethod string, access []auth.AccessEntry) error
|
|
}
|
|
|
|
// AnonymousAuthorizer gates the credential-less pull path, answering which of
|
|
// the requested repositories this AppView is actually willing to sign a pull
|
|
// token for.
|
|
//
|
|
// It exists because /auth/token and /v2/ are the authorization server and the
|
|
// resource server for the same request, and they used to disagree: the token
|
|
// endpoint minted `pull` on any repository, while the registry refused that
|
|
// same token when the owner's hold denies anonymous reads. Nothing was exposed
|
|
// (the hold gates the bytes, and the registry middleware still enforces), but a
|
|
// signed grant the issuer knows will be refused is a lie the client cannot act
|
|
// on. Deciding here makes docker see one 401 and prompt for credentials.
|
|
//
|
|
// Implementations must follow the same narrowing contract as Authorizer: return
|
|
// a subset of `access`, never a superset, and never add actions to an entry.
|
|
// Entries that grant nothing (the actionless placeholder NarrowToPullOnly
|
|
// preserves) must be passed through rather than treated as denials.
|
|
// Implementations fail open — a lookup failure returns the entry, since /v2/
|
|
// remains the enforcing layer and a transient error must not break anonymous
|
|
// pulls of public images.
|
|
type AnonymousAuthorizer interface {
|
|
AuthorizeAnonymous(ctx context.Context, access []auth.AccessEntry) []auth.AccessEntry
|
|
}
|
|
|
|
// ServiceAuthFetcher pre-mints the AppView↔hold service-auth at /auth/token
|
|
// time so the registry JWT can be bound to its lifetime. JWT and service-auth
|
|
// then expire concurrently; when Docker hits 401, the next /auth/token call
|
|
// mints both fresh in lockstep.
|
|
type ServiceAuthFetcher interface {
|
|
// Fetch ensures a service-auth exists for (did, hold derived from did)
|
|
// and returns its expiry. Returns (zero time, nil) when the user has no
|
|
// hold configured — caller falls back to the issuer's default exp.
|
|
Fetch(ctx context.Context, did, authMethod string) (expiresAt time.Time, err error)
|
|
}
|
|
|
|
// Handler handles /auth/token requests
|
|
type Handler struct {
|
|
issuer *Issuer
|
|
validator *auth.SessionValidator
|
|
deviceStore *db.DeviceStore // For validating device secrets
|
|
postAuthCallback PostAuthCallback
|
|
oauthSessionValidator OAuthSessionValidator
|
|
authorizer Authorizer
|
|
anonymousAuthorizer AnonymousAuthorizer
|
|
serviceAuthFetcher ServiceAuthFetcher
|
|
|
|
// services is the set of registry domains this AppView fronts, keyed by
|
|
// normalized hostname. Nil means single-domain: the lookups in
|
|
// resolveService miss and every token gets the issuer's own service.
|
|
services map[string]bool
|
|
|
|
// serviceDisplay maps a normalized service back to the registry domain as
|
|
// it was configured, port and all. Only the human-readable guidance in
|
|
// sendAuthError reads it; audiences and routing keep using the normalized
|
|
// key. Nil (or a miss) means the normalized form is printed as-is.
|
|
serviceDisplay map[string]string
|
|
}
|
|
|
|
// NewHandler creates a new token handler
|
|
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler {
|
|
return &Handler{
|
|
issuer: issuer,
|
|
validator: auth.NewSessionValidator(),
|
|
deviceStore: deviceStore,
|
|
}
|
|
}
|
|
|
|
// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication
|
|
// This allows AppView to inject business logic without coupling the token package
|
|
func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) {
|
|
h.postAuthCallback = callback
|
|
}
|
|
|
|
// SetOAuthSessionValidator sets the OAuth session validator for validating device auth
|
|
// When set, the handler will validate OAuth sessions are usable before issuing tokens for device auth
|
|
// This prevents the flood of errors that occurs when a stale session is discovered during push
|
|
func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
|
|
h.oauthSessionValidator = validator
|
|
}
|
|
|
|
// SetAuthorizer wires the auth-phase gate. When set, every token request
|
|
// runs Authorize after credentials validate; a non-nil error is returned to
|
|
// the client as a 403 (distribution error JSON) and the JWT is not issued.
|
|
func (h *Handler) SetAuthorizer(authorizer Authorizer) {
|
|
h.authorizer = authorizer
|
|
}
|
|
|
|
// SetAnonymousAuthorizer wires the credential-less pull gate. When set, an
|
|
// anonymous token request is narrowed to the repositories the gate is willing
|
|
// to grant, and a request where nothing grantable survives gets the standard
|
|
// 401 challenge instead of a token the registry would refuse. Unset leaves the
|
|
// previous behavior: mint pull for whatever was asked and let /v2/ decide.
|
|
func (h *Handler) SetAnonymousAuthorizer(authorizer AnonymousAuthorizer) {
|
|
h.anonymousAuthorizer = authorizer
|
|
}
|
|
|
|
// SetServiceAuthFetcher binds JWT issuance to the AppView↔hold service-auth.
|
|
// When set, the handler pre-mints the service-auth and stamps the JWT's exp
|
|
// from the cached expiry, so both tokens expire concurrently.
|
|
func (h *Handler) SetServiceAuthFetcher(fetcher ServiceAuthFetcher) {
|
|
h.serviceAuthFetcher = fetcher
|
|
}
|
|
|
|
// SetServices declares the registry domains this AppView fronts, e.g.
|
|
// ["buoy.cr", "seamark.cr", "atcr.io"]. Each issued JWT is stamped with
|
|
// whichever of these the client is authenticating against, so the audience
|
|
// names the front door actually used (see pkg/appview/registryauth). Unset
|
|
// leaves every token on the issuer's configured service, which is correct for
|
|
// a single-domain deployment.
|
|
func (h *Handler) SetServices(services []string) {
|
|
if len(services) == 0 {
|
|
h.services = nil
|
|
return
|
|
}
|
|
set := make(map[string]bool, len(services))
|
|
for _, s := range services {
|
|
if n := NormalizeService(s); n != "" {
|
|
set[n] = true
|
|
}
|
|
}
|
|
h.services = set
|
|
}
|
|
|
|
// SetServiceDisplayNames records the registry domains as configured, before
|
|
// NormalizeService lowercased them and stripped their ports, so the plain-text
|
|
// guidance can print a host `docker login` will actually accept.
|
|
//
|
|
// SetServices is fed the normalized list on purpose: a JWT audience has to
|
|
// match the port-stripped r.Host that DomainRoutingMiddleware routes on, so a
|
|
// dev stack configured as "127.0.0.1:5000" is the service "127.0.0.1". That is
|
|
// right for the audience and wrong for a printed command, which needs the port
|
|
// back. This is the only place the unstripped form survives, and it feeds
|
|
// nothing but the message.
|
|
//
|
|
// Pass the raw server.registry_domains list. Entries are keyed by their
|
|
// normalized form; when two entries collide (say "atcr.io" and "atcr.io:443")
|
|
// the first wins, matching the first-wins dedupe in the AppView's
|
|
// deriveServices and the "first entry is primary" rule. Unset, or a service
|
|
// with no configured entry, prints the normalized name as before.
|
|
func (h *Handler) SetServiceDisplayNames(domains []string) {
|
|
if len(domains) == 0 {
|
|
h.serviceDisplay = nil
|
|
return
|
|
}
|
|
display := make(map[string]string, len(domains))
|
|
for _, d := range domains {
|
|
d = strings.TrimSpace(d)
|
|
n := NormalizeService(d)
|
|
if n == "" {
|
|
continue
|
|
}
|
|
if _, ok := display[n]; ok {
|
|
continue
|
|
}
|
|
display[n] = d
|
|
}
|
|
h.serviceDisplay = display
|
|
}
|
|
|
|
// displayService renders a normalized service for human consumption, restoring
|
|
// the port the configured registry domain carried. Audience and routing never
|
|
// call this; it exists only so the guidance names a host docker can reach.
|
|
func (h *Handler) displayService(service string) string {
|
|
if service == "" {
|
|
return ""
|
|
}
|
|
if d, ok := h.serviceDisplay[service]; ok && d != "" {
|
|
return d
|
|
}
|
|
return service
|
|
}
|
|
|
|
// resolveService picks the registry domain to stamp as the JWT's audience.
|
|
//
|
|
// The requested service is the primary signal: Docker echoes back whatever the
|
|
// WWW-Authenticate challenge advertised, and that challenge is built per front
|
|
// door. It arrives in the ?service= query parameter on the GET form and in the
|
|
// service form field on the POST form, so callers extract it per method and
|
|
// pass it in. The request's own host is the fallback, which covers clients that
|
|
// reach /auth/token directly on a registry domain rather than via the realm.
|
|
// Both are client-influenced, so both are only honoured when they name a
|
|
// configured registry domain; anything else falls back to the issuer's service.
|
|
// That makes the worst case a token scoped to the primary domain, not a
|
|
// caller-chosen audience.
|
|
func (h *Handler) resolveService(r *http.Request, requested string) string {
|
|
if s := NormalizeService(requested); h.services[s] {
|
|
return s
|
|
}
|
|
if s := NormalizeService(r.Host); h.services[s] {
|
|
return s
|
|
}
|
|
return h.issuer.service
|
|
}
|
|
|
|
// TokenResponse represents the response from /auth/token
|
|
type TokenResponse struct {
|
|
Token string `json:"token,omitempty"` // Legacy field
|
|
AccessToken string `json:"access_token,omitempty"` // Standard field
|
|
ExpiresIn int `json:"expires_in,omitempty"`
|
|
IssuedAt string `json:"issued_at,omitempty"`
|
|
}
|
|
|
|
// getBaseURL extracts the base URL from the request, handling proxies
|
|
func getBaseURL(r *http.Request) string {
|
|
baseURL := r.Header.Get("X-Forwarded-Host")
|
|
if baseURL == "" {
|
|
baseURL = r.Host
|
|
}
|
|
if !strings.HasPrefix(baseURL, "http") {
|
|
// Add scheme
|
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
|
baseURL = "https://" + baseURL
|
|
} else {
|
|
baseURL = "http://" + baseURL
|
|
}
|
|
}
|
|
return baseURL
|
|
}
|
|
|
|
// sendAuthError sends the plain-text guidance a user sees when docker login fails.
|
|
//
|
|
// registryHost is the registry domain the token is being issued for, as
|
|
// resolved by resolveService and then run back through displayService, which
|
|
// restores whatever port server.registry_domains configured. The normalized
|
|
// service key has its port stripped because a JWT audience has to match the
|
|
// port-stripped routing host, but a command a human is told to run does not:
|
|
// on the dev stack the audience is "127.0.0.1" and the command is
|
|
// "docker login 127.0.0.1:5000".
|
|
//
|
|
// It is deliberately not r.Host: /auth/token is served on the UI domain as
|
|
// well as on every registry domain (the realm below points at the UI domain's
|
|
// copy, and DomainRoutingMiddleware serves the endpoint directly on registry
|
|
// domains so the Authorization header survives).
|
|
// On a split-domain deployment the UI host refuses /v2/* with an OCI
|
|
// UNSUPPORTED error, so "docker login <r.Host>" printed there names a host
|
|
// where the handshake cannot succeed. resolveService falls back to the
|
|
// deployment's primary registry domain, which is also right when the UI host
|
|
// and the registry domain are the same host.
|
|
//
|
|
// The install URL keeps using the request's own base URL: that page lives on
|
|
// the UI domain, and a registry domain redirects non-registry paths there.
|
|
//
|
|
// An empty registryHost (only reachable from a handler whose issuer has no
|
|
// service configured) drops step 2 rather than printing a hostname that cannot
|
|
// work.
|
|
func sendAuthError(w http.ResponseWriter, r *http.Request, registryHost, message string) {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
|
|
|
|
guidance := fmt.Sprintf(`%s
|
|
|
|
To authenticate:
|
|
1. Install credential helper: %s/install`, message, getBaseURL(r))
|
|
if registryHost != "" {
|
|
guidance += fmt.Sprintf(`
|
|
2. Or run: docker login %s
|
|
(use your ATProto handle + app-password)`, registryHost)
|
|
}
|
|
|
|
http.Error(w, guidance, http.StatusUnauthorized)
|
|
}
|
|
|
|
// AuthErrorResponse is returned when authentication fails in a way the credential helper can handle
|
|
type AuthErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
LoginURL string `json:"login_url,omitempty"`
|
|
}
|
|
|
|
// sendOAuthSessionExpiredError sends a JSON error response when OAuth session is missing
|
|
// This allows the credential helper to detect this specific error and open the browser
|
|
func sendOAuthSessionExpiredError(w http.ResponseWriter, r *http.Request) {
|
|
baseURL := getBaseURL(r)
|
|
loginURL := baseURL + "/auth/oauth/login"
|
|
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
resp := AuthErrorResponse{
|
|
Error: "oauth_session_expired",
|
|
Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.",
|
|
LoginURL: loginURL,
|
|
}
|
|
render.JSON(w, r, resp)
|
|
}
|
|
|
|
// slowPhaseThreshold flags /auth/token phases that take long enough to risk
|
|
// the Docker client's ~15s deadline; production incidents showed ~14s stalls
|
|
// before the first PDS call, and these phase timings exist to attribute them.
|
|
const slowPhaseThreshold = 5 * time.Second
|
|
|
|
// oauthError is the RFC 6749 section 5.2 error body used by the POST form.
|
|
type oauthError struct {
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description,omitempty"`
|
|
}
|
|
|
|
// writeOAuthError responds in the shape the OAuth2 token spec defines. Only the
|
|
// POST form uses it; the GET form keeps its plain-text guidance, which is what
|
|
// users actually see when they run docker login by hand.
|
|
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(oauthError{Error: code, ErrorDescription: description}); err != nil {
|
|
slog.Warn("failed to write OAuth error response", "error", err)
|
|
}
|
|
}
|
|
|
|
// collectScopes flattens the scope parameter into the individual scope strings
|
|
// ParseScope consumes.
|
|
//
|
|
// The Docker token spec lets the requested scope arrive in either of two forms,
|
|
// and clients use both: one space-separated value
|
|
// (scope=repository:a/b:pull repository:c/d:pull) or the parameter repeated
|
|
// (scope=repository:a/b:pull&scope=repository:c/d:pull). Reading only the first
|
|
// value dropped every repository after the first without comment, so a client
|
|
// asking for two got a token covering one and a 401 on the other. Both
|
|
// dimensions are flattened here, so the two forms produce identical access.
|
|
//
|
|
// strings.Fields skips empty and whitespace-only values, so a bare "scope="
|
|
// contributes nothing instead of an entry ParseScope would reject.
|
|
//
|
|
// An exactly repeated scope string collapses to one. Duplicates are dropped,
|
|
// never merged: folding two entries that name the same repository with
|
|
// different actions would union their action sets, and that is a widening,
|
|
// while every gate downstream (ValidateAccess, Authorize, AuthorizeAnonymous)
|
|
// is written only to narrow. A repeat of an identical string cannot mean more
|
|
// than the string itself, which is what makes removing it safe; two entries for
|
|
// one repository with different actions are left as they arrived, exactly as
|
|
// the space-separated form has always delivered them.
|
|
func collectScopes(values []string) []string {
|
|
var scopes []string
|
|
seen := make(map[string]bool)
|
|
for _, value := range values {
|
|
for scope := range strings.FieldsSeq(value) {
|
|
if seen[scope] {
|
|
continue
|
|
}
|
|
seen[scope] = true
|
|
scopes = append(scopes, scope)
|
|
}
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
// ServeHTTP handles the token request.
|
|
//
|
|
// Both Docker token specs land here. The original spec is GET with HTTP Basic
|
|
// and the scope in the query string. The OAuth2 spec is POST with a form-encoded
|
|
// body; containerd and Docker try it first whenever they hold a secret and only
|
|
// fall back to the GET form on 404/401/405, so answering it saves every such
|
|
// client a wasted round trip. Once credentials and scope are extracted the two
|
|
// paths are identical.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
phaseStart := time.Now()
|
|
slog.Debug("Received token request", "method", r.Method, "path", r.URL.Path)
|
|
|
|
var username, password, requestedService string
|
|
var scopes []string
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Missing credentials are not fatal on this form: an anonymous pull-only
|
|
// request is served without them, and whether that applies depends on the
|
|
// scope, which is parsed below. An empty username is the signal.
|
|
username, password, _ = r.BasicAuth()
|
|
scopes = collectScopes(r.URL.Query()["scope"])
|
|
requestedService = r.URL.Query().Get("service")
|
|
|
|
case http.MethodPost:
|
|
if err := r.ParseForm(); err != nil {
|
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "malformed request body")
|
|
return
|
|
}
|
|
// Only the password grant is supported, and no refresh token is issued.
|
|
// The registry JWT's lifetime is deliberately pinned to the AppView<->hold
|
|
// service-auth (see SetServiceAuthFetcher), so a refresh token would be a
|
|
// fourth long-lived credential with its own storage and revocation.
|
|
// Clients handle its absence by continuing to use the credential they hold.
|
|
//
|
|
// The status here is deliberately 401 rather than the 400 that RFC 6749
|
|
// section 5.2 prescribes. containerd only sends grant_type=refresh_token
|
|
// when it has no username, which is the same condition that disables its
|
|
// 405 fallback, so a 400 would hard-fail those clients. 401 is on its
|
|
// retry list, sending them to the GET form, where a device secret
|
|
// authenticates off the password alone and succeeds.
|
|
if grant := r.PostFormValue("grant_type"); grant != "" && grant != "password" {
|
|
writeOAuthError(w, http.StatusUnauthorized, "unsupported_grant_type",
|
|
fmt.Sprintf("grant_type %q is not supported, use password", grant))
|
|
return
|
|
}
|
|
username = r.PostFormValue("username")
|
|
password = r.PostFormValue("password")
|
|
if username == "" || password == "" {
|
|
// 401 is one of the statuses clients retry on the GET form, where the
|
|
// plain-text guidance in sendAuthError is waiting for them.
|
|
writeOAuthError(w, http.StatusUnauthorized, "invalid_client",
|
|
"username and password are required")
|
|
return
|
|
}
|
|
// ParseForm above has already populated PostForm, so the repeated form
|
|
// is readable here too. PostFormValue would take only the first value.
|
|
scopes = collectScopes(r.PostForm["scope"])
|
|
requestedService = r.PostFormValue("service")
|
|
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Reconstruct DID usernames that were mangled by BasicAuth's colon split.
|
|
// Form bodies carry the username as a discrete field so only the
|
|
// hyphen-encoding case can fire there, but running both keeps the two paths
|
|
// accepting exactly the same set of usernames.
|
|
username, password = parseBasicAuthDID(username, password)
|
|
|
|
slog.Debug("Got credentials", "username", username, "passwordLength", len(password))
|
|
|
|
// The service names the front door the client is authenticating against and
|
|
// becomes the JWT's audience; resolveService validates it against the
|
|
// configured registry domains.
|
|
service := h.resolveService(r, requestedService)
|
|
|
|
// The same domain, rendered for a human: resolveService returns the
|
|
// normalized (port-stripped) key the audience needs, which is not
|
|
// necessarily something `docker login` can dial. Only the guidance below
|
|
// uses this.
|
|
loginHost := h.displayService(service)
|
|
|
|
access, err := auth.ParseScope(scopes)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// No credentials. Only reachable on the GET form — the POST form rejects an
|
|
// empty username above, and a client that lands there without one is sent to
|
|
// the GET form by that 401.
|
|
//
|
|
// Narrow the request to its pull component rather than demanding it already
|
|
// be pull-only: clients commonly ask for pull,push (or pull,push,delete) for
|
|
// an operation that only reads, and rejecting those outright makes anonymous
|
|
// pull unreachable for them. What comes back carries "pull" and nothing else,
|
|
// so a push or delete still requires credentials. A scope with no pull
|
|
// component at all gets the standard challenge.
|
|
//
|
|
// The hold still owns the real decision via captain.Public, and /v2/ still
|
|
// enforces it from the local captain cache before serving anything. But the
|
|
// same answer is available here, so we ask for it: signing `pull` on a
|
|
// repository whose hold denies anonymous reads produces a token the registry
|
|
// then refuses, which is the authorization server contradicting the resource
|
|
// server. gateAnonymous drops the entries that would be refused, and a
|
|
// request with nothing grantable left falls through to the challenge below.
|
|
if username == "" {
|
|
if pullAccess, ok := NarrowToPullOnly(access); ok {
|
|
if granted, allowed := h.gateAnonymous(r.Context(), pullAccess); allowed {
|
|
h.issueAnonymousToken(w, r, granted, service)
|
|
return
|
|
}
|
|
slog.Debug("Anonymous pull denied: no requested repository admits anonymous reads")
|
|
sendAuthError(w, r, loginHost, "authentication required")
|
|
return
|
|
}
|
|
slog.Debug("No Basic auth credentials provided")
|
|
sendAuthError(w, r, loginHost, "authentication required")
|
|
return
|
|
}
|
|
|
|
var did string
|
|
var handle string
|
|
var accessToken string
|
|
var authMethod string
|
|
|
|
// 1. Check if it's a device secret (starts with "atcr_device_")
|
|
if strings.HasPrefix(password, "atcr_device_") {
|
|
device, err := h.deviceStore.ValidateDeviceSecret(password)
|
|
if err != nil {
|
|
slog.Debug("Device secret validation failed", "error", err)
|
|
sendAuthError(w, r, loginHost, "authentication failed")
|
|
return
|
|
}
|
|
|
|
// Validate OAuth session is usable (not just exists)
|
|
// Device secrets are permanent, but they require a working OAuth session to push
|
|
// By validating here, we prevent the flood of errors that occurs when a stale
|
|
// session is discovered during parallel layer uploads
|
|
if h.oauthSessionValidator != nil {
|
|
if err := h.oauthSessionValidator.ValidateSession(r.Context(), device.DID); err != nil {
|
|
slog.Debug("OAuth session validation failed", "did", device.DID, "error", err)
|
|
sendOAuthSessionExpiredError(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
did = device.DID
|
|
handle = device.Handle
|
|
authMethod = AuthMethodOAuth
|
|
// Device is linked to OAuth session via DID
|
|
// OAuth refresher will provide access token when needed via middleware
|
|
} else {
|
|
// 2. Try app password (direct PDS authentication)
|
|
slog.Debug("Trying app password authentication", "username", username)
|
|
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
|
|
if err != nil {
|
|
// Log at WARN level with specific error type
|
|
if errors.Is(err, auth.ErrIdentityResolution) {
|
|
slog.Warn("Identity resolution failed", "error", err, "username", username)
|
|
sendAuthError(w, r, loginHost, "authentication failed: could not resolve handle")
|
|
} else if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
slog.Warn("Invalid credentials", "username", username)
|
|
sendAuthError(w, r, loginHost, "authentication failed: invalid credentials")
|
|
} else if errors.Is(err, auth.ErrPDSUnavailable) {
|
|
slog.Warn("PDS unavailable", "error", err, "username", username)
|
|
sendAuthError(w, r, loginHost, "authentication failed: PDS unavailable")
|
|
} else {
|
|
slog.Warn("Authentication failed", "error", err, "username", username)
|
|
sendAuthError(w, r, loginHost, "authentication failed")
|
|
}
|
|
return
|
|
}
|
|
|
|
authMethod = AuthMethodAppPassword
|
|
|
|
slog.Debug("App password validated successfully",
|
|
"did", did,
|
|
"handle", handle,
|
|
"accessTokenLength", len(accessToken))
|
|
|
|
// Cache the access token for later use (e.g., when pushing manifests)
|
|
// TTL of 2 hours (ATProto tokens typically last longer)
|
|
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
|
|
slog.Debug("Cached access token", "did", did)
|
|
|
|
// Call post-auth callback for AppView business logic (profile management, etc.)
|
|
if h.postAuthCallback != nil {
|
|
// Resolve PDS endpoint for callback
|
|
_, _, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), username)
|
|
if err != nil {
|
|
// Log error but don't fail auth - profile management is not critical
|
|
slog.Warn("Failed to resolve PDS for callback", "error", err, "username", username)
|
|
} else {
|
|
if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil {
|
|
// Log error but don't fail auth - business logic is non-critical
|
|
slog.Warn("Post-auth callback failed", "error", err, "did", did)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Credential phase covers device-secret/app-password validation including
|
|
// any OAuth session validation (and its lazy token refresh).
|
|
credDur := time.Since(phaseStart)
|
|
if credDur > slowPhaseThreshold {
|
|
slog.Warn("slow /auth/token phase",
|
|
"phase", "credentials",
|
|
"did", did,
|
|
"authMethod", authMethod,
|
|
"duration", credDur.Round(time.Millisecond))
|
|
}
|
|
|
|
// Validate that the user has permission for the requested access
|
|
// Use the actual handle from the validated credentials, not the Basic Auth username
|
|
if err := auth.ValidateAccess(did, handle, access); err != nil {
|
|
slog.Debug("Access validation failed", "error", err, "did", did)
|
|
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Auth-phase gate (crew reconciliation, plus membership/quota for
|
|
// non-wildcard push) and service-auth pre-mint are independent network
|
|
// paths — one talks to the hold, the other to the PDS. Run them in
|
|
// parallel so a cold /auth/token pays max(both) instead of sum(both).
|
|
// Nil-authorizer / nil-fetcher configurations short-circuit each branch
|
|
// independently. The gate runs for pull too: first-time CLI users on a
|
|
// private hold need crew reconciliation before the hold-side read check
|
|
// will accept the resulting JWT.
|
|
runGate := h.authorizer != nil
|
|
runFetch := h.serviceAuthFetcher != nil
|
|
|
|
type gateRes struct{ err error }
|
|
type fetchRes struct {
|
|
expiresAt time.Time
|
|
err error
|
|
}
|
|
var (
|
|
gateCh chan gateRes
|
|
fetchCh chan fetchRes
|
|
)
|
|
if runGate {
|
|
gateCh = make(chan gateRes, 1)
|
|
go func() {
|
|
gateCh <- gateRes{err: h.authorizer.Authorize(r.Context(), did, authMethod, access)}
|
|
}()
|
|
}
|
|
if runFetch {
|
|
fetchCh = make(chan fetchRes, 1)
|
|
go func() {
|
|
exp, err := h.serviceAuthFetcher.Fetch(r.Context(), did, authMethod)
|
|
fetchCh <- fetchRes{expiresAt: exp, err: err}
|
|
}()
|
|
}
|
|
|
|
// Drain the gate first so a denial wins over a transient fetch error.
|
|
drainStart := time.Now()
|
|
var gateDur time.Duration
|
|
if runGate {
|
|
res := <-gateCh
|
|
gateDur = time.Since(drainStart)
|
|
if gateDur > slowPhaseThreshold {
|
|
slog.Warn("slow /auth/token phase",
|
|
"phase", "gate",
|
|
"did", did,
|
|
"duration", gateDur.Round(time.Millisecond))
|
|
}
|
|
if res.err != nil {
|
|
slog.Info("Authorization denied", "did", did, "error", res.err)
|
|
_ = errcode.ServeJSON(w, errcode.ErrorCodeDenied.WithMessage(res.err.Error()))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Bind JWT lifetime to the AppView↔hold service-auth: pre-mint it now and
|
|
// stamp the JWT's exp from the cached expiry. They expire concurrently;
|
|
// when Docker hits 401, the next /auth/token call mints both fresh.
|
|
issueExp := h.issuer.expiration
|
|
var fetchDur time.Duration
|
|
if runFetch {
|
|
res := <-fetchCh
|
|
// Both goroutines started together, so measure from the drain start,
|
|
// not after the gate drain, to reflect the fetch's real wall time.
|
|
fetchDur = time.Since(drainStart)
|
|
if fetchDur > slowPhaseThreshold {
|
|
slog.Warn("slow /auth/token phase",
|
|
"phase", "service-auth-fetch",
|
|
"did", did,
|
|
"duration", fetchDur.Round(time.Millisecond))
|
|
}
|
|
if res.err != nil {
|
|
// A read-only app password can authenticate (createSession) but can't
|
|
// mint the hold service-auth token that pulls and pushes require. That
|
|
// is a permanent authorization failure, not a transient outage, so
|
|
// return a 403 with actionable guidance instead of a retry-inviting 503.
|
|
if errors.Is(res.err, auth.ErrAppPasswordInsufficientScope) {
|
|
slog.Info("service-auth pre-mint denied: app-password lacks scope", "did", did, "error", res.err)
|
|
_ = errcode.ServeJSON(w, errcode.ErrorCodeDenied.WithMessage(
|
|
"your app password lacks the permissions ATCR needs. A read-only app password cannot mint the service token used to authenticate with your storage hold. Use a standard (full-access) app password, or pull public images without logging in."))
|
|
return
|
|
}
|
|
slog.Warn("service-auth pre-mint failed", "did", did, "error", res.err)
|
|
_ = errcode.ServeJSON(w, errcode.ErrorCodeUnavailable.WithMessage(fmt.Sprintf("service-auth fetch failed: %v", res.err)))
|
|
return
|
|
}
|
|
if !res.expiresAt.IsZero() {
|
|
// Cap JWT lifetime at the service-auth's expiry, stamping exactly
|
|
// the value the fetcher returned. That value already has
|
|
// auth.ServiceTokenSafetyMargin subtracted from the PDS-granted
|
|
// exp, and the margin is deliberately >= distribution's
|
|
// token.Leeway (60s): the registry auth package accepts this JWT
|
|
// for Leeway past its exp, so the last instant a client can use it
|
|
// is still inside the service token's real life. Do not shrink the
|
|
// margin below that leeway or the gap reopens, and Docker never
|
|
// gets the 401 that would make it re-authenticate.
|
|
//
|
|
// We never extend beyond the configured default.
|
|
until := time.Until(res.expiresAt)
|
|
if until < issueExp {
|
|
issueExp = until
|
|
}
|
|
}
|
|
}
|
|
|
|
// Issue JWT token
|
|
tokenString, err := h.issuer.IssueWithExpiration(did, access, authMethod, issueExp, service)
|
|
if err != nil {
|
|
slog.Error("Failed to issue token", "error", err, "did", did)
|
|
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
slog.Debug("Issued JWT token",
|
|
"tokenLength", len(tokenString),
|
|
"did", did,
|
|
"authMethod", authMethod,
|
|
"credentialsDur", credDur.Round(time.Millisecond),
|
|
"gateDur", gateDur.Round(time.Millisecond),
|
|
"fetchDur", fetchDur.Round(time.Millisecond),
|
|
"totalDur", time.Since(phaseStart).Round(time.Millisecond))
|
|
|
|
// Return token response
|
|
now := time.Now()
|
|
expiresIn := int(issueExp.Seconds())
|
|
|
|
resp := TokenResponse{
|
|
Token: tokenString,
|
|
AccessToken: tokenString,
|
|
ExpiresIn: expiresIn,
|
|
IssuedAt: now.Format(time.RFC3339),
|
|
}
|
|
|
|
render.JSON(w, r, resp)
|
|
}
|
|
|
|
// gateAnonymous asks the anonymous authorizer which of an already-narrowed
|
|
// pull scope this AppView will sign, and reports whether a token is still worth
|
|
// issuing. Returns the surviving access on true; on false the caller must send
|
|
// the 401 challenge rather than a token with nothing in it — an empty-access
|
|
// token lets the client proceed to /v2/ and collect its 401 there, which is a
|
|
// smaller version of the disagreement this gate exists to remove, and the
|
|
// challenge is what makes docker prompt for credentials.
|
|
func (h *Handler) gateAnonymous(ctx context.Context, access []auth.AccessEntry) ([]auth.AccessEntry, bool) {
|
|
if h.anonymousAuthorizer == nil {
|
|
return access, true
|
|
}
|
|
|
|
// Nothing in the request grants anything: either the /v2/ ping (empty
|
|
// access) or the actionless entry NarrowToPullOnly preserves on purpose.
|
|
// Neither names a hold to check and both must keep working, so they are
|
|
// returned untouched — no identity resolution, no hold lookup, no cost on
|
|
// the discovery path.
|
|
if countGranting(access) == 0 {
|
|
return access, true
|
|
}
|
|
|
|
granted := h.anonymousAuthorizer.AuthorizeAnonymous(ctx, access)
|
|
if countGranting(granted) == 0 {
|
|
return nil, false
|
|
}
|
|
return granted, true
|
|
}
|
|
|
|
// countGranting counts the entries that actually authorize something. After
|
|
// NarrowToPullOnly every such entry carries exactly ["pull"]; the rest are the
|
|
// deliberately preserved placeholders that grant nothing.
|
|
func countGranting(access []auth.AccessEntry) int {
|
|
n := 0
|
|
for _, entry := range access {
|
|
if len(entry.Actions) > 0 {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// issueAnonymousToken mints a credential-less registry JWT for a pull-only
|
|
// scope. The token carries an empty Subject (no puller DID) and the anonymous
|
|
// auth method. It deliberately skips the authorizer gate and service-auth
|
|
// pre-mint: there is no identity to reconcile and no AppView↔hold service-auth
|
|
// to bind, since anonymous reads never carry a service token to the hold.
|
|
//
|
|
// The service is still stamped: an anonymous pull against a secondary registry
|
|
// domain has to satisfy that domain's access controller, which demands its own
|
|
// audience, so the issuer's default would be rejected there.
|
|
func (h *Handler) issueAnonymousToken(w http.ResponseWriter, r *http.Request, access []auth.AccessEntry, service string) {
|
|
// Defensive: ValidateAccess only restricts push/delete to the owner, so a
|
|
// pull-only scope (the only thing routed here) always passes. Empty DID is
|
|
// fine — anonymous tokens have no owner.
|
|
if err := auth.ValidateAccess("", "", access); err != nil {
|
|
slog.Debug("Anonymous access validation failed", "error", err)
|
|
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
tokenString, err := h.issuer.IssueWithExpiration("", access, AuthMethodAnonymous, h.issuer.expiration, service)
|
|
if err != nil {
|
|
slog.Error("Failed to issue anonymous token", "error", err)
|
|
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
slog.Debug("Issued anonymous pull token", "tokenLength", len(tokenString))
|
|
|
|
now := time.Now()
|
|
resp := TokenResponse{
|
|
Token: tokenString,
|
|
AccessToken: tokenString,
|
|
ExpiresIn: int(h.issuer.expiration.Seconds()),
|
|
IssuedAt: now.Format(time.RFC3339),
|
|
}
|
|
render.JSON(w, r, resp)
|
|
}
|
|
|
|
// parseBasicAuthDID fixes DID usernames that are mangled by HTTP Basic Auth.
|
|
//
|
|
// This handles two cases:
|
|
// 1. Hyphen-encoded DIDs (did-plc-abc123) — converted to did:plc:abc123.
|
|
// This is the recommended format for tools like helm that reject colons in usernames.
|
|
// 2. Raw DIDs split by BasicAuth — "did:plc:abc123" gets split on the first colon
|
|
// into username="did", password="plc:abc123:<real-password>". Reconstructed here.
|
|
func parseBasicAuthDID(username, password string) (string, string) {
|
|
// Case 1: Hyphen-encoded DID (e.g., did-plc-abc123 or did-web-example.com)
|
|
if did, ok := auth.DecodeDIDFromHyphens(username); ok {
|
|
return did, password
|
|
}
|
|
|
|
// Case 2: Raw DID was split by BasicAuth on the first colon
|
|
// username="did", password="plc:<id>:<real-password>" or "web:<host>:<real-password>"
|
|
if username != "did" {
|
|
return username, password
|
|
}
|
|
|
|
if after, ok := strings.CutPrefix(password, "plc:"); ok {
|
|
rest := after
|
|
if idx := strings.Index(rest, ":"); idx > 0 {
|
|
return "did:plc:" + rest[:idx], rest[idx+1:]
|
|
}
|
|
} else if after, ok := strings.CutPrefix(password, "web:"); ok {
|
|
rest := after
|
|
if idx := strings.Index(rest, ":"); idx > 0 {
|
|
return "did:web:" + rest[:idx], rest[idx+1:]
|
|
}
|
|
}
|
|
|
|
return username, password
|
|
}
|