Skip to content

Commit 64829f9

Browse files
rchinchaskymoore
andauthored
feat: allow claim mapping for user name with oidc (#3540)
* feat: allow claim mapping for user name with oidc * feat: bats test for claim mapping * test: fix dex config in openid mapping test Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * test: add panva idp Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * fix: address copilot comments Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> --------- Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> Co-authored-by: Sky Moore <i@msky.me>
1 parent 7fa53f5 commit 64829f9

File tree

10 files changed

+740
-13
lines changed

10 files changed

+740
-13
lines changed

.github/workflows/ecosystem-tools.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ jobs:
2828
go mod download
2929
sudo apt-get update
3030
sudo apt-get install -y libgpgme-dev libassuan-dev libbtrfs-dev \
31-
libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools whois
31+
libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools whois \
32+
npm
3233
# install skopeo
3334
git clone -b v1.12.0 https://github.com/containers/skopeo.git
3435
cd skopeo
@@ -55,14 +56,16 @@ jobs:
5556
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
5657
sudo apt update
5758
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
59+
# install nodejs deps (for oidc claim mapping support)
60+
sudo npm install -g oidc-provider express
5861
# install dex
5962
git clone https://github.com/dexidp/dex.git
6063
cd dex/
6164
git checkout v2.39.1
6265
make bin/dex
6366
./bin/dex serve $GITHUB_WORKSPACE/test/dex/config-dev.yaml &
64-
cd $GITHUB_WORKSPACE
6567
# Prepare for stacker run on Ubuntu 24
68+
cd $GITHUB_WORKSPACE
6669
sudo ./scripts/enable_userns.sh
6770
- name: Run CI tests
6871
run: |
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"distSpecVersion": "1.1.1",
3+
"storage": {
4+
"rootDirectory": "/tmp/zot",
5+
"dedupe": true
6+
},
7+
"http": {
8+
"address": "127.0.0.1",
9+
"port": "8080",
10+
"externalUrl": "http://127.0.0.1:8080",
11+
"realm": "zot",
12+
"auth": {
13+
"sessionKeysFile": "examples/sessionKeys.json",
14+
"openid": {
15+
"providers": {
16+
"oidc": {
17+
"name": "Zitadel",
18+
"issuer": "https://iam.example.com",
19+
"credentialsFile": "examples/config-openid-oidc-credentials.json",
20+
"scopes": ["openid", "profile", "email", "groups"],
21+
"claimMapping": {
22+
"username": "preferred_username"
23+
}
24+
}
25+
}
26+
},
27+
"failDelay": 5
28+
},
29+
"accessControl": {
30+
"repositories": {
31+
"**": {
32+
"policies": [
33+
{
34+
"users": [
35+
"admin"
36+
],
37+
"actions": [
38+
"read",
39+
"create",
40+
"update",
41+
"delete"
42+
]
43+
}
44+
],
45+
"defaultPolicy": ["read"]
46+
}
47+
}
48+
}
49+
},
50+
"log": {
51+
"level": "debug"
52+
},
53+
"extensions": {}
54+
}

pkg/api/authn.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc {
567567
callback ui where we will redirect after openid/oauth2 logic is completed*/
568568
session, _ := rh.c.CookieStore.Get(r, "statecookie")
569569

570-
session.Options.Secure = true
570+
session.Options.Secure = rh.c.Config.UseSecureSession()
571571
session.Options.HttpOnly = true
572572
session.Options.SameSite = http.SameSiteDefaultMode
573573
session.Options.Path = constants.CallbackBasePath

pkg/api/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ type OpenIDProviderConfig struct {
170170
AuthURL string
171171
TokenURL string
172172
Scopes []string
173+
ClaimMapping *ClaimMapping `mapstructure:",omitempty"`
174+
}
175+
176+
// ClaimMapping specifies how OpenID claims are mapped to application fields.
177+
// It allows customization of which claim is used as the username when authenticating users.
178+
type ClaimMapping struct {
179+
// Username specifies which OpenID claim to use as the username for the authenticated user.
180+
// Acceptable values include "preferred_username", "email", "sub", "name", or any custom claim name.
181+
// If not configured, the default is "email".
182+
Username string `mapstructure:"username,omitempty"`
173183
}
174184

175185
type MethodRatelimitConfig struct {
@@ -611,6 +621,7 @@ func (c *Config) Sanitize() *Config {
611621
AuthURL: config.AuthURL,
612622
TokenURL: config.TokenURL,
613623
Scopes: config.Scopes,
624+
ClaimMapping: config.ClaimMapping,
614625
}
615626
}
616627
}

pkg/api/routes.go

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func (rh *RouteHandler) SetupRoutes() {
9090
rp.CodeExchangeHandler(rh.GithubCodeExchangeCallback(), relyingParty))
9191
} else if config.IsOpenIDSupported(provider) {
9292
rh.c.Router.HandleFunc(constants.CallbackBasePath+"/"+provider,
93-
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallback()), relyingParty))
93+
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallbackWithProvider(provider)), relyingParty))
9494
}
9595
}
9696
}
@@ -1998,17 +1998,73 @@ func (rh *RouteHandler) GithubCodeExchangeCallback() rp.CodeExchangeCallback[*oi
19981998
}
19991999
}
20002000

2001-
// Openid CodeExchange callback.
2001+
// Openid CodeExchange callback (legacy, kept for compatibility).
20022002
func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCallback[
20032003
*oidc.IDTokenClaims,
20042004
*oidc.UserInfo,
2005+
] {
2006+
return rh.OpenIDCodeExchangeCallbackWithProvider("")
2007+
}
2008+
2009+
// OpenIDCodeExchangeCallbackWithProvider is the OIDC CodeExchange callback that supports configurable claim mapping.
2010+
// The providerName parameter is used to lookup provider-specific claim mapping configuration.
2011+
// This differs from the legacy version by allowing per-provider claim mapping based on the providerName.
2012+
func (rh *RouteHandler) OpenIDCodeExchangeCallbackWithProvider(providerName string) rp.CodeExchangeUserinfoCallback[
2013+
*oidc.IDTokenClaims,
2014+
*oidc.UserInfo,
20052015
] {
20062016
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string,
20072017
relyingParty rp.RelyingParty, info *oidc.UserInfo,
20082018
) {
2009-
email := info.UserInfoEmail.Email
2010-
if email == "" {
2011-
rh.c.Log.Error().Msg("failed to set user record for empty email value")
2019+
// Extract username based on claim mapping configuration
2020+
var username string
2021+
authConfig := rh.c.Config.CopyAuthConfig()
2022+
2023+
if authConfig != nil && authConfig.OpenID != nil && providerName != "" {
2024+
if providerConfig, ok := authConfig.OpenID.Providers[providerName]; ok {
2025+
// Check if claim mapping is configured
2026+
if providerConfig.ClaimMapping != nil && providerConfig.ClaimMapping.Username != "" {
2027+
claimName := providerConfig.ClaimMapping.Username
2028+
2029+
// Use the configured claim
2030+
switch claimName {
2031+
case "preferred_username":
2032+
username = info.PreferredUsername
2033+
case "email":
2034+
username = info.UserInfoEmail.Email
2035+
case "sub":
2036+
username = info.Subject
2037+
case "name":
2038+
username = info.Name
2039+
default:
2040+
// Try to get from custom claims in UserInfo
2041+
if val, ok := info.Claims[claimName].(string); ok {
2042+
username = val
2043+
}
2044+
}
2045+
2046+
if username != "" {
2047+
rh.c.Log.Debug().
2048+
Str("provider", providerName).
2049+
Str("claim", claimName).
2050+
Str("username", username).
2051+
Msg("extracted username from configured claim")
2052+
}
2053+
}
2054+
}
2055+
}
2056+
2057+
// Fallback to email if no username was extracted
2058+
if username == "" {
2059+
username = info.UserInfoEmail.Email
2060+
rh.c.Log.Debug().
2061+
Str("provider", providerName).
2062+
Str("username", username).
2063+
Msg("using email as username (fallback)")
2064+
}
2065+
2066+
if username == "" {
2067+
rh.c.Log.Error().Msg("failed to set user record for empty username value")
20122068
w.WriteHeader(http.StatusUnauthorized)
20132069

20142070
return
@@ -2018,7 +2074,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
20182074

20192075
val, ok := info.Claims["groups"].([]interface{})
20202076
if !ok {
2021-
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", email)
2077+
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", username)
20222078
}
20232079

20242080
for _, group := range val {
@@ -2027,7 +2083,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
20272083

20282084
val, ok = tokens.IDTokenClaims.Claims["groups"].([]interface{})
20292085
if !ok {
2030-
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", email)
2086+
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", username)
20312087
}
20322088

20332089
for _, group := range val {
@@ -2037,7 +2093,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
20372093
slices.Sort(groups)
20382094
groups = slices.Compact(groups)
20392095

2040-
callbackUI, err := OAuth2Callback(rh.c, w, r, state, email, groups)
2096+
callbackUI, err := OAuth2Callback(rh.c, w, r, state, username, groups)
20412097
if err != nil {
20422098
if errors.Is(err, zerr.ErrInvalidStateCookie) {
20432099
w.WriteHeader(http.StatusUnauthorized)

test/blackbox/ci.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "metadata" "anony
1515
"annotations" "detect_manifest_collision" "cve" "sync" "sync_docker" "sync_replica_cluster"
1616
"scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local" "redis_session_store"
1717
"events_nats" "events_http" "events_nats_lint_failure" "events_http_lint_failure" "events_sink_failure" "events_config_decoding"
18-
"fips140" "fips140_authn")
18+
"fips140" "fips140_authn" "openid_claim_mapping")
1919

2020
for test in ${tests[*]}; do
2121
${BATS} ${BATS_FLAGS} ${SCRIPTPATH}/${test}.bats > ${test}.log & pids+=($!)

test/blackbox/helpers_zot.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,6 @@ function zb_run() {
6565
}
6666

6767
function log_output() {
68-
local zot_log_file=${BATS_FILE_TMPDIR}/zot/zot-log.json
68+
local zot_log_file=${1:-${BATS_FILE_TMPDIR}/zot/zot-log.json}
6969
cat ${zot_log_file} | jq ' .["message"] '
7070
}

0 commit comments

Comments
 (0)