Add OIDC workload identity authentication support

- Add BearerOIDCConfig to configuration for OIDC workload auth
- Implement OIDCBearerAuthorizer for validating OIDC ID tokens
- Update bearerAuthHandler to support both traditional and OIDC bearer auth
- Add claim mapping support for extracting username from OIDC tokens
- Support multiple audiences for token validation
- Extract groups from token claims for authorization

Co-authored-by: rchincha <45800463+rchincha@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-14 21:15:38 +00:00
parent d8110cf6ec
commit f03445b632
4 changed files with 345 additions and 26 deletions
+81 -23
View File
@@ -485,18 +485,34 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
// Get auth config safely
authConfig := ctlr.Config.CopyAuthConfig()
// although the configuration option is called 'cert', this function will also parse a public key directly
// see https://github.com/project-zot/zot/issues/3173 for info
publicKey, err := loadPublicKeyFromFile(authConfig.Bearer.Cert)
if err != nil {
ctlr.Log.Panic().Err(err).Msg("failed to load public key for bearer authentication")
// Initialize authorizers based on configuration
var traditionalAuthorizer *BearerAuthorizer
var oidcAuthorizer *OIDCBearerAuthorizer
// Traditional bearer auth with public key/certificate
if authConfig.Bearer.Cert != "" {
// although the configuration option is called 'cert', this function will also parse a public key directly
// see https://github.com/project-zot/zot/issues/3173 for info
publicKey, err := loadPublicKeyFromFile(authConfig.Bearer.Cert)
if err != nil {
ctlr.Log.Panic().Err(err).Msg("failed to load public key for bearer authentication")
}
traditionalAuthorizer = &BearerAuthorizer{
realm: authConfig.Bearer.Realm,
service: authConfig.Bearer.Service,
key: publicKey,
}
}
authorizer := NewBearerAuthorizer(
authConfig.Bearer.Realm,
authConfig.Bearer.Service,
publicKey,
)
// OIDC bearer auth for workload identity
if authConfig.Bearer.OIDC != nil {
var err error
oidcAuthorizer, err = NewOIDCBearerAuthorizer(context.Background(), authConfig.Bearer.OIDC, ctlr.Log)
if err != nil {
ctlr.Log.Panic().Err(err).Msg("failed to initialize OIDC bearer authorizer")
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
@@ -548,27 +564,69 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
}
}
err := authorizer.Authorize(header, requestedAccess)
if err != nil {
var challenge *AuthChallengeError
if errors.As(err, &challenge) {
ctlr.Log.Debug().Err(challenge).Msg("bearer token authorization failed")
// Try OIDC authentication first if configured
authenticated := false
var username string
var groups []string
if oidcAuthorizer != nil {
var err error
username, groups, authenticated, err = oidcAuthorizer.AuthenticateRequest(request.Context(), header)
if err == nil && authenticated {
// OIDC authentication succeeded
ctlr.Log.Debug().Str("username", username).Msg("OIDC bearer authentication successful")
// Set user context for authorization
userAc := reqCtx.NewUserAccessControl()
userAc.SetUsername(username)
userAc.AddGroups(groups)
userAc.SaveOnRequest(request)
// Update user groups in MetaDB if available
if ctlr.MetaDB != nil {
if err := ctlr.MetaDB.SetUserGroups(request.Context(), groups); err != nil {
ctlr.Log.Error().Err(err).Str("username", username).Msg("failed to update user profile")
response.WriteHeader(http.StatusInternalServerError)
return
}
}
amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request)
next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck
return
}
}
// Fall back to traditional bearer token auth if OIDC didn't succeed
if traditionalAuthorizer != nil {
err := traditionalAuthorizer.Authorize(header, requestedAccess)
if err != nil {
var challenge *AuthChallengeError
if errors.As(err, &challenge) {
ctlr.Log.Debug().Err(challenge).Msg("bearer token authorization failed")
response.Header().Set("Content-Type", "application/json")
response.Header().Set("WWW-Authenticate", challenge.Header())
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
return
}
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
response.Header().Set("Content-Type", "application/json")
response.Header().Set("WWW-Authenticate", challenge.Header())
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))
return
}
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
response.Header().Set("Content-Type", "application/json")
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))
amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request)
next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck
return
}
amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request)
next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck
// No authentication succeeded
ctlr.Log.Error().Msg("bearer authentication failed")
response.Header().Set("Content-Type", "application/json")
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
})
}
}
+2 -2
View File
@@ -65,8 +65,8 @@ type BearerAuthorizer struct {
key crypto.PublicKey
}
func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) BearerAuthorizer {
return BearerAuthorizer{
func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) *BearerAuthorizer {
return &BearerAuthorizer{
realm: realm,
service: service,
key: key,
+225
View File
@@ -0,0 +1,225 @@
package api
import (
"context"
"fmt"
"regexp"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
zerr "zotregistry.dev/zot/v2/errors"
"zotregistry.dev/zot/v2/pkg/api/config"
"zotregistry.dev/zot/v2/pkg/log"
)
var bearerOIDCTokenMatch = regexp.MustCompile("(?i)bearer (.*)")
// OIDCBearerAuthorizer validates OIDC ID tokens for workload identity authentication.
type OIDCBearerAuthorizer struct {
issuer string
audiences []string
claimMapping *config.ClaimMapping
verifier *oidc.IDTokenVerifier
skipIssuerCheck bool
log log.Logger
}
// NewOIDCBearerAuthorizer creates a new OIDC bearer token authorizer.
func NewOIDCBearerAuthorizer(ctx context.Context, oidcConfig *config.BearerOIDCConfig, log log.Logger) (*OIDCBearerAuthorizer, error) {
if oidcConfig == nil {
return nil, fmt.Errorf("%w: OIDC config is nil", zerr.ErrBadConfig)
}
if oidcConfig.Issuer == "" {
return nil, fmt.Errorf("%w: issuer is required", zerr.ErrBadConfig)
}
if len(oidcConfig.Audiences) == 0 {
return nil, fmt.Errorf("%w: at least one audience is required", zerr.ErrBadConfig)
}
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
if err != nil {
return nil, fmt.Errorf("%w: failed to create OIDC provider: %w", zerr.ErrBadConfig, err)
}
// Configure verifier
verifierConfig := &oidc.Config{
ClientID: oidcConfig.Audiences[0], // Primary audience
SkipIssuerCheck: oidcConfig.SkipIssuerVerification,
SkipClientIDCheck: false,
SkipExpiryCheck: false,
Now: time.Now,
}
// Support multiple audiences
if len(oidcConfig.Audiences) > 1 {
verifierConfig.SupportedSigningAlgs = []string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"}
}
verifier := provider.Verifier(verifierConfig)
log.Info().Str("issuer", oidcConfig.Issuer).Strs("audiences", oidcConfig.Audiences).
Msg("OIDC workload identity authentication enabled")
return &OIDCBearerAuthorizer{
issuer: oidcConfig.Issuer,
audiences: oidcConfig.Audiences,
claimMapping: oidcConfig.ClaimMapping,
verifier: verifier,
skipIssuerCheck: oidcConfig.SkipIssuerVerification,
log: log,
}, 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) (string, []string, 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
}
// Verify the token
idToken, err := a.verifier.Verify(ctx, tokenString)
if err != nil {
a.log.Debug().Err(err).Msg("OIDC token verification failed")
return "", nil, fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err)
}
// Verify audience (the verifier checks the first audience, but we need to check all)
if !a.skipIssuerCheck && !a.verifyAudience(idToken) {
a.log.Debug().Str("token_aud", fmt.Sprintf("%v", idToken.Audience)).
Strs("accepted_aud", a.audiences).
Msg("token audience not accepted")
return "", nil, fmt.Errorf("%w: audience not accepted", zerr.ErrInvalidBearerToken)
}
// Extract claims
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
return "", nil, fmt.Errorf("%w: failed to extract claims: %w", zerr.ErrInvalidBearerToken, err)
}
// Extract username from configured claim
username := a.extractUsername(claims)
if username == "" {
a.log.Debug().Interface("claims", claims).Msg("failed to extract username from token")
return "", nil, fmt.Errorf("%w: no username found in token", zerr.ErrInvalidBearerToken)
}
// Extract groups if present
groups := a.extractGroups(claims)
a.log.Debug().Str("username", username).Strs("groups", groups).Msg("OIDC token authenticated")
return username, groups, nil
}
// verifyAudience checks if the token's audience matches any of the accepted audiences.
func (a *OIDCBearerAuthorizer) verifyAudience(token *oidc.IDToken) bool {
tokenAudiences := token.Audience
for _, tokenAud := range tokenAudiences {
for _, acceptedAud := range a.audiences {
if tokenAud == acceptedAud {
return true
}
}
}
return false
}
// extractUsername extracts the username from token claims based on claim mapping configuration.
func (a *OIDCBearerAuthorizer) extractUsername(claims map[string]interface{}) string {
// Default claim to use for username
claimName := "sub"
// Use configured claim mapping if available
if a.claimMapping != nil && a.claimMapping.Username != "" {
claimName = a.claimMapping.Username
}
// Try to get the claim value
if val, ok := claims[claimName]; ok {
if strVal, ok := val.(string); ok {
return strVal
}
}
// Fallback: try "sub" if configured claim didn't work and wasn't "sub"
if claimName != "sub" {
if val, ok := claims["sub"]; ok {
if strVal, ok := val.(string); ok {
return strVal
}
}
}
return ""
}
// extractGroups extracts groups from token claims.
// It looks for "groups" claim as an array of strings.
func (a *OIDCBearerAuthorizer) extractGroups(claims map[string]interface{}) []string {
groups := []string{}
// Try to extract groups from "groups" claim
if groupsClaim, ok := claims["groups"]; ok {
switch v := groupsClaim.(type) {
case []interface{}:
for _, g := range v {
if str, ok := g.(string); ok {
groups = append(groups, str)
}
}
case []string:
groups = v
case string:
// Single group as string
groups = append(groups, v)
}
}
return groups
}
// 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) {
username, groups, err := a.Authenticate(ctx, authHeader)
if err != nil {
return "", nil, false, err
}
if username == "" {
return "", nil, false, fmt.Errorf("%w: empty username", zerr.ErrInvalidBearerToken)
}
return username, groups, true, nil
}
// CreateOAuth2Config creates an oauth2.Config for use with the OIDC provider.
// This is a helper method for testing purposes.
func CreateOAuth2Config(issuer string, clientID string, clientSecret string, redirectURL string, scopes []string) (*oauth2.Config, error) {
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC provider: %w", err)
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: scopes,
}, nil
}
+37 -1
View File
@@ -129,7 +129,17 @@ func (a *AuthConfig) IsHtpasswdAuthEnabled() bool {
// IsBearerAuthEnabled checks if Bearer authentication is enabled in this auth config.
func (a *AuthConfig) IsBearerAuthEnabled() bool {
return a != nil && a.Bearer != nil && a.Bearer.Cert != "" && a.Bearer.Realm != "" && a.Bearer.Service != ""
if a == nil || a.Bearer == nil {
return false
}
// Traditional bearer auth with certificate
traditionalBearer := a.Bearer.Cert != "" && a.Bearer.Realm != "" && a.Bearer.Service != ""
// OIDC bearer auth for workload identity
oidcBearer := a.Bearer.OIDC != nil && a.Bearer.OIDC.Issuer != "" && len(a.Bearer.OIDC.Audiences) > 0
return traditionalBearer || oidcBearer
}
// IsOpenIDAuthEnabled checks if OpenID authentication is enabled in this auth config.
@@ -183,6 +193,32 @@ type BearerConfig struct {
Realm string
Service string
Cert string
// OIDC configuration for workload identity authentication
OIDC *BearerOIDCConfig `json:"oidc,omitempty" mapstructure:"oidc,omitempty"`
}
// BearerOIDCConfig configures OIDC token validation for workload identity.
// This enables workloads to authenticate using OIDC ID tokens in the Authorization header.
type BearerOIDCConfig struct {
// Issuer is the OIDC issuer URL. Required for OIDC workload identity.
// Example: "https://kubernetes.default.svc.cluster.local"
Issuer string `json:"issuer" mapstructure:"issuer"`
// Audiences is a list of acceptable audiences for the OIDC token.
// At least one audience must be specified.
// Example: ["zot", "https://zot.example.com"]
Audiences []string `json:"audiences" mapstructure:"audiences"`
// JWKSDiscoveryURL is the URL to fetch JWKS keys for token validation.
// If not provided, it defaults to {Issuer}/.well-known/openid-configuration
JWKSDiscoveryURL string `json:"jwksDiscoveryUrl,omitempty" mapstructure:"jwksDiscoveryUrl,omitempty"`
// ClaimMapping specifies how OIDC claims are mapped to Zot identities.
ClaimMapping *ClaimMapping `json:"claimMapping,omitempty" mapstructure:"claimMapping,omitempty"`
// SkipIssuerVerification skips issuer verification (for testing only).
// Default: false
SkipIssuerVerification bool `json:"skipIssuerVerification,omitempty" mapstructure:"skipIssuerVerification,omitempty"`
}
type SessionKeys struct {