From 9daec94b27c80c871e2e112d524d60f33353cd1a Mon Sep 17 00:00:00 2001 From: Christopher Johnstone Date: Sun, 26 Mar 2023 10:45:32 -0400 Subject: [PATCH] Allow use of the preferred_username OIDC claim. Previously, Headscale would only use the `email` OIDC claim to set the Headscale user. In certain cases (self-hosted SSO), it may be useful to instead use the `preferred_username` to set the Headscale username. This also closes #938. This adds a config setting to use this claim instead. The OIDC docs have been updated to include this entry as well. In addition, this adds an Authelia OIDC example to the docs. --- config-example.yaml | 10 +++++++++ config.go | 2 ++ docs/oidc.md | 51 +++++++++++++++++++++++++++++++++++++++++++++ oidc.go | 21 +++++++++++++++---- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/config-example.yaml b/config-example.yaml index 249a0442e5..ee5653f540 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -311,6 +311,16 @@ unix_socket_permission: "0770" # allowed_users: # - alice@example.com # +# # By default, Headscale will use the OIDC email address claim to determine the username. +# # OIDC also returns a `preferred_username` claim. +# # +# # If `use_username_claim` is set to `true`, then the `preferred_username` claim will +# # be used instead to set the Headscale username. +# # If `use_username_claim` is set to `false`, then the `email` claim will be used +# # to derive the Headscale username (as modified by the `strip_email_domain` entry). +# +# use_username_claim: false +# # # 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 diff --git a/config.go b/config.go index c0dd1c9864..c8b6b25bc6 100644 --- a/config.go +++ b/config.go @@ -107,6 +107,7 @@ type OIDCConfig struct { AllowedUsers []string AllowedGroups []string StripEmaildomain bool + UseUsernameClaim bool Expiry time.Duration UseExpiryFromToken bool } @@ -610,6 +611,7 @@ func GetHeadscaleConfig() (*Config, error) { AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"), AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), + UseUsernameClaim: viper.GetBool("oidc.use_username_claim"), StripEmaildomain: viper.GetBool("oidc.strip_email_domain"), Expiry: func() time.Duration { // if set to 0, we assume no expiry diff --git a/docs/oidc.md b/docs/oidc.md index 189d7cd736..dc52c29263 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -43,6 +43,16 @@ oidc: allowed_users: - alice@example.com + # By default, Headscale will use the OIDC email address claim to determine the username. + # OIDC also returns a `preferred_username` claim. + # + # If `use_username_claim` is set to `true`, then the `preferred_username` claim will + # be used instead to set the Headscale username. + # If `use_username_claim` is set to `false`, then the `email` claim will be used + # to derive the Headscale username (as modified by the `strip_email_domain` entry). + + use_username_claim: false + # 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 @@ -170,3 +180,44 @@ oidc: ``` You can also use `allowed_domains` and `allowed_users` to restrict the users who can authenticate. + +## Authelia Example + +In order to integrate Headscale with your Authelia instance, you need to generate a client secret add your Headscale instance as a client. + +First, generate a client secret. If you are running Authelia inside docker, prepend `docker-compose exec ` before these commands: +```shell +authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 +``` +This will return two strings, a "Random Password" which you will fill into Headscale, and a "Digest" you will fill into Authelia. + +In your Authelia configuration, add Headscale under the client section: +```yaml +clients: + - id: headscale + description: Headscale + secret: "DIGEST_STRING_FROM_ABOVE" + public: false + authorization_policy: two_factor + redirect_uris: + - https://your.headscale.domain/oidc/callback + scopes: + - openid + - profile + - email + - groups +``` + +In your Headscale `config.yaml`, edit the config under `oidc`, filling in the `client_id` to match the `id` line in the Authelia config and filling in `client_secret` from the "Random Password" output. +You may want to tune the `expiry`, `only_start_if_oidc_available`, and other entries. The following are only the required entries. +```yaml +oidc: + issuer: "https://your.authelia.domain" + client_id: "headscale" + client_secret: "RANDOM_PASSWORD_STRING_FROM_ABOVE" + scope: ["openid", "profile", "email", "groups"] + allowed_groups: + - authelia_groups_you_want_to_limit +``` + +In particular, you may want to set `use_username_claim: true` to use Authelia's `preferred_username` grant to set Headscale usernames. \ No newline at end of file diff --git a/oidc.go b/oidc.go index 5aa3ba6203..205784c352 100644 --- a/oidc.go +++ b/oidc.go @@ -239,7 +239,7 @@ func (h *Headscale) OIDCCallback( return } - userName, err := getUserName(writer, claims, h.cfg.OIDC.StripEmaildomain) + userName, err := getUserName(writer, claims, h.cfg.OIDC.UseUsernameClaim, h.cfg.OIDC.StripEmaildomain) if err != nil { return } @@ -625,17 +625,30 @@ func (h *Headscale) validateMachineForOIDCCallback( func getUserName( writer http.ResponseWriter, claims *IDTokenClaims, + useUsernameClaim bool, stripEmaildomain bool, ) (string, error) { + var claim string + if useUsernameClaim { + claim = claims.Username + } else { + claim = claims.Email + } userName, err := NormalizeToFQDNRules( - claims.Email, + claim, stripEmaildomain, ) if err != nil { - log.Error().Err(err).Caller().Msgf("couldn't normalize email") + var friendlyErrMsg string + if useUsernameClaim { + friendlyErrMsg = "couldn't normalize username (preferred_username OIDC claim)" + } else { + friendlyErrMsg = "couldn't normalize username (email OIDC claim)" + } + log.Error().Err(err).Caller().Msgf(friendlyErrMsg) writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.WriteHeader(http.StatusInternalServerError) - _, werr := writer.Write([]byte("couldn't normalize email")) + _, werr := writer.Write([]byte(friendlyErrMsg)) if werr != nil { log.Error(). Caller().