1
0
mirror of https://github.com/juanfont/headscale.git synced 2026-02-07 20:04:00 +01:00

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!
This commit is contained in:
Lukas Wolfsteiner 2026-02-09 02:13:07 +01:00
parent 0f6d312ada
commit b07bd37f39
7 changed files with 171 additions and 9 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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.

View File

@ -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)

View File

@ -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" {

View File

@ -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) {

View File

@ -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)