diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 04b6f58b..9c7d55f3 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -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)) }) } } diff --git a/pkg/api/bearer.go b/pkg/api/bearer.go index c2f9d669..5fad0470 100644 --- a/pkg/api/bearer.go +++ b/pkg/api/bearer.go @@ -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, diff --git a/pkg/api/bearer_oidc.go b/pkg/api/bearer_oidc.go new file mode 100644 index 00000000..0e4bef17 --- /dev/null +++ b/pkg/api/bearer_oidc.go @@ -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 +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index c8e78592..75fc33cf 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -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 {