oidc: allow reading the client secret from a file

Currently the most "secret" way to specify the oidc client secret is via
an environment variable `OIDC_CLIENT_SECRET`, which is problematic[1].
Lets allow reading oidc client secret from a file. For extra convenience
the path to the secret will resolve the environment variables.

[1]: https://systemd.io/CREDENTIALS/
This commit is contained in:
Motiejus Jakštys 2023-01-10 13:46:42 +02:00 committed by Kristoffer Dalby
parent 6edac4863a
commit bafb6791d3
6 changed files with 59 additions and 8 deletions

View file

@ -13,6 +13,7 @@
- Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067) - 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) - 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) - 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) ## 0.17.1 (2022-12-05)

View file

@ -276,6 +276,11 @@ unix_socket_permission: "0770"
# issuer: "https://your-oidc.issuer.com/path" # issuer: "https://your-oidc.issuer.com/path"
# client_id: "your-oidc-client-id" # client_id: "your-oidc-client-id"
# client_secret: "your-oidc-client-secret" # 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 # 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". # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".

View file

@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
@ -26,6 +27,8 @@ const (
TextLogFormat = "text" TextLogFormat = "text"
) )
var errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
// Config contains the initial Headscale configuration. // Config contains the initial Headscale configuration.
type Config struct { type Config struct {
ServerURL string ServerURL string
@ -528,6 +531,19 @@ func GetHeadscaleConfig() (*Config, error) {
Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes) 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{ return &Config{
ServerURL: viper.GetString("server_url"), ServerURL: viper.GetString("server_url"),
Addr: viper.GetString("listen_addr"), Addr: viper.GetString("listen_addr"),
@ -580,7 +596,7 @@ func GetHeadscaleConfig() (*Config, error) {
), ),
Issuer: viper.GetString("oidc.issuer"), Issuer: viper.GetString("oidc.issuer"),
ClientID: viper.GetString("oidc.client_id"), ClientID: viper.GetString("oidc.client_id"),
ClientSecret: viper.GetString("oidc.client_secret"), ClientSecret: oidcClientSecret,
Scope: viper.GetStringSlice("oidc.scope"), Scope: viper.GetStringSlice("oidc.scope"),
ExtraParams: viper.GetStringMapString("oidc.extra_params"), ExtraParams: viper.GetStringMapString("oidc.extra_params"),
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"), AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),

View file

@ -20,6 +20,10 @@ oidc:
# Specified/generated by your OIDC provider # Specified/generated by your OIDC provider
client_id: "your-oidc-client-id" client_id: "your-oidc-client-id"
client_secret: "your-oidc-client-secret" 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 # 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". # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".

View file

@ -60,7 +60,8 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
oidcMap := map[string]string{ oidcMap := map[string]string{
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer, "HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID, "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), "HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain),
} }
@ -69,6 +70,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
hsic.WithTestName("oidcauthping"), hsic.WithTestName("oidcauthping"),
hsic.WithConfigEnv(oidcMap), hsic.WithConfigEnv(oidcMap),
hsic.WithHostnameAsServerURL(), hsic.WithHostnameAsServerURL(),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
) )
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)

View file

@ -36,6 +36,11 @@ const (
var errHeadscaleStatusCodeNotOk = errors.New("headscale status code not ok") var errHeadscaleStatusCodeNotOk = errors.New("headscale status code not ok")
type fileInContainer struct {
path string
contents []byte
}
type HeadscaleInContainer struct { type HeadscaleInContainer struct {
hostname string hostname string
@ -44,11 +49,12 @@ type HeadscaleInContainer struct {
network *dockertest.Network network *dockertest.Network
// optional config // optional config
port int port int
aclPolicy *headscale.ACLPolicy aclPolicy *headscale.ACLPolicy
env map[string]string env map[string]string
tlsCert []byte tlsCert []byte
tlsKey []byte tlsKey []byte
filesInContainer []fileInContainer
} }
type Option = func(c *HeadscaleInContainer) 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( func New(
pool *dockertest.Pool, pool *dockertest.Pool,
network *dockertest.Network, network *dockertest.Network,
@ -129,7 +145,8 @@ func New(
pool: pool, pool: pool,
network: network, network: network,
env: DefaultConfigEnv(), env: DefaultConfigEnv(),
filesInContainer: []fileInContainer{},
} }
for _, opt := range opts { 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 return hsic, nil
} }