mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
Both are pre-existing and were found while working on finding 27.
sendAuthError built its "docker login <host>" line from r.Host. /auth/token is
served on the UI domain as well as on every registry domain, and the
WWW-Authenticate realm points at the UI domain's copy, so a client following
the realm was told to run "docker login seamark.dev" - the one host that
deliberately refuses /v2/* with an OCI UNSUPPORTED error pointing at
seamark.cr. It now uses the service resolved for the token, which is the
registry domain, falling back to the deployment's primary rather than to
r.Host. The single-domain case still prints a host that serves /v2/, and an
unconfigured service supplied by the client cannot steer it.
Separately, the scope parameter was read with .Get, taking the first value
only. The Docker token spec allows scope to be repeated, so a client asking for
two repositories was issued a token covering one and got a 401 on the other.
Both wire forms are now flattened, on the GET query string and on the OAuth2
POST body, which had the same defect via PostFormValue.
Empty and whitespace-only values are dropped. Exact duplicate scope strings
collapse, but two entries naming the same repository with different actions are
left alone: merging them would union the action sets, and every gate downstream
is written only to narrow.
More entries now reach the anonymous gate added in af7522b, which is the
intended effect. Its per-entry verdict is unchanged: public entries survive,
private ones are dropped, and an all-private request still gets the challenge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
800 lines
33 KiB
Go
800 lines
33 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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. 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.Fields(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)
|
|
|
|
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, service, "authentication required")
|
|
return
|
|
}
|
|
slog.Debug("No Basic auth credentials provided")
|
|
sendAuthError(w, r, service, "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, service, "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, service, "authentication failed: could not resolve handle")
|
|
} else if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
slog.Warn("Invalid credentials", "username", username)
|
|
sendAuthError(w, r, service, "authentication failed: invalid credentials")
|
|
} else if errors.Is(err, auth.ErrPDSUnavailable) {
|
|
slog.Warn("PDS unavailable", "error", err, "username", username)
|
|
sendAuthError(w, r, service, "authentication failed: PDS unavailable")
|
|
} else {
|
|
slog.Warn("Authentication failed", "error", err, "username", username)
|
|
sendAuthError(w, r, service, "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)
|
|
}
|
|
|
|
// 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
|
|
}
|