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:
parent
0f6d312ada
commit
b07bd37f39
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user