From 5437ce88ef83ecd8d58a34c8e604a8c493a9d819 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 10:24:58 +0000 Subject: [PATCH] types: drop a stored OIDC email the login no longer vouches for --- docs/ref/oidc.md | 9 +++++---- hscontrol/oidc_test.go | 27 +++++++++++++++++++++++++++ hscontrol/types/users.go | 2 ++ hscontrol/types/users_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index 3d2e8764a..c3013dc6c 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -127,12 +127,13 @@ are configured, a user needs to pass all of them. ### Control email verification -Headscale uses the `email` claim from the identity provider to synchronize the email address to its user profile. By -default, a user's email address is only synchronized when the identity provider reports the email address as verified -via the `email_verified: true` claim. +Headscale synchronizes the `email` claim from the identity provider to its user profile on each OIDC login. By default, +the profile uses the email only when the latest login reports `email_verified: true`. An absent, unverified, or +malformed email clears the previously stored address. Unverified emails may be allowed in case an identity provider does not send the `email_verified` claim or email -verification is not required. In that case, a user's email address is always synchronized to the user profile. +verification is not required. With `email_verified_required: false`, the latest login's email is stored without +requiring verification. An absent or malformed email still clears the previously stored address. ```yaml hl_lines="5" oidc: diff --git a/hscontrol/oidc_test.go b/hscontrol/oidc_test.go index 6b10acd41..fd9097b72 100644 --- a/hscontrol/oidc_test.go +++ b/hscontrol/oidc_test.go @@ -712,3 +712,30 @@ func TestOIDCRefusesAlteredSubject(t *testing.T) { require.Len(t, users, 1) assert.Equal(t, *before, users[0], "a refused subject must not update an existing row") } + +func TestOIDCLoginClearsUnverifiedEmail(t *testing.T) { + app := createTestApp(t) + provider := &AuthProviderOIDC{ + h: app, + cfg: &types.OIDCConfig{ + EmailVerifiedRequired: true, + }, + } + claims := &types.OIDCClaims{ + Iss: "https://idp.example.com", Sub: "alice", Username: "alice", + Email: "alice@example.com", EmailVerified: true, + } + first, _, err := provider.createOrUpdateUserFromClaim(claims) + require.NoError(t, err) + require.Equal(t, "alice@example.com", first.Email) + + claims.EmailVerified = false + second, _, err := provider.createOrUpdateUserFromClaim(claims) + require.NoError(t, err) + assert.Equal(t, first.ID, second.ID) + assert.Empty(t, second.Email) + + stored, err := app.state.GetUserByID(types.UserID(first.ID)) + require.NoError(t, err) + assert.Empty(t, stored.Email) +} diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index b8dbbc4be..1dc55732b 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -440,6 +440,8 @@ func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) { log.Debug().Caller().Err(err).Msgf("username %s is not valid", claims.Username) } + u.Email = "" + if claims.EmailVerified || !FlexibleBoolean(emailVerifiedRequired) { _, err = mail.ParseAddress(claims.Email) if err == nil { diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 80fb19237..05d166d9e 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -591,3 +591,31 @@ func TestOIDCClaimsIdentifierKeepsSubject(t *testing.T) { }) } } + +func TestFromClaimReplacesEmail(t *testing.T) { + for _, tt := range []struct { + name string + email string + verified FlexibleBoolean + required bool + want string + }{ + {"verified", "current@example.com", true, true, "current@example.com"}, + {"unverified", "current@example.com", false, true, ""}, + {"absent", "", false, true, ""}, + {"malformed", "not-an-address", true, true, ""}, + {"verification optional", "current@example.com", false, false, "current@example.com"}, + {"optional but absent", "", false, false, ""}, + {"optional but malformed", "not-an-address", false, false, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + user := User{Name: "alice", Email: "previous@example.com"} + user.FromClaim(&OIDCClaims{ + Iss: "https://idp.example.com", Sub: "alice", + Email: tt.email, EmailVerified: tt.verified, + }, tt.required) + assert.Equal(t, tt.want, user.Email) + assert.Equal(t, "alice", user.Name) + }) + } +}