From cb9d682a6936695d1d82d1a29b553775f24906f2 Mon Sep 17 00:00:00 2001 From: Akash Kumar <91385321+AkashKumar7902@users.noreply.github.com> Date: Fri, 1 May 2026 14:29:51 +0530 Subject: [PATCH] feat(auth): map OpenID groups claim (#3999) * feat(auth): map OpenID groups claim Signed-off-by: Akash Kumar * fix(auth): refine OIDC claim mapping logs Signed-off-by: Akash Kumar * 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 * 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 --------- Signed-off-by: Akash Kumar --- examples/README.md | 8 +- examples/config-openid-claim-mapping.json | 3 +- pkg/api/authn.go | 155 ++++++++++++ pkg/api/authn_internal_test.go | 278 ++++++++++++++++++++++ pkg/api/config/config.go | 4 + pkg/api/routes.go | 73 +----- pkg/api/routes_test.go | 177 ++++++++++++++ 7 files changed, 628 insertions(+), 70 deletions(-) create mode 100644 pkg/api/authn_internal_test.go diff --git a/examples/README.md b/examples/README.md index dd15a78b..df51e2b2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -440,7 +440,11 @@ zot can be configured to use dex with: "clientsecret": "ZXhhbXBsZS1hcHAtc2VjcmV0", "keypath": "", "issuer": "http://127.0.0.1:5556/dex", - "scopes": ["openid", "profile", "email", "groups"] + "scopes": ["openid", "profile", "email", "groups"], + "claimMapping": { + "username": "preferred_username", + "groups": "groups" + } } } } @@ -450,6 +454,8 @@ zot can be configured to use dex with: To login using openid dex provider use http://127.0.0.1:8080/zot/auth/login?provider=oidc +`claimMapping.username` defaults to `email`, and `claimMapping.groups` defaults to `groups`. + NOTE: Social login is not supported by command line tools, or other software responsible for pushing/pulling images to/from zot. Given this limitation, if openif authentication is enabled in the configuration, API keys are also enabled diff --git a/examples/config-openid-claim-mapping.json b/examples/config-openid-claim-mapping.json index bc0e9c24..00e22585 100644 --- a/examples/config-openid-claim-mapping.json +++ b/examples/config-openid-claim-mapping.json @@ -20,7 +20,8 @@ "credentialsFile": "examples/config-openid-oidc-credentials.json", "scopes": ["openid", "profile", "email", "groups"], "claimMapping": { - "username": "preferred_username" + "username": "preferred_username", + "groups": "groups" } } } diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 8528b148..fed8fca1 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -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, diff --git a/pkg/api/authn_internal_test.go b/pkg/api/authn_internal_test.go new file mode 100644 index 00000000..bed91740 --- /dev/null +++ b/pkg/api/authn_internal_test.go @@ -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) + } + } + }) + } +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index ae4112ac..adeb6e30 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -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 diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 36a45275..2fee2958 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -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) { diff --git a/pkg/api/routes_test.go b/pkg/api/routes_test.go index 7af6fe55..9ac6a765 100644 --- a/pkg/api/routes_test.go +++ b/pkg/api/routes_test.go @@ -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()