diff --git a/config-example.yaml b/config-example.yaml index dbb08202..05eafc7e 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -361,6 +361,15 @@ unix_socket_permission: "0770" # # Custom scopes can be configured as needed, be sure to always include the # # required "openid" scope. # scope: ["openid", "profile", "email"] + +# # Optional: control how Headscale derives the username (login name) from OIDC claims. +# # The first non-empty, valid value is used. +# # Supported values: preferred_username, email_localpart, email, name, sub +# # Defaults to [preferred_username, email_localpart, email, name, sub] +# # username_claim_order: +# # - preferred_username +# # - email_localpart +# # - email # # # Only verified email addresses are synchronized to the user profile by # # default. Unverified emails may be allowed in case an identity provider diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index f6ec1bcd..8da9253c 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -179,6 +179,26 @@ Access Token. headscale node expire -i ``` +### Customize username mapping + +Some identity providers (notably Google OAuth) do not include the `preferred_username` claim. You can configure how +Headscale derives the username (login name) by specifying a priority order of OIDC claims. The first non-empty, valid +value is used. + +Supported values: `preferred_username`, `email_localpart`, `email`, `name`, `sub`. + +If not set, Headscale uses the default order: `preferred_username`, `email_localpart`, `email`, `name`, `sub`. + +Example that prefers the email local-part when `preferred_username` is missing: + +```yaml +oidc: + username_claim_order: + - preferred_username + - email_localpart + - email +``` + ### Reference a user in the policy You may refer to users in the Headscale policy via: @@ -255,10 +275,15 @@ Authelia is fully supported by Headscale. ### Google OAuth -!!! warning "No username due to missing preferred_username" +!!! tip "Derive username with email local-part" - Google OAuth does not send the `preferred_username` claim when the scope `profile` is requested. The username in - Headscale will be blank/not set. + Google OAuth does not include the `preferred_username` claim. Configure `oidc.username_claim_order` so Headscale + derives a username from the email local-part: + + ```yaml + oidc: + username_claim_order: [preferred_username, email_localpart, email] + ``` 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 7013b8ed..1a144039 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -263,7 +263,19 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( util.LogErr(err, "could not get userinfo; only using claims from id token") } - // The user claims are now updated from the userinfo endpoint so we can verify the user + // The user claims are now updated from the userinfo endpoint so we can derive username + // and verify the user against authorization constraints. + + // Derive username according to configured claim order (with sensible defaults) + order := a.cfg.UsernameClaimOrder + if len(order) == 0 { + order = []string{"preferred_username", "email_localpart", "email", "name", "sub"} + } + if uname := types.DeriveUsername(&claims, order); uname != "" { + claims.Username = uname + } + + // Now we can verify the user // against allowed emails, email domains, and groups. err = doOIDCAuthorization(a.cfg, &claims) if err != nil { diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 4068d72e..09beb012 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -189,6 +189,16 @@ type OIDCConfig struct { Expiry time.Duration UseExpiryFromToken bool PKCE PKCEConfig + // UsernameClaimOrder controls which OIDC claims are used to derive the + // Headscale username (login name) when authenticating via OIDC. + // The first non-empty, valid value is selected. Supported values: + // - "preferred_username" (OIDC standard) + // - "email" (full email address) + // - "email_localpart" (portion before '@') + // - "name" (full display name) + // - "sub" (subject) + // If empty, the default order is: preferred_username, email_localpart, email, name, sub. + UsernameClaimOrder []string } type DERPConfig struct { diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index ec40492b..f97f5923 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -239,6 +239,56 @@ type OIDCClaims struct { Username string `json:"preferred_username,omitempty"` } +// DeriveUsername selects a username for the given claims based on the provided +// priority order. The first non-empty, valid value wins. Supported keys: +// - "preferred_username": claims.Username +// - "email": claims.Email +// - "email_localpart": part of claims.Email before '@' +// - "name": claims.Name +// - "sub": claims.Sub +// +// Values are validated with util.ValidateUsername; invalid candidates are skipped. +func DeriveUsername(claims *OIDCClaims, order []string) string { + pick := func(candidate string) (string, bool) { + if candidate == "" { + return "", false + } + if err := util.ValidateUsername(candidate); err != nil { + return "", false + } + return candidate, true + } + + for _, key := range order { + switch strings.ToLower(strings.TrimSpace(key)) { + case "preferred_username", "preferred-username", "username": + if v, ok := pick(claims.Username); ok { + return v + } + case "email": + if v, ok := pick(claims.Email); ok { + return v + } + case "email_localpart", "email-localpart", "email_local", "email-local": + if at := strings.Index(claims.Email, "@"); at > 0 { + if v, ok := pick(claims.Email[:at]); ok { + return v + } + } + case "name", "display_name", "display-name": + if v, ok := pick(claims.Name); ok { + return v + } + case "sub", "subject": + if v, ok := pick(claims.Sub); ok { + return v + } + } + } + + return "" +} + // Identifier returns a unique identifier string combining the Iss and Sub claims. // The format depends on whether Iss is a URL or not: // - For URLs: Joins the URL and sub path (e.g., "https://example.com/sub") diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 15386553..1d413d02 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -493,3 +493,95 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { }) } } + +// TestDeriveUsername validates username derivation from OIDC claims. +// Tests that DeriveUsername correctly selects and validates usernames +// from configured claim order, falling back to next claim if current is invalid. +func TestDeriveUsername(t *testing.T) { + tests := []struct { + name string + claims *OIDCClaims + claimOrder []string + expectedValid bool + expectedName string + }{ + { + name: "preferred_username-available", + claims: &OIDCClaims{ + Username: "alice", + Email: "alice@example.com", + Sub: "alice-sub-123", + }, + claimOrder: []string{"preferred_username", "email_localpart", "sub"}, + expectedValid: true, + expectedName: "alice", + }, + { + name: "fallback-to-email-localpart", + claims: &OIDCClaims{ + Username: "", // preferred_username is empty + Email: "bob@example.com", + Sub: "bob-sub-456", + }, + claimOrder: []string{"preferred_username", "email_localpart", "sub"}, + expectedValid: true, + expectedName: "bob", + }, + { + name: "fallback-to-subject", + claims: &OIDCClaims{ + Username: "", // preferred_username is empty + Email: "", // email is empty + Sub: "charlie-sub-789", + }, + claimOrder: []string{"preferred_username", "email_localpart", "sub"}, + expectedValid: true, + expectedName: "charlie-sub-789", + }, + { + name: "invalid-claim-skipped", + claims: &OIDCClaims{ + Username: "invalid user!", // Invalid: contains space and special character + Email: "diana@example.com", + Sub: "diana-sub-000", + }, + claimOrder: []string{"preferred_username", "email_localpart", "sub"}, + expectedValid: true, + expectedName: "diana", // Falls back to email_localpart + }, + { + name: "custom-claim-order", + claims: &OIDCClaims{ + Username: "eve", + Email: "eve@example.com", + Sub: "eve-sub-111", + }, + claimOrder: []string{"email_localpart", "preferred_username", "sub"}, + expectedValid: true, + expectedName: "eve", // email_localpart comes first, but both "eve" and "eve@example.com" -> "eve" + }, + { + name: "no-valid-username-available", + claims: &OIDCClaims{ + Username: "", // preferred_username is empty + Email: "@", // Invalid email, no local part + Sub: "", // sub is empty + }, + claimOrder: []string{"preferred_username", "email_localpart", "sub"}, + expectedValid: false, + expectedName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + derived := DeriveUsername(tt.claims, tt.claimOrder) + if tt.expectedValid { + assert.NotEmpty(t, derived, "expected username to be derived") + assert.Equal(t, tt.expectedName, derived, "expected derived username to match") + } else { + assert.Empty(t, derived, "expected no valid username") + } + }) + } +} diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 359dd456..a1bb1515 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -4,6 +4,7 @@ import ( "maps" "net/netip" "net/url" + "slices" "sort" "strconv" "testing" @@ -1915,3 +1916,118 @@ func TestOIDCReloginSameUserRoutesPreserved(t *testing.T) { t.Logf("Test completed - verifying issue #2896 fix for OIDC") } + +// TestOIDCUsernameClaimOrder validates configurable OIDC username claim mapping. +// Tests that when preferred_username is absent (e.g., Google OAuth), +// the system correctly derives usernames from configured claim order (email, name, sub). +func TestOIDCUsernameClaimOrder(t *testing.T) { + IntegrationSkip(t) + + type testCase struct { + name string + usernameClaimOrder string // Empty uses default, otherwise specify: "email_localpart,sub" + oidcUsers []mockoidc.MockUser + expectedUsernames []string // Expected usernames after OIDC registration + } + + tests := []testCase{ + { + name: "fallback-to-email-localpart-without-preferred-username", + usernameClaimOrder: "", // Use default: preferred_username, email_localpart, email, name, sub + oidcUsers: []mockoidc.MockUser{ + oidcMockUserNoPreferredUsername("google-user1", "alice@example.com", true), + oidcMockUserNoPreferredUsername("google-user2", "bob@example.com", true), + }, + expectedUsernames: []string{"alice", "bob"}, // Extracted from email local-part + }, + { + name: "custom-order-prioritize-email-localpart", + usernameClaimOrder: "email_localpart,sub", + oidcUsers: []mockoidc.MockUser{ + oidcMockUserNoPreferredUsername("google-user3", "charlie@domain.io", true), + }, + expectedUsernames: []string{"charlie"}, + }, + { + name: "fallback-to-subject-when-email-empty", + usernameClaimOrder: "email_localpart,sub", + oidcUsers: []mockoidc.MockUser{ + { + Subject: "diana-unique-id-12345", + Email: "", // Email is intentionally empty + EmailVerified: true, + }, + }, + expectedUsernames: []string{"diana-unique-id-12345"}, // Sub as fallback + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := ScenarioSpec{ + NodesPerUser: 1, + } + + // Create one user per expected username for Headscale CLI + spec.Users = tt.expectedUsernames + + // Use provided OIDC users + spec.OIDCUsers = tt.oidcUsers + + scenario, err := NewScenario(spec) + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + // Build OIDC configuration map + oidcMap := map[string]string{ + "HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(), + "HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(), + "CREDENTIALS_DIRECTORY_TEST": "/tmp", + "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", + } + + // Add custom username claim order if specified + if tt.usernameClaimOrder != "" { + oidcMap["HEADSCALE_OIDC_USERNAME_CLAIM_ORDER"] = tt.usernameClaimOrder + } + + err = scenario.CreateHeadscaleEnvWithLoginURL( + nil, + hsic.WithTestName("oidcusernameorder"), + hsic.WithConfigEnv(oidcMap), + hsic.WithTLS(), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())), + ) + requireNoErrHeadscaleEnv(t, err) + + // Wait for nodes to authenticate and trigger user creation via OIDC + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + // Verify users were created with expected usernames derived from claims + assert.EventuallyWithT(t, func(c *assert.CollectT) { + users, err := headscale.ListUsers() + assert.NoError(c, err) + + // Filter to only OIDC users (Provider = "oidc") + var oidcUsernames []string + for _, u := range users { + if u.GetProvider() == "oidc" { + oidcUsernames = append(oidcUsernames, u.GetName()) + } + } + + // Sort for consistent comparison + slices.Sort(oidcUsernames) + expectedSorted := slices.Clone(tt.expectedUsernames) + slices.Sort(expectedSorted) + + assert.Equal(c, expectedSorted, oidcUsernames, + "OIDC users should be created with usernames derived from configured claims") + }, 10*time.Second, 500*time.Millisecond, "OIDC users should be created with derived usernames") + }) + } +} diff --git a/integration/helpers.go b/integration/helpers.go index 7d40c8e6..d8db36d9 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -919,6 +919,18 @@ func oidcMockUser(username string, emailVerified bool) mockoidc.MockUser { } } +// oidcMockUserNoPreferredUsername creates a MockUser without PreferredUsername claim. +// This simulates OIDC providers (like Google) that don't include preferred_username in their claims, +// useful for testing username derivation from alternative claims (email, subject, etc). +func oidcMockUserNoPreferredUsername(subject, email string, emailVerified bool) mockoidc.MockUser { + return mockoidc.MockUser{ + Subject: subject, + Email: email, + EmailVerified: emailVerified, + // PreferredUsername is intentionally empty + } +} + // GetUserByName retrieves a user by name from the headscale server. // This is a common pattern used when creating preauth keys or managing users. func GetUserByName(headscale ControlServer, username string) (*v1.User, error) {