Files
zot/pkg/api/bearer_oidc.go
T
copilot-swe-agent[bot] a6ac562df2 Fix nolint directive format to match codebase style
Remove explanatory text after //nolint:goconst directives to be
consistent with the rest of the codebase. The codebase pattern is to
use just //nolint:goconst without additional comments.

This matches the style used in controller_test.go and other test files
throughout the project.

Co-authored-by: rchincha <45800463+rchincha@users.noreply.github.com>
2026-01-15 20:33:59 +00:00

210 lines
6.0 KiB
Go

package api
import (
"context"
"fmt"
"regexp"
"slices"
"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]any
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 {
if slices.Contains(a.audiences, tokenAud) {
return true
}
}
return false
}
// extractUsername extracts the username from token claims based on claim mapping configuration.
func (a *OIDCBearerAuthorizer) extractUsername(claims map[string]any) string {
// Default claim to use for username
claimName := "sub" //nolint:goconst
// 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]any) []string {
groups := []string{}
// Try to extract groups from "groups" claim
if groupsClaim, ok := claims["groups"]; ok {
switch groupsValue := groupsClaim.(type) {
case []any:
for _, g := range groupsValue {
if str, ok := g.(string); ok {
groups = append(groups, str)
}
}
case []string:
groups = groupsValue
case string:
// Single group as string
groups = append(groups, groupsValue)
}
}
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
}