From f646b6bd80471512dcc3cc4100dc38066112140c Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Sun, 2 Nov 2025 09:38:02 -0500 Subject: [PATCH 1/7] add oidc.use_unverified_email --- hscontrol/types/config.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 732b4d5a..cdae2407 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -179,6 +179,7 @@ type OIDCConfig struct { ClientSecret string Scope []string ExtraParams map[string]string + UseUnverifiedEmail bool AllowedDomains []string AllowedUsers []string AllowedGroups []string @@ -383,6 +384,11 @@ func validateServerConfig() error { if err := validatePKCEMethod(viper.GetString("oidc.pkce.method")); err != nil { return err } + if viper.IsSet("oidc.use_unverified_email") { + log.Warn().Msg("unverified emails will be accepted during oidc authentication (oidc.use_unverified_email=true)") + } else { + log.Warn().Msg("only verified emails will be accepted during oidc authentication (oidc.use_unverified_email=false)") + } } depr.Log() @@ -947,14 +953,15 @@ func LoadServerConfig() (*Config, error) { OnlyStartIfOIDCIsAvailable: viper.GetBool( "oidc.only_start_if_oidc_is_available", ), - Issuer: viper.GetString("oidc.issuer"), - ClientID: viper.GetString("oidc.client_id"), - ClientSecret: oidcClientSecret, - Scope: viper.GetStringSlice("oidc.scope"), - ExtraParams: viper.GetStringMapString("oidc.extra_params"), - AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"), - AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), - AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), + Issuer: viper.GetString("oidc.issuer"), + ClientID: viper.GetString("oidc.client_id"), + ClientSecret: oidcClientSecret, + Scope: viper.GetStringSlice("oidc.scope"), + ExtraParams: viper.GetStringMapString("oidc.extra_params"), + UseUnverifiedEmail: viper.GetBool("oidc.use_unverified_email"), + AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"), + AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), + AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), Expiry: func() time.Duration { // if set to 0, we assume no expiry if value := viper.GetString("oidc.expiry"); value == "0" { From 0b9fdb7a16f0ff5e0debb383747f500285852ec4 Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Sun, 2 Nov 2025 10:17:01 -0500 Subject: [PATCH 2/7] allow unverified email usage --- hscontrol/oidc.go | 14 ++++++++------ hscontrol/types/config.go | 12 +++++++----- hscontrol/types/users.go | 4 ++-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 84d00712..e732eb47 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -281,11 +281,13 @@ 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 - // against allowed emails, email domains, and groups. - if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil { - httpError(writer, err) - return + if bool(claims.EmailVerified) || a.cfg.UseUnverifiedEmail { + // 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 { @@ -505,7 +507,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim( user = &types.User{} } - user.FromClaim(claims) + user.FromClaim(claims, a.cfg.UseUnverifiedEmail) if newUser { user, c, err = a.h.state.CreateUser(*user) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index cdae2407..5d9f950f 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -328,6 +328,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.use_unverified_email", false) viper.SetDefault("logtail.enabled", false) viper.SetDefault("randomize_client_port", false) @@ -384,11 +385,12 @@ func validateServerConfig() error { if err := validatePKCEMethod(viper.GetString("oidc.pkce.method")); err != nil { return err } - if viper.IsSet("oidc.use_unverified_email") { - log.Warn().Msg("unverified emails will be accepted during oidc authentication (oidc.use_unverified_email=true)") - } else { - log.Warn().Msg("only verified emails will be accepted during oidc authentication (oidc.use_unverified_email=false)") - } + } + + if viper.IsSet("oidc.use_unverified_email") { + log.Warn().Msg("unverified emails will be accepted during oidc authentication (oidc.use_unverified_email=true)") + } else { + log.Warn().Msg("only verified emails will be accepted during oidc authentication (oidc.use_unverified_email=false)") } depr.Log() diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index b7cb1038..15340b33 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -324,7 +324,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, useUnverifiedEmail bool) { err := util.ValidateUsername(claims.Username) if err == nil { u.Name = claims.Username @@ -332,7 +332,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(useUnverifiedEmail) { _, err = mail.ParseAddress(claims.Email) if err == nil { u.Email = claims.Email From 6e55e61a1a3759c218a46aec8bd555c30cdf902b Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Sun, 2 Nov 2025 10:26:00 -0500 Subject: [PATCH 3/7] test oidc.use_unverified_email --- hscontrol/types/users_test.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index f36489a3..86f68501 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -291,9 +291,10 @@ func TestCleanIdentifier(t *testing.T) { func TestOIDCClaimsJSONToUser(t *testing.T) { tests := []struct { - name string - jsonstr string - want User + name string + jsonstr string + useUnverifiedEmail bool + want User }{ { name: "normal-bool", @@ -348,6 +349,24 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { }, }, }, + { + name: "use-unverified-email", + jsonstr: ` +{ + "sub": "test-unverified-email", + "email": "test-unverified-email@test.no", + "email_verified": "false" +} + `, + useUnverifiedEmail: true, + want: User{ + Provider: util.RegisterMethodOIDC, + ProviderIdentifier: sql.NullString{ + String: "/test-unverified-email", + Valid: true, + }, + }, + }, { // From https://github.com/juanfont/headscale/issues/2333 name: "okta-oidc-claim-20250121", @@ -458,7 +477,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { var user User - user.FromClaim(&got) + user.FromClaim(&got, tt.useUnverifiedEmail) if diff := cmp.Diff(user, tt.want); diff != "" { t.Errorf("TestOIDCClaimsJSONToUser() mismatch (-want +got):\n%s", diff) } From 01a8f6a994e5cf5228b5b1af281dd6915d465524 Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Sun, 2 Nov 2025 10:33:01 -0500 Subject: [PATCH 4/7] add email to want --- hscontrol/types/users_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 86f68501..da51928e 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -361,6 +361,7 @@ func TestOIDCClaimsJSONToUser(t *testing.T) { useUnverifiedEmail: true, want: User{ Provider: util.RegisterMethodOIDC, + Email: "test-unverified-email@test.no", ProviderIdentifier: sql.NullString{ String: "/test-unverified-email", Valid: true, From 57b7b032bfc248704f6a2aad0086e12ddaefc21e Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Mon, 3 Nov 2025 09:39:16 -0500 Subject: [PATCH 5/7] lint and formatting --- hscontrol/oidc.go | 46 ++++++++++++++++++++++++++++++++------- hscontrol/types/config.go | 20 ++++++++++++----- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index e732eb47..f125d290 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -167,7 +167,11 @@ func (a *AuthProviderOIDC) RegisterHandler( extras = append(extras, oauth2.S256ChallengeOption(verifier)) case types.PKCEMethodPlain: // oauth2 does not have a plain challenge option, so we add it manually - extras = append(extras, oauth2.SetAuthURLParam("code_challenge_method", "plain"), oauth2.SetAuthURLParam("code_challenge", verifier)) + extras = append( + extras, + oauth2.SetAuthURLParam("code_challenge_method", "plain"), + oauth2.SetAuthURLParam("code_challenge", verifier), + ) } } @@ -334,8 +338,14 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( newNode, err := a.handleRegistration(user, *registrationId, nodeExpiry) if err != nil { if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) { - log.Debug().Caller().Str("registration_id", registrationId.String()).Msg("registration session expired before authorization completed") - httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", err)) + log.Debug(). + Caller(). + Str("registration_id", registrationId.String()). + Msg("registration session expired before authorization completed") + httpError( + writer, + NewHTTPError(http.StatusGone, "login session expired, try again", err), + ) return } @@ -375,7 +385,11 @@ func extractCodeAndStateParamFromRequest( state := req.URL.Query().Get("state") if code == "" || state == "" { - return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams) + return "", "", NewHTTPError( + http.StatusBadRequest, + "missing code or state parameter", + errEmptyOIDCCallbackParams, + ) } return code, state, nil @@ -392,7 +406,11 @@ func (a *AuthProviderOIDC) getOauth2Token( if a.cfg.PKCE.Enabled { regInfo, ok := a.registrationCache.Get(state) if !ok { - return nil, NewHTTPError(http.StatusNotFound, "registration not found", errNoOIDCRegistrationInfo) + return nil, NewHTTPError( + http.StatusNotFound, + "registration not found", + errNoOIDCRegistrationInfo, + ) } if regInfo.Verifier != nil { exchangeOpts = []oauth2.AuthCodeOption{oauth2.VerifierOption(*regInfo.Verifier)} @@ -401,7 +419,11 @@ func (a *AuthProviderOIDC) getOauth2Token( oauth2Token, err := a.oauth2Config.Exchange(ctx, code, exchangeOpts...) if err != nil { - return nil, NewHTTPError(http.StatusForbidden, "invalid code", fmt.Errorf("could not exchange code for token: %w", err)) + return nil, NewHTTPError( + http.StatusForbidden, + "invalid code", + fmt.Errorf("could not exchange code for token: %w", err), + ) } return oauth2Token, err @@ -420,7 +442,11 @@ func (a *AuthProviderOIDC) extractIDToken( verifier := a.oidcProvider.Verifier(&oidc.Config{ClientID: a.cfg.ClientID}) idToken, err := verifier.Verify(ctx, rawIDToken) if err != nil { - return nil, NewHTTPError(http.StatusForbidden, "failed to verify id_token", fmt.Errorf("failed to verify ID token: %w", err)) + return nil, NewHTTPError( + http.StatusForbidden, + "failed to verify id_token", + fmt.Errorf("failed to verify ID token: %w", err), + ) } return idToken, nil @@ -435,7 +461,11 @@ func validateOIDCAllowedDomains( if len(allowedDomains) > 0 { if at := strings.LastIndex(claims.Email, "@"); at < 0 || !slices.Contains(allowedDomains, claims.Email[at+1:]) { - return NewHTTPError(http.StatusUnauthorized, "unauthorised domain", errOIDCAllowedDomains) + return NewHTTPError( + http.StatusUnauthorized, + "unauthorised domain", + errOIDCAllowedDomains, + ) } } diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 5d9f950f..0adb7610 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -31,10 +31,16 @@ const ( ) var ( - errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive") - errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable") - errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable") - errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'") + errOidcMutuallyExclusive = errors.New( + "oidc_client_secret and oidc_client_secret_path are mutually exclusive", + ) + errServerURLSuffix = errors.New( + "server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable", + ) + errServerURLSame = errors.New( + "server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable", + ) + errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'") ) type IPAllocationStrategy string @@ -388,7 +394,8 @@ func validateServerConfig() error { } if viper.IsSet("oidc.use_unverified_email") { - log.Warn().Msg("unverified emails will be accepted during oidc authentication (oidc.use_unverified_email=true)") + log.Warn(). + Msg("unverified emails will be accepted during oidc authentication (oidc.use_unverified_email=true)") } else { log.Warn().Msg("only verified emails will be accepted during oidc authentication (oidc.use_unverified_email=false)") } @@ -396,7 +403,8 @@ func validateServerConfig() error { depr.Log() if viper.IsSet("dns.extra_records") && viper.IsSet("dns.extra_records_path") { - log.Fatal().Msg("Fatal config error: dns.extra_records and dns.extra_records_path are mutually exclusive. Please remove one of them from your config file") + log.Fatal(). + Msg("Fatal config error: dns.extra_records and dns.extra_records_path are mutually exclusive. Please remove one of them from your config file") } // Collect any validation errors and return them all at once From e119053cc873a29093f3b436ab29bd7d6518f90a Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Wed, 5 Nov 2025 13:08:35 -0500 Subject: [PATCH 6/7] add use_unverified_email setting to the config --- config-example.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/config-example.yaml b/config-example.yaml index ec14dc03..0ce702cf 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -361,6 +361,18 @@ unix_socket_permission: "0770" # # required "openid" scope. # scope: ["openid", "profile", "email"] # +# # Enable this setting to accept the user's email address regardless +# # if "email_verified: true" is sent by identity provider. +# # +# # By default, "email_verified: true" must appear in claims or user info +# # before Headscale will accept the principal's email address as the user +# # account is created after successful authentication. +# # +# # This setting is useful when claims and their mapping can't be controlled, +# # such as when using Cloudflare One-time pin for authentication. +# +# use_unverified_email: false +# # # Provide custom key/value pairs which get sent to the identity provider's # # authorization endpoint. # extra_params: From 32da525b8d76c7c73adec713863ec4de053d9eb8 Mon Sep 17 00:00:00 2001 From: Justin Angel Date: Wed, 5 Nov 2025 13:09:08 -0500 Subject: [PATCH 7/7] remove whitespace --- config-example.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/config-example.yaml b/config-example.yaml index 0ce702cf..2051aada 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -370,7 +370,6 @@ unix_socket_permission: "0770" # # # # This setting is useful when claims and their mapping can't be controlled, # # such as when using Cloudflare One-time pin for authentication. -# # use_unverified_email: false # # # Provide custom key/value pairs which get sent to the identity provider's