mirror of
https://github.com/juanfont/headscale.git
synced 2024-11-26 08:53:05 +00:00
Set OpenID Connect Expiry
This commit adds a default OpenID Connect expiry to 180d to align with Tailscale SaaS (previously infinite or based on token expiry). In addition, it adds an option use the expiry time from the Token sent by the OpenID provider. This will typically cause really short expiry and you should only turn on this option if you know what you are desiring. This fixes #1176. Co-authored-by: Even Holthe <even.holthe@bekk.no> Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
parent
385fd93e73
commit
da48cf64b3
10 changed files with 88 additions and 22 deletions
|
@ -2,7 +2,7 @@
|
||||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
name: Integration Test v2 - TestOIDCExpireNodes
|
name: Integration Test v2 - TestOIDCExpireNodesBasedOnTokenExpiry
|
||||||
|
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ jobs:
|
||||||
-failfast \
|
-failfast \
|
||||||
-timeout 120m \
|
-timeout 120m \
|
||||||
-parallel 1 \
|
-parallel 1 \
|
||||||
-run "^TestOIDCExpireNodes$"
|
-run "^TestOIDCExpireNodesBasedOnTokenExpiry$"
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
|
@ -6,6 +6,9 @@
|
||||||
|
|
||||||
- Fix wrong behaviour in exit nodes [#1159](https://github.com/juanfont/headscale/pull/1159)
|
- Fix wrong behaviour in exit nodes [#1159](https://github.com/juanfont/headscale/pull/1159)
|
||||||
- Align behaviour of `dns_config.restricted_nameservers` to tailscale [#1162](https://github.com/juanfont/headscale/pull/1162)
|
- Align behaviour of `dns_config.restricted_nameservers` to tailscale [#1162](https://github.com/juanfont/headscale/pull/1162)
|
||||||
|
- Make OpenID Connect authenticated client expiry time configurable [#1191](https://github.com/juanfont/headscale/pull/1191)
|
||||||
|
- defaults to 180 days like Tailscale SaaS
|
||||||
|
- adds option to use the expiry time from the OpenID token for the node (see config-example.yaml)
|
||||||
|
|
||||||
## 0.19.0 (2023-01-29)
|
## 0.19.0 (2023-01-29)
|
||||||
|
|
||||||
|
|
|
@ -90,7 +90,7 @@ func main() {
|
||||||
"TestHeadscale",
|
"TestHeadscale",
|
||||||
"TestUserCommand",
|
"TestUserCommand",
|
||||||
"TestOIDCAuthenticationPingAll",
|
"TestOIDCAuthenticationPingAll",
|
||||||
"TestOIDCExpireNodes",
|
"TestOIDCExpireNodesBasedOnTokenExpiry",
|
||||||
"TestPingAllByHostname",
|
"TestPingAllByHostname",
|
||||||
"TestPingAllByIP",
|
"TestPingAllByIP",
|
||||||
"TestPreAuthKeyCommand",
|
"TestPreAuthKeyCommand",
|
||||||
|
|
|
@ -282,27 +282,38 @@ unix_socket_permission: "0770"
|
||||||
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||||
# # client_secret and client_secret_path are mutually exclusive.
|
# # 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
|
# # The amount of time from a node is authenticated with OpenID until it
|
||||||
# parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
|
# # 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.
|
||||||
|
# # 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".
|
||||||
#
|
#
|
||||||
# scope: ["openid", "profile", "email", "custom"]
|
# scope: ["openid", "profile", "email", "custom"]
|
||||||
# extra_params:
|
# extra_params:
|
||||||
# domain_hint: example.com
|
# domain_hint: example.com
|
||||||
#
|
#
|
||||||
# List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
||||||
# authentication request will be rejected.
|
# # authentication request will be rejected.
|
||||||
#
|
#
|
||||||
# allowed_domains:
|
# allowed_domains:
|
||||||
# - example.com
|
# - example.com
|
||||||
# Groups from keycloak have a leading '/'
|
# # Note: Groups from keycloak have a leading '/'
|
||||||
# allowed_groups:
|
# allowed_groups:
|
||||||
# - /headscale
|
# - /headscale
|
||||||
# allowed_users:
|
# allowed_users:
|
||||||
# - alice@example.com
|
# - alice@example.com
|
||||||
#
|
#
|
||||||
# If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
|
# # 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`
|
# # 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
|
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
||||||
# user: `first-name.last-name.example.com`
|
# user: `first-name.last-name.example.com`
|
||||||
#
|
#
|
||||||
# strip_email_domain: true
|
# strip_email_domain: true
|
||||||
|
|
28
config.go
28
config.go
|
@ -11,6 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
@ -25,9 +26,14 @@ const (
|
||||||
|
|
||||||
JSONLogFormat = "json"
|
JSONLogFormat = "json"
|
||||||
TextLogFormat = "text"
|
TextLogFormat = "text"
|
||||||
|
|
||||||
|
defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days
|
||||||
|
maxDuration time.Duration = 1<<63 - 1
|
||||||
)
|
)
|
||||||
|
|
||||||
var errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
|
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 {
|
||||||
|
@ -101,6 +107,8 @@ type OIDCConfig struct {
|
||||||
AllowedUsers []string
|
AllowedUsers []string
|
||||||
AllowedGroups []string
|
AllowedGroups []string
|
||||||
StripEmaildomain bool
|
StripEmaildomain bool
|
||||||
|
Expiry time.Duration
|
||||||
|
UseExpiryFromToken bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type DERPConfig struct {
|
type DERPConfig struct {
|
||||||
|
@ -180,6 +188,8 @@ func LoadConfig(path string, isFile bool) error {
|
||||||
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
|
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
|
||||||
viper.SetDefault("oidc.strip_email_domain", true)
|
viper.SetDefault("oidc.strip_email_domain", true)
|
||||||
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
|
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
|
||||||
|
viper.SetDefault("oidc.expiry", "180d")
|
||||||
|
viper.SetDefault("oidc.use_expiry_from_token", false)
|
||||||
|
|
||||||
viper.SetDefault("logtail.enabled", false)
|
viper.SetDefault("logtail.enabled", false)
|
||||||
viper.SetDefault("randomize_client_port", false)
|
viper.SetDefault("randomize_client_port", false)
|
||||||
|
@ -601,6 +611,22 @@ func GetHeadscaleConfig() (*Config, error) {
|
||||||
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
||||||
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
||||||
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
||||||
|
Expiry: func() time.Duration {
|
||||||
|
// if set to 0, we assume no expiry
|
||||||
|
if value := viper.GetString("oidc.expiry"); value == "0" {
|
||||||
|
return maxDuration
|
||||||
|
} else {
|
||||||
|
expiry, err := model.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Msg("failed to parse oidc.expiry, defaulting back to 180 days")
|
||||||
|
|
||||||
|
return defaultOIDCExpiryTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(expiry)
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
|
||||||
},
|
},
|
||||||
|
|
||||||
LogTail: logConfig,
|
LogTail: logConfig,
|
||||||
|
|
|
@ -100,7 +100,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOIDCExpireNodes(t *testing.T) {
|
func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
|
||||||
IntegrationSkip(t)
|
IntegrationSkip(t)
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
@ -125,10 +125,11 @@ func TestOIDCExpireNodes(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,
|
"HEADSCALE_OIDC_CLIENT_SECRET": oidcConfig.ClientSecret,
|
||||||
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain),
|
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": fmt.Sprintf("%t", oidcConfig.StripEmaildomain),
|
||||||
|
"HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
err = scenario.CreateHeadscaleEnv(
|
err = scenario.CreateHeadscaleEnv(
|
||||||
|
@ -278,7 +279,10 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration) (*headscale.OIDC
|
||||||
log.Printf("headscale mock oidc is ready for tests at %s", hostEndpoint)
|
log.Printf("headscale mock oidc is ready for tests at %s", hostEndpoint)
|
||||||
|
|
||||||
return &headscale.OIDCConfig{
|
return &headscale.OIDCConfig{
|
||||||
Issuer: fmt.Sprintf("http://%s/oidc", net.JoinHostPort(s.mockOIDC.GetIPInNetwork(s.network), strconv.Itoa(port))),
|
Issuer: fmt.Sprintf(
|
||||||
|
"http://%s/oidc",
|
||||||
|
net.JoinHostPort(s.mockOIDC.GetIPInNetwork(s.network), strconv.Itoa(port)),
|
||||||
|
),
|
||||||
ClientID: "superclient",
|
ClientID: "superclient",
|
||||||
ClientSecret: "supersecret",
|
ClientSecret: "supersecret",
|
||||||
StripEmaildomain: true,
|
StripEmaildomain: true,
|
||||||
|
|
|
@ -37,12 +37,14 @@ logtail:
|
||||||
enabled: false
|
enabled: false
|
||||||
metrics_listen_addr: 127.0.0.1:19090
|
metrics_listen_addr: 127.0.0.1:19090
|
||||||
oidc:
|
oidc:
|
||||||
|
expiry: 180d
|
||||||
only_start_if_oidc_is_available: true
|
only_start_if_oidc_is_available: true
|
||||||
scope:
|
scope:
|
||||||
- openid
|
- openid
|
||||||
- profile
|
- profile
|
||||||
- email
|
- email
|
||||||
strip_email_domain: true
|
strip_email_domain: true
|
||||||
|
use_expiry_from_token: false
|
||||||
private_key_path: private.key
|
private_key_path: private.key
|
||||||
noise:
|
noise:
|
||||||
private_key_path: noise_private.key
|
private_key_path: noise_private.key
|
||||||
|
|
|
@ -36,12 +36,14 @@ logtail:
|
||||||
enabled: false
|
enabled: false
|
||||||
metrics_listen_addr: 127.0.0.1:19090
|
metrics_listen_addr: 127.0.0.1:19090
|
||||||
oidc:
|
oidc:
|
||||||
|
expiry: 180d
|
||||||
only_start_if_oidc_is_available: true
|
only_start_if_oidc_is_available: true
|
||||||
scope:
|
scope:
|
||||||
- openid
|
- openid
|
||||||
- profile
|
- profile
|
||||||
- email
|
- email
|
||||||
strip_email_domain: true
|
strip_email_domain: true
|
||||||
|
use_expiry_from_token: false
|
||||||
private_key_path: private.key
|
private_key_path: private.key
|
||||||
noise:
|
noise:
|
||||||
private_key_path: noise_private.key
|
private_key_path: noise_private.key
|
||||||
|
|
|
@ -37,12 +37,14 @@ logtail:
|
||||||
enabled: false
|
enabled: false
|
||||||
metrics_listen_addr: 127.0.0.1:9090
|
metrics_listen_addr: 127.0.0.1:9090
|
||||||
oidc:
|
oidc:
|
||||||
|
expiry: 180d
|
||||||
only_start_if_oidc_is_available: true
|
only_start_if_oidc_is_available: true
|
||||||
scope:
|
scope:
|
||||||
- openid
|
- openid
|
||||||
- profile
|
- profile
|
||||||
- email
|
- email
|
||||||
strip_email_domain: true
|
strip_email_domain: true
|
||||||
|
use_expiry_from_token: false
|
||||||
private_key_path: private.key
|
private_key_path: private.key
|
||||||
noise:
|
noise:
|
||||||
private_key_path: noise_private.key
|
private_key_path: noise_private.key
|
||||||
|
|
24
oidc.go
24
oidc.go
|
@ -27,8 +27,10 @@ const (
|
||||||
errOIDCAllowedDomains = Error("authenticated principal does not match any allowed domain")
|
errOIDCAllowedDomains = Error("authenticated principal does not match any allowed domain")
|
||||||
errOIDCAllowedGroups = Error("authenticated principal is not in any allowed group")
|
errOIDCAllowedGroups = Error("authenticated principal is not in any allowed group")
|
||||||
errOIDCAllowedUsers = Error("authenticated principal does not match any allowed user")
|
errOIDCAllowedUsers = Error("authenticated principal does not match any allowed user")
|
||||||
errOIDCInvalidMachineState = Error("requested machine state key expired before authorisation completed")
|
errOIDCInvalidMachineState = Error(
|
||||||
errOIDCNodeKeyMissing = Error("could not get node key from cache")
|
"requested machine state key expired before authorisation completed",
|
||||||
|
)
|
||||||
|
errOIDCNodeKeyMissing = Error("could not get node key from cache")
|
||||||
)
|
)
|
||||||
|
|
||||||
type IDTokenClaims struct {
|
type IDTokenClaims struct {
|
||||||
|
@ -68,6 +70,14 @@ func (h *Headscale) initOIDC() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Headscale) determineTokenExpiration(idTokenExpiration time.Time) time.Time {
|
||||||
|
if h.cfg.OIDC.UseExpiryFromToken {
|
||||||
|
return idTokenExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now().Add(h.cfg.OIDC.Expiry)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterOIDC redirects to the OIDC provider for authentication
|
// RegisterOIDC redirects to the OIDC provider for authentication
|
||||||
// Puts NodeKey in cache so the callback can retrieve it using the oidc state param
|
// Puts NodeKey in cache so the callback can retrieve it using the oidc state param
|
||||||
// Listens in /oidc/register/:nKey.
|
// Listens in /oidc/register/:nKey.
|
||||||
|
@ -193,6 +203,7 @@ func (h *Headscale) OIDCCallback(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idTokenExpiry := h.determineTokenExpiration(idToken.Expiry)
|
||||||
|
|
||||||
// TODO: we can use userinfo at some point to grab additional information about the user (groups membership, etc)
|
// TODO: we can use userinfo at some point to grab additional information about the user (groups membership, etc)
|
||||||
// userInfo, err := oidcProvider.UserInfo(context.Background(), oauth2.StaticTokenSource(oauth2Token))
|
// userInfo, err := oidcProvider.UserInfo(context.Background(), oauth2.StaticTokenSource(oauth2Token))
|
||||||
|
@ -218,7 +229,12 @@ func (h *Headscale) OIDCCallback(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeKey, machineExists, err := h.validateMachineForOIDCCallback(writer, state, claims, idToken.Expiry)
|
nodeKey, machineExists, err := h.validateMachineForOIDCCallback(
|
||||||
|
writer,
|
||||||
|
state,
|
||||||
|
claims,
|
||||||
|
idTokenExpiry,
|
||||||
|
)
|
||||||
if err != nil || machineExists {
|
if err != nil || machineExists {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -236,7 +252,7 @@ func (h *Headscale) OIDCCallback(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.registerMachineForOIDCCallback(writer, user, nodeKey, idToken.Expiry); err != nil {
|
if err := h.registerMachineForOIDCCallback(writer, user, nodeKey, idTokenExpiry); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue