mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +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>
134 lines
4.5 KiB
Go
134 lines
4.5 KiB
Go
// Package token provides JWT claims and token handling for registry authentication.
|
|
package token
|
|
|
|
import (
|
|
"slices"
|
|
"time"
|
|
|
|
"atcr.io/pkg/auth"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Auth method constants
|
|
const (
|
|
AuthMethodOAuth = "oauth"
|
|
AuthMethodAppPassword = "app_password"
|
|
// AuthMethodAnonymous marks a token issued without credentials for a
|
|
// pull-only scope. Such tokens carry an empty Subject (no puller DID); the
|
|
// destination hold decides whether anonymous reads are allowed (captain.Public).
|
|
AuthMethodAnonymous = "anonymous"
|
|
)
|
|
|
|
// Claims represents the JWT claims for registry authentication
|
|
// This follows the Docker Registry token specification
|
|
type Claims struct {
|
|
jwt.RegisteredClaims
|
|
Access []auth.AccessEntry `json:"access,omitempty"`
|
|
AuthMethod string `json:"auth_method,omitempty"` // "oauth" or "app_password"
|
|
}
|
|
|
|
// NewClaims creates a new Claims structure with standard fields
|
|
func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry, authMethod string) *Claims {
|
|
now := time.Now()
|
|
return &Claims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: subject, // User's DID
|
|
Issuer: issuer, // "atcr.io"
|
|
Audience: jwt.ClaimStrings{audience}, // Service name
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(expiration)),
|
|
},
|
|
Access: access,
|
|
AuthMethod: authMethod, // "oauth" or "app_password"
|
|
}
|
|
}
|
|
|
|
// ExtractAuthMethod parses a JWT token string and extracts the auth_method claim
|
|
// Returns the auth method or empty string if not found or token is invalid
|
|
// This does NOT validate the token - it only parses it to extract the claim
|
|
func ExtractAuthMethod(tokenString string) string {
|
|
// Parse token without validation (we only need the claims, validation is done by distribution library)
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
|
if err != nil {
|
|
return "" // Invalid token format
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return "" // Wrong claims type
|
|
}
|
|
|
|
return claims.AuthMethod
|
|
}
|
|
|
|
// ExtractAccess parses a JWT token string and extracts the access entries (scopes)
|
|
// Returns nil if not found or token is invalid
|
|
// This does NOT validate the token - it only parses it to extract the claim
|
|
func ExtractAccess(tokenString string) []auth.AccessEntry {
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return claims.Access
|
|
}
|
|
|
|
// HasPushScope checks if any access entry contains a "push" action
|
|
func HasPushScope(access []auth.AccessEntry) bool {
|
|
for _, entry := range access {
|
|
if slices.Contains(entry.Actions, "push") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsPullOnlyScope reports whether the requested access is safe to grant without
|
|
// credentials. This is the gate for anonymous token issuance, so it allowlists:
|
|
// every requested action must be exactly "pull". Empty access (the /v2/ ping)
|
|
// and an entry with no actions are both fine.
|
|
//
|
|
// It must not be written as a denylist of "push"/"delete". Distribution's
|
|
// actionSet.contains returns true for ANY action when the set holds "*"
|
|
// (registry/auth/token/util.go), so a scope of `repository:victim/img:*` names
|
|
// neither string yet authorizes push and delete. Denylisting would hand an
|
|
// unauthenticated caller a token that clears the whole appview authorization
|
|
// layer — the authgate is deliberately skipped for anonymous tokens.
|
|
func IsPullOnlyScope(access []auth.AccessEntry) bool {
|
|
for _, entry := range access {
|
|
for _, action := range entry.Actions {
|
|
if action != "pull" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ExtractSubject parses a JWT token string and extracts the Subject claim (the user's DID)
|
|
// Returns the subject or empty string if not found or token is invalid
|
|
// This does NOT validate the token - it only parses it to extract the claim
|
|
func ExtractSubject(tokenString string) string {
|
|
// Parse token without validation (we only need the claims, validation is done by distribution library)
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
|
if err != nil {
|
|
return "" // Invalid token format
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return "" // Wrong claims type
|
|
}
|
|
|
|
return claims.Subject
|
|
}
|