mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
Credential-less pulls of public images. /auth/token issues a pull-only
token with an empty subject when no Basic auth is present; the
destination hold still enforces captain.Public, and push or delete always
challenges.
- token.IsPullOnlyScope and AuthMethodAnonymous;
Handler.issueAnonymousToken skips the authorizer gate and the
service-auth pre-mint, since there is no identity to reconcile and no
AppView-to-hold service token to bind. The token is still stamped
with the resolved registry domain, so anonymous pull works on
secondary front doors whose access controller demands their own
audience.
- auth.allow_anonymous_pull (default true) turns it fully off, restoring
the previous always-challenge behavior. Mirrored into the deploy
template, since the default means existing deploys pick this up.
- RegistryContext.Anonymous is plumbed from the middleware.
- ProxyBlobStore sends no Authorization header when the service token is
empty, and returns 401 rather than 403 for anonymous denials so Docker
prompts for credentials, including when a stale captain cache lets the
request through and the hold says private.
- BearerChallenge wraps the /v2/ subtree so a 401 raised deep in the
stack via errcode.ServeJSON still carries WWW-Authenticate.
Distribution's own scoped challenges are left alone.
IsPullOnlyScope allowlists the pull action instead of denylisting push and
delete. Distribution's actionSet.contains treats "*" as *every* action, so
a scope of `repository:victim/img:*` names neither denied string and would
have handed an unauthenticated caller a token valid for push and delete on
someone else's repository — clearing the authgate entirely, since anonymous
tokens deliberately skip it. Writes would still have failed further down
(no PDS credential), but the gate itself was bypassable. Now every
requested action must be exactly "pull". Covered by new claims tests.
Unresolvable identities return NAME_UNKNOWN instead of a bare error that
distribution renders as 500. This path was previously unreachable without
credentials; anonymous pull opens it to the internet, and a 5xx on
arbitrary input both misreports a bad request as a server fault and sends
clients that retry 5xx into a retry loop. That loop was real: in the auth
matrix, regclient spent 83s on a single case before this fix, and the
suite now runs in 5s.
Stat preserves an authorization verdict from getPresignedURL rather than
flattening it to ErrBlobUnknown. Distribution calls Stat before ServeBlob
on GET and HEAD, so without this an anonymous pull from a private hold
answered 404 and BearerChallenge had no 401 to annotate — the 401 path
above could never actually reach a client.
The auth matrix is updated to match: anonymous pull of the seeded public
repo now succeeds, anonymous push is denied against a real identity's
namespace (rather than an unresolvable one, which was testing name
resolution rather than authorization), and a new case pins the
NAME_UNKNOWN behavior for an unknown identity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
662 lines
26 KiB
Go
662 lines
26 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
|
|
}
|
|
|
|
// 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
|
|
serviceAuthFetcher ServiceAuthFetcher
|
|
allowAnonymousPull bool // issue credential-less tokens for pull-only scopes
|
|
|
|
// 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
|
|
}
|
|
|
|
// NewHandler creates a new token handler
|
|
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler {
|
|
return &Handler{
|
|
issuer: issuer,
|
|
validator: auth.NewSessionValidator(),
|
|
deviceStore: deviceStore,
|
|
allowAnonymousPull: true,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetAllowAnonymousPull toggles credential-less token issuance for pull-only
|
|
// scopes. When false, requests without credentials are always challenged, so
|
|
// the handler behaves exactly as it did before anonymous pull existed. Per-hold
|
|
// privacy (captain.Public) still applies regardless of this setting.
|
|
func (h *Handler) SetAllowAnonymousPull(allow bool) {
|
|
h.allowAnonymousPull = allow
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 a formatted authentication error response
|
|
func sendAuthError(w http.ResponseWriter, r *http.Request, message string) {
|
|
baseURL := getBaseURL(r)
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
|
|
http.Error(w, fmt.Sprintf(`%s
|
|
|
|
To authenticate:
|
|
1. Install credential helper: %s/install
|
|
2. Or run: docker login %s
|
|
(use your ATProto handle + app-password)`, message, baseURL, r.Host), 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)
|
|
}
|
|
}
|
|
|
|
// 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, scopeParam, requestedService 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()
|
|
scopeParam = r.URL.Query().Get("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
|
|
}
|
|
scopeParam = r.PostFormValue("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)
|
|
|
|
// Parse scopes
|
|
var scopes []string
|
|
if scopeParam != "" {
|
|
scopes = strings.Split(scopeParam, " ")
|
|
}
|
|
|
|
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. Issue an anonymous, pull-only token if enabled
|
|
// and the requested scope carries no write actions; the destination hold then
|
|
// decides whether anonymous reads are allowed (captain.Public). Anything
|
|
// requesting push/delete still gets the standard auth challenge.
|
|
if username == "" {
|
|
if h.allowAnonymousPull && IsPullOnlyScope(access) {
|
|
h.issueAnonymousToken(w, r, access, service)
|
|
return
|
|
}
|
|
slog.Debug("No Basic auth credentials provided")
|
|
sendAuthError(w, r, "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, "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, "authentication failed: could not resolve handle")
|
|
} else if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
slog.Warn("Invalid credentials", "username", username)
|
|
sendAuthError(w, r, "authentication failed: invalid credentials")
|
|
} else if errors.Is(err, auth.ErrPDSUnavailable) {
|
|
slog.Warn("PDS unavailable", "error", err, "username", username)
|
|
sendAuthError(w, r, "authentication failed: PDS unavailable")
|
|
} else {
|
|
slog.Warn("Authentication failed", "error", err, "username", username)
|
|
sendAuthError(w, r, "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. The cache's
|
|
// expiresAt already includes a 10s safety margin
|
|
// (pkg/auth/cache.go:71), so this guarantees the service-auth
|
|
// is still cache-valid for any /v2/* request the JWT can
|
|
// authorize. 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)
|
|
}
|
|
|
|
// 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
|
|
}
|