This commit is contained in:
Ángel 2026-01-22 08:52:26 +00:00 committed by GitHub
commit c2abfae951
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 330 additions and 4 deletions

View file

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

View file

@ -179,6 +179,26 @@ Access Token.
headscale node expire -i <NODE_ID>
```
### 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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