Files
zot/pkg/api/bearer_oidc.go
T
Matheus Pimenta bf619c570e Introduce support for OIDC workload identity federation (#3711)
* feat(oidc): introduce support for OIDC workload identity federation

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): add e2e test for bearer OIDC and a kind cluster

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): make OIDC workload identity federation its own feature

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): move errors to the errors package

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): fix race in cel package

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): compile cel expressions

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

---------

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2026-01-24 21:03:53 -08:00

245 lines
7.5 KiB
Go

package api
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
zerr "zotregistry.dev/zot/v2/errors"
"zotregistry.dev/zot/v2/pkg/api/config"
"zotregistry.dev/zot/v2/pkg/cel"
"zotregistry.dev/zot/v2/pkg/log"
)
// oidcProviderRefreshInterval defines how often to refresh the OIDC provider configuration.
// With 1 minute interval, even if there are too many API calls authenticating via OIDC
// bearer tokens at once, we will only refresh the provider at most once per minute (safe).
const oidcProviderRefreshInterval = 1 * time.Minute
var bearerOIDCTokenMatch = regexp.MustCompile("(?i)bearer (.*)")
// OIDCBearerAuthorizer validates OIDC ID tokens for workload identity authentication.
type OIDCBearerAuthorizer struct {
providers []*oidcProvider
}
// oidcProvider validates OIDC ID tokens for workload identity authentication.
// It holds the configuration for a single OIDC issuer.
type oidcProvider struct {
issuer string
audiences []string
claimProcessor *cel.ClaimProcessor
skipIssuerCheck bool
log log.Logger
// The *oidc.IDTokenVerifier is created lazily to avoid network calls during initialization.
// We really don't want to block startup if the OIDC issuer is temporarily unreachable.
// Also, we periodically refresh the provider to pick up any changes in the issuer's configuration.
verifier *oidc.IDTokenVerifier
verifierMu sync.RWMutex
verifierDeadline time.Time
}
// NewOIDCBearerAuthorizer creates a new OIDC bearer token authorizer.
func NewOIDCBearerAuthorizer(oidcConfig []config.BearerOIDCConfig, log log.Logger) (*OIDCBearerAuthorizer, error) {
providers := make([]*oidcProvider, 0, len(oidcConfig))
issuers := make([]string, 0, len(oidcConfig))
for i := range oidcConfig {
conf := &oidcConfig[i]
provider, err := newOIDCProvider(conf, log)
if err != nil {
return nil, fmt.Errorf("%w: failed to create OIDC bearer provider[%d]: %w", zerr.ErrBadConfig, i, err)
}
providers = append(providers, provider)
issuers = append(issuers, conf.Issuer)
}
log.Info().Strs("issuers", issuers).Msg("the OIDC workload identity authentication was enabled")
return &OIDCBearerAuthorizer{
providers: providers,
}, nil
}
// AuthenticateRequest is a convenience method that handles the full authentication flow
// and returns whether authentication succeeded and any error.
func (a *OIDCBearerAuthorizer) AuthenticateRequest(ctx context.Context,
authHeader string,
) (string, []string, bool, error) {
res, err := a.Authenticate(ctx, authHeader)
if err != nil {
return "", nil, false, err
}
if res.Username == "" {
return "", nil, false, fmt.Errorf("%w: empty username", zerr.ErrInvalidBearerToken)
}
return res.Username, res.Groups, true, nil
}
// Authenticate validates an OIDC token and extracts the identity.
// Returns the username and groups extracted from the token claims.
func (a *OIDCBearerAuthorizer) Authenticate(ctx context.Context, header string) (*cel.ClaimResult, error) {
errs := make([]error, 0, len(a.providers))
for _, provider := range a.providers {
res, err := provider.authenticate(ctx, header)
if err == nil {
return res, nil
}
errs = append(errs, err)
}
switch len(errs) {
case 0:
return nil, zerr.ErrInvalidBearerToken
case 1:
return nil, errs[0]
default:
return nil, errors.Join(errs...)
}
}
// newOIDCProvider creates a new OIDC provider based on the given configuration.
func newOIDCProvider(oidcConfig *config.BearerOIDCConfig, log log.Logger) (*oidcProvider, error) {
// Validate configuration
if oidcConfig.Issuer == "" {
return nil, fmt.Errorf("%w: issuer is required", zerr.ErrBadConfig)
}
claimProcessor, err := cel.NewClaimProcessor(oidcConfig.Audiences, oidcConfig.ClaimMapping)
if err != nil {
return nil, fmt.Errorf("failed to create claim processor: %w", err)
}
return &oidcProvider{
issuer: oidcConfig.Issuer,
audiences: oidcConfig.Audiences,
claimProcessor: claimProcessor,
skipIssuerCheck: oidcConfig.SkipIssuerVerification,
log: log,
}, nil
}
func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.ClaimResult, error) {
if header == "" {
return nil, zerr.ErrNoBearerToken
}
// Extract token from Authorization header
tokenString := bearerOIDCTokenMatch.ReplaceAllString(header, "$1")
if tokenString == "" || tokenString == header {
return nil, zerr.ErrInvalidBearerToken
}
// Get verifier.
verifier, err := a.getVerifier(ctx)
if err != nil {
a.log.Err(err).Msg("failed to get OIDC token verifier")
return nil, fmt.Errorf("%w: %w", zerr.ErrInvalidOrUnreachableOIDCIssuer, err)
}
// Verify the token
idToken, err := verifier.Verify(ctx, tokenString)
if err != nil {
a.log.Debug().Err(err).Msg("the OIDC token verification failed")
return nil, fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err)
}
// Extract claims
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("%w: failed to extract claims: %w", zerr.ErrInvalidBearerToken, err)
}
// Process claims to extract username and groups.
res, err := a.claimProcessor.Process(ctx, claims)
if err != nil {
a.log.Debug().Err(err).Msg("the OIDC token claim processing failed")
return nil, fmt.Errorf("%w: failed to process claims: %w", zerr.ErrInvalidBearerToken, err)
}
a.log.Debug().Str("username", res.Username).Strs("groups", res.Groups).Msg("the OIDC token was authenticated")
return res, nil
}
// getVerifier retrieves or refreshes the oidc.IDTokenVerifier as needed.
func (o *oidcProvider) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
// If the verifier is still fresh, return it.
o.verifierMu.RLock()
verifier, deadline := o.verifier, o.verifierDeadline
o.verifierMu.RUnlock()
if verifier != nil && time.Now().Before(deadline) {
return verifier, nil
}
// Time to refresh the verifier.
if hc := GetBearerOIDCTestHTTPClient(); hc != nil {
ctx = oidc.ClientContext(ctx, hc)
}
p, err := oidc.NewProvider(ctx, o.issuer)
if err != nil {
return nil, fmt.Errorf("failed to refresh OIDC provider from issuer %s: %w", o.issuer, err)
}
verifier = p.Verifier(&oidc.Config{
ClientID: "", // We'll check audiences manually
SkipIssuerCheck: o.skipIssuerCheck,
SkipClientIDCheck: true, // Check audiences manually to support multiple
SkipExpiryCheck: false,
Now: time.Now,
})
// Update the verifier and deadline.
o.verifierMu.Lock()
o.verifier = verifier
o.verifierDeadline = time.Now().Add(oidcProviderRefreshInterval)
o.verifierMu.Unlock()
return o.verifier, nil
}
// GetBearerOIDCTestHTTPClient returns an HTTP client for testing purposes.
// It looks up a test environment variable pointing to a PEM-encoded
// CA certificate to trust when making requests to the OIDC issuer.
// If no such variable is set, it returns nil. This environment variable
// is not meant for production use.
func GetBearerOIDCTestHTTPClient() *http.Client {
caFile := os.Getenv("ZOT_BEARER_OIDC_TEST_CA_FILE")
if caFile == "" {
return nil
}
caCert, err := os.ReadFile(caFile)
if err != nil {
return nil
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caCert) {
return nil
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil
}
testTransport := defaultTransport.Clone()
testTransport.TLSClientConfig = &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
}
return &http.Client{Transport: testTransport}
}