OIDC configure expiry time #419

Closed
opened 2025-12-29 01:28:39 +01:00 by adam · 13 comments
Owner

Originally created by @charlez-700 on GitHub (Jan 27, 2023).

Hi,
I run Headscale 0.17 and planning to upgrade to 0.18. on my test server running 0.18 I see that the oidc users have a default expiration time of 60 minutes.

how do I configure the expiration time for oidc users to make it longer?

thanks.

Originally created by @charlez-700 on GitHub (Jan 27, 2023). Hi, I run Headscale 0.17 and planning to upgrade to 0.18. on my test server running 0.18 I see that the oidc users have a default expiration time of 60 minutes. how do I configure the expiration time for oidc users to make it longer? thanks.
adam added the bug label 2025-12-29 01:28:39 +01:00
adam closed this issue 2025-12-29 01:28:39 +01:00
Author
Owner

@evenh commented on GitHub (Jan 28, 2023):

The implementation uses whatever your OIDC provider puts in the exp-field of the issued token.

It may indicate that your provider has a default token lifetime of 1hr.

@evenh commented on GitHub (Jan 28, 2023): The implementation uses whatever your OIDC provider puts in the exp-field of the issued token. It may indicate that your provider has a default token lifetime of 1hr.
Author
Owner

@ported-pw commented on GitHub (Jan 28, 2023):

I just came across this while wondering why my node was getting such a short expiry time or expiring at all for that matter.
I take it that OIDC token refresh is not implemented yet? Are there any plans for doing that?
For example Keycloak uses very short lived tokens (5 minutes by default) and prefers to let users refresh often to ensure that they have up to date tokens/credentials. This way I'd have to make it somewhat less secure and configure the access token lifetime to something usable like an entire week or month which doesn't sound great.

@ported-pw commented on GitHub (Jan 28, 2023): I just came across this while wondering why my node was getting such a short expiry time or expiring at all for that matter. I take it that OIDC token refresh is not implemented yet? Are there any plans for doing that? For example Keycloak uses very short lived tokens (5 minutes by default) and prefers to let users refresh often to ensure that they have up to date tokens/credentials. This way I'd have to make it somewhat less secure and configure the access token lifetime to something usable like an entire week or month which doesn't sound great.
Author
Owner

@charlez-700 commented on GitHub (Jan 28, 2023):

Hhhmmmm that may be a problem then because all my OIDC clients will disconnect once an hour.

I take it that OIDC token refresh is not implemented yet?

I think the same yes.

@charlez-700 commented on GitHub (Jan 28, 2023): Hhhmmmm that may be a problem then because all my OIDC clients will disconnect once an hour. > I take it that OIDC token refresh is not implemented yet? I think the same yes.
Author
Owner

@ported-pw commented on GitHub (Jan 28, 2023):

Maybe it could be made possible to disable expiring nodes on token expiry for now until the OIDC implementation is complete? See https://github.com/juanfont/headscale/pull/1067

@ported-pw commented on GitHub (Jan 28, 2023): Maybe it could be made possible to disable expiring nodes on token expiry for now until the OIDC implementation is complete? See https://github.com/juanfont/headscale/pull/1067
Author
Owner

@reynico commented on GitHub (Jan 28, 2023):

Maybe it could be made possible to disable expiring nodes on token expiry for now until the OIDC implementation is complete? See #1067

maybe passing a default expiry time by configuration?

@reynico commented on GitHub (Jan 28, 2023): > Maybe it could be made possible to disable expiring nodes on token expiry for now until the OIDC implementation is complete? See #1067 maybe passing a default expiry time by configuration?
Author
Owner

@evenh commented on GitHub (Jan 29, 2023):

I just came across this while wondering why my node was getting such a short expiry time or expiring at all for that matter. I take it that OIDC token refresh is not implemented yet? Are there any plans for doing that? For example Keycloak uses very short lived tokens (5 minutes by default) and prefers to let users refresh often to ensure that they have up to date tokens/credentials. This way I'd have to make it somewhat less secure and configure the access token lifetime to something usable like an entire week or month which doesn't sound great.

Only the ID token from OIDC is used per right now. Related code and discussions:

Looking forward, there is a couple of obvious things that could be done:

  • Add a configuration option/environment variable that overrides whatever expiry comes from OIDC (or possibly when running without OIDC). HS_OVERRIDE_DEFAULT_EXPIRY_TIME where 0=never otherwise parse the given duration.
  • Determine what the behaviour is for the commercial Tailscale control plane and implement a similar flow. Are they using access/refresh tokens?

What would be the preferred approach to handling this @kradalby?

@evenh commented on GitHub (Jan 29, 2023): > I just came across this while wondering why my node was getting such a short expiry time or expiring at all for that matter. I take it that OIDC token refresh is not implemented yet? Are there any plans for doing that? For example Keycloak uses very short lived tokens (5 minutes by default) and prefers to let users refresh often to ensure that they have up to date tokens/credentials. This way I'd have to make it somewhat less secure and configure the access token lifetime to something usable like an entire week or month which doesn't sound great. Only the ID token from OIDC is used per right now. Related code and discussions: - https://github.com/juanfont/headscale/pull/126 - https://github.com/juanfont/headscale/issues/935 - https://github.com/juanfont/headscale/pull/1067 Looking forward, there is a couple of obvious things that could be done: - Add a configuration option/environment variable that overrides whatever expiry comes from OIDC (or possibly when running without OIDC). `HS_OVERRIDE_DEFAULT_EXPIRY_TIME` where `0=never` otherwise parse the given duration. - Determine what the behaviour is for the commercial Tailscale control plane and implement a similar flow. Are they using access/refresh tokens? What would be the preferred approach to handling this @kradalby?
Author
Owner

@jsiebens commented on GitHub (Jan 29, 2023):

As OIDC tokens tend to be short-lived (with google it's 1h, with others perhaps even shorter), I wonder why machines should follow the same expiry.

On the commercial Tailscale server, newly registered machines get a fixed expiration period (default 180 days but can be configured on your tailnet using the admin console) unless they have ACL tags, then key expiry is disabled.

@jsiebens commented on GitHub (Jan 29, 2023): As OIDC tokens tend to be short-lived (with google it's 1h, with others perhaps even shorter), I wonder why machines should follow the same expiry. On the commercial Tailscale server, newly registered machines get a fixed expiration period (default 180 days but can be configured on your tailnet using the admin console) unless they have ACL tags, then key expiry is disabled. - https://tailscale.com/kb/1028/key-expiry/ - https://tailscale.com/kb/1068/acl-tags/#key-expiry-for-tagged-devices
Author
Owner

@ported-pw commented on GitHub (Jan 29, 2023):

As OIDC tokens tend to be short-lived (with google it's 1h, with others perhaps even shorter), I wonder why machines should follow the same expiry.

Exactly. As I mentioned, for Keycloak the default is 5 minutes.

It would be the best if

  1. Machines were expired once the OIDC session cannot be refreshed anymore (forces re-login at SSO provider)
    and
  2. Machines were expired if on token refresh the token does not match the required allowed groups/domains/etc. anymore (this would allow expiring machines within [access token expiry time] of removing a user's permissions for example)

But if the original Tailscale behaviour is 180 days or a different configurable value on registration via OIDC, that would be fine too and I'd assume much easier to implement. But right now due to security concerns with creating such long-lived access tokens (as they could be valid on other services by configuration error for example) the feature is not really usable for me.

@ported-pw commented on GitHub (Jan 29, 2023): > As OIDC tokens tend to be short-lived (with google it's 1h, with others perhaps even shorter), I wonder why machines should follow the same expiry. Exactly. As I mentioned, for Keycloak the default is 5 minutes. It would be the best if 1. Machines were expired once the OIDC session cannot be refreshed anymore (forces re-login at SSO provider) and 2. Machines were expired if on token refresh the token does not match the required allowed groups/domains/etc. anymore (this would allow expiring machines within [access token expiry time] of removing a user's permissions for example) But if the original Tailscale behaviour is 180 days or a different configurable value on registration via OIDC, that would be fine too and I'd assume much easier to implement. But right now due to security concerns with creating such long-lived access tokens (as they could be valid on other services by configuration error for example) the feature is not really usable for me.
Author
Owner

@Hacksawfred3232 commented on GitHub (Jan 30, 2023):

The implementation uses whatever your OIDC provider puts in the exp-field of the issued token.

It may indicate that your provider has a default token lifetime of 1hr.

Following @evenh comment, trying to increase the token expiry time in Keycloak - which is what I use for my OIDC provider - it seems to only max out at 10 hours when Headscale updates a client. Which seems really random, so either a bug with Keycloak/OIDC implementation or built-in limitation. Though while the token time is fair in terms of short-lived tokens, some users may need it at max a month without making the OIDC provider insecure. Implementing HS_OVERRIDE_DEFAULT_EXPIRY_TIME sounds like a good idea.

@Hacksawfred3232 commented on GitHub (Jan 30, 2023): > The implementation uses whatever your OIDC provider puts in the exp-field of the issued token. > > It may indicate that your provider has a default token lifetime of 1hr. Following @evenh comment, trying to increase the token expiry time in Keycloak - which is what I use for my OIDC provider - it seems to only max out at 10 hours when Headscale updates a client. Which seems really random, so either a bug with Keycloak/OIDC implementation or built-in limitation. Though while the token time is fair in terms of short-lived tokens, some users may need it at max a month without making the OIDC provider insecure. Implementing `HS_OVERRIDE_DEFAULT_EXPIRY_TIME` sounds like a good idea.
Author
Owner

@ported-pw commented on GitHub (Jan 30, 2023):

Implementing HS_OVERRIDE_DEFAULT_EXPIRY_TIME sounds like a good idea.

I agree that it's an easy and acceptable stopgap solution.

@ported-pw commented on GitHub (Jan 30, 2023): > Implementing HS_OVERRIDE_DEFAULT_EXPIRY_TIME sounds like a good idea. I agree that it's an easy and acceptable stopgap solution.
Author
Owner

@evenh commented on GitHub (Jan 30, 2023):

I won't have time to tackle the integration tests anytime soon, but could something like this work?

From 28d8dd3d32003b826febcc2699c0706f71258b2e Mon Sep 17 00:00:00 2001
From: Even Holthe <even.holthe@bekk.no>
Date: Mon, 30 Jan 2023 16:05:56 +0100
Subject: [PATCH] Add option for overriding OIDC ID Token expiry

Closes #1176
---
 config.go | 13 +++++++++++++
 oidc.go   | 16 ++++++++++++++--
 2 files changed, 27 insertions(+), 2 deletions(-)

diff --git a/config.go b/config.go
index 6865b30..f23e827 100644
--- a/config.go
+++ b/config.go
@@ -101,6 +101,7 @@ type OIDCConfig struct {
 	AllowedUsers               []string
 	AllowedGroups              []string
 	StripEmaildomain           bool
+	OverrideExpiry             *time.Duration
 }
 
 type DERPConfig struct {
@@ -180,6 +181,7 @@ func LoadConfig(path string, isFile bool) error {
 	viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
 	viper.SetDefault("oidc.strip_email_domain", true)
 	viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
+	viper.SetDefault("oidc.override_expiry", "")
 
 	viper.SetDefault("logtail.enabled", false)
 	viper.SetDefault("randomize_client_port", false)
@@ -603,6 +605,17 @@ func GetHeadscaleConfig() (*Config, error) {
 			AllowedUsers:     viper.GetStringSlice("oidc.allowed_users"),
 			AllowedGroups:    viper.GetStringSlice("oidc.allowed_groups"),
 			StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
+			OverrideExpiry: func() *time.Duration {
+				// if empty, no override set
+				if e := viper.GetString("oidc.override_expiry"); len(e) == 0 {
+					return nil
+				}
+
+				// return parsed override expiry
+				e := viper.GetDuration("oidc.override_expiry")
+
+				return &e
+			}(),
 		},
 
 		LogTail:             logConfig,
diff --git a/oidc.go b/oidc.go
index 4909ba9..4961041 100644
--- a/oidc.go
+++ b/oidc.go
@@ -68,6 +68,17 @@ func (h *Headscale) initOIDC() error {
 	return nil
 }
 
+func (h *Headscale) determineTokenExpiration(originalExpiration time.Time) time.Time {
+	overriddenExpirationDuration := h.cfg.OIDC.OverrideExpiry
+	// don't modify expiry
+	if overriddenExpirationDuration == nil {
+		return originalExpiration
+	}
+
+	// calculate a new expiry based on override
+	return time.Now().Add(*overriddenExpirationDuration)
+}
+
 // RegisterOIDC redirects to the OIDC provider for authentication
 // Puts NodeKey in cache so the callback can retrieve it using the oidc state param
 // Listens in /oidc/register/:nKey.
@@ -193,6 +204,7 @@ func (h *Headscale) OIDCCallback(
 	if err != nil {
 		return
 	}
+	idTokenExpiry := h.determineTokenExpiration(idToken.Expiry)
 
 	// 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))
@@ -218,7 +230,7 @@ func (h *Headscale) OIDCCallback(
 		return
 	}
 
-	nodeKey, machineExists, err := h.validateMachineForOIDCCallback(writer, state, claims, idToken.Expiry)
+	nodeKey, machineExists, err := h.validateMachineForOIDCCallback(writer, state, claims, idTokenExpiry)
 	if err != nil || machineExists {
 		return
 	}
@@ -236,7 +248,7 @@ func (h *Headscale) OIDCCallback(
 		return
 	}
 
-	if err := h.registerMachineForOIDCCallback(writer, user, nodeKey, idToken.Expiry); err != nil {
+	if err := h.registerMachineForOIDCCallback(writer, user, nodeKey, idTokenExpiry); err != nil {
 		return
 	}
 
-- 
2.37.5


@evenh commented on GitHub (Jan 30, 2023): I won't have time to tackle the integration tests anytime soon, but could something like this work? ```patch From 28d8dd3d32003b826febcc2699c0706f71258b2e Mon Sep 17 00:00:00 2001 From: Even Holthe <even.holthe@bekk.no> Date: Mon, 30 Jan 2023 16:05:56 +0100 Subject: [PATCH] Add option for overriding OIDC ID Token expiry Closes #1176 --- config.go | 13 +++++++++++++ oidc.go | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index 6865b30..f23e827 100644 --- a/config.go +++ b/config.go @@ -101,6 +101,7 @@ type OIDCConfig struct { AllowedUsers []string AllowedGroups []string StripEmaildomain bool + OverrideExpiry *time.Duration } type DERPConfig struct { @@ -180,6 +181,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"}) viper.SetDefault("oidc.strip_email_domain", true) viper.SetDefault("oidc.only_start_if_oidc_is_available", true) + viper.SetDefault("oidc.override_expiry", "") viper.SetDefault("logtail.enabled", false) viper.SetDefault("randomize_client_port", false) @@ -603,6 +605,17 @@ func GetHeadscaleConfig() (*Config, error) { AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), StripEmaildomain: viper.GetBool("oidc.strip_email_domain"), + OverrideExpiry: func() *time.Duration { + // if empty, no override set + if e := viper.GetString("oidc.override_expiry"); len(e) == 0 { + return nil + } + + // return parsed override expiry + e := viper.GetDuration("oidc.override_expiry") + + return &e + }(), }, LogTail: logConfig, diff --git a/oidc.go b/oidc.go index 4909ba9..4961041 100644 --- a/oidc.go +++ b/oidc.go @@ -68,6 +68,17 @@ func (h *Headscale) initOIDC() error { return nil } +func (h *Headscale) determineTokenExpiration(originalExpiration time.Time) time.Time { + overriddenExpirationDuration := h.cfg.OIDC.OverrideExpiry + // don't modify expiry + if overriddenExpirationDuration == nil { + return originalExpiration + } + + // calculate a new expiry based on override + return time.Now().Add(*overriddenExpirationDuration) +} + // RegisterOIDC redirects to the OIDC provider for authentication // Puts NodeKey in cache so the callback can retrieve it using the oidc state param // Listens in /oidc/register/:nKey. @@ -193,6 +204,7 @@ func (h *Headscale) OIDCCallback( if err != nil { return } + idTokenExpiry := h.determineTokenExpiration(idToken.Expiry) // 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)) @@ -218,7 +230,7 @@ func (h *Headscale) OIDCCallback( return } - nodeKey, machineExists, err := h.validateMachineForOIDCCallback(writer, state, claims, idToken.Expiry) + nodeKey, machineExists, err := h.validateMachineForOIDCCallback(writer, state, claims, idTokenExpiry) if err != nil || machineExists { return } @@ -236,7 +248,7 @@ func (h *Headscale) OIDCCallback( return } - if err := h.registerMachineForOIDCCallback(writer, user, nodeKey, idToken.Expiry); err != nil { + if err := h.registerMachineForOIDCCallback(writer, user, nodeKey, idTokenExpiry); err != nil { return } -- 2.37.5 ```
Author
Owner

@Hacksawfred3232 commented on GitHub (Jan 30, 2023):

I won't have time to tackle the integration tests anytime soon, but could something like this work?

@evenh That patch seems to work! I'll roll with the patched version until it becomes added officially.

@Hacksawfred3232 commented on GitHub (Jan 30, 2023): > I won't have time to tackle the integration tests anytime soon, but could something like this work? @evenh That patch seems to work! I'll roll with the patched version until it becomes added officially.
Author
Owner

@kradalby commented on GitHub (Jan 31, 2023):

I have created https://github.com/juanfont/headscale/pull/1191 based on @evenh's patch, it takes a slightly different approach:

Instead of adding an option to override the new behaviour (using the token expiry) it makes that an optional behaviour for people who desire it.

Instead of reverting back to setting no expiry for OpenID clients, it will now align with Tailscale SaaS and set 180 days by default, and of course exposing the option to the user so it can be set longer or short as desired.

Please help testing it or voice concerns on the PR.

@kradalby commented on GitHub (Jan 31, 2023): I have created https://github.com/juanfont/headscale/pull/1191 based on @evenh's patch, it takes a slightly different approach: Instead of adding an option to override the new behaviour (using the token expiry) it makes that an optional behaviour for people who desire it. Instead of reverting back to setting no expiry for OpenID clients, it will now align with Tailscale SaaS and set 180 days by default, and of course exposing the option to the user so it can be set longer or short as desired. Please help testing it or voice concerns on the PR.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/headscale#419