From a4d2d2f913a5de2940eb93062ab30495e04fb799 Mon Sep 17 00:00:00 2001 From: Even Holthe Date: Thu, 15 Dec 2022 01:10:26 +0100 Subject: [PATCH 1/5] add expiration from OIDC token to machine --- grpcv1.go | 1 + machine.go | 5 +++++ oidc.go | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/grpcv1.go b/grpcv1.go index 7b12fa0543..c3936df600 100644 --- a/grpcv1.go +++ b/grpcv1.go @@ -176,6 +176,7 @@ func (api headscaleV1APIServer) RegisterMachine( machine, err := api.h.RegisterMachineFromAuthCallback( request.GetKey(), request.GetNamespace(), + nil, RegisterMethodCLI, ) if err != nil { diff --git a/machine.go b/machine.go index 3ddf471c64..79485f7d6e 100644 --- a/machine.go +++ b/machine.go @@ -852,6 +852,7 @@ func getTags( func (h *Headscale) RegisterMachineFromAuthCallback( nodeKeyStr string, namespaceName string, + machineExpiry *time.Time, registrationMethod string, ) (*Machine, error) { nodeKey := key.NodePublic{} @@ -885,6 +886,10 @@ func (h *Headscale) RegisterMachineFromAuthCallback( registrationMachine.NamespaceID = namespace.ID registrationMachine.RegisterMethod = registrationMethod + if machineExpiry != nil { + registrationMachine.Expiry = machineExpiry + } + machine, err := h.RegisterMachine( registrationMachine, ) diff --git a/oidc.go b/oidc.go index 3eed91878f..8c7e8304ed 100644 --- a/oidc.go +++ b/oidc.go @@ -236,7 +236,7 @@ func (h *Headscale) OIDCCallback( return } - if err := h.registerMachineForOIDCCallback(writer, namespace, nodeKey); err != nil { + if err := h.registerMachineForOIDCCallback(writer, namespace, nodeKey, idToken.Expiry); err != nil { return } @@ -679,10 +679,12 @@ func (h *Headscale) registerMachineForOIDCCallback( writer http.ResponseWriter, namespace *Namespace, nodeKey *key.NodePublic, + expiry time.Time, ) error { if _, err := h.RegisterMachineFromAuthCallback( nodeKey.String(), namespace.Name, + &expiry, RegisterMethodOIDC, ); err != nil { log.Error(). From f687b3c99ff41f69af0b47b85d59c3e8f6a0ec89 Mon Sep 17 00:00:00 2001 From: Even Holthe Date: Thu, 15 Dec 2022 02:02:39 +0100 Subject: [PATCH 2/5] expire machines after db expiry --- app.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/app.go b/app.go index d82ecc516c..64907572ed 100644 --- a/app.go +++ b/app.go @@ -217,6 +217,15 @@ func (h *Headscale) expireEphemeralNodes(milliSeconds int64) { } } +// expireExpiredMachines expires machines that have an explicit expiry set +// after that expiry time has passed. +func (h *Headscale) expireExpiredMachines(milliSeconds int64) { + ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond) + for range ticker.C { + h.expireExpiredMachinesWorker() + } +} + func (h *Headscale) failoverSubnetRoutes(milliSeconds int64) { ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond) for range ticker.C { @@ -273,6 +282,53 @@ func (h *Headscale) expireEphemeralNodesWorker() { } } +func (h *Headscale) expireExpiredMachinesWorker() { + namespaces, err := h.ListNamespaces() + if err != nil { + log.Error().Err(err).Msg("Error listing namespaces") + + return + } + + for _, namespace := range namespaces { + machines, err := h.ListMachinesInNamespace(namespace.Name) + if err != nil { + log.Error(). + Err(err). + Str("namespace", namespace.Name). + Msg("Error listing machines in namespace") + + return + } + + expiredFound := false + for index, machine := range machines { + if machine.isExpired() && + machine.Expiry.After(h.getLastStateChange(namespace)) { + expiredFound = true + + err := h.ExpireMachine(&machines[index]) + if err != nil { + log.Error(). + Err(err). + Str("machine", machine.Hostname). + Str("name", machine.GivenName). + Msg("🤮 Cannot expire machine") + } else { + log.Info(). + Str("machine", machine.Hostname). + Str("name", machine.GivenName). + Msg("Machine successfully expired") + } + } + } + + if expiredFound { + h.setLastStateChangeToNow() + } + } +} + func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, @@ -494,6 +550,7 @@ func (h *Headscale) Serve() error { } go h.expireEphemeralNodes(updateInterval) + go h.expireExpiredMachines(updateInterval) go h.failoverSubnetRoutes(updateInterval) From bcae0a3225e52039c9137fa2e7025552098ea455 Mon Sep 17 00:00:00 2001 From: Even Holthe Date: Sat, 31 Dec 2022 01:23:55 +0100 Subject: [PATCH 3/5] oidc: add test for expiring nodes after token expiration --- cmd/headscale/cli/mockoidc.go | 13 ++- integration/auth_oidc_test.go | 135 ++++++++++++++++++++------ integration/dockertestutil/network.go | 18 ++++ 3 files changed, 137 insertions(+), 29 deletions(-) diff --git a/cmd/headscale/cli/mockoidc.go b/cmd/headscale/cli/mockoidc.go index 165a51158e..568a2a03e8 100644 --- a/cmd/headscale/cli/mockoidc.go +++ b/cmd/headscale/cli/mockoidc.go @@ -16,10 +16,11 @@ const ( errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined") errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined") errMockOidcPortNotDefined = Error("MOCKOIDC_PORT not defined") - accessTTL = 10 * time.Minute refreshTTL = 60 * time.Minute ) +var accessTTL = 2 * time.Minute + func init() { rootCmd.AddCommand(mockOidcCmd) } @@ -54,6 +55,16 @@ func mockOIDC() error { if portStr == "" { return errMockOidcPortNotDefined } + accessTTLOverride := os.Getenv("MOCKOIDC_ACCESS_TTL") + if accessTTLOverride != "" { + newTTL, err := time.ParseDuration(accessTTLOverride) + if err != nil { + return err + } + accessTTL = newTTL + } + + log.Info().Msgf("Access token TTL: %s", accessTTL) port, err := strconv.Atoi(portStr) if err != nil { diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 0c3c901ed0..5c21f567d4 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -9,8 +9,10 @@ import ( "log" "net" "net/http" + "net/netip" "strconv" "testing" + "time" "github.com/juanfont/headscale" "github.com/juanfont/headscale/integration/dockertestutil" @@ -22,7 +24,7 @@ import ( const ( dockerContextPath = "../." hsicOIDCMockHashLength = 6 - oidcServerPort = 10000 + defaultAccessTTL = 10 * time.Minute ) var errStatusCodeNotOK = errors.New("status code not OK") @@ -50,7 +52,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { "namespace1": len(TailscaleVersions), } - oidcConfig, err := scenario.runMockOIDC() + oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL) if err != nil { t.Errorf("failed to run mock OIDC server: %s", err) } @@ -87,20 +89,76 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { t.Errorf("failed wait for tailscale clients to be in sync: %s", err) } - success := 0 + success := pingAll(t, allClients, allIps) + t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps)) - for _, client := range allClients { - for _, ip := range allIps { - err := client.Ping(ip.String()) - if err != nil { - t.Errorf("failed to ping %s from %s: %s", ip, client.Hostname(), err) - } else { - success++ - } - } + err = scenario.Shutdown() + if err != nil { + t.Errorf("failed to tear down scenario: %s", err) } +} - t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps)) +func TestOIDCExpireNodes(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + shortAccessTTL := 5 * time.Minute + + baseScenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + scenario := AuthOIDCScenario{ + Scenario: baseScenario, + } + + spec := map[string]int{ + "namespace1": len(TailscaleVersions), + } + + oidcConfig, err := scenario.runMockOIDC(shortAccessTTL) + if err != nil { + t.Fatalf("failed to run mock OIDC server: %s", err) + } + + oidcMap := map[string]string{ + "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), + } + + err = scenario.CreateHeadscaleEnv( + spec, + hsic.WithTestName("oidcexpirenodes"), + hsic.WithConfigEnv(oidcMap), + hsic.WithHostnameAsServerURL(), + ) + 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) + } + + 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) + } + + success := pingAll(t, allClients, allIps) + t.Logf("%d successful pings out of %d (before expiry)", success, len(allClients)*len(allIps)) + + // await all nodes being logged out after OIDC token expiry + scenario.WaitForTailscaleLogout() err = scenario.Shutdown() if err != nil { @@ -143,7 +201,13 @@ func (s *AuthOIDCScenario) CreateHeadscaleEnv( return nil } -func (s *AuthOIDCScenario) runMockOIDC() (*headscale.OIDCConfig, error) { +func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*headscale.OIDCConfig, error) { + port, err := dockertestutil.RandomFreeHostPort() + if err != nil { + log.Fatalf("could not find an open port: %s", err) + } + portNotation := fmt.Sprintf("%d/tcp", port) + hash, _ := headscale.GenerateRandomStringDNSSafe(hsicOIDCMockHashLength) hostname := fmt.Sprintf("hs-oidcmock-%s", hash) @@ -151,16 +215,17 @@ func (s *AuthOIDCScenario) runMockOIDC() (*headscale.OIDCConfig, error) { mockOidcOptions := &dockertest.RunOptions{ Name: hostname, Cmd: []string{"headscale", "mockoidc"}, - ExposedPorts: []string{"10000/tcp"}, + ExposedPorts: []string{portNotation}, PortBindings: map[docker.Port][]docker.PortBinding{ - "10000/tcp": {{HostPort: "10000"}}, + docker.Port(portNotation): {{HostPort: strconv.Itoa(port)}}, }, Networks: []*dockertest.Network{s.Scenario.network}, Env: []string{ fmt.Sprintf("MOCKOIDC_ADDR=%s", hostname), - "MOCKOIDC_PORT=10000", + fmt.Sprintf("MOCKOIDC_PORT=%d", port), "MOCKOIDC_CLIENT_ID=superclient", "MOCKOIDC_CLIENT_SECRET=supersecret", + fmt.Sprintf("MOCKOIDC_ACCESS_TTL=%s", accessTTL.String()), }, } @@ -169,7 +234,7 @@ func (s *AuthOIDCScenario) runMockOIDC() (*headscale.OIDCConfig, error) { ContextDir: dockerContextPath, } - err := s.pool.RemoveContainerByName(hostname) + err = s.pool.RemoveContainerByName(hostname) if err != nil { return nil, err } @@ -184,11 +249,7 @@ func (s *AuthOIDCScenario) runMockOIDC() (*headscale.OIDCConfig, error) { } log.Println("Waiting for headscale mock oidc to be ready for tests") - hostEndpoint := fmt.Sprintf( - "%s:%s", - s.mockOIDC.GetIPInNetwork(s.network), - s.mockOIDC.GetPort(fmt.Sprintf("%d/tcp", oidcServerPort)), - ) + hostEndpoint := fmt.Sprintf("%s:%d", s.mockOIDC.GetIPInNetwork(s.network), port) if err := s.pool.Retry(func() error { oidcConfigURL := fmt.Sprintf("http://%s/oidc/.well-known/openid-configuration", hostEndpoint) @@ -215,11 +276,11 @@ func (s *AuthOIDCScenario) runMockOIDC() (*headscale.OIDCConfig, error) { log.Printf("headscale mock oidc is ready for tests at %s", hostEndpoint) return &headscale.OIDCConfig{ - Issuer: fmt.Sprintf("http://%s/oidc", - net.JoinHostPort(s.mockOIDC.GetIPInNetwork(s.network), strconv.Itoa(oidcServerPort))), - ClientID: "superclient", - ClientSecret: "supersecret", - StripEmaildomain: true, + Issuer: fmt.Sprintf("http://%s/oidc", net.JoinHostPort(s.mockOIDC.GetIPInNetwork(s.network), strconv.Itoa(port))), + ClientID: "superclient", + ClientSecret: "supersecret", + StripEmaildomain: true, + OnlyStartIfOIDCIsAvailable: true, }, nil } @@ -292,6 +353,24 @@ func (s *AuthOIDCScenario) runTailscaleUp( return fmt.Errorf("failed to up tailscale node: %w", errNoNamespaceAvailable) } +func pingAll(t *testing.T, clients []TailscaleClient, ips []netip.Addr) int { + t.Helper() + success := 0 + + for _, client := range clients { + for _, ip := range ips { + err := client.Ping(ip.String()) + if err != nil { + t.Errorf("failed to ping %s from %s: %s", ip, client.Hostname(), err) + } else { + success++ + } + } + } + + return success +} + func (s *AuthOIDCScenario) Shutdown() error { err := s.pool.Purge(s.mockOIDC) if err != nil { diff --git a/integration/dockertestutil/network.go b/integration/dockertestutil/network.go index 15c9908267..89fdc8ec35 100644 --- a/integration/dockertestutil/network.go +++ b/integration/dockertestutil/network.go @@ -2,6 +2,7 @@ package dockertestutil import ( "errors" + "net" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" @@ -60,3 +61,20 @@ func AddContainerToNetwork( return nil } + +// RandomFreeHostPort asks the kernel for a free open port that is ready to use. +// (from https://github.com/phayes/freeport) +func RandomFreeHostPort() (int, error) { + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + if err != nil { + return 0, err + } + + listener, err := net.ListenTCP("tcp", addr) + if err != nil { + return 0, err + } + defer listener.Close() + //nolint:forcetypeassert + return listener.Addr().(*net.TCPAddr).Port, nil +} From 4466b5354521e6267346f7f59d5ace3f71083d3b Mon Sep 17 00:00:00 2001 From: Even Holthe Date: Tue, 3 Jan 2023 15:06:00 +0100 Subject: [PATCH 4/5] oidc: add basic docs --- docs/README.md | 1 + docs/oidc.md | 137 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 docs/oidc.md diff --git a/docs/README.md b/docs/README.md index f215e80f9e..fcf819bc00 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ please ask on [Discord](https://discord.gg/c84AZQhmpx) instead of opening an Iss - [Running headscale on Linux](running-headscale-linux.md) - [Control headscale remotely](remote-cli.md) - [Using a Windows client with headscale](windows-client.md) +- [Configuring OIDC](oidc.md) ### References diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 0000000000..1677381704 --- /dev/null +++ b/docs/oidc.md @@ -0,0 +1,137 @@ +# Configuring Headscale to use OIDC authentication + +In order to authenticate users through a centralized solution one must enable the OIDC integration. + +Known limitations: + +- No dynamic ACL support +- OIDC groups cannot be used in ACLs + +## Basic configuration + +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" + + # 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 + + # 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 namespace `first-name.last-name` + # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following + # namespace: `first-name.last-name.example.com` + strip_email_domain: true +``` + +## Azure AD example + +In order to integrate Headscale with Azure Active Directory, we'll need to provision an App Registration with the correct scopes and redirect URI. Here with Terraform: + +```hcl +resource "azuread_application" "headscale" { + display_name = "Headscale" + + sign_in_audience = "AzureADMyOrg" + fallback_public_client_enabled = false + + required_resource_access { + // Microsoft Graph + resource_app_id = "00000003-0000-0000-c000-000000000000" + + resource_access { + // scope: profile + id = "14dad69e-099b-42c9-810b-d002981feec1" + type = "Scope" + } + resource_access { + // scope: openid + id = "37f7f235-527c-4136-accd-4a02d197296e" + type = "Scope" + } + resource_access { + // scope: email + id = "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0" + type = "Scope" + } + } + web { + # Points at your running Headscale instance + redirect_uris = ["https://headscale.example.com/oidc/callback"] + + implicit_grant { + access_token_issuance_enabled = false + id_token_issuance_enabled = true + } + } + + group_membership_claims = ["SecurityGroup"] + optional_claims { + # Expose group memberships + id_token { + name = "groups" + } + } +} + +resource "azuread_application_password" "headscale-application-secret" { + display_name = "Headscale Server" + application_object_id = azuread_application.headscale.object_id +} + +resource "azuread_service_principal" "headscale" { + application_id = azuread_application.headscale.application_id +} + +resource "azuread_service_principal_password" "headscale" { + service_principal_id = azuread_service_principal.headscale.id + end_date_relative = "44640h" +} + +output "headscale_client_id" { + value = azuread_application.headscale.application_id +} + +output "headscale_client_secret" { + value = azuread_application_password.headscale-application-secret.value +} +``` + +And in your Headscale `config.yaml`: + +```yaml +oidc: + issuer: "https://login.microsoftonline.com//v2.0" + client_id: "" + client_secret: "" + + # Optional: add "groups" + scope: ["openid", "profile", "email"] + extra_params: + # Use your own domain, associated with Azure AD + domain_hint: example.com + # Optional: Force the Azure AD account picker + prompt: select_account +``` From e1d940450e07bab6723d2dc4806cd63afaf8fdbc Mon Sep 17 00:00:00 2001 From: Even Holthe Date: Tue, 3 Jan 2023 15:28:45 +0100 Subject: [PATCH 5/5] oidc: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d92fa5d70..6b5de68d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fix duplicate nodes due to incorrect implementation of the protocol [#1058](https://github.com/juanfont/headscale/pull/1058) - Report if a machine is online in CLI more accurately [#1062](https://github.com/juanfont/headscale/pull/1062) - Added config option for custom DNS records [#1035](https://github.com/juanfont/headscale/pull/1035) +- Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067) ## 0.17.1 (2022-12-05)