mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
Introduce support for OIDC workload identity federation (#3711)
* feat(oidc): introduce support for OIDC workload identity federation Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com> * feat(oidc): add e2e test for bearer OIDC and a kind cluster Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com> * feat(oidc): make OIDC workload identity federation its own feature Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com> * feat(oidc): move errors to the errors package Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com> * feat(oidc): fix race in cel package Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com> * feat(oidc): compile cel expressions Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com> --------- Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sync"
|
||||
"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/cel"
|
||||
"zotregistry.dev/zot/v2/pkg/log"
|
||||
)
|
||||
|
||||
// oidcProviderRefreshInterval defines how often to refresh the OIDC provider configuration.
|
||||
// With 1 minute interval, even if there are too many API calls authenticating via OIDC
|
||||
// bearer tokens at once, we will only refresh the provider at most once per minute (safe).
|
||||
const oidcProviderRefreshInterval = 1 * time.Minute
|
||||
|
||||
var bearerOIDCTokenMatch = regexp.MustCompile("(?i)bearer (.*)")
|
||||
|
||||
// OIDCBearerAuthorizer validates OIDC ID tokens for workload identity authentication.
|
||||
type OIDCBearerAuthorizer struct {
|
||||
providers []*oidcProvider
|
||||
}
|
||||
|
||||
// oidcProvider validates OIDC ID tokens for workload identity authentication.
|
||||
// It holds the configuration for a single OIDC issuer.
|
||||
type oidcProvider struct {
|
||||
issuer string
|
||||
audiences []string
|
||||
claimProcessor *cel.ClaimProcessor
|
||||
skipIssuerCheck bool
|
||||
log log.Logger
|
||||
|
||||
// The *oidc.IDTokenVerifier is created lazily to avoid network calls during initialization.
|
||||
// We really don't want to block startup if the OIDC issuer is temporarily unreachable.
|
||||
// Also, we periodically refresh the provider to pick up any changes in the issuer's configuration.
|
||||
verifier *oidc.IDTokenVerifier
|
||||
verifierMu sync.RWMutex
|
||||
verifierDeadline time.Time
|
||||
}
|
||||
|
||||
// NewOIDCBearerAuthorizer creates a new OIDC bearer token authorizer.
|
||||
func NewOIDCBearerAuthorizer(oidcConfig []config.BearerOIDCConfig, log log.Logger) (*OIDCBearerAuthorizer, error) {
|
||||
providers := make([]*oidcProvider, 0, len(oidcConfig))
|
||||
issuers := make([]string, 0, len(oidcConfig))
|
||||
|
||||
for i := range oidcConfig {
|
||||
conf := &oidcConfig[i]
|
||||
provider, err := newOIDCProvider(conf, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to create OIDC bearer provider[%d]: %w", zerr.ErrBadConfig, i, err)
|
||||
}
|
||||
|
||||
providers = append(providers, provider)
|
||||
issuers = append(issuers, conf.Issuer)
|
||||
}
|
||||
|
||||
log.Info().Strs("issuers", issuers).Msg("the OIDC workload identity authentication was enabled")
|
||||
|
||||
return &OIDCBearerAuthorizer{
|
||||
providers: providers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
res, err := a.Authenticate(ctx, authHeader)
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
|
||||
if res.Username == "" {
|
||||
return "", nil, false, fmt.Errorf("%w: empty username", zerr.ErrInvalidBearerToken)
|
||||
}
|
||||
|
||||
return res.Username, res.Groups, true, 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) (*cel.ClaimResult, error) {
|
||||
errs := make([]error, 0, len(a.providers))
|
||||
|
||||
for _, provider := range a.providers {
|
||||
res, err := provider.authenticate(ctx, header)
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
switch len(errs) {
|
||||
case 0:
|
||||
return nil, zerr.ErrInvalidBearerToken
|
||||
case 1:
|
||||
return nil, errs[0]
|
||||
default:
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
// newOIDCProvider creates a new OIDC provider based on the given configuration.
|
||||
func newOIDCProvider(oidcConfig *config.BearerOIDCConfig, log log.Logger) (*oidcProvider, error) {
|
||||
// Validate configuration
|
||||
if oidcConfig.Issuer == "" {
|
||||
return nil, fmt.Errorf("%w: issuer is required", zerr.ErrBadConfig)
|
||||
}
|
||||
claimProcessor, err := cel.NewClaimProcessor(oidcConfig.Audiences, oidcConfig.ClaimMapping)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create claim processor: %w", err)
|
||||
}
|
||||
|
||||
return &oidcProvider{
|
||||
issuer: oidcConfig.Issuer,
|
||||
audiences: oidcConfig.Audiences,
|
||||
claimProcessor: claimProcessor,
|
||||
skipIssuerCheck: oidcConfig.SkipIssuerVerification,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.ClaimResult, 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
|
||||
}
|
||||
|
||||
// Get verifier.
|
||||
verifier, err := a.getVerifier(ctx)
|
||||
if err != nil {
|
||||
a.log.Err(err).Msg("failed to get OIDC token verifier")
|
||||
|
||||
return nil, fmt.Errorf("%w: %w", zerr.ErrInvalidOrUnreachableOIDCIssuer, err)
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
idToken, err := verifier.Verify(ctx, tokenString)
|
||||
if err != nil {
|
||||
a.log.Debug().Err(err).Msg("the OIDC token verification failed")
|
||||
|
||||
return nil, fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Process claims to extract username and groups.
|
||||
res, err := a.claimProcessor.Process(ctx, claims)
|
||||
if err != nil {
|
||||
a.log.Debug().Err(err).Msg("the OIDC token claim processing failed")
|
||||
|
||||
return nil, fmt.Errorf("%w: failed to process claims: %w", zerr.ErrInvalidBearerToken, err)
|
||||
}
|
||||
|
||||
a.log.Debug().Str("username", res.Username).Strs("groups", res.Groups).Msg("the OIDC token was authenticated")
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// getVerifier retrieves or refreshes the oidc.IDTokenVerifier as needed.
|
||||
func (o *oidcProvider) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||
// If the verifier is still fresh, return it.
|
||||
o.verifierMu.RLock()
|
||||
verifier, deadline := o.verifier, o.verifierDeadline
|
||||
o.verifierMu.RUnlock()
|
||||
if verifier != nil && time.Now().Before(deadline) {
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// Time to refresh the verifier.
|
||||
if hc := GetBearerOIDCTestHTTPClient(); hc != nil {
|
||||
ctx = oidc.ClientContext(ctx, hc)
|
||||
}
|
||||
p, err := oidc.NewProvider(ctx, o.issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh OIDC provider from issuer %s: %w", o.issuer, err)
|
||||
}
|
||||
verifier = p.Verifier(&oidc.Config{
|
||||
ClientID: "", // We'll check audiences manually
|
||||
SkipIssuerCheck: o.skipIssuerCheck,
|
||||
SkipClientIDCheck: true, // Check audiences manually to support multiple
|
||||
SkipExpiryCheck: false,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
// Update the verifier and deadline.
|
||||
o.verifierMu.Lock()
|
||||
o.verifier = verifier
|
||||
o.verifierDeadline = time.Now().Add(oidcProviderRefreshInterval)
|
||||
o.verifierMu.Unlock()
|
||||
|
||||
return o.verifier, nil
|
||||
}
|
||||
|
||||
// GetBearerOIDCTestHTTPClient returns an HTTP client for testing purposes.
|
||||
// It looks up a test environment variable pointing to a PEM-encoded
|
||||
// CA certificate to trust when making requests to the OIDC issuer.
|
||||
// If no such variable is set, it returns nil. This environment variable
|
||||
// is not meant for production use.
|
||||
func GetBearerOIDCTestHTTPClient() *http.Client {
|
||||
caFile := os.Getenv("ZOT_BEARER_OIDC_TEST_CA_FILE")
|
||||
if caFile == "" {
|
||||
return nil
|
||||
}
|
||||
caCert, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
certPool := x509.NewCertPool()
|
||||
if !certPool.AppendCertsFromPEM(caCert) {
|
||||
return nil
|
||||
}
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
testTransport := defaultTransport.Clone()
|
||||
testTransport.TLSClientConfig = &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
return &http.Client{Transport: testTransport}
|
||||
}
|
||||
Reference in New Issue
Block a user