mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 20:27:16 +00:00
425 lines
16 KiB
Go
425 lines
16 KiB
Go
package token
|
|
|
|
import (
|
|
"context"
|
|
"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.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ServeHTTP handles the token request
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
slog.Debug("Received token request", "method", r.Method, "path", r.URL.Path)
|
|
|
|
// Only accept GET requests (per Docker spec)
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Extract Basic auth credentials
|
|
username, password, ok := r.BasicAuth()
|
|
if !ok {
|
|
slog.Debug("No Basic auth credentials provided")
|
|
sendAuthError(w, r, "authentication required")
|
|
return
|
|
}
|
|
|
|
// Reconstruct DID usernames that were mangled by BasicAuth's colon split
|
|
username, password = parseBasicAuthDID(username, password)
|
|
|
|
slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
|
|
|
|
// Parse query parameters
|
|
_ = r.URL.Query().Get("service") // service parameter - validated by issuer
|
|
scopeParam := r.URL.Query().Get("scope")
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
if runGate {
|
|
if res := <-gateCh; 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
|
|
if runFetch {
|
|
res := <-fetchCh
|
|
if res.err != nil {
|
|
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)
|
|
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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|