mirror of
https://github.com/juanfont/headscale.git
synced 2025-12-23 04:56:16 +00:00
oidc: make email verification configurable
Co-authored-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
@@ -57,6 +57,9 @@ sequentially through each stable release, selecting the latest patch version ava
|
||||
|
||||
- Smarter change notifications send partial map updates and node removals instead of full maps [#2961](https://github.com/juanfont/headscale/pull/2961)
|
||||
- Send lightweight endpoint and DERP region updates instead of full maps [#2856](https://github.com/juanfont/headscale/pull/2856)
|
||||
- Add `oidc.email_verified_required` config option to control email verification requirement [#2860](https://github.com/juanfont/headscale/pull/2860)
|
||||
- When `true` (default), only verified emails can authenticate via OIDC with `allowed_domains` or `allowed_users`
|
||||
- When `false`, unverified emails are allowed for OIDC authentication
|
||||
- Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857)
|
||||
- Add favicon to webpages [#2858](https://github.com/juanfont/headscale/pull/2858)
|
||||
- Redesign OIDC callback and registration web templates [#2832](https://github.com/juanfont/headscale/pull/2832)
|
||||
|
||||
@@ -31,6 +31,7 @@ type LockFreeBatcher struct {
|
||||
workCh chan work
|
||||
workChOnce sync.Once // Ensures workCh is only closed once
|
||||
done chan struct{}
|
||||
doneOnce sync.Once // Ensures done is only closed once
|
||||
|
||||
// Batching state
|
||||
pendingChanges *xsync.Map[types.NodeID, []change.Change]
|
||||
@@ -151,10 +152,12 @@ func (b *LockFreeBatcher) Start() {
|
||||
}
|
||||
|
||||
func (b *LockFreeBatcher) Close() {
|
||||
// Signal shutdown to all goroutines
|
||||
// Signal shutdown to all goroutines, only once
|
||||
b.doneOnce.Do(func() {
|
||||
if b.done != nil {
|
||||
close(b.done)
|
||||
}
|
||||
})
|
||||
|
||||
// Only close workCh once using sync.Once to prevent races
|
||||
b.workChOnce.Do(func() {
|
||||
|
||||
@@ -41,6 +41,7 @@ var (
|
||||
errOIDCAllowedUsers = errors.New(
|
||||
"authenticated principal does not match any allowed user",
|
||||
)
|
||||
errOIDCUnverifiedEmail = errors.New("authenticated principal has an unverified email")
|
||||
)
|
||||
|
||||
// RegistrationInfo contains both machine key and verifier information for OIDC validation.
|
||||
@@ -264,17 +265,8 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
// The user claims are now updated from the userinfo endpoint so we can verify the user
|
||||
// against allowed emails, email domains, and groups.
|
||||
if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil {
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateOIDCAllowedGroups(a.cfg.AllowedGroups, &claims); err != nil {
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateOIDCAllowedUsers(a.cfg.AllowedUsers, &claims); err != nil {
|
||||
err = doOIDCAuthorization(a.cfg, &claims)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
@@ -434,7 +426,6 @@ func validateOIDCAllowedGroups(
|
||||
allowedGroups []string,
|
||||
claims *types.OIDCClaims,
|
||||
) error {
|
||||
if len(allowedGroups) > 0 {
|
||||
for _, group := range allowedGroups {
|
||||
if slices.Contains(claims.Groups, group) {
|
||||
return nil
|
||||
@@ -444,23 +435,68 @@ func validateOIDCAllowedGroups(
|
||||
return NewHTTPError(http.StatusUnauthorized, "unauthorised group", errOIDCAllowedGroups)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOIDCAllowedUsers checks that if AllowedUsers is provided,
|
||||
// that the authenticated principal is part of that list.
|
||||
func validateOIDCAllowedUsers(
|
||||
allowedUsers []string,
|
||||
claims *types.OIDCClaims,
|
||||
) error {
|
||||
if len(allowedUsers) > 0 &&
|
||||
!slices.Contains(allowedUsers, claims.Email) {
|
||||
if !slices.Contains(allowedUsers, claims.Email) {
|
||||
return NewHTTPError(http.StatusUnauthorized, "unauthorised user", errOIDCAllowedUsers)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// doOIDCAuthorization applies authorization tests to claims.
|
||||
//
|
||||
// The following tests are always applied:
|
||||
//
|
||||
// - validateOIDCAllowedGroups
|
||||
//
|
||||
// The following tests are applied if cfg.EmailVerifiedRequired=false
|
||||
// or claims.email_verified=true:
|
||||
//
|
||||
// - validateOIDCAllowedDomains
|
||||
// - validateOIDCAllowedUsers
|
||||
//
|
||||
// NOTE that, contrary to the function name, validateOIDCAllowedUsers
|
||||
// only checks the email address -- not the username.
|
||||
func doOIDCAuthorization(
|
||||
cfg *types.OIDCConfig,
|
||||
claims *types.OIDCClaims,
|
||||
) error {
|
||||
if len(cfg.AllowedGroups) > 0 {
|
||||
err := validateOIDCAllowedGroups(cfg.AllowedGroups, claims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
trustEmail := !cfg.EmailVerifiedRequired || bool(claims.EmailVerified)
|
||||
|
||||
hasEmailTests := len(cfg.AllowedDomains) > 0 || len(cfg.AllowedUsers) > 0
|
||||
if !trustEmail && hasEmailTests {
|
||||
return NewHTTPError(http.StatusUnauthorized, "unverified email", errOIDCUnverifiedEmail)
|
||||
}
|
||||
|
||||
if len(cfg.AllowedDomains) > 0 {
|
||||
err := validateOIDCAllowedDomains(cfg.AllowedDomains, claims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.AllowedUsers) > 0 {
|
||||
err := validateOIDCAllowedUsers(cfg.AllowedUsers, claims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRegistrationIDFromState retrieves the registration ID from the state.
|
||||
func (a *AuthProviderOIDC) getRegistrationIDFromState(state string) *types.RegistrationID {
|
||||
regInfo, ok := a.registrationCache.Get(state)
|
||||
@@ -493,7 +529,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
|
||||
user = &types.User{}
|
||||
}
|
||||
|
||||
user.FromClaim(claims)
|
||||
user.FromClaim(claims, a.cfg.EmailVerifiedRequired)
|
||||
|
||||
if newUser {
|
||||
user, c, err = a.h.state.CreateUser(*user)
|
||||
|
||||
173
hscontrol/oidc_test.go
Normal file
173
hscontrol/oidc_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
func TestDoOIDCAuthorization(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
cfg *types.OIDCConfig
|
||||
claims *types.OIDCClaims
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "verified email domain",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{"test.com"},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{},
|
||||
},
|
||||
claims: &types.OIDCClaims{
|
||||
Email: "user@test.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "verified email user",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{},
|
||||
AllowedUsers: []string{"user@test.com"},
|
||||
AllowedGroups: []string{},
|
||||
},
|
||||
claims: &types.OIDCClaims{
|
||||
Email: "user@test.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unverified email domain",
|
||||
wantErr: true,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{"test.com"},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{},
|
||||
},
|
||||
claims: &types.OIDCClaims{
|
||||
Email: "user@test.com",
|
||||
EmailVerified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group member",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{"test"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"test"}},
|
||||
},
|
||||
{
|
||||
name: "non group member",
|
||||
wantErr: true,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{"nope"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"testo"}},
|
||||
},
|
||||
{
|
||||
name: "group member but bad domain",
|
||||
wantErr: true,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{"user@good.com"},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{"test group"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "bad@bad.com", EmailVerified: true},
|
||||
},
|
||||
{
|
||||
name: "all checks pass",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{"test.com"},
|
||||
AllowedUsers: []string{"user@test.com"},
|
||||
AllowedGroups: []string{"test group"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "user@test.com", EmailVerified: true},
|
||||
},
|
||||
{
|
||||
name: "all checks pass with unverified email",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: false,
|
||||
AllowedDomains: []string{"test.com"},
|
||||
AllowedUsers: []string{"user@test.com"},
|
||||
AllowedGroups: []string{"test group"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "user@test.com", EmailVerified: false},
|
||||
},
|
||||
{
|
||||
name: "fail on unverified email",
|
||||
wantErr: true,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{"test.com"},
|
||||
AllowedUsers: []string{"user@test.com"},
|
||||
AllowedGroups: []string{"test group"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"test group"}, Email: "user@test.com", EmailVerified: false},
|
||||
},
|
||||
{
|
||||
name: "unverified email user only",
|
||||
wantErr: true,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{},
|
||||
AllowedUsers: []string{"user@test.com"},
|
||||
AllowedGroups: []string{},
|
||||
},
|
||||
claims: &types.OIDCClaims{
|
||||
Email: "user@test.com",
|
||||
EmailVerified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no filters configured",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{},
|
||||
},
|
||||
claims: &types.OIDCClaims{
|
||||
Email: "anyone@anywhere.com",
|
||||
EmailVerified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple allowed groups second matches",
|
||||
wantErr: false,
|
||||
cfg: &types.OIDCConfig{
|
||||
EmailVerifiedRequired: true,
|
||||
AllowedDomains: []string{},
|
||||
AllowedUsers: []string{},
|
||||
AllowedGroups: []string{"group1", "group2", "group3"},
|
||||
},
|
||||
claims: &types.OIDCClaims{Groups: []string{"group2"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tC := range testCases {
|
||||
t.Run(tC.name, func(t *testing.T) {
|
||||
err := doOIDCAuthorization(tC.cfg, tC.claims)
|
||||
if ((err != nil) && !tC.wantErr) || ((err == nil) && tC.wantErr) {
|
||||
t.Errorf("bad authorization: %s > want=%v | got=%v", tC.name, tC.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ type OIDCConfig struct {
|
||||
AllowedDomains []string
|
||||
AllowedUsers []string
|
||||
AllowedGroups []string
|
||||
EmailVerifiedRequired bool
|
||||
Expiry time.Duration
|
||||
UseExpiryFromToken bool
|
||||
PKCE PKCEConfig
|
||||
@@ -384,6 +385,7 @@ func LoadConfig(path string, isFile bool) error {
|
||||
viper.SetDefault("oidc.use_expiry_from_token", false)
|
||||
viper.SetDefault("oidc.pkce.enabled", false)
|
||||
viper.SetDefault("oidc.pkce.method", "S256")
|
||||
viper.SetDefault("oidc.email_verified_required", true)
|
||||
|
||||
viper.SetDefault("logtail.enabled", false)
|
||||
viper.SetDefault("randomize_client_port", false)
|
||||
@@ -1030,6 +1032,7 @@ func LoadServerConfig() (*Config, error) {
|
||||
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
|
||||
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
||||
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
||||
EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"),
|
||||
Expiry: func() time.Duration {
|
||||
// if set to 0, we assume no expiry
|
||||
if value := viper.GetString("oidc.expiry"); value == "0" {
|
||||
|
||||
@@ -353,7 +353,7 @@ type OIDCUserInfo struct {
|
||||
|
||||
// FromClaim overrides a User from OIDC claims.
|
||||
// All fields will be updated, except for the ID.
|
||||
func (u *User) FromClaim(claims *OIDCClaims) {
|
||||
func (u *User) FromClaim(claims *OIDCClaims, emailVerifiedRequired bool) {
|
||||
err := util.ValidateUsername(claims.Username)
|
||||
if err == nil {
|
||||
u.Name = claims.Username
|
||||
@@ -361,7 +361,7 @@ func (u *User) FromClaim(claims *OIDCClaims) {
|
||||
log.Debug().Caller().Err(err).Msgf("Username %s is not valid", claims.Username)
|
||||
}
|
||||
|
||||
if claims.EmailVerified {
|
||||
if claims.EmailVerified || !FlexibleBoolean(emailVerifiedRequired) {
|
||||
_, err = mail.ParseAddress(claims.Email)
|
||||
if err == nil {
|
||||
u.Email = claims.Email
|
||||
|
||||
@@ -293,10 +293,12 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonstr string
|
||||
emailVerifiedRequired bool
|
||||
want User
|
||||
}{
|
||||
{
|
||||
name: "normal-bool",
|
||||
emailVerifiedRequired: true,
|
||||
jsonstr: `
|
||||
{
|
||||
"sub": "test",
|
||||
@@ -315,6 +317,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "string-bool-true",
|
||||
emailVerifiedRequired: true,
|
||||
jsonstr: `
|
||||
{
|
||||
"sub": "test2",
|
||||
@@ -333,6 +336,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "string-bool-false",
|
||||
emailVerifiedRequired: true,
|
||||
jsonstr: `
|
||||
{
|
||||
"sub": "test3",
|
||||
@@ -348,9 +352,29 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allow-unverified-email",
|
||||
emailVerifiedRequired: false,
|
||||
jsonstr: `
|
||||
{
|
||||
"sub": "test4",
|
||||
"email": "test4@test.no",
|
||||
"email_verified": "false"
|
||||
}
|
||||
`,
|
||||
want: User{
|
||||
Provider: util.RegisterMethodOIDC,
|
||||
Email: "test4@test.no",
|
||||
ProviderIdentifier: sql.NullString{
|
||||
String: "/test4",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// From https://github.com/juanfont/headscale/issues/2333
|
||||
name: "okta-oidc-claim-20250121",
|
||||
emailVerifiedRequired: true,
|
||||
jsonstr: `
|
||||
{
|
||||
"sub": "00u7dr4qp7XXXXXXXXXX",
|
||||
@@ -375,6 +399,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
want: User{
|
||||
Provider: util.RegisterMethodOIDC,
|
||||
DisplayName: "Tim Horton",
|
||||
Email: "",
|
||||
Name: "tim.horton@company.com",
|
||||
ProviderIdentifier: sql.NullString{
|
||||
String: "https://sso.company.com/oauth2/default/00u7dr4qp7XXXXXXXXXX",
|
||||
@@ -385,6 +410,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
{
|
||||
// From https://github.com/juanfont/headscale/issues/2333
|
||||
name: "okta-oidc-claim-20250121",
|
||||
emailVerifiedRequired: true,
|
||||
jsonstr: `
|
||||
{
|
||||
"aud": "79xxxxxx-xxxx-xxxx-xxxx-892146xxxxxx",
|
||||
@@ -409,6 +435,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
Provider: util.RegisterMethodOIDC,
|
||||
DisplayName: "XXXXXX XXXX",
|
||||
Name: "user@domain.com",
|
||||
Email: "",
|
||||
ProviderIdentifier: sql.NullString{
|
||||
String: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||
Valid: true,
|
||||
@@ -418,6 +445,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
{
|
||||
// From https://github.com/juanfont/headscale/issues/2333
|
||||
name: "casby-oidc-claim-20250513",
|
||||
emailVerifiedRequired: true,
|
||||
jsonstr: `
|
||||
{
|
||||
"sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
@@ -458,7 +486,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||
|
||||
var user User
|
||||
|
||||
user.FromClaim(&got)
|
||||
user.FromClaim(&got, tt.emailVerifiedRequired)
|
||||
if diff := cmp.Diff(user, tt.want); diff != "" {
|
||||
t.Errorf("TestOIDCClaimsJSONToUser() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user