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>
This commit is contained in:
Ramkumar Chinchani
2025-11-20 08:54:56 -08:00
committed by GitHub
parent 7fa53f5b0f
commit 64829f9502
10 changed files with 740 additions and 13 deletions
+1 -1
View File
@@ -567,7 +567,7 @@ func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc {
callback ui where we will redirect after openid/oauth2 logic is completed*/
session, _ := rh.c.CookieStore.Get(r, "statecookie")
session.Options.Secure = true
session.Options.Secure = rh.c.Config.UseSecureSession()
session.Options.HttpOnly = true
session.Options.SameSite = http.SameSiteDefaultMode
session.Options.Path = constants.CallbackBasePath
+11
View File
@@ -170,6 +170,16 @@ type OpenIDProviderConfig struct {
AuthURL string
TokenURL string
Scopes []string
ClaimMapping *ClaimMapping `mapstructure:",omitempty"`
}
// ClaimMapping specifies how OpenID claims are mapped to application fields.
// It allows customization of which claim is used as the username when authenticating users.
type ClaimMapping struct {
// Username specifies which OpenID claim to use as the username for the authenticated user.
// 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"`
}
type MethodRatelimitConfig struct {
@@ -611,6 +621,7 @@ func (c *Config) Sanitize() *Config {
AuthURL: config.AuthURL,
TokenURL: config.TokenURL,
Scopes: config.Scopes,
ClaimMapping: config.ClaimMapping,
}
}
}
+64 -8
View File
@@ -90,7 +90,7 @@ func (rh *RouteHandler) SetupRoutes() {
rp.CodeExchangeHandler(rh.GithubCodeExchangeCallback(), relyingParty))
} else if config.IsOpenIDSupported(provider) {
rh.c.Router.HandleFunc(constants.CallbackBasePath+"/"+provider,
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallback()), relyingParty))
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallbackWithProvider(provider)), relyingParty))
}
}
}
@@ -1998,17 +1998,73 @@ func (rh *RouteHandler) GithubCodeExchangeCallback() rp.CodeExchangeCallback[*oi
}
}
// Openid CodeExchange callback.
// Openid CodeExchange callback (legacy, kept for compatibility).
func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCallback[
*oidc.IDTokenClaims,
*oidc.UserInfo,
] {
return rh.OpenIDCodeExchangeCallbackWithProvider("")
}
// OpenIDCodeExchangeCallbackWithProvider is the OIDC CodeExchange callback that supports configurable claim mapping.
// The providerName parameter is used to lookup provider-specific claim mapping configuration.
// This differs from the legacy version by allowing per-provider claim mapping based on the providerName.
func (rh *RouteHandler) OpenIDCodeExchangeCallbackWithProvider(providerName string) rp.CodeExchangeUserinfoCallback[
*oidc.IDTokenClaims,
*oidc.UserInfo,
] {
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string,
relyingParty rp.RelyingParty, info *oidc.UserInfo,
) {
email := info.UserInfoEmail.Email
if email == "" {
rh.c.Log.Error().Msg("failed to set user record for empty email value")
// 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")
}
}
}
}
// 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 == "" {
rh.c.Log.Error().Msg("failed to set user record for empty username value")
w.WriteHeader(http.StatusUnauthorized)
return
@@ -2018,7 +2074,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
val, ok := info.Claims["groups"].([]interface{})
if !ok {
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", email)
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in UserInfo", username)
}
for _, group := range val {
@@ -2027,7 +2083,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
val, ok = tokens.IDTokenClaims.Claims["groups"].([]interface{})
if !ok {
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", email)
rh.c.Log.Info().Msgf("failed to find any 'groups' claim for user %s in IDTokenClaimsToken", username)
}
for _, group := range val {
@@ -2037,7 +2093,7 @@ func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCall
slices.Sort(groups)
groups = slices.Compact(groups)
callbackUI, err := OAuth2Callback(rh.c, w, r, state, email, groups)
callbackUI, err := OAuth2Callback(rh.c, w, r, state, username, groups)
if err != nil {
if errors.Is(err, zerr.ErrInvalidStateCookie) {
w.WriteHeader(http.StatusUnauthorized)