From 890d6e73fbc5fd93f22ebf9d84c5b723b56381dd Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 9 May 2024 15:15:57 +0200 Subject: [PATCH 1/7] re-construct oidc config --- hscontrol/oidc.go | 102 ++++++++++++++++++--- hscontrol/suite_test.go | 4 +- hscontrol/types/config.go | 166 +++++++++++++++++++++++----------- integration/auth_oidc_test.go | 9 +- 4 files changed, 210 insertions(+), 71 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index b728a6d..95ca39a 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -6,6 +6,7 @@ import ( "crypto/rand" _ "embed" "encoding/hex" + "encoding/json" "errors" "fmt" "html/template" @@ -77,11 +78,11 @@ func (h *Headscale) initOIDC() error { } func (h *Headscale) determineTokenExpiration(idTokenExpiration time.Time) time.Time { - if h.cfg.OIDC.UseExpiryFromToken { + if h.cfg.OIDC.Expiry.FromToken { return idTokenExpiration } - return time.Now().Add(h.cfg.OIDC.Expiry) + return time.Now().Add(h.cfg.OIDC.Expiry.FixedTime) } // RegisterOIDC redirects to the OIDC provider for authentication @@ -197,20 +198,20 @@ func (h *Headscale) OIDCCallback( // return // } - claims, err := extractIDTokenClaims(writer, idToken) + claims, err := extractIDTokenClaims(writer, idToken, h.cfg.OIDC.ClaimsMap, h.cfg.OIDC.Misc.FlattenGroups, h.cfg.OIDC.Misc.FlattenSplter) if err != nil { return } - if err := validateOIDCAllowedDomains(writer, h.cfg.OIDC.AllowedDomains, claims); err != nil { + if err := validateOIDCAllowedDomains(writer, h.cfg.OIDC.Allowed.Domains, claims); err != nil { return } - if err := validateOIDCAllowedGroups(writer, h.cfg.OIDC.AllowedGroups, claims); err != nil { + if err := validateOIDCAllowedGroups(writer, h.cfg.OIDC.Allowed.Groups, claims); err != nil { return } - if err := validateOIDCAllowedUsers(writer, h.cfg.OIDC.AllowedUsers, claims); err != nil { + if err := validateOIDCAllowedUsers(writer, h.cfg.OIDC.Allowed.Users, claims); err != nil { return } @@ -223,8 +224,7 @@ func (h *Headscale) OIDCCallback( if err != nil || nodeExists { return } - - userName, err := getUserName(writer, claims, h.cfg.OIDC.StripEmaildomain) + userName, err := getUserName(writer, claims, h.cfg.OIDC.Misc.StripEmaildomain) if err != nil { return } @@ -338,24 +338,74 @@ func (h *Headscale) verifyIDTokenForOIDCCallback( func extractIDTokenClaims( writer http.ResponseWriter, idToken *oidc.IDToken, + claimsMap types.OIDCClaimsMap, + flattenGroup bool, + flattenSpliter string, ) (*IDTokenClaims, error) { - var claims IDTokenClaims + var claims json.RawMessage + // Parse the ID Token claims into the struct if err := idToken.Claims(&claims); err != nil { util.LogErr(err, "Failed to decode id token claims") - writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.WriteHeader(http.StatusBadRequest) _, werr := writer.Write([]byte("Failed to decode id token claims")) if werr != nil { - util.LogErr(err, "Failed to write response") + util.LogErr(err,"Failed to write response") } - return nil, err } - return &claims, nil + // Unmarshal the claims into a map + mappedClaims := make(map[string]interface{}) + if err := json.Unmarshal(claims, &mappedClaims); err != nil { + util.LogErr(err,"Failed to unmarshal id token claims") + return nil, err + } + + // Map the claims to the final struct + var finalClaims IDTokenClaims + if val, ok := mappedClaims[claimsMap.Name]; ok { + finalClaims.Name = val.(string) + } + if val, ok := mappedClaims[claimsMap.Username]; ok { + finalClaims.Username = val.(string) + } + if val, ok := mappedClaims[claimsMap.Email]; ok { + finalClaims.Email = val.(string) + } + if val, ok := mappedClaims[claimsMap.Groups]; ok && val != nil { + groups, ok := val.([]interface{}) + if ok { + for _, group := range groups { + finalClaims.Groups = append(finalClaims.Groups, group.(string)) + } + } + } + // Flatten groups if needed + if flattenGroup { + finalClaims.Groups = flattenGroups(finalClaims.Groups, flattenSpliter) + } + return &finalClaims, nil } +// { +// var claims IDTokenClaims +// if err := idToken.Claims(&claims); err != nil { +// util.LogErr(err, "Failed to decode id token claims") + +// writer.Header().Set("Content-Type", "text/plain; charset=utf-8") +// writer.WriteHeader(http.StatusBadRequest) +// _, werr := writer.Write([]byte("Failed to decode id token claims")) +// if werr != nil { +// util.LogErr(err, "Failed to write response") +// } + +// return nil, err +// } + +// return &claims, nil +// } + // validateOIDCAllowedDomains checks that if AllowedDomains is provided, // that the authenticated principal ends with @. func validateOIDCAllowedDomains( @@ -539,9 +589,9 @@ func getUserName( writer http.ResponseWriter, claims *IDTokenClaims, stripEmaildomain bool, -) (string, error) { +) (string, error) { userName, err := util.NormalizeToFQDNRules( - claims.Email, + claims.Username, stripEmaildomain, ) if err != nil { @@ -653,3 +703,25 @@ func renderOIDCCallbackTemplate( return &content, nil } + +// flattenGroups takes a list of groups and returns a list of all groups and subgroups. +// groups format is a list of strings with the groups separated by slashes. e.g.: ["a/b/c", "a/b/d"] +func flattenGroups(groups []string, spliter string) []string { + // A map to keep track of which groups we have seen + seen := make(map[string]bool) + var result []string + + // Iterate over each group, format is a/b/c + for _, group := range groups { + // Split the group into segments, e.g. ["a", "b", "c"] + segments := strings.Split(group, spliter) + for _, segment := range segments { + if !seen[segment] && segment != "" { + seen[segment] = true + result = append(result, segment) + } + } + } + + return result +} diff --git a/hscontrol/suite_test.go b/hscontrol/suite_test.go index b03e5c9..6200167 100644 --- a/hscontrol/suite_test.go +++ b/hscontrol/suite_test.go @@ -47,7 +47,9 @@ func (s *Suite) ResetDB(c *check.C) { }, }, OIDC: types.OIDCConfig{ - StripEmaildomain: false, + Misc: types.OIDCMiscConfig{ + StripEmaildomain: false, + }, }, } diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index fa3a64c..51fee1d 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -126,12 +126,34 @@ type OIDCConfig struct { ClientSecret string Scope []string ExtraParams map[string]string - AllowedDomains []string - AllowedUsers []string - AllowedGroups []string - StripEmaildomain bool - Expiry time.Duration - UseExpiryFromToken bool + ClaimsMap OIDCClaimsMap + Allowed OIDCAllowedConfig + Expiry OIDCExpireConfig + Misc OIDCMiscConfig +} + +type OIDCExpireConfig struct { + FromToken bool + FixedTime time.Duration +} + +type OIDCAllowedConfig struct { + Domains []string + Users []string + Groups []string +} + +type OIDCClaimsMap struct { + Name string + Username string + Email string + Groups string +} + +type OIDCMiscConfig struct { + StripEmaildomain bool + FlattenGroups bool + FlattenSplter string } type DERPConfig struct { @@ -222,10 +244,19 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("database.postgres.conn_max_idle_time_secs", 3600) viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"}) - viper.SetDefault("oidc.strip_email_domain", true) viper.SetDefault("oidc.only_start_if_oidc_is_available", true) - viper.SetDefault("oidc.expiry", "180d") - viper.SetDefault("oidc.use_expiry_from_token", false) + // expiry + viper.SetDefault("oidc.expiry.fixed_time", "180d") + viper.SetDefault("oidc.expiry.from_token", false) + // claims_map + viper.SetDefault("oidc.claims_map.name", "name") + viper.SetDefault("oidc.claims_map.username", "preferred_username") + viper.SetDefault("oidc.claims_map.email", "email") + viper.SetDefault("oidc.claims_map.groups", "groups") + // misc + viper.SetDefault("oidc.strip_email_domain", false) + viper.SetDefault("oidc.flatten_groups", false) + viper.SetDefault("oidc.flatten_splitter", "/") viper.SetDefault("logtail.enabled", false) viper.SetDefault("randomize_client_port", false) @@ -628,6 +659,76 @@ func PrefixV6() (*netip.Prefix, error) { return &prefixV6, nil } +func GetOIDCConfig() (OIDCConfig, error) { + + // get expiry config + expireConfig := OIDCExpireConfig{ + FromToken: viper.GetBool("oidc.expiry.from_token"), + FixedTime: func() time.Duration { + // if set to 0, we assume no expiry + if value := viper.GetString("oidc.expiry.fixed_time"); value == "0" { + return maxDuration + } else { + expiry, err := model.ParseDuration(value) + if err != nil { + log.Warn().Msg("failed to parse oidc.expiry.fixed_time, defaulting back to 180 days") + + return defaultOIDCExpiryTime + } + + return time.Duration(expiry) + } + }(), + } + // get allowed config + allowedConfig := OIDCAllowedConfig{ + Domains: viper.GetStringSlice("oidc.allowed.domains"), + Users: viper.GetStringSlice("oidc.allowed.users"), + Groups: viper.GetStringSlice("oidc.allowed.groups"), + } + // get claims map + claimsMap := OIDCClaimsMap{ + Name: viper.GetString("oidc.claims_map.name"), + Username: viper.GetString("oidc.claims_map.username"), + Email: viper.GetString("oidc.claims_map.email"), + Groups: viper.GetString("oidc.claims_map.groups"), + } + // get misc config + miscConfig := OIDCMiscConfig{ + StripEmaildomain: viper.GetBool("oidc.strip_email_domain"), + FlattenGroups: viper.GetBool("oidc.flatten_groups"), + FlattenSplter: viper.GetString("oidc.flatten_splitter"), + } + // get client secret + oidcClientSecret := viper.GetString("oidc.client_secret") + oidcClientSecretPath := viper.GetString("oidc.client_secret_path") + if oidcClientSecretPath != "" && oidcClientSecret != "" { + return OIDCConfig{}, errOidcMutuallyExclusive + } + if oidcClientSecretPath != "" { + secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath)) + if err != nil { + return OIDCConfig{}, err + } + oidcClientSecret = strings.TrimSpace(string(secretBytes)) + } + OIDC := OIDCConfig{ + 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"), + Allowed: allowedConfig, + ClaimsMap: claimsMap, + Expiry: expireConfig, + Misc: miscConfig, + } + return OIDC, nil +} + func GetHeadscaleConfig() (*Config, error) { if IsCLIConfigured() { return &Config{ @@ -670,17 +771,9 @@ func GetHeadscaleConfig() (*Config, error) { logConfig := GetLogTailConfig() randomizeClientPort := viper.GetBool("randomize_client_port") - oidcClientSecret := viper.GetString("oidc.client_secret") - oidcClientSecretPath := viper.GetString("oidc.client_secret_path") - if oidcClientSecretPath != "" && oidcClientSecret != "" { - return nil, errOidcMutuallyExclusive - } - if oidcClientSecretPath != "" { - secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath)) - if err != nil { - return nil, err - } - oidcClientSecret = strings.TrimSpace(string(secretBytes)) + oidcConfig, err := GetOIDCConfig() + if err != nil { + return nil, err } return &Config{ @@ -717,38 +810,7 @@ func GetHeadscaleConfig() (*Config, error) { UnixSocket: viper.GetString("unix_socket"), UnixSocketPermission: util.GetFileMode("unix_socket_permission"), - - OIDC: OIDCConfig{ - 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"), - StripEmaildomain: viper.GetBool("oidc.strip_email_domain"), - Expiry: func() time.Duration { - // if set to 0, we assume no expiry - if value := viper.GetString("oidc.expiry"); value == "0" { - return maxDuration - } else { - expiry, err := model.ParseDuration(value) - if err != nil { - log.Warn().Msg("failed to parse oidc.expiry, defaulting back to 180 days") - - return defaultOIDCExpiryTime - } - - return time.Duration(expiry) - } - }(), - UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"), - }, - + OIDC: oidcConfig, LogTail: logConfig, RandomizeClientPort: randomizeClientPort, diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index d24bf45..639cc9b 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -62,7 +62,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { "HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID, "CREDENTIALS_DIRECTORY_TEST": "/tmp", "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", - "HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain), + "HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.Misc.StripEmaildomain), } err = scenario.CreateHeadscaleEnv( @@ -121,7 +121,7 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) { "HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer, "HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID, "HEADSCALE_OIDC_CLIENT_SECRET": oidcConfig.ClientSecret, - "HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain), + "HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.Misc.StripEmaildomain), "HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1", } @@ -269,6 +269,9 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConf log.Printf("headscale mock oidc is ready for tests at %s", hostEndpoint) + oidcMisc := types.OIDCMiscConfig{ + StripEmaildomain: true, + } return &types.OIDCConfig{ Issuer: fmt.Sprintf( "http://%s/oidc", @@ -276,7 +279,7 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConf ), ClientID: "superclient", ClientSecret: "supersecret", - StripEmaildomain: true, + Misc: oidcMisc, OnlyStartIfOIDCIsAvailable: true, }, nil } From 51a257b70061802295c4dbd350e2d6d84908546f Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 9 May 2024 16:02:23 +0200 Subject: [PATCH 2/7] change var name --- hscontrol/types/config.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 51fee1d..b62df54 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -662,7 +662,7 @@ func PrefixV6() (*netip.Prefix, error) { func GetOIDCConfig() (OIDCConfig, error) { // get expiry config - expireConfig := OIDCExpireConfig{ + oidcExpireConfig := OIDCExpireConfig{ FromToken: viper.GetBool("oidc.expiry.from_token"), FixedTime: func() time.Duration { // if set to 0, we assume no expiry @@ -681,20 +681,20 @@ func GetOIDCConfig() (OIDCConfig, error) { }(), } // get allowed config - allowedConfig := OIDCAllowedConfig{ + oidcAllowed := OIDCAllowedConfig{ Domains: viper.GetStringSlice("oidc.allowed.domains"), Users: viper.GetStringSlice("oidc.allowed.users"), Groups: viper.GetStringSlice("oidc.allowed.groups"), } // get claims map - claimsMap := OIDCClaimsMap{ + oidcClaimsMap := OIDCClaimsMap{ Name: viper.GetString("oidc.claims_map.name"), Username: viper.GetString("oidc.claims_map.username"), Email: viper.GetString("oidc.claims_map.email"), Groups: viper.GetString("oidc.claims_map.groups"), } // get misc config - miscConfig := OIDCMiscConfig{ + oidcMiscConfig := OIDCMiscConfig{ StripEmaildomain: viper.GetBool("oidc.strip_email_domain"), FlattenGroups: viper.GetBool("oidc.flatten_groups"), FlattenSplter: viper.GetString("oidc.flatten_splitter"), @@ -721,10 +721,10 @@ func GetOIDCConfig() (OIDCConfig, error) { ClientSecret: oidcClientSecret, Scope: viper.GetStringSlice("oidc.scope"), ExtraParams: viper.GetStringMapString("oidc.extra_params"), - Allowed: allowedConfig, - ClaimsMap: claimsMap, - Expiry: expireConfig, - Misc: miscConfig, + Allowed: oidcAllowed, + ClaimsMap: oidcClaimsMap, + Expiry: oidcExpireConfig, + Misc: oidcMiscConfig, } return OIDC, nil } From 77c6bcaccae645fe015c6c22aaa3e75bfeeb4c57 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 9 May 2024 16:42:30 +0200 Subject: [PATCH 3/7] update example --- config-example.yaml | 91 ++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/config-example.yaml b/config-example.yaml index 0f1c241..a727b48 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -272,10 +272,10 @@ dns_config: unix_socket: /var/run/headscale/headscale.sock unix_socket_permission: "0770" # -# headscale supports experimental OpenID connect support, -# it is still being tested and might have some bugs, please -# help us test it. -# OpenID Connect +# # headscale supports experimental OpenID connect support, +# # it is still being tested and might have some bugs, please +# # help us test it. +# # OpenID Connect # oidc: # only_start_if_oidc_is_available: true # issuer: "https://your-oidc.issuer.com/path" @@ -284,44 +284,59 @@ unix_socket_permission: "0770" # # Alternatively, set `client_secret_path` to read the secret from the file. # # It resolves environment variables, making integration to systemd's # # `LoadCredential` straightforward: -# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" +# # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" # # client_secret and client_secret_path are mutually exclusive. -# -# # The amount of time from a node is authenticated with OpenID until it -# # expires and needs to reauthenticate. -# # Setting the value to "0" will mean no expiry. -# expiry: 180d -# -# # Use the expiry from the token received from OpenID when the user logged -# # in, this will typically lead to frequent need to reauthenticate and should -# # only been enabled if you know what you are doing. -# # Note: enabling this will cause `oidc.expiry` to be ignored. -# use_expiry_from_token: false -# +# # # # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query # # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". -# # scope: ["openid", "profile", "email", "custom"] -# extra_params: -# domain_hint: example.com -# -# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the -# # authentication request will be rejected. -# -# allowed_domains: -# - example.com -# # Note: Groups from keycloak have a leading '/' -# allowed_groups: -# - /headscale -# allowed_users: -# - alice@example.com -# -# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. -# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` -# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following -# user: `first-name.last-name.example.com` -# -# strip_email_domain: true +# # extra_params: +# # domain_hint: example.com + +# expiry: +# # +# # Use the expiry from the token received from OpenID when the user logged +# # in, this will typically lead to frequent need to reauthenticate and should +# # only been enabled if you know what you are doing. +# # Note: enabling this will cause `oidc.expiry.fixed_time` to be ignored. +# from_token: false +# # +# # The amount of time from a node is authenticated with OpenID until it +# # expires and needs to reauthenticate. +# # Setting the value to "0" will mean no expiry. +# fixed_time: 180d + +# # # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the +# # # authentication request will be rejected. +# # allowd: +# # domains: +# # - example.com +# # groups: +# # - admins +# # users: +# # - admin@example.com + +# # Map claims from the OIDC token to the user object +# claims_map: +# name: name +# username: preferred_username +# email: email +# groups: groups + + +# # some random configuration +# misc: +# # if the username is set to `email` then `strip_email_domain` is valid +# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. +# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` +# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following +# # user: `first-name.last-name.example.com` +# strip_email_domain: true +# # If `flatten_groups` is set to `true`, the groups claim will be flattened to a single level. +# # this is used for keycloak where the groups are nested. the groups format from keycloak is `group1/subgroup1/subgroup2` +# flatten_groups: true +# # If `flatten_splitter` is set to a string, the groups claim will be split by the string and flattened to a single level. +# flatten_splitter: "/" # Logtail configuration # Logtail is Tailscales logging and auditing infrastructure, it allows the control panel From bd78f564b9210cf41caf3c70c4340dadac3a10d8 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 9 May 2024 16:42:39 +0200 Subject: [PATCH 4/7] fix bug --- hscontrol/oidc.go | 24 ++------------ hscontrol/types/config.go | 66 +++++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 54 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 95ca39a..c6b3ef2 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -350,7 +350,7 @@ func extractIDTokenClaims( writer.WriteHeader(http.StatusBadRequest) _, werr := writer.Write([]byte("Failed to decode id token claims")) if werr != nil { - util.LogErr(err,"Failed to write response") + util.LogErr(err, "Failed to write response") } return nil, err } @@ -358,7 +358,7 @@ func extractIDTokenClaims( // Unmarshal the claims into a map mappedClaims := make(map[string]interface{}) if err := json.Unmarshal(claims, &mappedClaims); err != nil { - util.LogErr(err,"Failed to unmarshal id token claims") + util.LogErr(err, "Failed to unmarshal id token claims") return nil, err } @@ -388,24 +388,6 @@ func extractIDTokenClaims( return &finalClaims, nil } -// { -// var claims IDTokenClaims -// if err := idToken.Claims(&claims); err != nil { -// util.LogErr(err, "Failed to decode id token claims") - -// writer.Header().Set("Content-Type", "text/plain; charset=utf-8") -// writer.WriteHeader(http.StatusBadRequest) -// _, werr := writer.Write([]byte("Failed to decode id token claims")) -// if werr != nil { -// util.LogErr(err, "Failed to write response") -// } - -// return nil, err -// } - -// return &claims, nil -// } - // validateOIDCAllowedDomains checks that if AllowedDomains is provided, // that the authenticated principal ends with @. func validateOIDCAllowedDomains( @@ -589,7 +571,7 @@ func getUserName( writer http.ResponseWriter, claims *IDTokenClaims, stripEmaildomain bool, -) (string, error) { +) (string, error) { userName, err := util.NormalizeToFQDNRules( claims.Username, stripEmaildomain, diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index b62df54..ecdaf51 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -126,34 +126,34 @@ type OIDCConfig struct { ClientSecret string Scope []string ExtraParams map[string]string - ClaimsMap OIDCClaimsMap - Allowed OIDCAllowedConfig + ClaimsMap OIDCClaimsMap + Allowed OIDCAllowedConfig Expiry OIDCExpireConfig - Misc OIDCMiscConfig + Misc OIDCMiscConfig } type OIDCExpireConfig struct { - FromToken bool - FixedTime time.Duration + FromToken bool + FixedTime time.Duration } type OIDCAllowedConfig struct { - Domains []string - Users []string - Groups []string + Domains []string + Users []string + Groups []string } type OIDCClaimsMap struct { - Name string - Username string - Email string - Groups string + Name string + Username string + Email string + Groups string } type OIDCMiscConfig struct { - StripEmaildomain bool - FlattenGroups bool - FlattenSplter string + StripEmaildomain bool + FlattenGroups bool + FlattenSplter string } type DERPConfig struct { @@ -254,9 +254,9 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("oidc.claims_map.email", "email") viper.SetDefault("oidc.claims_map.groups", "groups") // misc - viper.SetDefault("oidc.strip_email_domain", false) - viper.SetDefault("oidc.flatten_groups", false) - viper.SetDefault("oidc.flatten_splitter", "/") + viper.SetDefault("oidc.misc.strip_email_domain", false) + viper.SetDefault("oidc.misc.flatten_groups", false) + viper.SetDefault("oidc.misc.flatten_splitter", "/") viper.SetDefault("logtail.enabled", false) viper.SetDefault("randomize_client_port", false) @@ -695,9 +695,9 @@ func GetOIDCConfig() (OIDCConfig, error) { } // get misc config oidcMiscConfig := OIDCMiscConfig{ - StripEmaildomain: viper.GetBool("oidc.strip_email_domain"), - FlattenGroups: viper.GetBool("oidc.flatten_groups"), - FlattenSplter: viper.GetString("oidc.flatten_splitter"), + StripEmaildomain: viper.GetBool("oidc.misc.strip_email_domain"), + FlattenGroups: viper.GetBool("oidc.misc.flatten_groups"), + FlattenSplter: viper.GetString("oidc.misc.flatten_splitter"), } // get client secret oidcClientSecret := viper.GetString("oidc.client_secret") @@ -716,15 +716,15 @@ func GetOIDCConfig() (OIDCConfig, 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"), - Allowed: oidcAllowed, - ClaimsMap: oidcClaimsMap, - Expiry: oidcExpireConfig, - Misc: oidcMiscConfig, + Issuer: viper.GetString("oidc.issuer"), + ClientID: viper.GetString("oidc.client_id"), + ClientSecret: oidcClientSecret, + Scope: viper.GetStringSlice("oidc.scope"), + ExtraParams: viper.GetStringMapString("oidc.extra_params"), + Allowed: oidcAllowed, + ClaimsMap: oidcClaimsMap, + Expiry: oidcExpireConfig, + Misc: oidcMiscConfig, } return OIDC, nil } @@ -810,9 +810,9 @@ func GetHeadscaleConfig() (*Config, error) { UnixSocket: viper.GetString("unix_socket"), UnixSocketPermission: util.GetFileMode("unix_socket_permission"), - OIDC: oidcConfig, - LogTail: logConfig, - RandomizeClientPort: randomizeClientPort, + OIDC: oidcConfig, + LogTail: logConfig, + RandomizeClientPort: randomizeClientPort, ACL: GetACLConfig(), From 1e128fc854905e21691e925aca356cc936cad55e Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 9 May 2024 17:04:00 +0200 Subject: [PATCH 5/7] username=email --- config-example.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-example.yaml b/config-example.yaml index a727b48..083bcf4 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -319,7 +319,8 @@ unix_socket_permission: "0770" # # Map claims from the OIDC token to the user object # claims_map: # name: name -# username: preferred_username +# username: email +# # username: preferred_username # email: email # groups: groups From d211b930eecc7effd0a47f24d2263e800b18f646 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 16 May 2024 12:35:01 +0200 Subject: [PATCH 6/7] fix spell error: allowd -> allowed --- config-example.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-example.yaml b/config-example.yaml index 083bcf4..90217ad 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -308,7 +308,7 @@ unix_socket_permission: "0770" # # # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the # # # authentication request will be rejected. -# # allowd: +# # allowed: # # domains: # # - example.com # # groups: From 39b34ad1cb93f99851ed126bf3cd4f7b8407f31e Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Fri, 17 May 2024 20:40:05 +0200 Subject: [PATCH 7/7] update oidc README.md --- docs/oidc.md | 80 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/docs/oidc.md b/docs/oidc.md index c8746bb..922dc70 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -13,43 +13,69 @@ In your `config.yaml`, customize this to your liking: ```yaml oidc: - # Block further startup until the OIDC provider is healthy and available only_start_if_oidc_is_available: true - # Specified by your OIDC provider issuer: "https://your-oidc.issuer.com/path" - # Specified/generated by your OIDC provider client_id: "your-oidc-client-id" client_secret: "your-oidc-client-secret" - # alternatively, set `client_secret_path` to read the secret from the file. + # Alternatively, set `client_secret_path` to read the secret from the file. # It resolves environment variables, making integration to systemd's # `LoadCredential` straightforward: - #client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" - # as third option, it's also possible to load the oidc secret from environment variables - # set HEADSCALE_OIDC_CLIENT_SECRET to the required value - + # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" + # client_secret and client_secret_path are mutually exclusive. + # # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". scope: ["openid", "profile", "email", "custom"] - # Optional: Passed on to the browser login request – used to tweak behaviour for the OIDC provider - extra_params: - domain_hint: example.com + extra_params: + domain_hint: example.com + + expiry: + # + # Use the expiry from the token received from OpenID when the user logged + # in, this will typically lead to frequent need to reauthenticate and should + # only been enabled if you know what you are doing. + # Note: enabling this will cause `oidc.expiry.fixed_time` to be ignored. + from_token: false + # + # The amount of time from a node is authenticated with OpenID until it + # expires and needs to reauthenticate. + # Setting the value to "0" will mean no expiry. + fixed_time: 180d + + # # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the + # # authentication request will be rejected. + allowed: + domains: + - example.com + groups: + - admins + users: + - admin@example.com + + # Map claims from the OIDC token to the user object + claims_map: + name: name + username: email + # username: preferred_username + email: email + groups: groups + + + # some random configuration + misc: + # if the username is set to `email` then `strip_email_domain` is valid + # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. + # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` + # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following + # user: `first-name.last-name.example.com` + strip_email_domain: true + # If `flatten_groups` is set to `true`, the groups claim will be flattened to a single level. + # this is used for keycloak where the groups are nested. the groups format from keycloak is `group1/subgroup1/subgroup2` + flatten_groups: true + # If `flatten_splitter` is set to a string, the groups claim will be split by the string and flattened to a single level. + flatten_splitter: "/" - # Optional: List allowed principal domains and/or users. If an authenticated user's domain is not in this list, - # the authentication request will be rejected. - allowed_domains: - - example.com - # Optional. Note that groups from Keycloak have a leading '/'. - allowed_groups: - - /headscale - # Optional. - allowed_users: - - alice@example.com - # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. - # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` - # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following - # user: `first-name.last-name.example.com` - strip_email_domain: true ``` ## Azure AD example @@ -171,4 +197,4 @@ oidc: scope: ["openid", "profile", "email"] ``` -You can also use `allowed_domains` and `allowed_users` to restrict the users who can authenticate. +You can also use `allowed.domains` and `allowed.users` to restrict the users who can authenticate.