mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-23 02:24:10 +00:00
Merge a8077c1a13 into 606e5f68a0
This commit is contained in:
commit
c2abfae951
8 changed files with 330 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue