Skip to content

Commit 137b523

Browse files
feat: add refresh-nonce parameter to control nonce behavior on refresh (#590)
Co-authored-by: Jan-Otto Kröpke <[email protected]>
1 parent 348ffca commit 137b523

File tree

11 files changed

+177
-33
lines changed

11 files changed

+177
-33
lines changed

docs/Configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Usage of openvpn-auth-oauth2:
7676
oauth2 issuer (env: CONFIG_OAUTH2_ISSUER)
7777
--oauth2.nonce
7878
If true, a nonce will be defined on the auth URL which is expected inside the token. (env: CONFIG_OAUTH2_NONCE) (default true)
79+
--oauth2.refresh-nonce value
80+
Controls nonce behavior on refresh token requests. Options: auto (try with nonce, retry without on error), empty (always use empty nonce), equal (use same nonce as initial auth). (env: CONFIG_OAUTH2_REFRESH__NONCE) (default auto)
7981
--oauth2.pkce
8082
If true, Proof Key for Code Exchange (PKCE) RFC 7636 is used for token exchange. (env: CONFIG_OAUTH2_PKCE) (default true)
8183
--oauth2.provider string

docs/Non-interactive session refresh.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ References:
6161

6262
- https://openvpn.net/community-resources/reference-manual-for-openvpn-2-6/#server-options
6363

64+
# Troubleshooting
65+
66+
## OIDC Provider Issues with Refresh Tokens
67+
68+
Some OIDC providers may generate new refresh tokens or behave unexpectedly during non-interactive refresh requests. If you experience issues where refresh tokens are invalidated or users need to re-authenticate frequently, you can adjust the nonce behavior using the `oauth2.refresh-nonce` parameter:
69+
70+
- `auto` (default): Try with nonce, retry without nonce on error
71+
- `empty`: Always use empty nonce for refresh requests
72+
- `equal`: Use the same nonce as initial authentication
73+
74+
For providers like Authentik that return empty nonces on refresh (per OIDC spec), use `refresh-nonce: empty` to avoid retry logic that could invalidate refresh tokens.
6475

6576
## openvpn-auth-oauth2 config
6677

internal/config/defaults.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,13 @@ var Defaults = Config{
8181
Discovery: types.URL{URL: &url.URL{Scheme: "", Host: ""}},
8282
Token: types.URL{URL: &url.URL{Scheme: "", Host: ""}},
8383
},
84-
Issuer: types.URL{URL: &url.URL{Scheme: "", Host: ""}},
85-
Nonce: true,
86-
PKCE: true,
87-
UserInfo: false,
88-
GroupsClaim: "groups",
89-
Provider: "generic",
84+
Issuer: types.URL{URL: &url.URL{Scheme: "", Host: ""}},
85+
Nonce: true,
86+
RefreshNonce: OAuth2RefreshNonceAuto,
87+
PKCE: true,
88+
UserInfo: false,
89+
GroupsClaim: "groups",
90+
Provider: "generic",
9091
Refresh: OAuth2Refresh{
9192
Expires: time.Hour * 8,
9293
ValidateUser: true,

internal/config/flags.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,15 @@ func (c *Config) flagSetOAuth2(flagSet *flag.FlagSet) {
308308
lookupEnvOrDefault("oauth2.nonce", c.OAuth2.Nonce),
309309
"If true, a nonce will be defined on the auth URL which is expected inside the token.",
310310
)
311+
flagSet.TextVar(
312+
&c.OAuth2.RefreshNonce,
313+
"oauth2.refresh-nonce",
314+
lookupEnvOrDefault("oauth2.refresh-nonce", c.OAuth2.RefreshNonce),
315+
"Controls nonce behavior on refresh token requests. "+
316+
"Options: auto (try with nonce, retry without on error), "+
317+
"empty (always use empty nonce), "+
318+
"equal (use same nonce as initial auth).",
319+
)
311320
flagSet.TextVar(
312321
&c.OAuth2.AuthStyle,
313322
"oauth2.auth-style",

internal/config/types.go

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ type Config struct {
2222
HTTP HTTP `json:"http" yaml:"http"`
2323
Debug Debug `json:"debug" yaml:"debug"`
2424
Log Log `json:"log" yaml:"log"`
25-
OAuth2 OAuth2 `json:"oauth2" yaml:"oauth2"`
2625
OpenVPN OpenVPN `json:"openvpn" yaml:"openvpn"`
26+
OAuth2 OAuth2 `json:"oauth2" yaml:"oauth2"`
2727
}
2828

2929
type HTTP struct {
@@ -78,19 +78,20 @@ type OpenVPNCommonName struct {
7878
}
7979

8080
type OAuth2 struct {
81-
Endpoints OAuth2Endpoints `json:"endpoint" yaml:"endpoint"`
82-
Issuer types.URL `json:"issuer" yaml:"issuer"`
83-
Client OAuth2Client `json:"client" yaml:"client"`
84-
GroupsClaim string `json:"groups-claim" yaml:"groups-claim"`
85-
AuthorizeParams string `json:"authorize-params" yaml:"authorize-params"`
86-
Provider string `json:"provider" yaml:"provider"`
87-
Scopes types.StringSlice `json:"scopes" yaml:"scopes"`
88-
Validate OAuth2Validate `json:"validate" yaml:"validate"`
89-
Refresh OAuth2Refresh `json:"refresh" yaml:"refresh"`
90-
AuthStyle OAuth2AuthStyle `json:"auth-style" yaml:"auth-style"`
91-
Nonce bool `json:"nonce" yaml:"nonce"`
92-
PKCE bool `json:"pkce" yaml:"pkce"`
93-
UserInfo bool `json:"user-info" yaml:"user-info"`
81+
Endpoints OAuth2Endpoints `json:"endpoint" yaml:"endpoint"`
82+
Issuer types.URL `json:"issuer" yaml:"issuer"`
83+
Client OAuth2Client `json:"client" yaml:"client"`
84+
GroupsClaim string `json:"groups-claim" yaml:"groups-claim"`
85+
AuthorizeParams string `json:"authorize-params" yaml:"authorize-params"`
86+
Provider string `json:"provider" yaml:"provider"`
87+
Scopes types.StringSlice `json:"scopes" yaml:"scopes"`
88+
Validate OAuth2Validate `json:"validate" yaml:"validate"`
89+
Refresh OAuth2Refresh `json:"refresh" yaml:"refresh"`
90+
AuthStyle OAuth2AuthStyle `json:"auth-style" yaml:"auth-style"`
91+
RefreshNonce OAuth2RefreshNonce `json:"refresh-nonce" yaml:"refresh-nonce"`
92+
Nonce bool `json:"nonce" yaml:"nonce"`
93+
PKCE bool `json:"pkce" yaml:"pkce"`
94+
UserInfo bool `json:"user-info" yaml:"user-info"`
9495
}
9596

9697
type OAuth2Client struct {
@@ -243,6 +244,61 @@ func (s *OAuth2AuthStyle) UnmarshalText(text []byte) error {
243244
return nil
244245
}
245246

247+
type OAuth2RefreshNonce int
248+
249+
const (
250+
OAuth2RefreshNonceAuto OAuth2RefreshNonce = iota
251+
OAuth2RefreshNonceEmpty
252+
OAuth2RefreshNonceEqual
253+
)
254+
255+
// String returns the string representation of the refresh nonce mode.
256+
//
257+
//goland:noinspection GoMixedReceiverTypes
258+
func (s OAuth2RefreshNonce) String() string {
259+
text, err := s.MarshalText()
260+
if err != nil {
261+
panic(err)
262+
}
263+
264+
return string(text)
265+
}
266+
267+
// MarshalText implements the [encoding.TextMarshaler] interface.
268+
//
269+
//goland:noinspection GoMixedReceiverTypes
270+
func (s OAuth2RefreshNonce) MarshalText() ([]byte, error) {
271+
switch s {
272+
case OAuth2RefreshNonceAuto:
273+
return []byte("auto"), nil
274+
case OAuth2RefreshNonceEmpty:
275+
return []byte("empty"), nil
276+
case OAuth2RefreshNonceEqual:
277+
return []byte("equal"), nil
278+
default:
279+
return nil, fmt.Errorf("unknown refresh-nonce %d", s)
280+
}
281+
}
282+
283+
// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
284+
//
285+
//goland:noinspection GoMixedReceiverTypes
286+
func (s *OAuth2RefreshNonce) UnmarshalText(text []byte) error {
287+
config := strings.ToLower(string(text))
288+
switch config {
289+
case "auto":
290+
*s = OAuth2RefreshNonceAuto
291+
case "empty":
292+
*s = OAuth2RefreshNonceEmpty
293+
case "equal":
294+
*s = OAuth2RefreshNonceEqual
295+
default:
296+
return fmt.Errorf("invalid value %s", config)
297+
}
298+
299+
return nil
300+
}
301+
246302
//goland:noinspection GoMixedReceiverTypes
247303
func (c Config) String() string {
248304
jsonString, err := json.Marshal(c)

internal/config/types_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,53 @@ func TestOAuth2AuthStyleGetAuthStyle(t *testing.T) {
112112
oAuth2AuthStyle = config.OAuth2AuthStyle(oauth2.AuthStyleAutoDetect).AuthStyle()
113113
assert.Equal(t, oauth2.AuthStyleAutoDetect, oAuth2AuthStyle)
114114
}
115+
116+
func TestOAuth2RefreshNonceUnmarshalText(t *testing.T) {
117+
t.Parallel()
118+
119+
var refreshNonce config.OAuth2RefreshNonce
120+
121+
require.NoError(t, refreshNonce.UnmarshalText([]byte("auto")))
122+
assert.Equal(t, config.OAuth2RefreshNonceAuto, refreshNonce)
123+
124+
require.NoError(t, refreshNonce.UnmarshalText([]byte("empty")))
125+
assert.Equal(t, config.OAuth2RefreshNonceEmpty, refreshNonce)
126+
127+
require.NoError(t, refreshNonce.UnmarshalText([]byte("equal")))
128+
assert.Equal(t, config.OAuth2RefreshNonceEqual, refreshNonce)
129+
130+
require.Error(t, refreshNonce.UnmarshalText([]byte("unknown")))
131+
}
132+
133+
func TestOAuth2RefreshNonceMarshalText(t *testing.T) {
134+
t.Parallel()
135+
136+
refreshNonce, err := config.OAuth2RefreshNonceAuto.MarshalText()
137+
138+
require.NoError(t, err)
139+
assert.Equal(t, []byte("auto"), refreshNonce)
140+
141+
refreshNonce, err = config.OAuth2RefreshNonceEmpty.MarshalText()
142+
143+
require.NoError(t, err)
144+
assert.Equal(t, []byte("empty"), refreshNonce)
145+
146+
refreshNonce, err = config.OAuth2RefreshNonceEqual.MarshalText()
147+
148+
require.NoError(t, err)
149+
assert.Equal(t, []byte("equal"), refreshNonce)
150+
151+
_, err = config.OAuth2RefreshNonce(-1).MarshalText()
152+
153+
require.Error(t, err)
154+
}
155+
156+
func TestOAuth2RefreshNonceString(t *testing.T) {
157+
t.Parallel()
158+
159+
assert.Equal(t, "auto", config.OAuth2RefreshNonceAuto.String())
160+
assert.Equal(t, "empty", config.OAuth2RefreshNonceEmpty.String())
161+
assert.Equal(t, "equal", config.OAuth2RefreshNonceEqual.String())
162+
163+
assert.Panics(t, func() { _ = config.OAuth2RefreshNonce(-1).String() }, "The code did not panic")
164+
}

internal/oauth2/providers/generic/oidc.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88

9+
"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
910
"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
1011
"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/idtoken"
1112
"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/types"
@@ -30,14 +31,28 @@ func (p Provider) GetRefreshToken(tokens idtoken.IDToken) (string, error) {
3031
func (p Provider) Refresh(ctx context.Context, logger *slog.Logger, relyingParty rp.RelyingParty, refreshToken string) (idtoken.IDToken, error) {
3132
ctx = logging.ToContext(ctx, logger)
3233

34+
// Apply refresh nonce control based on configuration
35+
switch p.Conf.OAuth2.RefreshNonce {
36+
case config.OAuth2RefreshNonceEmpty:
37+
// Always use empty nonce for refresh requests
38+
ctx = context.WithValue(ctx, types.CtxNonce{}, "")
39+
case config.OAuth2RefreshNonceEqual:
40+
// Use the same nonce as initial authentication (default behavior)
41+
// No additional action needed - relies on the nonce set by calling code
42+
case config.OAuth2RefreshNonceAuto:
43+
// Fallback to original behavior: try with nonce, retry without on error
44+
}
45+
3346
tokens, err := rp.RefreshTokens[*idtoken.Claims](ctx, relyingParty, refreshToken, "", "")
34-
// OIDC spec says that nonce is optional for refresh tokens
35-
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens
36-
// This means that we have to retry the refresh without a nonce if we get an error,
37-
// However, trying to refresh session with the same refresh token could lead into an error
38-
// because refresh token may have a one time use policy
39-
// see: https://github.com/zitadel/oidc/issues/509
40-
if errors.Is(err, oidc.ErrNonceInvalid) {
47+
48+
// Only retry for auto mode when we get a nonce error
49+
if p.Conf.OAuth2.RefreshNonce == config.OAuth2RefreshNonceAuto && errors.Is(err, oidc.ErrNonceInvalid) {
50+
// OIDC spec says that nonce is optional for refresh tokens
51+
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens
52+
// This means that we have to retry the refresh without a nonce if we get an error,
53+
// However, trying to refresh session with the same refresh token could lead into an error
54+
// because refresh token may have a one time use policy
55+
// see: https://github.com/zitadel/oidc/issues/509
4156
ctx = context.WithValue(ctx, types.CtxNonce{}, "")
4257
tokens, err = rp.RefreshTokens[*idtoken.Claims](ctx, relyingParty, refreshToken, "", "")
4358
}

internal/oauth2/types/errors.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,4 @@ import (
44
"errors"
55
)
66

7-
var (
8-
ErrInvalidClaimType = errors.New("invalid claim type")
9-
)
7+
var ErrInvalidClaimType = errors.New("invalid claim type")

internal/utils/testutils/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,8 @@ func SetupMockEnvironment(ctx context.Context, tb testing.TB, conf config.Config
285285
}
286286

287287
conf.OAuth2.Issuer = resourceServerURL
288-
conf.OAuth2.Nonce = false // not supported by the mock
288+
conf.OAuth2.Nonce = true // enable nonce for mock testing
289+
conf.OAuth2.RefreshNonce = config.OAuth2RefreshNonceEmpty // use empty nonce for refresh to avoid mock issues
289290

290291
if conf.OAuth2.Client.ID == "" {
291292
conf.OAuth2.Client.ID = clientCredentials.ID

packaging/etc/openvpn-auth-oauth2/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
# ipaddr: false
5151
# issuer: true
5252
# nonce: true
53+
# refresh-nonce: "auto" # Options: auto (try with nonce, retry without on error), empty (always use empty nonce for refresh), equal (use same nonce as initial auth)
5354
# pkce: true
5455
# refresh:
5556
# enabled: false

0 commit comments

Comments
 (0)