diff --git a/.prettierignore b/.prettierignore index 11d7a573..4452a8a6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ .github/workflows/test-integration-v2* docs/about/features.md docs/ref/configuration.md +docs/ref/oidc.md docs/ref/remote-cli.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf62ae3..65518f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ [#2614](https://github.com/juanfont/headscale/pull/2614) - Support client verify for DERP [#2046](https://github.com/juanfont/headscale/pull/2046) +- Refactor OpenID Connect documentation + [#2625](https://github.com/juanfont/headscale/pull/2625) ## 0.26.1 (2025-06-06) diff --git a/config-example.yaml b/config-example.yaml index 047fb731..44f87676 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -322,51 +322,60 @@ dns: # Note: for production you will want to set this to something like: 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 # oidc: +# # Block startup until the identity provider is available and healthy. # only_start_if_oidc_is_available: true +# +# # OpenID Connect Issuer URL from the identity provider # issuer: "https://your-oidc.issuer.com/path" +# +# # Client ID from the identity provider # client_id: "your-oidc-client-id" +# +# # Client secret generated by the identity provider +# # Note: client_secret and client_secret_path are mutually exclusive. # client_secret: "your-oidc-client-secret" # # 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 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. +# # The amount of time 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. +# # in. This will typically lead to frequent need to reauthenticate and should +# # only be 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". +# # The OIDC scopes to use, defaults to "openid", "profile" and "email". +# # Custom scopes can be configured as needed, be sure to always include the +# # required "openid" scope. +# scope: ["openid", "profile", "email"] # -# scope: ["openid", "profile", "email", "custom"] +# # Provide custom key/value pairs which get sent to the identity provider's +# # authorization endpoint. # 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. -# +# # Only accept users whose email domain is part of the allowed_domains list. # allowed_domains: # - example.com -# # Note: Groups from keycloak have a leading '/' -# allowed_groups: -# - /headscale +# +# # Only accept users whose email address is part of the allowed_users list. # allowed_users: # - alice@example.com # +# # Only accept users which are members of at least one group in the +# # allowed_groups list. +# allowed_groups: +# - /headscale +# # # Optional: PKCE (Proof Key for Code Exchange) configuration # # PKCE adds an additional layer of security to the OAuth 2.0 authorization code flow # # by preventing authorization code interception attacks @@ -374,6 +383,7 @@ unix_socket_permission: "0770" # pkce: # # Enable or disable PKCE support (default: false) # enabled: false +# # # PKCE method to use: # # - plain: Use plain code verifier # # - S256: Use SHA256 hashed code verifier (default, recommended) diff --git a/docs/about/features.md b/docs/about/features.md index 3ee913db..33b32618 100644 --- a/docs/about/features.md +++ b/docs/about/features.md @@ -28,10 +28,9 @@ provides on overview of Headscale's feature and compatibility with the Tailscale routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers) - [x] [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) -* [ ] Node registration using Single-Sign-On (OpenID Connect) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC)) +* [x] [Node registration using Single-Sign-On (OpenID Connect)](../ref/oidc.md) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC)) - [x] Basic registration - [x] Update user profile from identity provider - - [ ] Dynamic ACL support - [ ] OIDC groups cannot be used in ACLs - [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040)) - [ ] [Serve](https://tailscale.com/kb/1312/serve) ([#1234](https://github.com/juanfont/headscale/issues/1921)) diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index c2586d30..ac4516d5 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -1,162 +1,272 @@ -# Configuring headscale to use OIDC authentication +# OpenID Connect -In order to authenticate users through a centralized solution one must enable the OIDC integration. +Headscale supports authentication via external identity providers using OpenID Connect (OIDC). It features: -Known limitations: +- Autoconfiguration via OpenID Connect Discovery Protocol +- [Proof Key for Code Exchange (PKCE) code verification](#enable-pkce-recommended) +- [Authorization based on a user's domain, email address or group membership](#authorize-users-with-filters) +- Synchronization of [standard OIDC claims](#supported-oidc-claims) -- No dynamic ACL support -- OIDC groups cannot be used in ACLs +Please see [limitations](#limitations) for known issues and limitations. -## Basic configuration +## Configuration -In your `config.yaml`, customize this to your liking: +OpenID requires configuration in Headscale and your identity provider: -```yaml title="config.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. - # 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 +- Headscale: The `oidc` section of the Headscale [configuration](configuration.md) contains all available configuration + options along with a description and their default values. +- Identity provider: Please refer to the official documentation of your identity provider for specific instructions. + Additionally, there might be some useful hints in the [Identity provider specific + configuration](#identity-provider-specific-configuration) section below. - # 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 +### Basic configuration - # 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 +A basic configuration connects Headscale to an identity provider and typically requires: - # Optional: PKCE (Proof Key for Code Exchange) configuration - # PKCE adds an additional layer of security to the OAuth 2.0 authorization code flow - # by preventing authorization code interception attacks - # See https://datatracker.ietf.org/doc/html/rfc7636 - pkce: - # Enable or disable PKCE support (default: false) - enabled: false - # PKCE method to use: - # - plain: Use plain code verifier - # - S256: Use SHA256 hashed code verifier (default, recommended) - method: S256 -``` +- OpenID Connect Issuer URL from the identity provider. Headscale uses the OpenID Connect Discovery Protocol 1.0 to + automatically obtain OpenID configuration parameters (example: `https://sso.example.com`). +- Client ID from the identity provider (example: `headscale`). +- Client secret generated by the identity provider (example: `generated-secret`). +- Redirect URI for your identity provider (example: `https://headscale.example.com/oidc/callback`). -## Azure AD example +=== "Headscale" -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: + ```yaml + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + ``` -```hcl title="terraform.hcl" -resource "azuread_application" "headscale" { - display_name = "Headscale" +=== "Identity provider" - sign_in_audience = "AzureADMyOrg" - fallback_public_client_enabled = false + * Create a new confidential client (`Client ID`, `Client secret`) + * Add Headscale's OIDC callback URL as valid redirect URL: `https://headscale.example.com/oidc/callback` + * Configure additional parameters to improve user experience such as: name, description, logo, … - required_resource_access { - // Microsoft Graph - resource_app_id = "00000003-0000-0000-c000-000000000000" +### Enable PKCE (recommended) - 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"] +Proof Key for Code Exchange (PKCE) adds an additional layer of security to the OAuth 2.0 authorization code flow by +preventing authorization code interception attacks, see: . PKCE is +recommended and needs to be configured for Headscale and the identity provider alike: - implicit_grant { - access_token_issuance_enabled = false - id_token_issuance_enabled = true - } - } +=== "Headscale" - group_membership_claims = ["SecurityGroup"] - optional_claims { - # Expose group memberships - id_token { - name = "groups" - } - } -} + ```yaml hl_lines="5-6" + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + pkce: + enabled: true + ``` -resource "azuread_application_password" "headscale-application-secret" { - display_name = "Headscale Server" - application_object_id = azuread_application.headscale.object_id -} +=== "Identity provider" -resource "azuread_service_principal" "headscale" { - application_id = azuread_application.headscale.application_id -} + * Enable PKCE for the headscale client + * Set the PKCE challenge method to "S256" -resource "azuread_service_principal_password" "headscale" { - service_principal_id = azuread_service_principal.headscale.id - end_date_relative = "44640h" -} +### Authorize users with filters -output "headscale_client_id" { - value = azuread_application.headscale.application_id -} +Headscale allows to filter for allowed users based on their domain, email address or group membership. These filters can +be helpful to apply additional restrictions and control which users are allowed to join. Filters are disabled by +default, users are allowed to join once the authentication with the identity provider succeeds. In case multiple filters +are configured, a user needs to pass all of them. -output "headscale_client_secret" { - value = azuread_application_password.headscale-application-secret.value -} -``` +=== "Allowed domains" -And in your headscale `config.yaml`: + * Check the email domain of each authenticating user against the list of allowed domains and only authorize users + whose email domain matches `example.com`. + * Access allowed: `alice@example.com` + * Access denied: `bob@example.net` -```yaml title="config.yaml" -oidc: - issuer: "https://login.microsoftonline.com//v2.0" - client_id: "" - client_secret: "" + ```yaml hl_lines="5-6" + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + allowed_domains: + - "example.com" + ``` - # 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 -``` +=== "Allowed users/emails" -## Google OAuth Example + * Check the email address of each authenticating user against the list of allowed email addresses and only authorize + users whose email is part of the `allowed_users` list. + * Access allowed: `alice@example.com`, `bob@example.net` + * Access denied: `mallory@example.net` -In order to integrate headscale with Google, you'll need to have a [Google Cloud Console](https://console.cloud.google.com) account. + ```yaml hl_lines="5-7" + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + allowed_users: + - "alice@example.com" + - "bob@example.net" + ``` -Google OAuth has a [verification process](https://support.google.com/cloud/answer/9110914?hl=en) if you need to have users authenticate who are outside of your domain. If you only need to authenticate users from your domain name (ie `@example.com`), you don't need to go through the verification process. +=== "Allowed groups" -However if you don't have a domain, or need to add users outside of your domain, you can manually add emails via Google Console. + * Use the OIDC `groups` claim of each authenticating user to get their group membership and only authorize users + which are members in at least one of the referenced groups. + * Access allowed: users in the `headscale_users` group + * Access denied: users without groups, users with other groups -### Steps + ```yaml hl_lines="5-7" + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + scope: ["openid", "profile", "email", "groups"] + allowed_groups: + - "headscale_users" + ``` + +### Customize node expiration + +The node expiration is the amount of time a node is authenticated with OpenID Connect until it expires and needs to +reauthenticate. The default node expiration is 180 days. This can either be customized or set to the expiration from the +Access Token. + +=== "Customize node expiration" + + ```yaml hl_lines="5" + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + expiry: 30d # Use 0 to disable node expiration + ``` + +=== "Use expiration from Access Token" + + Please keep in mind that the Access Token is typically a short-lived token that expires within a few minutes. You + will have to configure token expiration in your identity provider to avoid frequent reauthentication. + + + ```yaml hl_lines="5" + oidc: + issuer: "https://sso.example.com" + client_id: "headscale" + client_secret: "generated-secret" + use_expiry_from_token: true + ``` + +!!! tip "Expire a node and force re-authentication" + + A node can be expired immediately via: + ```console + headscale node expire -i + ``` + +### Reference a user in the policy + +You may refer to users in the Headscale policy via: + +- Email address +- Username +- Provider identifier (only available in the database or from your identity provider) + +!!! note "A user identifier in the policy must contain a single `@`" + + The Headscale policy requires a single `@` to reference a user. If the username or provider identifier doesn't + already contain a single `@`, it needs to be appended at the end. For example: the username `ssmith` has to be + written as `ssmith@` to be correctly identified as user within the policy. + +!!! warning "Email address or username might be updated by users" + + Many identity providers allow users to update their own profile. Depending on the identity provider and its + configuration, the values for username or email address might change over time. This might have unexpected + consequences for Headscale where a policy might no longer work or a user might obtain more access by hijacking an + existing username or email address. + +## Supported OIDC claims + +Headscale uses [the standard OIDC claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) to +populate and update its local user profile on each login. OIDC claims are read from the ID Token or from the UserInfo +endpoint. + +| Headscale profile | OIDC claim | Notes / examples | +| ------------------- | -------------------- | ------------------------------------------------------------------------------------------------- | +| email address | `email` | Only used when `email_verified: true` | +| display name | `name` | eg: `Sam Smith` | +| username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith` | +| profile picture | `picture` | URL to a profile picture or avatar | +| provider identifier | `iss`, `sub` | A stable and unique identifier for a user, typically a combination of `iss` and `sub` OIDC claims | +| | `groups` | [Only used to filter for allowed groups](#authorize-users-with-filters) | + +## Limitations + +- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of + specific identity providers. +- OIDC groups cannot be used in ACLs. +- The username provided by the identity provider needs to adhere to this pattern: + - The username must be at least two characters long. + - It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`. + - The username must start with a letter. +- A user's email address is only synchronized to the local user profile when the identity provider marks the email + address as verified (`email_verified: true`). + +Please see the [GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC) for OIDC related issues. + +## Identity provider specific configuration + +!!! warning "Third-party software and services" + + This section of the documentation is specific for third-party software and services. We recommend users read the + third-party documentation on how to configure and integrate an OIDC client. Please see the [Configuration + section](#configuration) for a description of Headscale's OIDC related configuration settings. + +Any identity provider with OpenID Connect support should "just work" with Headscale. The following identity providers +are known to work: + +- [Authelia](#authelia) +- [Authentik](#authentik) +- [Kanidm](#kanidm) +- [Keycloak](#keycloak) + +### Authelia + +Authelia is fully supported by Headscale. + +#### Additional configuration to authorize users based on filters + +Authelia (4.39.0 or newer) no longer provides standard OIDC claims such as `email` or `groups` via the ID Token. The +OIDC `email` and `groups` claims are used to [authorize users with filters](#authorize-users-with-filters). This extra +configuration step is **only** needed if you need to authorize access based on one of the following user properties: + +- domain +- email address +- group membership + +Please follow the instructions from Authelia's documentation on how to [Restore Functionality Prior to Claims +Parameter](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter). + +### Authentik + +- Authentik is fully supported by Headscale. +- [Headscale does not JSON Web Encryption](https://github.com/juanfont/headscale/issues/2446). Leave the field + `Encryption Key` in the providers section unset. + +### Google OAuth + +!!! warning "No username due to missing preferred_username" + + Google OAuth does not send the `preferred_username` claim when the scope `profile` is requested. The username in + Headscale will be blank/not set. + +In order to integrate Headscale with Google, you'll need to have a [Google Cloud +Console](https://console.cloud.google.com) account. + +Google OAuth has a [verification process](https://support.google.com/cloud/answer/9110914?hl=en) if you need to have +users authenticate who are outside of your domain. If you only need to authenticate users from your domain name (ie +`@example.com`), you don't need to go through the verification process. + +However if you don't have a domain, or need to add users outside of your domain, you can manually add emails via Google +Console. + +#### Steps 1. Go to [Google Console](https://console.cloud.google.com) and login or create an account if you don't have one. 2. Create a project (if you don't already have one). @@ -164,58 +274,44 @@ However if you don't have a domain, or need to add users outside of your domain, 4. Click `Create Credentials` -> `OAuth client ID` 5. Under `Application Type`, choose `Web Application` 6. For `Name`, enter whatever you like -7. Under `Authorised redirect URIs`, use `https://example.com/oidc/callback`, replacing example.com with your headscale URL. +7. Under `Authorised redirect URIs`, add Headscale's OIDC callback URL: `https://headscale.example.com/oidc/callback` 8. Click `Save` at the bottom of the form 9. Take note of the `Client ID` and `Client secret`, you can also download it for reference if you need it. -10. Edit your headscale config, under `oidc`, filling in your `client_id` and `client_secret`: - ```yaml title="config.yaml" - oidc: - issuer: "https://accounts.google.com" - client_id: "" - client_secret: "" - scope: ["openid", "profile", "email"] - ``` +10. [Configure Headscale following the "Basic configuration" steps](#basic-configuration). The issuer URL for Google + OAuth is: `https://accounts.google.com`. -You can also use `allowed_domains` and `allowed_users` to restrict the users who can authenticate. +### Kanidm -## Authelia +- Kanidm is fully supported by Headscale. +- Groups for the [allowed groups filter](#authorize-users-with-filters) need to be specified with their full SPN, for + example: `headscale_users@sso.example.com`. -Authelia since v4.39.0, has removed most claims from the `ID Token`, they are still available when application queries [UserInfo Endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). +### Keycloak -Following config restores sending 'default' claims in the `ID Token` +Keycloak is fully supported by Headscale. -For more information please read: [Authelia restore functionality prior to claims parameter](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter) +#### Additional configuration to use the allowed groups filter -```yaml -identity_providers: - oidc: - claims_policies: - default: - id_token: - [ - "groups", - "email", - "email_verified", - "alt_emails", - "preferred_username", - "name", - ] - clients: - - client_id: "headscale" - client_name: "headscale" - client_secret: "" - public: false - claims_policy: "default" - authorization_policy: "two_factor" - require_pkce: true - pkce_challenge_method: "S256" - redirect_uris: - - "https://headscale.example.com/oidc/callback" - scopes: - - "openid" - - "profile" - - "groups" - - "email" - userinfo_signed_response_alg: "none" - token_endpoint_auth_method: "client_secret_basic" -``` +Keycloak has no built-in client scope for the OIDC `groups` claim. This extra configuration step is **only** needed if +you need to [authorize access based on group membership](#authorize-users-with-filters). + +- Create a new client scope `groups` for OpenID Connect: + - Configure a `Group Membership` mapper with name `groups` and the token claim name `groups`. + - Enable the mapper for the ID Token, Access Token and UserInfo endpoint. +- Configure the new client scope for your Headscale client: + - Edit the Headscale client. + - Search for the client scope `group`. + - Add it with assigned type `Default`. +- [Configure the allowed groups in Headscale](#authorize-users-with-filters). Keep in mind that groups in Keycloak start + with a leading `/`. + +### Microsoft Entra ID + +In order to integrate Headscale with Microsoft Entra ID, you'll need to provision an App Registration with the correct +scopes and redirect URI. + +[Configure Headscale following the "Basic configuration" steps](#basic-configuration). The issuer URL for Microsoft +Entra ID is: `https://login.microsoftonline.com//v2.0`. The following `extra_params` might be useful: + +- `domain_hint: example.com` to use your own domain +- `prompt: select_account` to force an account picker during login diff --git a/mkdocs.yml b/mkdocs.yml index 65cf4556..b096aed8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -176,7 +176,7 @@ nav: - Windows: usage/connect/windows.md - Reference: - Configuration: ref/configuration.md - - OIDC authentication: ref/oidc.md + - OpenID Connect: ref/oidc.md - Routes: ref/routes.md - TLS: ref/tls.md - ACLs: ref/acls.md