mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user