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()), }, }