Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,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
#
# # Provide custom key/value pairs which get sent to the identity provider's
# # authorization endpoint.
Expand Down
31 changes: 28 additions & 3 deletions docs/ref/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,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:
Expand Down Expand Up @@ -238,10 +258,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 include the `preferred_username` claim. Configure `oidc.username_claim_order` so Headscale
derives a username from the 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.
```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.
Expand Down
14 changes: 13 additions & 1 deletion hscontrol/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions hscontrol/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions hscontrol/types/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,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")
Expand Down
92 changes: 92 additions & 0 deletions hscontrol/types/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
}
116 changes: 116 additions & 0 deletions integration/auth_oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"maps"
"net/netip"
"net/url"
"slices"
"sort"
"strconv"
"testing"
Expand Down Expand Up @@ -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")
})
}
}
12 changes: 12 additions & 0 deletions integration/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down