Skip to content

Commit cb9d682

Browse files
feat(auth): map OpenID groups claim (#3999)
* feat(auth): map OpenID groups claim Signed-off-by: Akash Kumar <meakash7902@gmail.com> * fix(auth): refine OIDC claim mapping logs Signed-off-by: Akash Kumar <meakash7902@gmail.com> * refactor(auth): collapse OIDC username fallback into nested if Reuse the empty-username branch for the email fallback so the value is checked once and the failure path lives next to the recovery attempt. Signed-off-by: Akash Kumar <meakash7902@gmail.com> * refactor(auth): consolidate OIDC claim extraction into authn.go Move getOpenIDClaimMapping, getOpenIDUsername, and appendOpenIDGroups out of routes.go into authn.go alongside a new extractOpenIDIdentity helper that owns the username/groups extraction flow. This keeps the HTTP callback in routes.go thin and groups OIDC plumbing with the rest of the authentication code. Also: - Filter nil and empty entries consistently across the []any, []string, and string branches of appendOpenIDGroups, with new test cases covering []any{nil, ""} and []string{"admin","",...}. - Surface a Warn log when an operator-configured username claim is missing/empty so the fallback to email isn't silent. - Rename openid_claim_mapping_internal_test.go to authn_internal_test.go and drop the build tags that aren't needed for the internal tests. Signed-off-by: Akash Kumar <meakash7902@gmail.com> --------- Signed-off-by: Akash Kumar <meakash7902@gmail.com>
1 parent 0b2eaa0 commit cb9d682

7 files changed

Lines changed: 628 additions & 70 deletions

File tree

examples/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,11 @@ zot can be configured to use dex with:
440440
"clientsecret": "ZXhhbXBsZS1hcHAtc2VjcmV0",
441441
"keypath": "",
442442
"issuer": "http://127.0.0.1:5556/dex",
443-
"scopes": ["openid", "profile", "email", "groups"]
443+
"scopes": ["openid", "profile", "email", "groups"],
444+
"claimMapping": {
445+
"username": "preferred_username",
446+
"groups": "groups"
447+
}
444448
}
445449
}
446450
}
@@ -450,6 +454,8 @@ zot can be configured to use dex with:
450454

451455
To login using openid dex provider use http://127.0.0.1:8080/zot/auth/login?provider=oidc
452456

457+
`claimMapping.username` defaults to `email`, and `claimMapping.groups` defaults to `groups`.
458+
453459
NOTE: Social login is not supported by command line tools, or other software responsible for pushing/pulling
454460
images to/from zot.
455461
Given this limitation, if openif authentication is enabled in the configuration, API keys are also enabled

examples/config-openid-claim-mapping.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"credentialsFile": "examples/config-openid-oidc-credentials.json",
2121
"scopes": ["openid", "profile", "email", "groups"],
2222
"claimMapping": {
23-
"username": "preferred_username"
23+
"username": "preferred_username",
24+
"groups": "groups"
2425
}
2526
}
2627
}

pkg/api/authn.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,161 @@ func saveUserLoggedSession(cookieStore sessions.Store, response http.ResponseWri
11061106
return nil
11071107
}
11081108

1109+
const (
1110+
defaultUsernameClaim = "email"
1111+
defaultGroupsClaim = "groups"
1112+
)
1113+
1114+
// getOpenIDClaimMapping resolves the username and groups claim names for a given provider.
1115+
// The third return value reports whether the username claim was explicitly configured
1116+
// (false means the default "email" claim is being used as a fallback).
1117+
func getOpenIDClaimMapping(authConfig *config.AuthConfig, providerName string) (string, string, bool) {
1118+
usernameClaim := defaultUsernameClaim
1119+
groupsClaim := defaultGroupsClaim
1120+
usernameConfigured := false
1121+
1122+
if authConfig == nil || authConfig.OpenID == nil || providerName == "" {
1123+
return usernameClaim, groupsClaim, usernameConfigured
1124+
}
1125+
1126+
providerConfig, ok := authConfig.OpenID.Providers[providerName]
1127+
if !ok || providerConfig.ClaimMapping == nil {
1128+
return usernameClaim, groupsClaim, usernameConfigured
1129+
}
1130+
1131+
if providerConfig.ClaimMapping.Username != "" {
1132+
usernameClaim = providerConfig.ClaimMapping.Username
1133+
usernameConfigured = true
1134+
}
1135+
1136+
if providerConfig.ClaimMapping.Groups != "" {
1137+
groupsClaim = providerConfig.ClaimMapping.Groups
1138+
}
1139+
1140+
return usernameClaim, groupsClaim, usernameConfigured
1141+
}
1142+
1143+
func getOpenIDUsername(info *oidc.UserInfo, claimName string) string {
1144+
if info == nil {
1145+
return ""
1146+
}
1147+
1148+
switch claimName {
1149+
case "preferred_username":
1150+
return info.PreferredUsername
1151+
case defaultUsernameClaim:
1152+
return info.UserInfoEmail.Email
1153+
case "sub":
1154+
return info.Subject
1155+
case "name":
1156+
return info.Name
1157+
default:
1158+
if val, ok := info.Claims[claimName].(string); ok {
1159+
return val
1160+
}
1161+
}
1162+
1163+
return ""
1164+
}
1165+
1166+
func appendOpenIDGroups(groups []string, claims map[string]any, claimName string) ([]string, bool) {
1167+
switch val := claims[claimName].(type) {
1168+
case []any:
1169+
for _, group := range val {
1170+
if group == nil {
1171+
continue
1172+
}
1173+
1174+
if str := fmt.Sprint(group); str != "" {
1175+
groups = append(groups, str)
1176+
}
1177+
}
1178+
1179+
return groups, true
1180+
case []string:
1181+
for _, group := range val {
1182+
if group != "" {
1183+
groups = append(groups, group)
1184+
}
1185+
}
1186+
1187+
return groups, true
1188+
case string:
1189+
if val != "" {
1190+
groups = append(groups, val)
1191+
}
1192+
1193+
return groups, true
1194+
}
1195+
1196+
return groups, false
1197+
}
1198+
1199+
// extractOpenIDIdentity resolves the username and groups for an OIDC callback
1200+
// based on the provider's configured claim mapping. It returns the resolved
1201+
// username, the deduplicated/sorted groups, and a boolean reporting whether
1202+
// the username could be resolved at all (false means callers should reject).
1203+
func extractOpenIDIdentity(logger log.Logger, authConfig *config.AuthConfig, providerName string,
1204+
info *oidc.UserInfo, idTokenClaims map[string]any,
1205+
) (string, []string, bool) {
1206+
usernameClaim, groupsClaim, usernameConfigured := getOpenIDClaimMapping(authConfig, providerName)
1207+
1208+
username := getOpenIDUsername(info, usernameClaim)
1209+
1210+
if username == "" && usernameConfigured {
1211+
configuredClaim := usernameClaim
1212+
usernameClaim = defaultUsernameClaim
1213+
usernameConfigured = false
1214+
username = getOpenIDUsername(info, usernameClaim)
1215+
1216+
logger.Warn().
1217+
Str("provider", providerName).
1218+
Str("claim", configuredClaim).
1219+
Msg("configured username claim missing or empty, falling back to email")
1220+
}
1221+
1222+
if username == "" {
1223+
return "", nil, false
1224+
}
1225+
1226+
if usernameConfigured {
1227+
logger.Debug().
1228+
Str("provider", providerName).
1229+
Str("claim", usernameClaim).
1230+
Str("username", username).
1231+
Msg("extracted username from configured claim")
1232+
} else {
1233+
logger.Debug().
1234+
Str("provider", providerName).
1235+
Str("username", username).
1236+
Msg("using email as username (fallback)")
1237+
}
1238+
1239+
var (
1240+
groups []string
1241+
groupsFound bool
1242+
)
1243+
1244+
if info != nil {
1245+
groups, groupsFound = appendOpenIDGroups(groups, info.Claims, groupsClaim)
1246+
if !groupsFound {
1247+
logger.Info().Msgf("failed to find any %q claim for user %s in UserInfo", groupsClaim, username)
1248+
}
1249+
}
1250+
1251+
if idTokenClaims != nil {
1252+
groups, groupsFound = appendOpenIDGroups(groups, idTokenClaims, groupsClaim)
1253+
if !groupsFound {
1254+
logger.Info().Msgf("failed to find any %q claim for user %s in IDTokenClaimsToken", groupsClaim, username)
1255+
}
1256+
}
1257+
1258+
slices.Sort(groups)
1259+
groups = slices.Compact(groups)
1260+
1261+
return username, groups, true
1262+
}
1263+
11091264
// OAuth2Callback is the callback logic where openid/oauth2 will redirect back to our app.
11101265
func OAuth2Callback(ctlr *Controller, w http.ResponseWriter, r *http.Request, state, email, provider string,
11111266
groups []string,

0 commit comments

Comments
 (0)