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} }