Files
at-container-registry/pkg/auth/token/issuer.go
T
Evan JarrettandClaude Opus 5 2719428071 appview: give each registry domain its own JWT service name
An AppView can front several registry domains that all reach the same
backend (seamark.dev serving buoy.cr, seamark.cr, and soon atcr.io).
Distribution's token access controller holds `service` as a single string
and uses it twice: as the value advertised in the WWW-Authenticate
challenge, and as the sole accepted JWT audience. So it announced one
domain's name on every domain, and honoured one domain's tokens
everywhere. A push to seamark.cr was challenged with service="buoy.cr".

Both uses sit inside Authorized, which already has the request, but the
value is fixed at construction and reachable through no hook — autoredirect
only templates the realm. So register an "atcr-token" controller that
builds one upstream controller per domain and dispatches on r.Host. Each
front door now advertises its own name and demands its own audience. All
signature, certificate and claim verification stays in upstream code; this
only routes.

The token handler stops discarding ?service= and stamps the audience with
the front door the client used, allowlist-checked against the configured
domains so the value stays server-determined despite arriving from the
client. It has to come from the query param because the realm lives on the
UI host, where r.Host names no registry domain.

This is token hygiene and spec conformance, not a privilege boundary: every
domain fronts the same backend, so a client can obtain a token for any of
them just by handshaking there. What it buys is a truthful challenge and
the decoupling needed to later split a domain onto its own AppView.

Also unify the domain list. DomainRoutingMiddleware keyed its map on the
raw config while matching a port-stripped host, so a domain configured with
a port could never match its own requests. It now shares the normalized
cfg.Auth.Services, so routing and authorization agree on one set of names.
cfg.Auth.ServiceName was an exact alias for Services[0] and is replaced by
PrimaryService(), which also removes an empty-slice index.

Rollout: the audience for seamark.cr and bouy.cr changes, so a token minted
just before the restart draws one 401 and Docker re-handshakes into a valid
one. buoy.cr is unchanged (it stays primary), and atcr.io keeps the service
name it already has today. The challenge and the accepted audience come
from the same delegate, so the retry converges by construction. Deploy as a
single flip, not a canary: an old instance ignores ?service= and would keep
minting the primary audience while a new one rejects it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:40:55 -05:00

240 lines
7.4 KiB
Go

package token
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"atcr.io/pkg/auth"
"github.com/golang-jwt/jwt/v5"
)
// Issuer handles JWT token creation and signing
type Issuer struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
certificate []byte // DER-encoded certificate
issuer string
service string
expiration time.Duration
}
// NewIssuer creates a new JWT issuer
func NewIssuer(privateKeyPath, issuer, service string, expiration time.Duration) (*Issuer, error) {
privateKey, err := loadOrGenerateKey(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load private key: %w", err)
}
// Load the certificate for x5c header
certPath := strings.TrimSuffix(privateKeyPath, ".pem") + ".crt"
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate: %w", err)
}
// Parse PEM to get DER-encoded certificate
block, _ := pem.Decode(certPEM)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("failed to decode certificate PEM")
}
return &Issuer{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
certificate: block.Bytes, // DER-encoded certificate
issuer: issuer,
service: service,
expiration: expiration,
}, nil
}
// NewIssuerFromKey creates a JWT issuer from pre-loaded key material.
// certDER is the DER-encoded X.509 certificate for the x5c JWT header.
func NewIssuerFromKey(privateKey *rsa.PrivateKey, certDER []byte, issuer, service string, expiration time.Duration) *Issuer {
return &Issuer{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
certificate: certDER,
issuer: issuer,
service: service,
expiration: expiration,
}
}
// Issue creates and signs a new JWT token using the issuer's configured
// expiration and service.
func (i *Issuer) Issue(subject string, access []auth.AccessEntry, authMethod string) (string, error) {
return i.IssueWithExpiration(subject, access, authMethod, i.expiration, i.service)
}
// IssueWithExpiration creates and signs a JWT with a per-call expiration and
// audience.
//
// The expiration is per-call because the JWT's lifetime is bound to a
// downstream credential that can expire sooner than the issuer's default — the
// AppView↔hold service-auth, where the cache applies a 10s safety margin
// against the PDS-granted exp.
//
// The audience is per-call because an AppView can front several registry
// domains (server.registry_domains), and the Docker token spec makes `service`
// the name of the registry the client is authenticating against. Stamping the
// front door the client actually used lets the matching access controller
// (pkg/appview/registryauth) demand its own domain's audience. Note this is
// scoping, not a privilege boundary: the same client can obtain a token for any
// configured domain just by handshaking there.
//
// An empty service falls back to the issuer's default. The caller is
// responsible for validating a non-empty service against the configured
// registry domains, since the value ultimately derives from client input.
func (i *Issuer) IssueWithExpiration(subject string, access []auth.AccessEntry, authMethod string, expiration time.Duration, service string) (string, error) {
if service == "" {
service = i.service
}
claims := NewClaims(subject, i.issuer, service, expiration, access, authMethod)
slog.Debug("Creating JWT token",
"issuer", i.issuer,
"service", service,
"subject", subject,
"access", access,
"expiration", expiration)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
// Add x5c header - embeds the certificate chain in the JWT
// This is base64-encoded DER certificate(s)
certChain := []string{
base64.StdEncoding.EncodeToString(i.certificate),
}
token.Header["x5c"] = certChain
signedToken, err := token.SignedString(i.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
slog.Debug("Successfully signed token with x5c header")
return signedToken, nil
}
// PublicKey returns the public key for token verification
func (i *Issuer) PublicKey() *rsa.PublicKey {
return i.publicKey
}
// Expiration returns the token expiration duration
func (i *Issuer) Expiration() time.Duration {
return i.expiration
}
// loadOrGenerateKey loads an existing RSA private key or generates a new one
func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) {
// Try to load existing key
if _, err := os.Stat(path); err == nil {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}
block, _ := pem.Decode(keyData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return nil, fmt.Errorf("failed to decode PEM block containing private key")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
// Ensure certificate exists
certPath := strings.TrimSuffix(path, ".pem") + ".crt"
if _, err := os.Stat(certPath); os.IsNotExist(err) {
// Certificate doesn't exist, generate it
if err := generateCertificate(privateKey, certPath); err != nil {
return nil, fmt.Errorf("failed to generate certificate: %w", err)
}
}
return privateKey, nil
}
// Generate new key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create key directory: %w", err)
}
// Save key to file
keyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyBytes,
})
if err := os.WriteFile(path, keyPEM, 0600); err != nil {
return nil, fmt.Errorf("failed to write private key: %w", err)
}
// Also generate a self-signed certificate for the public key
certPath := strings.TrimSuffix(path, ".pem") + ".crt"
if err := generateCertificate(privateKey, certPath); err != nil {
return nil, fmt.Errorf("failed to generate certificate: %w", err)
}
return privateKey, nil
}
// generateCertificate creates a self-signed certificate for JWT validation
func generateCertificate(privateKey *rsa.PrivateKey, certPath string) error {
// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"ATCR"},
CommonName: "ATCR Token Signing Certificate",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Create self-signed certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Encode certificate to PEM
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
// Write certificate to file
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return fmt.Errorf("failed to write certificate: %w", err)
}
return nil
}