diff --git a/.github/workflows/ecosystem-tools.yaml b/.github/workflows/ecosystem-tools.yaml index 3479a137..ed60e91f 100644 --- a/.github/workflows/ecosystem-tools.yaml +++ b/.github/workflows/ecosystem-tools.yaml @@ -28,7 +28,8 @@ jobs: go mod download sudo apt-get update sudo apt-get install -y libgpgme-dev libassuan-dev libbtrfs-dev \ - libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools whois + libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools whois \ + npm # install skopeo git clone -b v1.12.0 https://github.com/containers/skopeo.git cd skopeo @@ -55,14 +56,16 @@ jobs: sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + # install nodejs deps (for oidc claim mapping support) + sudo npm install -g oidc-provider express # install dex git clone https://github.com/dexidp/dex.git cd dex/ git checkout v2.39.1 make bin/dex ./bin/dex serve $GITHUB_WORKSPACE/test/dex/config-dev.yaml & - cd $GITHUB_WORKSPACE # Prepare for stacker run on Ubuntu 24 + cd $GITHUB_WORKSPACE sudo ./scripts/enable_userns.sh - name: Run CI tests run: | diff --git a/examples/config-openid-claim-mapping.json b/examples/config-openid-claim-mapping.json new file mode 100644 index 00000000..3896e8b8 --- /dev/null +++ b/examples/config-openid-claim-mapping.json @@ -0,0 +1,54 @@ +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot", + "dedupe": true + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "externalUrl": "http://127.0.0.1:8080", + "realm": "zot", + "auth": { + "sessionKeysFile": "examples/sessionKeys.json", + "openid": { + "providers": { + "oidc": { + "name": "Zitadel", + "issuer": "https://iam.example.com", + "credentialsFile": "examples/config-openid-oidc-credentials.json", + "scopes": ["openid", "profile", "email", "groups"], + "claimMapping": { + "username": "preferred_username" + } + } + } + }, + "failDelay": 5 + }, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": [ + "admin" + ], + "actions": [ + "read", + "create", + "update", + "delete" + ] + } + ], + "defaultPolicy": ["read"] + } + } + } + }, + "log": { + "level": "debug" + }, + "extensions": {} +} diff --git a/pkg/api/authn.go b/pkg/api/authn.go index a9976698..e643d142 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -567,7 +567,7 @@ func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc { callback ui where we will redirect after openid/oauth2 logic is completed*/ session, _ := rh.c.CookieStore.Get(r, "statecookie") - session.Options.Secure = true + session.Options.Secure = rh.c.Config.UseSecureSession() session.Options.HttpOnly = true session.Options.SameSite = http.SameSiteDefaultMode session.Options.Path = constants.CallbackBasePath diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 3a12ac93..a0d55502 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -170,6 +170,16 @@ type OpenIDProviderConfig struct { AuthURL string TokenURL string Scopes []string + ClaimMapping *ClaimMapping `mapstructure:",omitempty"` +} + +// ClaimMapping specifies how OpenID claims are mapped to application fields. +// It allows customization of which claim is used as the username when authenticating users. +type ClaimMapping struct { + // Username specifies which OpenID claim to use as the username for the authenticated user. + // Acceptable values include "preferred_username", "email", "sub", "name", or any custom claim name. + // If not configured, the default is "email". + Username string `mapstructure:"username,omitempty"` } type MethodRatelimitConfig struct { @@ -611,6 +621,7 @@ func (c *Config) Sanitize() *Config { AuthURL: config.AuthURL, TokenURL: config.TokenURL, Scopes: config.Scopes, + ClaimMapping: config.ClaimMapping, } } } diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 1655e634..9da4f246 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -90,7 +90,7 @@ func (rh *RouteHandler) SetupRoutes() { rp.CodeExchangeHandler(rh.GithubCodeExchangeCallback(), relyingParty)) } else if config.IsOpenIDSupported(provider) { rh.c.Router.HandleFunc(constants.CallbackBasePath+"/"+provider, - rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallback()), relyingParty)) + rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallbackWithProvider(provider)), relyingParty)) } } } @@ -1998,17 +1998,73 @@ func (rh *RouteHandler) GithubCodeExchangeCallback() rp.CodeExchangeCallback[*oi } } -// Openid CodeExchange callback. +// Openid CodeExchange callback (legacy, kept for compatibility). func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCallback[ *oidc.IDTokenClaims, *oidc.UserInfo, +] { + return rh.OpenIDCodeExchangeCallbackWithProvider("") +} + +// OpenIDCodeExchangeCallbackWithProvider is the OIDC CodeExchange callback that supports configurable claim mapping. +// The providerName parameter is used to lookup provider-specific claim mapping configuration. +// This differs from the legacy version by allowing per-provider claim mapping based on the providerName. +func (rh *RouteHandler) OpenIDCodeExchangeCallbackWithProvider(providerName string) rp.CodeExchangeUserinfoCallback[ + *oidc.IDTokenClaims, + *oidc.UserInfo, ] { return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, relyingParty rp.RelyingParty, info *oidc.UserInfo, ) { - email := info.UserInfoEmail.Email - if email == "" { - rh.c.Log.Error().Msg("failed to set user record for empty email value") + // Extract username based on claim mapping configuration + var username string + authConfig := rh.c.Config.CopyAuthConfig() + + if authConfig != nil && authConfig.OpenID != nil && providerName != "" { + if providerConfig, ok := authConfig.OpenID.Providers[providerName]; ok { + // Check if claim mapping is configured + if providerConfig.ClaimMapping != nil && providerConfig.ClaimMapping.Username != "" { + claimName := providerConfig.ClaimMapping.Username + + // Use the configured claim + switch claimName { + case "preferred_username": + username = info.PreferredUsername + case "email": + username = info.UserInfoEmail.Email + case "sub": + username = info.Subject + case "name": + username = info.Name + default: + // Try to get from custom claims in UserInfo + if val, ok := info.Claims[claimName].(string); ok { + username = val + } + } + + if username != "" { + rh.c.Log.Debug(). + Str("provider", providerName). + Str("claim", claimName). + Str("username", username). + Msg("extracted username from configured claim") + } + } + } + } + + // Fallback to email if no username was extracted + if username == "" { + username = info.UserInfoEmail.Email + rh.c.Log.Debug(). + Str("provider", providerName). + Str("username", username). + Msg("using email as username (fallback)") + } + + if username == "" { + rh.c.Log.Error().Msg("failed to set user record for empty username value") w.WriteHeader(http.StatusUnauthorized) return @@ -2018,7 +2074,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall val, ok := info.Claims["groups"].([]interface{}) if !ok { - rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", email) + rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", username) } for _, group := range val { @@ -2027,7 +2083,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall val, ok = tokens.IDTokenClaims.Claims["groups"].([]interface{}) if !ok { - rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", email) + rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", username) } for _, group := range val { @@ -2037,7 +2093,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall slices.Sort(groups) groups = slices.Compact(groups) - callbackUI, err := OAuth2Callback(rh.c, w, r, state, email, groups) + callbackUI, err := OAuth2Callback(rh.c, w, r, state, username, groups) if err != nil { if errors.Is(err, zerr.ErrInvalidStateCookie) { w.WriteHeader(http.StatusUnauthorized) diff --git a/test/blackbox/ci.sh b/test/blackbox/ci.sh index e7cd607f..e4f365a6 100755 --- a/test/blackbox/ci.sh +++ b/test/blackbox/ci.sh @@ -15,7 +15,7 @@ tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "metadata" "anony "annotations" "detect_manifest_collision" "cve" "sync" "sync_docker" "sync_replica_cluster" "scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local" "redis_session_store" "events_nats" "events_http" "events_nats_lint_failure" "events_http_lint_failure" "events_sink_failure" "events_config_decoding" - "fips140" "fips140_authn") + "fips140" "fips140_authn" "openid_claim_mapping") for test in ${tests[*]}; do ${BATS} ${BATS_FLAGS} ${SCRIPTPATH}/${test}.bats > ${test}.log & pids+=($!) diff --git a/test/blackbox/helpers_zot.bash b/test/blackbox/helpers_zot.bash index c6684ad8..90eda757 100644 --- a/test/blackbox/helpers_zot.bash +++ b/test/blackbox/helpers_zot.bash @@ -65,6 +65,6 @@ function zb_run() { } function log_output() { - local zot_log_file=${BATS_FILE_TMPDIR}/zot/zot-log.json + local zot_log_file=${1:-${BATS_FILE_TMPDIR}/zot/zot-log.json} cat ${zot_log_file} | jq ' .["message"] ' } diff --git a/test/blackbox/openid_claim_mapping.bats b/test/blackbox/openid_claim_mapping.bats new file mode 100755 index 00000000..728e263e --- /dev/null +++ b/test/blackbox/openid_claim_mapping.bats @@ -0,0 +1,399 @@ +# Note: Intended to be run as "make run-blackbox-tests" or "make run-blackbox-ci" +# Makefile target installs & checks all necessary tooling +# Extra tools that are not covered in Makefile target needs to be added in verify_prerequisites() + +load helpers_zot +load helpers_wait +load ../port_helper + +function verify_prerequisites { + if [ ! $(command -v curl) ]; then + echo "you need to install curl as a prerequisite to running the tests" >&3 + return 1 + fi + + if [ ! $(command -v jq) ]; then + echo "you need to install jq as a prerequisite to running the tests" >&3 + return 1 + fi + + if [ ! $(command -v docker) ]; then + echo "you need to install docker as a prerequisite to running the tests" >&3 + return 1 + fi + + return 0 +} + +IDP_PID="" + +function setup_file() { + # Verify prerequisites are available + if ! $(verify_prerequisites); then + exit 1 + fi + + # Pre-allocate all ports we'll need + idp_port=$(get_free_port_for_service "idp") + echo ${idp_port} > ${BATS_FILE_TMPDIR}/idp.port + + # Setup oidc provider + npm link oidc-provider express + node ${ROOT_DIR}/test/scripts/panva-idp.js ${idp_port} > ${BATS_FILE_TMPDIR}/idp.log 2>&1 & + IDP_PID=$! + + # Wait for oidc provider to be ready + echo "Waiting for idp to be ready on port ${idp_port}..." >&3 + local idp_url=http://127.0.0.1:${idp_port}/.well-known/openid-configuration + local max_attempts=60 + local attempt=0 + while [ $attempt -lt $max_attempts ]; do + if curl -f -s -o /dev/null ${idp_url} 2>&1; then + echo "oidc idp is ready!" >&3 + break + fi + attempt=$((attempt + 1)) + sleep 2 + done + + if [ $attempt -eq $max_attempts ]; then + echo "idp failed to start within timeout" >&3 + return 1 + fi +} + +function teardown_file() { + zot_stop_all + kill ${IDP_PID} 2>/dev/null || true + if [ -f "${BATS_FILE_TMPDIR}/idp.log" ]; then + echo "--- IDP LOG ---" + cat ${BATS_FILE_TMPDIR}/idp.log + echo "--- END IDP LOG ---" + fi +} + +function teardown() { + # conditionally printing on failure is possible from teardown but not from teardown_file + if [ -f "${BATS_FILE_TMPDIR}/zot-preferred-username.log" ]; then + cat ${BATS_FILE_TMPDIR}/zot-preferred-username.log + fi + if [ -f "${BATS_FILE_TMPDIR}/zot-email.log" ]; then + cat ${BATS_FILE_TMPDIR}/zot-email.log + fi + if [ -f "${BATS_FILE_TMPDIR}/zot-sub.log" ]; then + cat ${BATS_FILE_TMPDIR}/zot-sub.log + fi + if [ -f "${BATS_FILE_TMPDIR}/zot-default.log" ]; then + cat ${BATS_FILE_TMPDIR}/zot-default.log + fi + if [ -f "${BATS_FILE_TMPDIR}/idp.log" ]; then + echo "--- IDP LOG (teardown) ---" + cat ${BATS_FILE_TMPDIR}/idp.log + echo "--- END IDP LOG ---" + fi +} + +function idp_session() { + local zot_port=${1} + local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port) + + # With the auto-login enabled IDP, we just need to hit the login endpoint and follow redirects. + # We use a cookie jar to handle the session cookies during the redirects. + curl -v -L -c ${BATS_FILE_TMPDIR}/cookies "http://127.0.0.1:${zot_port}/zot/auth/login?provider=oidc" +} + +@test "test OIDC claim mapping with preferred_username" { + local zot_root_dir=${BATS_FILE_TMPDIR}/zot-preferred-username + local zot_config_file=${BATS_FILE_TMPDIR}/zot_config_preferred_username.json + local zot_credentials_file=${BATS_FILE_TMPDIR}/zot_credentials_preferred_username.json + local idp_port=$(cat ${BATS_FILE_TMPDIR}/idp.port) + zot_port=$(get_free_port_for_service "zot_preferred_username") + + mkdir -p ${zot_root_dir} + + cat > ${zot_credentials_file}< ${zot_config_file}< ${zot_credentials_file}< ${zot_config_file}< ${zot_credentials_file}< ${zot_config_file}< ${zot_credentials_file}< ${zot_config_file}< u.id === id); + if (!user) return undefined; + + return { + accountId: id, + async claims(use, scope) { + console.log(`[IDP] Account.claims called with use=${use} scope=${scope}`); + return { + sub: user.claims.sub, + email: user.claims.email, + email_verified: user.claims.email_verified, + preferred_username: user.claims.preferred_username, + name: user.claims.name, + groups: user.claims.groups, + }; + }, + }; + }, + + async authenticate(username, password) { + return users.find( + (u) => u.username === username && u.password === password + ); + }, +}; + +// ---- OIDC provider configuration ---- +const configuration = { + clients: [{ + client_id: 'zot-client', + client_secret: 'ZXhhbXBsZS1hcHAtc2VjcmV0', + redirect_uris: [cb_url], // Will be overridden dynamically + response_types: ["code"], + grant_types: ["authorization_code"], + token_endpoint_auth_method: "client_secret_basic", + scope: "openid profile email groups", + }], + + features: { + devInteractions: { enabled: false }, // we provide our own login + introspection: { enabled: true }, + revocation: { enabled: true }, + resourceIndicators: { enabled: false }, + // claimsParameter is now automatically supported + }, + + interactions: { + url(ctx, interaction) { + return `/login?uid=${interaction.uid}`; + }, + }, + + async findAccount(ctx, id) { + return Account.findAccount(ctx, id); + }, + + // Required for ZITADEL: return standard claims + scopes: ["openid", "profile", "email", "groups"], + claims: { + openid: ["sub"], + profile: ["name", "preferred_username"], + email: ["email", "email_verified"], + groups: ["groups"], + }, + + async renderError(ctx, out, error) { + ctx.body = `OIDC Error: ${error.error_description}`; + }, +}; + +// ---- Create provider ---- +const issuer = `http://127.0.0.1:${port}/`; +const provider = new Provider(issuer, configuration); + +// Monkey-patch Client prototype to allow dynamic redirect URIs +const Client = provider.Client; +const originalRedirectUriAllowed = Client.prototype.redirectUriAllowed; + +Client.prototype.redirectUriAllowed = function(value) { + // Allow any localhost URI for testing + try { + const parsed = new URL(value); + if (parsed.hostname === '127.0.0.1' && parsed.pathname === '/zot/auth/callback/oidc') { + return true; + } + } catch (e) { + // ignore invalid URLs + } + return originalRedirectUriAllowed.call(this, value); +}; + +provider.Client.find('zot-client').then(client => { + console.log('Startup check: Client found:', client ? 'yes' : 'no'); +}).catch(err => console.error('Startup check error:', err)); + +// ---- Simple login UI ---- +const app = express(); +app.use(express.urlencoded({ extended: true })); + +app.use((req, res, next) => { + console.log(`[IDP] Request: ${req.method} ${req.url}`); + next(); +}); + +app.get("/login", async (req, res) => { + console.log("Hit /login endpoint"); + const { uid } = req.query; + const user = users[0]; + + const result = await provider.interactionDetails(req, res); + console.log("Interaction details:", result); + if (result.prompt.details) { + console.log("Prompt details:", JSON.stringify(result.prompt.details, null, 2)); + } + + if (result.prompt.name === 'login') { + await provider.interactionFinished( + req, + res, + { + login: { + accountId: user.id, + }, + }, + { mergeWithLastSubmission: false } + ); + } else { + // Manually create a grant to ensure scopes are accepted + const grant = new provider.Grant({ + accountId: result.session.accountId, + clientId: result.params.client_id, + }); + + const scopes = result.params.scope.split(' '); + console.log(`[IDP] Manually granting scopes: ${scopes.join(' ')}`); + grant.addOIDCScope(scopes.join(' ')); + + const grantId = await grant.save(); + + await provider.interactionFinished( + req, + res, + { consent: { grantId } }, + { mergeWithLastSubmission: true } + ); + } + console.log("Interaction finished"); +}); + +// app.post("/login", async (req, res, next) => { ... }); + +// ---- mount OIDC provider ---- +app.use(provider.callback()); +app.listen(port, () => { + console.log(`OIDC Provider listening at ${issuer}`); +});