diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fca10c4..f1515de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067) - Remove ephemeral nodes on logout [#1098](https://github.com/juanfont/headscale/pull/1098) - Performance improvements in ACLs [#1129](https://github.com/juanfont/headscale/pull/1129) +- OIDC client secret can be passed via a file [#1127](https://github.com/juanfont/headscale/pull/1127) ## 0.17.1 (2022-12-05) diff --git a/config-example.yaml b/config-example.yaml index 1e404a4a..b4539f48 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -276,6 +276,11 @@ unix_socket_permission: "0770" # issuer: "https://your-oidc.issuer.com/path" # 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" +# # client_secret and client_secret_path are mutually exclusive. # # 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". diff --git a/config.go b/config.go index bb7837c0..6865b301 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "io/fs" "net/netip" "net/url" + "os" "strings" "time" @@ -26,6 +27,8 @@ const ( TextLogFormat = "text" ) +var errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive") + // Config contains the initial Headscale configuration. type Config struct { ServerURL string @@ -528,6 +531,19 @@ func GetHeadscaleConfig() (*Config, error) { Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes) } + oidcClientSecret := viper.GetString("oidc.client_secret") + oidcClientSecretPath := viper.GetString("oidc.client_secret_path") + if oidcClientSecretPath != "" && oidcClientSecret != "" { + return nil, errOidcMutuallyExclusive + } + if oidcClientSecretPath != "" { + secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath)) + if err != nil { + return nil, err + } + oidcClientSecret = string(secretBytes) + } + return &Config{ ServerURL: viper.GetString("server_url"), Addr: viper.GetString("listen_addr"), @@ -580,7 +596,7 @@ func GetHeadscaleConfig() (*Config, error) { ), Issuer: viper.GetString("oidc.issuer"), ClientID: viper.GetString("oidc.client_id"), - ClientSecret: viper.GetString("oidc.client_secret"), + ClientSecret: oidcClientSecret, Scope: viper.GetStringSlice("oidc.scope"), ExtraParams: viper.GetStringMapString("oidc.extra_params"), AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"), diff --git a/docs/oidc.md b/docs/oidc.md index 16773817..59651b61 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -20,6 +20,10 @@ oidc: # 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" # 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". diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 5c21f567..6142e2f0 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -60,7 +60,8 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { oidcMap := map[string]string{ "HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer, "HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID, - "HEADSCALE_OIDC_CLIENT_SECRET": oidcConfig.ClientSecret, + "CREDENTIALS_DIRECTORY_TEST": "/tmp", + "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", "HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain), } @@ -69,6 +70,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { 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) diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index b598c758..9f6391a1 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -36,6 +36,11 @@ const ( var errHeadscaleStatusCodeNotOk = errors.New("headscale status code not ok") +type fileInContainer struct { + path string + contents []byte +} + type HeadscaleInContainer struct { hostname string @@ -44,11 +49,12 @@ type HeadscaleInContainer struct { network *dockertest.Network // optional config - port int - aclPolicy *headscale.ACLPolicy - env map[string]string - tlsCert []byte - tlsKey []byte + port int + aclPolicy *headscale.ACLPolicy + env map[string]string + tlsCert []byte + tlsKey []byte + filesInContainer []fileInContainer } type Option = func(c *HeadscaleInContainer) @@ -110,6 +116,16 @@ func WithHostnameAsServerURL() Option { } } +func WithFileInContainer(path string, contents []byte) Option { + return func(hsic *HeadscaleInContainer) { + hsic.filesInContainer = append(hsic.filesInContainer, + fileInContainer{ + path: path, + contents: contents, + }) + } +} + func New( pool *dockertest.Pool, network *dockertest.Network, @@ -129,7 +145,8 @@ func New( pool: pool, network: network, - env: DefaultConfigEnv(), + env: DefaultConfigEnv(), + filesInContainer: []fileInContainer{}, } for _, opt := range opts { @@ -214,6 +231,12 @@ func New( } } + for _, f := range hsic.filesInContainer { + if err := hsic.WriteFile(f.path, f.contents); err != nil { + return nil, fmt.Errorf("failed to write %q: %w", f.path, err) + } + } + return hsic, nil }