mirror of
https://github.com/project-zot/zot.git
synced 2026-06-18 05:28:07 +08:00
699358cefe
- Standardize terminology: use 'OIDC claims' consistently - Clarify audience verification comment - Improve error handling when no bearer method is configured - Fix Authorization header case in documentation (Bearer not bearer) Co-authored-by: rchincha <45800463+rchincha@users.noreply.github.com>
202 lines
6.0 KiB
Go
202 lines
6.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"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/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: "", // We'll check audiences manually
|
|
SkipIssuerCheck: oidcConfig.SkipIssuerVerification,
|
|
SkipClientIDCheck: true, // Check audiences manually to support multiple
|
|
SkipExpiryCheck: false,
|
|
Now: time.Now,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Additional audience verification to support multiple audiences
|
|
if !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
|
|
}
|