mirror of
https://github.com/project-zot/zot.git
synced 2026-06-18 13:37:57 +08:00
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:
+81
-23
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user