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>
This commit is contained in:
Akash Kumar
2026-05-01 14:29:51 +05:30
committed by GitHub
parent 0b2eaa0f9a
commit cb9d682a69
7 changed files with 628 additions and 70 deletions
+155
View File
@@ -1106,6 +1106,161 @@ func saveUserLoggedSession(cookieStore sessions.Store, response http.ResponseWri
return nil
}
const (
defaultUsernameClaim = "email"
defaultGroupsClaim = "groups"
)
// getOpenIDClaimMapping resolves the username and groups claim names for a given provider.
// The third return value reports whether the username claim was explicitly configured
// (false means the default "email" claim is being used as a fallback).
func getOpenIDClaimMapping(authConfig *config.AuthConfig, providerName string) (string, string, bool) {
usernameClaim := defaultUsernameClaim
groupsClaim := defaultGroupsClaim
usernameConfigured := false
if authConfig == nil || authConfig.OpenID == nil || providerName == "" {
return usernameClaim, groupsClaim, usernameConfigured
}
providerConfig, ok := authConfig.OpenID.Providers[providerName]
if !ok || providerConfig.ClaimMapping == nil {
return usernameClaim, groupsClaim, usernameConfigured
}
if providerConfig.ClaimMapping.Username != "" {
usernameClaim = providerConfig.ClaimMapping.Username
usernameConfigured = true
}
if providerConfig.ClaimMapping.Groups != "" {
groupsClaim = providerConfig.ClaimMapping.Groups
}
return usernameClaim, groupsClaim, usernameConfigured
}
func getOpenIDUsername(info *oidc.UserInfo, claimName string) string {
if info == nil {
return ""
}
switch claimName {
case "preferred_username":
return info.PreferredUsername
case defaultUsernameClaim:
return info.UserInfoEmail.Email
case "sub":
return info.Subject
case "name":
return info.Name
default:
if val, ok := info.Claims[claimName].(string); ok {
return val
}
}
return ""
}
func appendOpenIDGroups(groups []string, claims map[string]any, claimName string) ([]string, bool) {
switch val := claims[claimName].(type) {
case []any:
for _, group := range val {
if group == nil {
continue
}
if str := fmt.Sprint(group); str != "" {
groups = append(groups, str)
}
}
return groups, true
case []string:
for _, group := range val {
if group != "" {
groups = append(groups, group)
}
}
return groups, true
case string:
if val != "" {
groups = append(groups, val)
}
return groups, true
}
return groups, false
}
// extractOpenIDIdentity resolves the username and groups for an OIDC callback
// based on the provider's configured claim mapping. It returns the resolved
// username, the deduplicated/sorted groups, and a boolean reporting whether
// the username could be resolved at all (false means callers should reject).
func extractOpenIDIdentity(logger log.Logger, authConfig *config.AuthConfig, providerName string,
info *oidc.UserInfo, idTokenClaims map[string]any,
) (string, []string, bool) {
usernameClaim, groupsClaim, usernameConfigured := getOpenIDClaimMapping(authConfig, providerName)
username := getOpenIDUsername(info, usernameClaim)
if username == "" && usernameConfigured {
configuredClaim := usernameClaim
usernameClaim = defaultUsernameClaim
usernameConfigured = false
username = getOpenIDUsername(info, usernameClaim)
logger.Warn().
Str("provider", providerName).
Str("claim", configuredClaim).
Msg("configured username claim missing or empty, falling back to email")
}
if username == "" {
return "", nil, false
}
if usernameConfigured {
logger.Debug().
Str("provider", providerName).
Str("claim", usernameClaim).
Str("username", username).
Msg("extracted username from configured claim")
} else {
logger.Debug().
Str("provider", providerName).
Str("username", username).
Msg("using email as username (fallback)")
}
var (
groups []string
groupsFound bool
)
if info != nil {
groups, groupsFound = appendOpenIDGroups(groups, info.Claims, groupsClaim)
if !groupsFound {
logger.Info().Msgf("failed to find any %q claim for user %s in UserInfo", groupsClaim, username)
}
}
if idTokenClaims != nil {
groups, groupsFound = appendOpenIDGroups(groups, idTokenClaims, groupsClaim)
if !groupsFound {
logger.Info().Msgf("failed to find any %q claim for user %s in IDTokenClaimsToken", groupsClaim, username)
}
}
slices.Sort(groups)
groups = slices.Compact(groups)
return username, groups, true
}
// OAuth2Callback is the callback logic where openid/oauth2 will redirect back to our app.
func OAuth2Callback(ctlr *Controller, w http.ResponseWriter, r *http.Request, state, email, provider string,
groups []string,
+278
View File
@@ -0,0 +1,278 @@
package api
import (
"testing"
"github.com/zitadel/oidc/v3/pkg/oidc"
"zotregistry.dev/zot/v2/pkg/api/config"
)
func TestGetOpenIDClaimMapping(t *testing.T) {
t.Parallel()
tests := []struct {
name string
authConfig *config.AuthConfig
providerName string
expectedUsername string
expectedGroups string
expectedConfigured bool
}{
{
name: "nil auth config uses defaults",
expectedUsername: defaultUsernameClaim,
expectedGroups: defaultGroupsClaim,
expectedConfigured: false,
},
{
name: "empty provider uses defaults",
authConfig: &config.AuthConfig{
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{},
},
},
expectedUsername: defaultUsernameClaim,
expectedGroups: defaultGroupsClaim,
expectedConfigured: false,
},
{
name: "missing provider uses defaults",
authConfig: &config.AuthConfig{
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{},
},
},
providerName: "oidc",
expectedUsername: defaultUsernameClaim,
expectedGroups: defaultGroupsClaim,
expectedConfigured: false,
},
{
name: "provider without claim mapping uses defaults",
authConfig: &config.AuthConfig{
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{
"oidc": {},
},
},
},
providerName: "oidc",
expectedUsername: defaultUsernameClaim,
expectedGroups: defaultGroupsClaim,
expectedConfigured: false,
},
{
name: "custom username and groups claims",
authConfig: &config.AuthConfig{
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{
"oidc": {
ClaimMapping: &config.ClaimMapping{
Username: "preferred_username",
Groups: "roles",
},
},
},
},
},
providerName: "oidc",
expectedUsername: "preferred_username",
expectedGroups: "roles",
expectedConfigured: true,
},
{
name: "custom groups keeps default username",
authConfig: &config.AuthConfig{
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{
"oidc": {
ClaimMapping: &config.ClaimMapping{
Groups: "roles",
},
},
},
},
},
providerName: "oidc",
expectedUsername: defaultUsernameClaim,
expectedGroups: "roles",
expectedConfigured: false,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
usernameClaim, groupsClaim, usernameConfigured := getOpenIDClaimMapping(test.authConfig, test.providerName)
if usernameClaim != test.expectedUsername {
t.Fatalf("expected username claim %q, got %q", test.expectedUsername, usernameClaim)
}
if groupsClaim != test.expectedGroups {
t.Fatalf("expected groups claim %q, got %q", test.expectedGroups, groupsClaim)
}
if usernameConfigured != test.expectedConfigured {
t.Fatalf("expected usernameConfigured %t, got %t", test.expectedConfigured, usernameConfigured)
}
})
}
}
func TestGetOpenIDUsername(t *testing.T) {
t.Parallel()
info := &oidc.UserInfo{
Subject: "subject-id",
UserInfoProfile: oidc.UserInfoProfile{
Name: "Full Name",
PreferredUsername: "preferred-user",
},
UserInfoEmail: oidc.UserInfoEmail{Email: "user@example.com"},
Claims: map[string]any{
"custom_username": "custom-user",
"numeric": 42,
},
}
tests := []struct {
name string
info *oidc.UserInfo
claim string
expected string
}{
{name: "nil userinfo", claim: defaultUsernameClaim},
{name: "preferred username", info: info, claim: "preferred_username", expected: "preferred-user"},
{name: "email", info: info, claim: defaultUsernameClaim, expected: "user@example.com"},
{name: "subject", info: info, claim: "sub", expected: "subject-id"},
{name: "name", info: info, claim: "name", expected: "Full Name"},
{name: "custom string claim", info: info, claim: "custom_username", expected: "custom-user"},
{name: "custom non-string claim", info: info, claim: "numeric"},
{name: "missing custom claim", info: info, claim: "missing"},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
username := getOpenIDUsername(test.info, test.claim)
if username != test.expected {
t.Fatalf("expected username %q, got %q", test.expected, username)
}
})
}
}
func TestAppendOpenIDGroups(t *testing.T) {
t.Parallel()
tests := []struct {
name string
groups []string
claims map[string]any
claim string
expected []string
expectedFound bool
}{
{
name: "appends any slice",
groups: []string{"existing"},
claims: map[string]any{"roles": []any{"dev", 7}},
claim: "roles",
expected: []string{"existing", "dev", "7"},
expectedFound: true,
},
{
name: "skips nil and empty entries in any slice",
claims: map[string]any{"roles": []any{"dev", nil, ""}},
claim: "roles",
expected: []string{"dev"},
expectedFound: true,
},
{
name: "appends string slice",
claims: map[string]any{"roles": []string{"admin", "ops"}},
claim: "roles",
expected: []string{"admin", "ops"},
expectedFound: true,
},
{
name: "skips empty entries in string slice",
claims: map[string]any{"roles": []string{"admin", "", "ops"}},
claim: "roles",
expected: []string{"admin", "ops"},
expectedFound: true,
},
{
name: "appends non-empty string",
claims: map[string]any{"roles": "admin"},
claim: "roles",
expected: []string{"admin"},
expectedFound: true,
},
{
name: "finds empty string",
claims: map[string]any{"roles": ""},
claim: "roles",
expected: nil,
expectedFound: true,
},
{
name: "finds empty any slice",
claims: map[string]any{"roles": []any{}},
claim: "roles",
expected: nil,
expectedFound: true,
},
{
name: "finds empty string slice",
claims: map[string]any{"roles": []string{}},
claim: "roles",
expected: nil,
expectedFound: true,
},
{
name: "does not find missing claim",
claims: map[string]any{},
claim: "roles",
expected: nil,
expectedFound: false,
},
{
name: "does not find unsupported claim type",
claims: map[string]any{"roles": 7},
claim: "roles",
expected: nil,
expectedFound: false,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
groups, found := appendOpenIDGroups(test.groups, test.claims, test.claim)
if found != test.expectedFound {
t.Fatalf("expected found %t, got %t", test.expectedFound, found)
}
if len(groups) != len(test.expected) {
t.Fatalf("expected groups %v, got %v", test.expected, groups)
}
for i := range test.expected {
if groups[i] != test.expected[i] {
t.Fatalf("expected groups %v, got %v", test.expected, groups)
}
}
})
}
}
+4
View File
@@ -322,6 +322,10 @@ type ClaimMapping struct {
// 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"`
// Groups specifies which OpenID claim to use as the groups for the authenticated user.
// If not configured, the default is "groups".
Groups string `mapstructure:"groups,omitempty"`
}
// CELClaimValidationAndMapping specifies Common Expression Language (CEL) expressions
+5 -68
View File
@@ -19,7 +19,6 @@ import (
"net/textproto"
"net/url"
"path"
"slices"
"sort"
"strconv"
"strings"
@@ -2526,83 +2525,21 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallbackWithProvider(providerName stri
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string,
relyingParty rp.RelyingParty, info *oidc.UserInfo,
) {
// 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")
}
}
}
var idTokenClaims map[string]any
if tokens != nil && tokens.IDTokenClaims != nil {
idTokenClaims = tokens.IDTokenClaims.Claims
}
// 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 == "" {
username, groups, ok := extractOpenIDIdentity(rh.c.Log, authConfig, providerName, info, idTokenClaims)
if !ok {
rh.c.Log.Error().Msg("failed to set user record for empty username value")
w.WriteHeader(http.StatusUnauthorized)
return
}
var groups []string
val, ok := info.Claims["groups"].([]any)
if !ok {
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", username)
}
for _, group := range val {
groups = append(groups, fmt.Sprint(group))
}
val, ok = tokens.IDTokenClaims.Claims["groups"].([]any)
if !ok {
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", username)
}
for _, group := range val {
groups = append(groups, fmt.Sprint(group))
}
slices.Sort(groups)
groups = slices.Compact(groups)
callbackUI, err := OAuth2Callback(rh.c, w, r, state, username, providerName, groups)
if err != nil {
if errors.Is(err, zerr.ErrInvalidStateCookie) {
+177
View File
@@ -155,6 +155,183 @@ func TestRoutes(t *testing.T) {
So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized)
})
Convey("Test OpenIDCodeExchangeCallback with claim mapping", func() {
authConfig := conf.HTTP.Auth.OpenID.Providers["oidc"]
authConfig.ClaimMapping = &config.ClaimMapping{
Username: "preferred_username",
Groups: "roles",
}
conf.HTTP.Auth.OpenID.Providers["oidc"] = authConfig
var capturedGroups []string
ctlr.MetaDB = mocks.MetaDBMock{
SetUserGroupsFn: func(ctx context.Context, groups []string) error {
capturedGroups = append(capturedGroups, groups...)
return nil
},
}
callback := rthdlr.OpenIDCodeExchangeCallbackWithProvider("oidc")
ctx := context.TODO()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
response := httptest.NewRecorder()
state := uuid.New().String()
session, _ := ctlr.CookieStore.Get(request, "statecookie")
session.Values["state"] = state
So(session.Save(request, response), ShouldBeNil)
tokens := &oidc.Tokens[*oidc.IDTokenClaims]{
IDTokenClaims: &oidc.IDTokenClaims{
Claims: map[string]any{
"groups": []any{"ignored-token-group"},
"roles": []any{"ops", "admin"},
},
},
}
relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{})
So(err, ShouldBeNil)
userinfo := &oidc.UserInfo{
Subject: "sub",
UserInfoProfile: oidc.UserInfoProfile{PreferredUsername: "mapped-user"},
Claims: map[string]any{
"email": "test@test.com",
"groups": []any{"ignored-userinfo-group"},
"roles": []any{"dev", "ops"},
},
UserInfoEmail: oidc.UserInfoEmail{Email: "test@test.com"},
}
callback(response, request, tokens, state, relyingParty, userinfo)
resp := response.Result()
defer resp.Body.Close()
So(resp, ShouldNotBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusCreated)
So(capturedGroups, ShouldResemble, []string{"admin", "dev", "ops"})
userAc, err := reqCtx.UserAcFromContext(request.Context())
So(err, ShouldBeNil)
So(userAc.GetUsername(), ShouldEqual, "mapped-user")
So(userAc.GetGroups(), ShouldResemble, []string{"admin", "dev", "ops"})
})
Convey("Test OpenIDCodeExchangeCallback falls back to email when mapped username is missing", func() {
authConfig := conf.HTTP.Auth.OpenID.Providers["oidc"]
authConfig.ClaimMapping = &config.ClaimMapping{
Username: "missing_username",
Groups: "roles",
}
conf.HTTP.Auth.OpenID.Providers["oidc"] = authConfig
ctlr.MetaDB = mocks.MetaDBMock{
SetUserGroupsFn: func(ctx context.Context, groups []string) error {
return nil
},
}
callback := rthdlr.OpenIDCodeExchangeCallbackWithProvider("oidc")
ctx := context.TODO()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
response := httptest.NewRecorder()
state := uuid.New().String()
session, _ := ctlr.CookieStore.Get(request, "statecookie")
session.Values["state"] = state
So(session.Save(request, response), ShouldBeNil)
tokens := &oidc.Tokens[*oidc.IDTokenClaims]{
IDTokenClaims: &oidc.IDTokenClaims{
Claims: map[string]any{
"roles": []any{"admin"},
},
},
}
relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{})
So(err, ShouldBeNil)
userinfo := &oidc.UserInfo{
Subject: "sub",
Claims: map[string]any{
"roles": []any{"dev"},
},
UserInfoEmail: oidc.UserInfoEmail{Email: "fallback@test.com"},
}
callback(response, request, tokens, state, relyingParty, userinfo)
resp := response.Result()
defer resp.Body.Close()
So(resp, ShouldNotBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusCreated)
userAc, err := reqCtx.UserAcFromContext(request.Context())
So(err, ShouldBeNil)
So(userAc.GetUsername(), ShouldEqual, "fallback@test.com")
So(userAc.GetGroups(), ShouldResemble, []string{"admin", "dev"})
})
Convey("Test OpenIDCodeExchangeCallback continues when mapped groups are missing", func() {
authConfig := conf.HTTP.Auth.OpenID.Providers["oidc"]
authConfig.ClaimMapping = &config.ClaimMapping{
Username: "preferred_username",
Groups: "roles",
}
conf.HTTP.Auth.OpenID.Providers["oidc"] = authConfig
var capturedGroups []string
ctlr.MetaDB = mocks.MetaDBMock{
SetUserGroupsFn: func(ctx context.Context, groups []string) error {
capturedGroups = append(capturedGroups, groups...)
return nil
},
}
callback := rthdlr.OpenIDCodeExchangeCallbackWithProvider("oidc")
ctx := context.TODO()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
response := httptest.NewRecorder()
state := uuid.New().String()
session, _ := ctlr.CookieStore.Get(request, "statecookie")
session.Values["state"] = state
So(session.Save(request, response), ShouldBeNil)
tokens := &oidc.Tokens[*oidc.IDTokenClaims]{
IDTokenClaims: &oidc.IDTokenClaims{
Claims: map[string]any{},
},
}
relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{})
So(err, ShouldBeNil)
userinfo := &oidc.UserInfo{
Subject: "sub",
UserInfoProfile: oidc.UserInfoProfile{PreferredUsername: "mapped-user"},
Claims: map[string]any{},
UserInfoEmail: oidc.UserInfoEmail{Email: "mapped@test.com"},
}
callback(response, request, tokens, state, relyingParty, userinfo)
resp := response.Result()
defer resp.Body.Close()
So(resp, ShouldNotBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusCreated)
So(capturedGroups, ShouldBeEmpty)
userAc, err := reqCtx.UserAcFromContext(request.Context())
So(err, ShouldBeNil)
So(userAc.GetUsername(), ShouldEqual, "mapped-user")
So(userAc.GetGroups(), ShouldBeEmpty)
})
Convey("Test OAuth2Callback errors", func() {
ctx := context.TODO()