mirror of
https://github.com/project-zot/zot.git
synced 2026-06-18 21:48:04 +08:00
f03445b632
- 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>
226 lines
6.8 KiB
Go
226 lines
6.8 KiB
Go
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
|
|
}
|