Files
zot/pkg/api/bearer_oidc.go
T

286 lines
8.5 KiB
Go

package api
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"strings"
"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 the target interval for refreshing the public keys.
// With a 1 minute interval, repeated calls will generally reuse cached keys and only trigger
// a refresh roughly once per minute, but this is best-effort and not a strict upper bound.
const oidcProviderRefreshInterval = 1 * time.Minute
// 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
httpClient *http.Client
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)
}
if oidcConfig.CertificateAuthority != "" && oidcConfig.CertificateAuthorityFile != "" {
return nil, fmt.Errorf("%w: only one of certificateAuthority or certificateAuthorityFile can be set",
zerr.ErrBadConfig)
}
// Prepare CA.
caCert := []byte(oidcConfig.CertificateAuthority)
if file := oidcConfig.CertificateAuthorityFile; file != "" {
caCert, err = os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("failed to read certificate authority file: %w", err)
}
}
var httpClient *http.Client
if len(caCert) > 0 {
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("%w: failed to append certificate authority PEM", zerr.ErrBadConfig)
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, fmt.Errorf("%w: failed to get default HTTP transport", zerr.ErrBadConfig)
}
testTransport := defaultTransport.Clone()
testTransport.TLSClientConfig = &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
}
httpClient = &http.Client{Transport: testTransport}
}
return &oidcProvider{
issuer: oidcConfig.Issuer,
audiences: oidcConfig.Audiences,
claimProcessor: claimProcessor,
skipIssuerCheck: oidcConfig.SkipIssuerVerification,
httpClient: httpClient,
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, err := getOIDCTokenFromAuthorizationHeader(header)
if err != nil {
return nil, err
}
// 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
}
func getOIDCTokenFromAuthorizationHeader(header string) (string, error) {
splitStr := strings.SplitN(header, " ", 2) //nolint:mnd
if len(splitStr) != 2 {
return "", zerr.ErrInvalidBearerToken
}
switch strings.ToLower(splitStr[0]) {
case "bearer":
tokenString := strings.TrimSpace(splitStr[1])
if tokenString == "" {
return "", zerr.ErrInvalidBearerToken
}
return tokenString, nil
case "basic":
decodedStr, err := base64.StdEncoding.DecodeString(splitStr[1])
if err != nil {
return "", zerr.ErrInvalidBearerToken
}
pair := strings.SplitN(string(decodedStr), ":", 2) //nolint:mnd
if len(pair) != 2 { //nolint:mnd
return "", zerr.ErrInvalidBearerToken
}
tokenString := pair[1]
if tokenString == "" {
tokenString = pair[0]
}
if strings.TrimSpace(tokenString) == "" {
return "", zerr.ErrInvalidBearerToken
}
return tokenString, nil
default:
return "", zerr.ErrInvalidBearerToken
}
}
// 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 := o.httpClient; 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 verifier, nil
}