From eb1ec1cee96147f6f801a2c4d52f18d34213978b 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. Added OIDC claim integration tests. Updated the MockOIDC wrapper to take an environment variable that lets you set the username/email claims to return. Added two integration tests, TestOIDCEmailGrant and TestOIDCUsernameGrant, which check the username by checking the FQDN of clients. Updated the HTML template shown after OIDC login to show whatever username is used, based on the Headscale settings. --- ...est-integration-v2-TestOIDCEmailGrant.yaml | 65 +++++++ ...-integration-v2-TestOIDCUsernameGrant.yaml | 65 +++++++ cmd/headscale/cli/mockoidc.go | 32 +++- config-example.yaml | 10 + docs/oidc.md | 55 ++++++ hscontrol/oidc.go | 35 +++- hscontrol/types/config.go | 3 + integration/auth_oidc_test.go | 171 +++++++++++++++++- 8 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml create mode 100644 .github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml diff --git a/.github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml b/.github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml new file mode 100644 index 0000000000..8880b5a50b --- /dev/null +++ b/.github/workflows/test-integration-v2-TestOIDCEmailGrant.yaml @@ -0,0 +1,65 @@ +# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go +# To regenerate, run "go generate" in cmd/gh-action-integration-generator/ + +name: Integration Test v2 - TestOIDCEmailGrant + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + TestOIDCEmailGrant: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: DeterminateSystems/nix-installer-action@main + - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: satackey/action-docker-layer-caching@main + continue-on-error: true + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v34 + with: + files: | + *.nix + go.* + **/*.go + integration_test/ + config-example.yaml + + - name: Run TestOIDCEmailGrant + if: steps.changed-files.outputs.any_changed == 'true' + run: | + nix develop --command -- docker run \ + --tty --rm \ + --volume ~/.cache/hs-integration-go:/go \ + --name headscale-test-suite \ + --volume $PWD:$PWD -w $PWD/integration \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume $PWD/control_logs:/tmp/control \ + golang:1 \ + go run gotest.tools/gotestsum@latest -- ./... \ + -tags ts2019 \ + -failfast \ + -timeout 120m \ + -parallel 1 \ + -run "^TestOIDCEmailGrant$" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: logs + path: "control_logs/*.log" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: pprof + path: "control_logs/*.pprof.tar" diff --git a/.github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml b/.github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml new file mode 100644 index 0000000000..6634a23775 --- /dev/null +++ b/.github/workflows/test-integration-v2-TestOIDCUsernameGrant.yaml @@ -0,0 +1,65 @@ +# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go +# To regenerate, run "go generate" in cmd/gh-action-integration-generator/ + +name: Integration Test v2 - TestOIDCUsernameGrant + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + TestOIDCUsernameGrant: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: DeterminateSystems/nix-installer-action@main + - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: satackey/action-docker-layer-caching@main + continue-on-error: true + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v34 + with: + files: | + *.nix + go.* + **/*.go + integration_test/ + config-example.yaml + + - name: Run TestOIDCUsernameGrant + if: steps.changed-files.outputs.any_changed == 'true' + run: | + nix develop --command -- docker run \ + --tty --rm \ + --volume ~/.cache/hs-integration-go:/go \ + --name headscale-test-suite \ + --volume $PWD:$PWD -w $PWD/integration \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume $PWD/control_logs:/tmp/control \ + golang:1 \ + go run gotest.tools/gotestsum@latest -- ./... \ + -tags ts2019 \ + -failfast \ + -timeout 120m \ + -parallel 1 \ + -run "^TestOIDCUsernameGrant$" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: logs + path: "control_logs/*.log" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: pprof + path: "control_logs/*.pprof.tar" diff --git a/cmd/headscale/cli/mockoidc.go b/cmd/headscale/cli/mockoidc.go index 568a2a03e8..bc2d489747 100644 --- a/cmd/headscale/cli/mockoidc.go +++ b/cmd/headscale/cli/mockoidc.go @@ -4,7 +4,9 @@ import ( "fmt" "net" "os" + "regexp" "strconv" + "strings" "time" "github.com/oauth2-proxy/mockoidc" @@ -64,6 +66,28 @@ func mockOIDC() error { accessTTL = newTTL } + mockUsers := os.Getenv("MOCKOIDC_USERS") + users := []mockoidc.User{} + if mockUsers != "" { + userStrings := strings.Split(mockUsers, ",") + userRe := regexp.MustCompile(`^\s*(?P\S+)\s*<(?P\S+@\S+)>\s*$`) + for _, v := range userStrings { + match := userRe.FindStringSubmatch(v) + if match != nil { + // Use the default mockoidc claims for other entries + users = append(users, &mockoidc.MockUser{ + Subject: "1234567890", + Email: match[2], + PreferredUsername: match[1], + Phone: "555-987-6543", + Address: "123 Main Street", + Groups: []string{"engineering", "design"}, + EmailVerified: true, + }) + } + } + } + log.Info().Msgf("Access token TTL: %s", accessTTL) port, err := strconv.Atoi(portStr) @@ -71,7 +95,7 @@ func mockOIDC() error { return err } - mock, err := getMockOIDC(clientID, clientSecret) + mock, err := getMockOIDC(clientID, clientSecret, users) if err != nil { return err } @@ -93,7 +117,7 @@ func mockOIDC() error { return nil } -func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, error) { +func getMockOIDC(clientID string, clientSecret string, users []mockoidc.User) (*mockoidc.MockOIDC, error) { keypair, err := mockoidc.NewKeypair(nil) if err != nil { return nil, err @@ -111,5 +135,9 @@ func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, erro ErrorQueue: &mockoidc.ErrorQueue{}, } + for _, v := range users { + mock.QueueUser(v) + } + return &mock, nil } diff --git a/config-example.yaml b/config-example.yaml index 99ce552be6..baf108d0ef 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -304,6 +304,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/docs/oidc.md b/docs/oidc.md index 189d7cd736..689e50c4b7 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,48 @@ 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. diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index b32d75133a..fd5d2500af 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -242,7 +242,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 } @@ -259,7 +259,7 @@ func (h *Headscale) OIDCCallback( return } - content, err := renderOIDCCallbackTemplate(writer, claims) + content, err := renderOIDCCallbackTemplate(writer, userName) if err != nil { return } @@ -539,9 +539,14 @@ func (h *Headscale) validateNodeForOIDCCallback( Str("expiresAt", fmt.Sprintf("%v", expiry)). Msg("successfully refreshed node") + userName, err := getUserName(writer, claims, h.cfg.OIDC.UseUsernameClaim, h.cfg.OIDC.StripEmaildomain) + if err != nil { + userName = "unknown" + } + var content bytes.Buffer if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{ - User: claims.Email, + User: userName, Verb: "Reauthenticated", }); err != nil { log.Error(). @@ -576,18 +581,30 @@ func (h *Headscale) validateNodeForOIDCCallback( 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 := util.NormalizeToFQDNRules( - claims.Email, + claim, stripEmaildomain, ) if err != nil { - util.LogErr(err, "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 { util.LogErr(err, "Failed to write response") } @@ -668,11 +685,11 @@ func (h *Headscale) registerNodeForOIDCCallback( func renderOIDCCallbackTemplate( writer http.ResponseWriter, - claims *IDTokenClaims, + user string, ) (*bytes.Buffer, error) { var content bytes.Buffer if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{ - User: claims.Email, + User: user, Verb: "Authenticated", }); err != nil { log.Error(). diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index e78795d8ab..983cf3492c 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -103,6 +103,7 @@ type OIDCConfig struct { AllowedUsers []string AllowedGroups []string StripEmaildomain bool + UseUsernameClaim bool Expiry time.Duration UseExpiryFromToken bool } @@ -183,6 +184,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"}) viper.SetDefault("oidc.strip_email_domain", true) + viper.SetDefault("oidc.use_username_claim", false) viper.SetDefault("oidc.only_start_if_oidc_is_available", true) viper.SetDefault("oidc.expiry", "180d") viper.SetDefault("oidc.use_expiry_from_token", false) @@ -631,6 +633,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/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 7a0ed9c74a..70fbcaa0ac 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/netip" "strconv" + "strings" "testing" "time" @@ -38,6 +39,169 @@ type AuthOIDCScenario struct { mockOIDC *dockertest.Resource } +func TestOIDCUsernameGrant(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + baseScenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + scenario := AuthOIDCScenario{ + Scenario: baseScenario, + } + defer scenario.Shutdown() + + spec := map[string]int{ + "user1": len(MustTestVersions), + } + + users := make([]string, len(MustTestVersions)) + for i := range users { + users[i] = "test-user " + } + userStr := strings.Join(users, ", ") + + oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, userStr) + assertNoErrf(t, "failed to run mock OIDC server: %s", err) + + oidcMap := map[string]string{ + "HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer, + "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": "false", + "HEADSCALE_OIDC_USE_USERNAME_CLAIM": "true", + } + + err = scenario.CreateHeadscaleEnv( + spec, + hsic.WithTestName("oidcauthping"), + hsic.WithConfigEnv(oidcMap), + hsic.WithHostnameAsServerURL(), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + allClients, err := scenario.ListTailscaleClients() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + // Check that clients are registered under the right username + for _, client := range allClients { + fqdn, err := client.FQDN() + if err != nil { + t.Errorf("Unable to get client FQDN: %s", err) + } + + if !strings.HasSuffix(fqdn, "test-user.headscale.net") { + t.Errorf("Client registered with unexpected username. Client FQDN: %s", fqdn) + } + } + + allIps, err := scenario.ListTailscaleClientsIPs() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string { + return x.String() + }) + + success := pingAllHelper(t, allClients, allAddrs) + t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps)) +} + +func TestOIDCEmailGrant(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + baseScenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + scenario := AuthOIDCScenario{ + Scenario: baseScenario, + } + defer scenario.Shutdown() + + spec := map[string]int{ + "user1": len(MustTestVersions), + } + + users := make([]string, len(MustTestVersions)) + for i := range users { + users[i] = "test-user " + } + userStr := strings.Join(users, ", ") + + oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, userStr) + assertNoErrf(t, "failed to run mock OIDC server: %s", err) + + oidcMap := map[string]string{ + "HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer, + "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": "true", + "HEADSCALE_OIDC_USE_USERNAME_CLAIM": "false", + } + + err = scenario.CreateHeadscaleEnv( + spec, + hsic.WithTestName("oidcauthping"), + hsic.WithConfigEnv(oidcMap), + hsic.WithHostnameAsServerURL(), + hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + allClients, err := scenario.ListTailscaleClients() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + // Check that clients are registered under the right username + for _, client := range allClients { + fqdn, err := client.FQDN() + if err != nil { + t.Errorf("Unable to get client FQDN: %s", err) + } + + if !strings.HasSuffix(fqdn, "test-email.headscale.net") { + t.Errorf("Client registered with unexpected username. Client FQDN: %s", fqdn) + } + } + + allIps, err := scenario.ListTailscaleClientsIPs() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string { + return x.String() + }) + + success := pingAllHelper(t, allClients, allAddrs) + t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps)) +} + func TestOIDCAuthenticationPingAll(t *testing.T) { IntegrationSkip(t) t.Parallel() @@ -54,7 +218,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { "user1": len(MustTestVersions), } - oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL) + oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, "") assertNoErrf(t, "failed to run mock OIDC server: %s", err) oidcMap := map[string]string{ @@ -112,7 +276,7 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) { "user1": 3, } - oidcConfig, err := scenario.runMockOIDC(shortAccessTTL) + oidcConfig, err := scenario.runMockOIDC(shortAccessTTL, "") assertNoErrf(t, "failed to run mock OIDC server: %s", err) oidcMap := map[string]string{ @@ -191,7 +355,7 @@ func (s *AuthOIDCScenario) CreateHeadscaleEnv( return nil } -func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConfig, error) { +func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration, users string) (*types.OIDCConfig, error) { port, err := dockertestutil.RandomFreeHostPort() if err != nil { log.Fatalf("could not find an open port: %s", err) @@ -215,6 +379,7 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*types.OIDCConf fmt.Sprintf("MOCKOIDC_PORT=%d", port), "MOCKOIDC_CLIENT_ID=superclient", "MOCKOIDC_CLIENT_SECRET=supersecret", + fmt.Sprintf("MOCKOIDC_USERS=%s", users), fmt.Sprintf("MOCKOIDC_ACCESS_TTL=%s", accessTTL.String()), }, }