From b07bd37f396649992acde2be335954a01642a1a1 Mon Sep 17 00:00:00 2001 From: Lukas Wolfsteiner Date: Mon, 9 Feb 2026 02:13:07 +0100 Subject: [PATCH] feat(oidc): add use_email_as_username option to fall back to email claim Some OIDC providers (e.g. Google OAuth) do not send the preferred_username claim when the profile scope is requested, causing the username in Headscale to be blank. Add oidc.use_email_as_username config option (default: false) that, when enabled, uses the email claim as the username if preferred_username is not available. When preferred_username is present, it is always used regardless of this setting. - Add UseEmailAsUsername to OIDCConfig with viper wiring - Extend FromClaim() to accept useEmailAsUsername parameter - Add 5 test cases covering fallback enabled/disabled, priority, empty email, and unverified email scenarios - Update docs, config example, and Google OAuth guidance Fixes #3071. Cheers! --- CHANGELOG.md | 1 + config-example.yaml | 6 ++ docs/ref/oidc.md | 20 +++++- hscontrol/oidc.go | 2 +- hscontrol/types/config.go | 3 + hscontrol/types/users.go | 20 ++++-- hscontrol/types/users_test.go | 128 +++++++++++++++++++++++++++++++++- 7 files changed, 171 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2178ad87..b8dc4d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ overall our implementation was very close. - **ACL Policy**: Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036) - **ACL Policy**: Use CIDR format for autogroup:self destination IPs matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036) - **ACL Policy**: Merge filter rules with identical SrcIPs and IPProto matching Tailscale behavior - multiple ACL rules with the same source now produce a single FilterRule with combined DstPorts [#3036](https://github.com/juanfont/headscale/pull/3036) +- **OIDC**: Add `oidc.use_email_as_username` config option to fall back to the `email` claim as username when `preferred_username` is not available. Useful for providers like Google OAuth that don't include `preferred_username`. Disabled by default for backward compatibility. [#3072](https://github.com/juanfont/headscale/pull/3072) ## 0.28.0 (2026-02-04) diff --git a/config-example.yaml b/config-example.yaml index dbb08202..5e2e46de 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -368,6 +368,12 @@ unix_socket_permission: "0770" # # not required. # email_verified_required: true # +# # Use the email claim as the username when the identity provider does not +# # send the "preferred_username" claim. When preferred_username is present, +# # it is always used regardless of this setting. Useful for providers like +# # Google OAuth that don't include preferred_username. +# use_email_as_username: false +# # # Provide custom key/value pairs which get sent to the identity provider's # # authorization endpoint. # extra_params: diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index 23ef64a6..a9da1644 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -142,6 +142,22 @@ oidc: email_verified_required: false ``` +### Use email as username + +Some identity providers (e.g. Google OAuth) do not send the `preferred_username` claim when the scope `profile` is +requested. This causes the username in Headscale to be blank/not set. When `use_email_as_username` is enabled, +Headscale will fall back to using the `email` claim as the username if `preferred_username` is not available. + +When `preferred_username` is present, it is always used regardless of this setting. + +```yaml hl_lines="5" +oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + use_email_as_username: true +``` + ### Customize node expiration The node expiration is the amount of time a node is authenticated with OpenID Connect until it expires and needs to @@ -210,7 +226,7 @@ endpoint. | ------------------- | -------------------- | ------------------------------------------------------------------------------------------------- | | email address | `email` | Only verified emails are synchronized, unless `email_verified_required: false` is configured | | display name | `name` | eg: `Sam Smith` | -| username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith` | +| username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith`. Falls back to `email` if [use_email_as_username](#use-email-as-username) is enabled. | | profile picture | `picture` | URL to a profile picture or avatar | | provider identifier | `iss`, `sub` | A stable and unique identifier for a user, typically a combination of `iss` and `sub` OIDC claims | | | `groups` | [Only used to filter for allowed groups](#authorize-users-with-filters) | @@ -258,7 +274,7 @@ Authelia is fully supported by Headscale. !!! warning "No username due to missing preferred_username" Google OAuth does not send the `preferred_username` claim when the scope `profile` is requested. The username in - Headscale will be blank/not set. + Headscale will be blank/not set unless [`use_email_as_username`](#use-email-as-username) is enabled. In order to integrate Headscale with Google, you'll need to have a [Google Cloud Console](https://console.cloud.google.com) account. diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 9d284921..cf2377ba 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -540,7 +540,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim( user = &types.User{} } - user.FromClaim(claims, a.cfg.EmailVerifiedRequired) + user.FromClaim(claims, a.cfg.EmailVerifiedRequired, a.cfg.UseEmailAsUsername) if newUser { user, c, err = a.h.state.CreateUser(*user) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index b7440f8b..b8dba75c 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -188,6 +188,7 @@ type OIDCConfig struct { AllowedUsers []string AllowedGroups []string EmailVerifiedRequired bool + UseEmailAsUsername bool Expiry time.Duration UseExpiryFromToken bool PKCE PKCEConfig @@ -390,6 +391,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("oidc.pkce.enabled", false) viper.SetDefault("oidc.pkce.method", "S256") viper.SetDefault("oidc.email_verified_required", true) + viper.SetDefault("oidc.use_email_as_username", false) viper.SetDefault("logtail.enabled", false) viper.SetDefault("randomize_client_port", false) @@ -1044,6 +1046,7 @@ func LoadServerConfig() (*Config, error) { AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"), + UseEmailAsUsername: viper.GetBool("oidc.use_email_as_username"), Expiry: func() time.Duration { // if set to 0, we assume no expiry if value := viper.GetString("oidc.expiry"); value == "0" { diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index 2593bda0..69a81e51 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -401,12 +401,22 @@ type OIDCUserInfo struct { // FromClaim overrides a User from OIDC claims. // All fields will be updated, except for the ID. -func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) { - err := util.ValidateUsername(claims.Username) +// When useEmailAsUsername is true and the preferred_username claim is empty, +// the email claim will be used as the username instead. +func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool, useEmailAsUsername bool) { + username := claims.Username + + // If preferred_username is not available and useEmailAsUsername is enabled, + // fall back to using the email claim as the username. + if username == "" && useEmailAsUsername { + username = claims.Email + } + + err := util.ValidateUsername(username) if err == nil { - u.Name = claims.Username - } else { - log.Debug().Caller().Err(err).Msgf("username %s is not valid", claims.Username) + u.Name = username + } else if username != "" { + log.Debug().Caller().Err(err).Msgf("username %s is not valid", username) } if claims.EmailVerified || !FlexibleBoolean(emailVerifiedRequired) { diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 064388eb..fd8322ff 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -299,6 +299,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { name string jsonstr string emailVerifiedRequired bool + useEmailAsUsername bool want User }{ { @@ -479,6 +480,131 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { ProfilePicURL: "https://cdn.casbin.org/img/casbin.svg", }, }, + { + // Test: email fallback disabled (default) - no preferred_username, no username set + name: "no-preferred-username-fallback-disabled", + emailVerifiedRequired: true, + useEmailAsUsername: false, + jsonstr: ` +{ + "sub": "google-user-123", + "iss": "https://accounts.google.com", + "email": "alice@gmail.com", + "email_verified": true, + "name": "Alice Smith", + "picture": "https://lh3.googleusercontent.com/photo.jpg" +} + `, + want: User{ + Provider: util.RegisterMethodOIDC, + DisplayName: "Alice Smith", + Email: "alice@gmail.com", + ProviderIdentifier: sql.NullString{ + String: "https://accounts.google.com/google-user-123", + Valid: true, + }, + ProfilePicURL: "https://lh3.googleusercontent.com/photo.jpg", + }, + }, + { + // Test: email fallback enabled - no preferred_username, email used as username + name: "no-preferred-username-fallback-enabled", + emailVerifiedRequired: true, + useEmailAsUsername: true, + jsonstr: ` +{ + "sub": "google-user-456", + "iss": "https://accounts.google.com", + "email": "bob@gmail.com", + "email_verified": true, + "name": "Bob Jones", + "picture": "https://lh3.googleusercontent.com/photo2.jpg" +} + `, + want: User{ + Provider: util.RegisterMethodOIDC, + Name: "bob@gmail.com", + DisplayName: "Bob Jones", + Email: "bob@gmail.com", + ProviderIdentifier: sql.NullString{ + String: "https://accounts.google.com/google-user-456", + Valid: true, + }, + ProfilePicURL: "https://lh3.googleusercontent.com/photo2.jpg", + }, + }, + { + // Test: email fallback enabled but preferred_username is present - preferred_username takes priority + name: "preferred-username-present-fallback-enabled", + emailVerifiedRequired: true, + useEmailAsUsername: true, + jsonstr: ` +{ + "sub": "user-789", + "iss": "https://idp.example.com", + "preferred_username": "charlie", + "email": "charlie@example.com", + "email_verified": true, + "name": "Charlie Brown" +} + `, + want: User{ + Provider: util.RegisterMethodOIDC, + Name: "charlie", + DisplayName: "Charlie Brown", + Email: "charlie@example.com", + ProviderIdentifier: sql.NullString{ + String: "https://idp.example.com/user-789", + Valid: true, + }, + }, + }, + { + // Test: email fallback enabled but email is also empty - no username set + name: "no-preferred-username-no-email-fallback-enabled", + emailVerifiedRequired: true, + useEmailAsUsername: true, + jsonstr: ` +{ + "sub": "user-000", + "iss": "https://idp.example.com", + "name": "No Email User" +} + `, + want: User{ + Provider: util.RegisterMethodOIDC, + DisplayName: "No Email User", + ProviderIdentifier: sql.NullString{ + String: "https://idp.example.com/user-000", + Valid: true, + }, + }, + }, + { + // Test: email fallback enabled, email_verified not required, no preferred_username + name: "email-fallback-unverified-email", + emailVerifiedRequired: false, + useEmailAsUsername: true, + jsonstr: ` +{ + "sub": "user-unverified", + "iss": "https://idp.example.com", + "email": "unverified@example.com", + "email_verified": false, + "name": "Unverified User" +} + `, + want: User{ + Provider: util.RegisterMethodOIDC, + Name: "unverified@example.com", + DisplayName: "Unverified User", + Email: "unverified@example.com", + ProviderIdentifier: sql.NullString{ + String: "https://idp.example.com/user-unverified", + Valid: true, + }, + }, + }, } for _, tt := range tests { @@ -493,7 +619,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { var user User - user.FromClaim(&got, tt.emailVerifiedRequired) + user.FromClaim(&got, tt.emailVerifiedRequired, tt.useEmailAsUsername) if diff := cmp.Diff(user, tt.want); diff != "" { t.Errorf("TestOIDCClaimsJSONToUser() mismatch (-want +got):\n%s", diff)