mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
1182981b0d
Agent-Logs-Url: https://github.com/project-zot/zot/sessions/1a321663-3147-46e9-9321-989e5dd0ed3c Co-authored-by: rchincha <45800463+rchincha@users.noreply.github.com>
295 lines
8.9 KiB
Go
295 lines
8.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"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 the target interval for refreshing the public keys.
|
|
// With a 1 minute interval, repeated calls will generally reuse cached keys and only trigger
|
|
// a refresh roughly once per minute, but this is best-effort and not a strict upper bound.
|
|
const oidcProviderRefreshInterval = 1 * time.Minute
|
|
|
|
// 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
|
|
allowBasicAuth bool
|
|
skipIssuerCheck bool
|
|
httpClient *http.Client
|
|
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)
|
|
}
|
|
if oidcConfig.CertificateAuthority != "" && oidcConfig.CertificateAuthorityFile != "" {
|
|
return nil, fmt.Errorf("%w: only one of certificateAuthority or certificateAuthorityFile can be set",
|
|
zerr.ErrBadConfig)
|
|
}
|
|
|
|
// Prepare CA.
|
|
caCert := []byte(oidcConfig.CertificateAuthority)
|
|
if file := oidcConfig.CertificateAuthorityFile; file != "" {
|
|
caCert, err = os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read certificate authority file: %w", err)
|
|
}
|
|
}
|
|
|
|
var httpClient *http.Client
|
|
if len(caCert) > 0 {
|
|
certPool := x509.NewCertPool()
|
|
if !certPool.AppendCertsFromPEM(caCert) {
|
|
return nil, fmt.Errorf("%w: failed to append certificate authority PEM", zerr.ErrBadConfig)
|
|
}
|
|
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: failed to get default HTTP transport", zerr.ErrBadConfig)
|
|
}
|
|
testTransport := defaultTransport.Clone()
|
|
testTransport.TLSClientConfig = &tls.Config{
|
|
RootCAs: certPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
httpClient = &http.Client{Transport: testTransport}
|
|
}
|
|
|
|
return &oidcProvider{
|
|
issuer: oidcConfig.Issuer,
|
|
audiences: oidcConfig.Audiences,
|
|
claimProcessor: claimProcessor,
|
|
allowBasicAuth: oidcConfig.AllowBasicAuth,
|
|
skipIssuerCheck: oidcConfig.SkipIssuerVerification,
|
|
httpClient: httpClient,
|
|
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, err := getOIDCTokenFromAuthorizationHeader(header, a.allowBasicAuth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func getOIDCTokenFromAuthorizationHeader(header string, allowBasicAuth bool) (string, error) {
|
|
splitStr := strings.SplitN(header, " ", 2) //nolint:mnd
|
|
if len(splitStr) != 2 {
|
|
return "", zerr.ErrInvalidBearerToken
|
|
}
|
|
|
|
switch strings.ToLower(splitStr[0]) {
|
|
case "bearer":
|
|
tokenString := strings.TrimSpace(splitStr[1])
|
|
if tokenString == "" {
|
|
return "", zerr.ErrInvalidBearerToken
|
|
}
|
|
|
|
return tokenString, nil
|
|
case "basic":
|
|
if !allowBasicAuth {
|
|
return "", zerr.ErrInvalidBearerToken
|
|
}
|
|
|
|
decodedStr, err := base64.StdEncoding.DecodeString(splitStr[1])
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err)
|
|
}
|
|
|
|
pair := strings.SplitN(string(decodedStr), ":", 2) //nolint:mnd
|
|
if len(pair) != 2 { //nolint:mnd
|
|
return "", zerr.ErrInvalidBearerToken
|
|
}
|
|
|
|
// Prefer the password field as the token; fall back to the username field
|
|
// when the password is empty (e.g. "token:" basic-auth encoding).
|
|
tokenString := pair[1]
|
|
if tokenString == "" {
|
|
tokenString = pair[0]
|
|
}
|
|
tokenString = strings.TrimSpace(tokenString)
|
|
|
|
if tokenString == "" {
|
|
return "", zerr.ErrInvalidBearerToken
|
|
}
|
|
|
|
return tokenString, nil
|
|
default:
|
|
return "", zerr.ErrInvalidBearerToken
|
|
}
|
|
}
|
|
|
|
// 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 := o.httpClient; 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 verifier, nil
|
|
}
|