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:
Kristoffer Dalby 2023-01-31 12:40:38 +01:00 committed by Kristoffer Dalby
parent 385fd93e73
commit da48cf64b3
10 changed files with 88 additions and 22 deletions

View file

@ -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'

View file

@ -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)

View file

@ -90,7 +90,7 @@ func main() {
"TestHeadscale", "TestHeadscale",
"TestUserCommand", "TestUserCommand",
"TestOIDCAuthenticationPingAll", "TestOIDCAuthenticationPingAll",
"TestOIDCExpireNodes", "TestOIDCExpireNodesBasedOnTokenExpiry",
"TestPingAllByHostname", "TestPingAllByHostname",
"TestPingAllByIP", "TestPingAllByIP",
"TestPreAuthKeyCommand", "TestPreAuthKeyCommand",

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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
} }