types: drop a stored OIDC email the login no longer vouches for

This commit is contained in:
Kristoffer Dalby committed 2026-10-28 18:28:23 +00:00
1 parent 5d7c1a7d14
commit 148fc208f9
4 files changed
+62 -4

No files matched your search

+5 -4
View File
@@ -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:
+27
View File
@@ -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)
}
+2
View File
@@ -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 {
+28
View File
@@ -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)
})
}
}