Skip to content

Commit 7be2091

Browse files
oidc: make email verification configurable
Co-authored-by: Kristoffer Dalby <kristoffer@tailscale.com>
1 parent e875361 commit 7be2091

7 files changed

Lines changed: 292 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ sequentially through each stable release, selecting the latest patch version ava
5757

5858
- Smarter change notifications send partial map updates and node removals instead of full maps [#2961](https://github.com/juanfont/headscale/pull/2961)
5959
- Send lightweight endpoint and DERP region updates instead of full maps [#2856](https://github.com/juanfont/headscale/pull/2856)
60+
- Add `oidc.email_verified_required` config option to control email verification requirement [#2860](https://github.com/juanfont/headscale/pull/2860)
61+
- When `true` (default), only verified emails can authenticate via OIDC with `allowed_domains` or `allowed_users`
62+
- When `false`, unverified emails are allowed for OIDC authentication
6063
- Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857)
6164
- Add favicon to webpages [#2858](https://github.com/juanfont/headscale/pull/2858)
6265
- Redesign OIDC callback and registration web templates [#2832](https://github.com/juanfont/headscale/pull/2832)

hscontrol/mapper/batcher_lockfree.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type LockFreeBatcher struct {
3131
workCh chan work
3232
workChOnce sync.Once // Ensures workCh is only closed once
3333
done chan struct{}
34+
doneOnce sync.Once // Ensures done is only closed once
3435

3536
// Batching state
3637
pendingChanges *xsync.Map[types.NodeID, []change.Change]
@@ -151,10 +152,12 @@ func (b *LockFreeBatcher) Start() {
151152
}
152153

153154
func (b *LockFreeBatcher) Close() {
154-
// Signal shutdown to all goroutines
155-
if b.done != nil {
156-
close(b.done)
157-
}
155+
// Signal shutdown to all goroutines, only once
156+
b.doneOnce.Do(func() {
157+
if b.done != nil {
158+
close(b.done)
159+
}
160+
})
158161

159162
// Only close workCh once using sync.Once to prevent races
160163
b.workChOnce.Do(func() {

hscontrol/oidc.go

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ var (
4141
errOIDCAllowedUsers = errors.New(
4242
"authenticated principal does not match any allowed user",
4343
)
44+
errOIDCUnverifiedEmail = errors.New("authenticated principal has an unverified email")
4445
)
4546

4647
// RegistrationInfo contains both machine key and verifier information for OIDC validation.
@@ -264,17 +265,8 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
264265

265266
// The user claims are now updated from the userinfo endpoint so we can verify the user
266267
// against allowed emails, email domains, and groups.
267-
if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil {
268-
httpError(writer, err)
269-
return
270-
}
271-
272-
if err := validateOIDCAllowedGroups(a.cfg.AllowedGroups, &claims); err != nil {
273-
httpError(writer, err)
274-
return
275-
}
276-
277-
if err := validateOIDCAllowedUsers(a.cfg.AllowedUsers, &claims); err != nil {
268+
err = doOIDCAuthorization(a.cfg, &claims)
269+
if err != nil {
278270
httpError(writer, err)
279271
return
280272
}
@@ -434,17 +426,13 @@ func validateOIDCAllowedGroups(
434426
allowedGroups []string,
435427
claims *types.OIDCClaims,
436428
) error {
437-
if len(allowedGroups) > 0 {
438-
for _, group := range allowedGroups {
439-
if slices.Contains(claims.Groups, group) {
440-
return nil
441-
}
429+
for _, group := range allowedGroups {
430+
if slices.Contains(claims.Groups, group) {
431+
return nil
442432
}
443-
444-
return NewHTTPError(http.StatusUnauthorized, "unauthorised group", errOIDCAllowedGroups)
445433
}
446434

447-
return nil
435+
return NewHTTPError(http.StatusUnauthorized, "unauthorised group", errOIDCAllowedGroups)
448436
}
449437

450438
// validateOIDCAllowedUsers checks that if AllowedUsers is provided,
@@ -453,14 +441,62 @@ func validateOIDCAllowedUsers(
453441
allowedUsers []string,
454442
claims *types.OIDCClaims,
455443
) error {
456-
if len(allowedUsers) > 0 &&
457-
!slices.Contains(allowedUsers, claims.Email) {
444+
if !slices.Contains(allowedUsers, claims.Email) {
458445
return NewHTTPError(http.StatusUnauthorized, "unauthorised user", errOIDCAllowedUsers)
459446
}
460447

461448
return nil
462449
}
463450

451+
// doOIDCAuthorization applies authorization tests to claims.
452+
//
453+
// The following tests are always applied:
454+
//
455+
// - validateOIDCAllowedGroups
456+
//
457+
// The following tests are applied if cfg.EmailVerifiedRequired=false
458+
// or claims.email_verified=true:
459+
//
460+
// - validateOIDCAllowedDomains
461+
// - validateOIDCAllowedUsers
462+
//
463+
// NOTE that, contrary to the function name, validateOIDCAllowedUsers
464+
// only checks the email address -- not the username.
465+
func doOIDCAuthorization(
466+
cfg *types.OIDCConfig,
467+
claims *types.OIDCClaims,
468+
) error {
469+
if len(cfg.AllowedGroups) > 0 {
470+
err := validateOIDCAllowedGroups(cfg.AllowedGroups, claims)
471+
if err != nil {
472+
return err
473+
}
474+
}
475+
476+
trustEmail := !cfg.EmailVerifiedRequired || bool(claims.EmailVerified)
477+
478+
hasEmailTests := len(cfg.AllowedDomains) > 0 || len(cfg.AllowedUsers) > 0
479+
if !trustEmail && hasEmailTests {
480+
return NewHTTPError(http.StatusUnauthorized, "unverified email", errOIDCUnverifiedEmail)
481+
}
482+
483+
if len(cfg.AllowedDomains) > 0 {
484+
err := validateOIDCAllowedDomains(cfg.AllowedDomains, claims)
485+
if err != nil {
486+
return err
487+
}
488+
}
489+
490+
if len(cfg.AllowedUsers) > 0 {
491+
err := validateOIDCAllowedUsers(cfg.AllowedUsers, claims)
492+
if err != nil {
493+
return err
494+
}
495+
}
496+
497+
return nil
498+
}
499+
464500
// getRegistrationIDFromState retrieves the registration ID from the state.
465501
func (a *AuthProviderOIDC) getRegistrationIDFromState(state string) *types.RegistrationID {
466502
regInfo, ok := a.registrationCache.Get(state)
@@ -493,7 +529,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
493529
user = &types.User{}
494530
}
495531

496-
user.FromClaim(claims)
532+
user.FromClaim(claims, a.cfg.EmailVerifiedRequired)
497533

498534
if newUser {
499535
user, c, err = a.h.state.CreateUser(*user)

hscontrol/oidc_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package hscontrol
2+
3+
import (
4+
"testing"
5+
6+
"github.com/juanfont/headscale/hscontrol/types"
7+
)
8+
9+
func TestDoOIDCAuthorization(t *testing.T) {
10+
testCases := []struct {
11+
name string
12+
cfg *types.OIDCConfig
13+
claims *types.OIDCClaims
14+
wantErr bool
15+
}{
16+
{
17+
name: "verified email domain",
18+
wantErr: false,
19+
cfg: &types.OIDCConfig{
20+
EmailVerifiedRequired: true,
21+
AllowedDomains: []string{"test.com"},
22+
AllowedUsers: []string{},
23+
AllowedGroups: []string{},
24+
},
25+
claims: &types.OIDCClaims{
26+
Email: "user@test.com",
27+
EmailVerified: true,
28+
},
29+
},
30+
{
31+
name: "verified email user",
32+
wantErr: false,
33+
cfg: &types.OIDCConfig{
34+
EmailVerifiedRequired: true,
35+
AllowedDomains: []string{},
36+
AllowedUsers: []string{"user@test.com"},
37+
AllowedGroups: []string{},
38+
},
39+
claims: &types.OIDCClaims{
40+
Email: "user@test.com",
41+
EmailVerified: true,
42+
},
43+
},
44+
{
45+
name: "unverified email domain",
46+
wantErr: true,
47+
cfg: &types.OIDCConfig{
48+
EmailVerifiedRequired: true,
49+
AllowedDomains: []string{"test.com"},
50+
AllowedUsers: []string{},
51+
AllowedGroups: []string{},
52+
},
53+
claims: &types.OIDCClaims{
54+
Email: "user@test.com",
55+
EmailVerified: false,
56+
},
57+
},
58+
{
59+
name: "group member",
60+
wantErr: false,
61+
cfg: &types.OIDCConfig{
62+
EmailVerifiedRequired: true,
63+
AllowedDomains: []string{},
64+
AllowedUsers: []string{},
65+
AllowedGroups: []string{"test"},
66+
},
67+
claims: &types.OIDCClaims{Groups: []string{"test"}},
68+
},
69+
{
70+
name: "non group member",
71+
wantErr: true,
72+
cfg: &types.OIDCConfig{
73+
EmailVerifiedRequired: true,
74+
AllowedDomains: []string{},
75+
AllowedUsers: []string{},
76+
AllowedGroups: []string{"nope"},
77+
},
78+
claims: &types.OIDCClaims{Groups: []string{"testo"}},
79+
},
80+
{
81+
name: "group member but bad domain",
82+
wantErr: true,
83+
cfg: &types.OIDCConfig{
84+
EmailVerifiedRequired: true,
85+
AllowedDomains: []string{"user@good.com"},
86+
AllowedUsers: []string{},
87+
AllowedGroups: []string{"test group"},
88+
},
89+
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "bad@bad.com", EmailVerified: true},
90+
},
91+
{
92+
name: "all checks pass",
93+
wantErr: false,
94+
cfg: &types.OIDCConfig{
95+
EmailVerifiedRequired: true,
96+
AllowedDomains: []string{"test.com"},
97+
AllowedUsers: []string{"user@test.com"},
98+
AllowedGroups: []string{"test group"},
99+
},
100+
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "user@test.com", EmailVerified: true},
101+
},
102+
{
103+
name: "all checks pass with unverified email",
104+
wantErr: false,
105+
cfg: &types.OIDCConfig{
106+
EmailVerifiedRequired: false,
107+
AllowedDomains: []string{"test.com"},
108+
AllowedUsers: []string{"user@test.com"},
109+
AllowedGroups: []string{"test group"},
110+
},
111+
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "user@test.com", EmailVerified: false},
112+
},
113+
{
114+
name: "fail on unverified email",
115+
wantErr: true,
116+
cfg: &types.OIDCConfig{
117+
EmailVerifiedRequired: true,
118+
AllowedDomains: []string{"test.com"},
119+
AllowedUsers: []string{"user@test.com"},
120+
AllowedGroups: []string{"test group"},
121+
},
122+
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "user@test.com", EmailVerified: false},
123+
},
124+
{
125+
name: "unverified email user only",
126+
wantErr: true,
127+
cfg: &types.OIDCConfig{
128+
EmailVerifiedRequired: true,
129+
AllowedDomains: []string{},
130+
AllowedUsers: []string{"user@test.com"},
131+
AllowedGroups: []string{},
132+
},
133+
claims: &types.OIDCClaims{
134+
Email: "user@test.com",
135+
EmailVerified: false,
136+
},
137+
},
138+
{
139+
name: "no filters configured",
140+
wantErr: false,
141+
cfg: &types.OIDCConfig{
142+
EmailVerifiedRequired: true,
143+
AllowedDomains: []string{},
144+
AllowedUsers: []string{},
145+
AllowedGroups: []string{},
146+
},
147+
claims: &types.OIDCClaims{
148+
Email: "anyone@anywhere.com",
149+
EmailVerified: false,
150+
},
151+
},
152+
{
153+
name: "multiple allowed groups second matches",
154+
wantErr: false,
155+
cfg: &types.OIDCConfig{
156+
EmailVerifiedRequired: true,
157+
AllowedDomains: []string{},
158+
AllowedUsers: []string{},
159+
AllowedGroups: []string{"group1", "group2", "group3"},
160+
},
161+
claims: &types.OIDCClaims{Groups: []string{"group2"}},
162+
},
163+
}
164+
165+
for _, tC := range testCases {
166+
t.Run(tC.name, func(t *testing.T) {
167+
err := doOIDCAuthorization(tC.cfg, tC.claims)
168+
if ((err != nil) && !tC.wantErr) || ((err == nil) && tC.wantErr) {
169+
t.Errorf("bad authorization: %s > want=%v | got=%v", tC.name, tC.wantErr, err)
170+
}
171+
})
172+
}
173+
}

hscontrol/types/config.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ type OIDCConfig struct {
185185
AllowedDomains []string
186186
AllowedUsers []string
187187
AllowedGroups []string
188+
EmailVerifiedRequired bool
188189
Expiry time.Duration
189190
UseExpiryFromToken bool
190191
PKCE PKCEConfig
@@ -384,6 +385,7 @@ func LoadConfig(path string, isFile bool) error {
384385
viper.SetDefault("oidc.use_expiry_from_token", false)
385386
viper.SetDefault("oidc.pkce.enabled", false)
386387
viper.SetDefault("oidc.pkce.method", "S256")
388+
viper.SetDefault("oidc.email_verified_required", true)
387389

388390
viper.SetDefault("logtail.enabled", false)
389391
viper.SetDefault("randomize_client_port", false)
@@ -1022,14 +1024,15 @@ func LoadServerConfig() (*Config, error) {
10221024
OnlyStartIfOIDCIsAvailable: viper.GetBool(
10231025
"oidc.only_start_if_oidc_is_available",
10241026
),
1025-
Issuer: viper.GetString("oidc.issuer"),
1026-
ClientID: viper.GetString("oidc.client_id"),
1027-
ClientSecret: oidcClientSecret,
1028-
Scope: viper.GetStringSlice("oidc.scope"),
1029-
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
1030-
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
1031-
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
1032-
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
1027+
Issuer: viper.GetString("oidc.issuer"),
1028+
ClientID: viper.GetString("oidc.client_id"),
1029+
ClientSecret: oidcClientSecret,
1030+
Scope: viper.GetStringSlice("oidc.scope"),
1031+
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
1032+
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
1033+
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
1034+
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
1035+
EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"),
10331036
Expiry: func() time.Duration {
10341037
// if set to 0, we assume no expiry
10351038
if value := viper.GetString("oidc.expiry"); value == "0" {

hscontrol/types/users.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,15 +353,15 @@ type OIDCUserInfo struct {
353353

354354
// FromClaim overrides a User from OIDC claims.
355355
// All fields will be updated, except for the ID.
356-
func (u *User) FromClaim(claims *OIDCClaims) {
356+
func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
357357
err := util.ValidateUsername(claims.Username)
358358
if err == nil {
359359
u.Name = claims.Username
360360
} else {
361361
log.Debug().Caller().Err(err).Msgf("Username %s is not valid", claims.Username)
362362
}
363363

364-
if claims.EmailVerified {
364+
if claims.EmailVerified || !FlexibleBoolean(emailVerifiedRequired) {
365365
_, err = mail.ParseAddress(claims.Email)
366366
if err == nil {
367367
u.Email = claims.Email

0 commit comments

Comments
 (0)