Skip to content

Commit 2b77431

Browse files
authored
chore: Additional CEL changes (#742)
1 parent 19b13ea commit 2b77431

File tree

10 files changed

+138
-33
lines changed

10 files changed

+138
-33
lines changed

docs/Client token validation.md

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ openvpn-auth-oauth2 supports advanced token validation using the [Common Express
44

55
## Overview
66

7-
CEL validation provides a flexible way to enforce security policies by allowing you to write custom expressions that evaluate to `true` or `false`. This validation happens after the OAuth2 authentication flow completes but before the OpenVPN connection is established.
7+
CEL validation provides a flexible way to enforce security policies by allowing you to write custom expressions that evaluate to `true` or `false`. This validation happens:
8+
9+
1. **During interactive authentication** - After the OAuth2 authentication flow completes but before the OpenVPN connection is established
10+
2. **During token refresh** - When an existing OpenVPN session is refreshed using a refresh token (non-interactive authentication)
11+
12+
This ensures that access policies are continuously enforced throughout the lifecycle of the VPN connection, not just during initial authentication.
813

914
## Configuration
1015

@@ -15,23 +20,28 @@ To enable CEL validation, configure the `oauth2.validate.cel` property in your c
1520
```yaml
1621
oauth2:
1722
validate:
18-
cel: 'openvpnUserCommonName == oauth2TokenClaims.preferred_username'
23+
cel: 'openVPNUserCommonName == oauth2TokenClaims.preferred_username'
1924
```
2025
2126
### Environment Variable
2227
2328
```bash
24-
CONFIG_OAUTH2_VALIDATE_VALIDATION__CEL='openvpnUserCommonName == oauth2TokenClaims.preferred_username'
29+
CONFIG_OAUTH2_VALIDATE_VALIDATION__CEL='openVPNUserCommonName == oauth2TokenClaims.preferred_username'
2530
```
2631

32+
> [!IMPORTANT]
33+
> CEL validation is performed **both during initial OAuth2 authentication and during token refresh**. This means your validation rules will be continuously enforced throughout the entire lifecycle of a VPN session. Make sure your expressions account for both scenarios using the `authMode` variable if needed.
34+
2735
## Available Variables
2836

2937
The following variables are available in your CEL expressions:
3038

3139
| Variable | Type | Description |
3240
|----------|------|-------------|
33-
| `openvpnUserCommonName` | `string` | The common name (CN) of the OpenVPN client certificate |
34-
| `openvpnUserIPAddr` | `string` | The IP address of the OpenVPN client |
41+
| `authMode` | `string` | The authentication mode: `"interactive"` (initial OAuth2 login) or `"non-interactive"` (token refresh) |
42+
| `openVPNSessionState` | `string` | The OpenVPN session state (e.g., `""`, `"Empty"`, `"Initial"`, `"Authenticated"`, `"Expired"`, `"Invalid"`, `"AuthenticatedEmptyUser"`, `"ExpiredEmptyUser"`) |
43+
| `openVPNUserCommonName` | `string` | The common name (CN) of the OpenVPN client certificate |
44+
| `openVPNUserIPAddr` | `string` | The IP address of the OpenVPN client |
3545
| `oauth2TokenClaims` | `map<string, dynamic>` | All claims from the OAuth2 ID token |
3646

3747
## Expression Requirements
@@ -53,7 +63,8 @@ oauth2:
5363
oauth2TokenClaims.department == 'engineering'
5464
```
5565
56-
**Important:** If you try to access a claim that doesn't exist without using `has()`, the expression evaluation will fail and the user will be denied access.
66+
> [!IMPORTANT]
67+
> If you try to access a claim that doesn't exist without using `has()`, the expression evaluation will fail, and the user will be denied access.
5768

5869
## Examples
5970

@@ -64,7 +75,7 @@ Ensure the OpenVPN common name matches the OAuth2 username claim:
6475
```yaml
6576
oauth2:
6677
validate:
67-
cel: 'openvpnUserCommonName == oauth2TokenClaims.preferred_username'
78+
cel: 'openVPNUserCommonName == oauth2TokenClaims.preferred_username'
6879
```
6980

7081
### Email Domain Validation
@@ -87,7 +98,7 @@ Combine multiple conditions with logical operators:
8798
oauth2:
8899
validate:
89100
cel: |
90-
openvpnUserCommonName == oauth2TokenClaims.preferred_username &&
101+
openVPNUserCommonName == oauth2TokenClaims.preferred_username &&
91102
has(oauth2TokenClaims.email_verified) &&
92103
oauth2TokenClaims.email_verified == true
93104
```
@@ -111,7 +122,7 @@ Validate that the VPN client IP is in an expected range:
111122
```yaml
112123
oauth2:
113124
validate:
114-
cel: 'openvpnUserIPAddr.startsWith("10.0.") || openvpnUserIPAddr.startsWith("192.168.")'
125+
cel: 'openVPNUserIPAddr.startsWith("10.0.") || openVPNUserIPAddr.startsWith("192.168.")'
115126
```
116127

117128
### Case-Insensitive Username Validation
@@ -122,7 +133,7 @@ Compare usernames in a case-insensitive manner using the `lowerAscii()` function
122133
oauth2:
123134
validate:
124135
cel: |
125-
has(oauth2TokenClaims.preferred_username) && openvpnUserCommonName.lowerAscii() == string(oauth2TokenClaims.preferred_username).lowerAscii()
136+
has(oauth2TokenClaims.preferred_username) && openVPNUserCommonName.lowerAscii() == string(oauth2TokenClaims.preferred_username).lowerAscii()
126137
```
127138

128139
> [!IMPORTANT]
@@ -136,7 +147,7 @@ Combine multiple conditions for sophisticated validation rules:
136147
oauth2:
137148
validate:
138149
cel: |
139-
openvpnUserCommonName == oauth2TokenClaims.sub &&
150+
openVPNUserCommonName == oauth2TokenClaims.sub &&
140151
(
141152
(has(oauth2TokenClaims.role) && oauth2TokenClaims.role == 'admin') ||
142153
(has(oauth2TokenClaims.vpn_access) && oauth2TokenClaims.vpn_access == true)
@@ -153,7 +164,7 @@ oauth2:
153164
validate:
154165
cel: |
155166
has(oauth2TokenClaims.email) &&
156-
string(oauth2TokenClaims.email).split('@')[0] == openvpnUserCommonName
167+
string(oauth2TokenClaims.email).split('@')[0] == openVPNUserCommonName
157168
```
158169

159170
### Username Format Validation
@@ -176,7 +187,7 @@ Ensure usernames meet minimum length requirements:
176187
oauth2:
177188
validate:
178189
cel: |
179-
openvpnUserCommonName.size() >= 3 &&
190+
openVPNUserCommonName.size() >= 3 &&
180191
has(oauth2TokenClaims.preferred_username) &&
181192
string(oauth2TokenClaims.preferred_username).size() >= 3
182193
```
@@ -192,12 +203,48 @@ oauth2:
192203
has(oauth2TokenClaims.email) &&
193204
(
194205
(string(oauth2TokenClaims.email).endsWith('@internal.company.com') &&
195-
openvpnUserIPAddr.startsWith('10.0.')) ||
206+
openVPNUserIPAddr.startsWith('10.0.')) ||
196207
(string(oauth2TokenClaims.email).endsWith('@company.com') &&
197-
openvpnUserIPAddr.startsWith('192.168.'))
208+
openVPNUserIPAddr.startsWith('192.168.'))
198209
)
199210
```
200211

212+
### Authentication Mode Based Validation
213+
214+
Apply different validation rules based on whether this is an initial login or a token refresh:
215+
216+
```yaml
217+
oauth2:
218+
validate:
219+
cel: |
220+
authMode == 'interactive' ||
221+
(authMode == 'non-interactive' && has(oauth2TokenClaims.refresh_allowed) && oauth2TokenClaims.refresh_allowed == true)
222+
```
223+
224+
### Session State Validation
225+
226+
Validate based on the current OpenVPN session state:
227+
228+
```yaml
229+
oauth2:
230+
validate:
231+
cel: |
232+
openVPNSessionState in ['Initial', 'Authenticated', 'AuthenticatedEmptyUser'] &&
233+
openVPNUserCommonName == oauth2TokenClaims.preferred_username
234+
```
235+
236+
### Combined Mode and State Validation
237+
238+
Combine authentication mode and session state for fine-grained control:
239+
240+
```yaml
241+
oauth2:
242+
validate:
243+
cel: |
244+
(authMode == 'interactive' && openVPNSessionState == 'Initial') ||
245+
(authMode == 'non-interactive' && openVPNSessionState == 'Authenticated')
246+
```
247+
201248
## CEL Language Features
202249

203250
CEL supports many standard operations:
@@ -310,10 +357,10 @@ The expression must evaluate to a boolean. If it evaluates to another type (stri
310357

311358
```yaml
312359
# ❌ Bad - evaluates to a string, not a boolean
313-
cel: 'openvpnUserCommonName'
360+
cel: 'openVPNUserCommonName'
314361
---
315362
# ✅ Good - evaluates to a boolean
316-
cel: 'openvpnUserCommonName != ""'
363+
cel: 'openVPNUserCommonName != ""'
317364
```
318365

319366
## Relationship with Other Validation Options

docs/Configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Usage of openvpn-auth-oauth2:
107107
--oauth2.validate.groups value
108108
oauth2 required user groups. If multiple groups are configured, the user needs to be least in one group. Comma separated list. Example: group1,group2,group3 (env: CONFIG_OAUTH2_VALIDATE_GROUPS)
109109
--oauth2.validate.cel string
110-
CEL expression for custom token validation. The expression must evaluate to a boolean value. Available variables: openvpnUserCommonName (string), openvpnUserIPAddr (string), oauth2TokenClaims (map). Example: openvpnUserCommonName == oauth2TokenClaims.preferred_username (env: CONFIG_OAUTH2_VALIDATE_CEL)
110+
CEL expression for custom token validation. The expression must evaluate to a boolean value.
111111
--oauth2.validate.ipaddr
112112
validate client ipaddr between VPN and oidc token (env: CONFIG_OAUTH2_VALIDATE_IPADDR)
113113
--oauth2.validate.issuer

internal/config/flags.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -407,13 +407,13 @@ func (c *Config) flagSetOAuth2(flagSet *flag.FlagSet) {
407407
&c.OAuth2.Validate.IPAddr,
408408
"oauth2.validate.ipaddr",
409409
lookupEnvOrDefault("oauth2.validate.ipaddr", c.OAuth2.Validate.IPAddr),
410-
"validate client ipaddr between VPN and oidc token",
410+
"validate client ipaddr between VPN and OIDC token",
411411
)
412412
flagSet.BoolVar(
413413
&c.OAuth2.Validate.Issuer,
414414
"oauth2.validate.issuer",
415415
lookupEnvOrDefault("oauth2.validate.issuer", c.OAuth2.Validate.Issuer),
416-
"validate issuer from oidc discovery",
416+
"validate issuer from OIDC discovery",
417417
)
418418
flagSet.StringVar(
419419
&c.OAuth2.Validate.CommonName,
@@ -433,8 +433,7 @@ func (c *Config) flagSetOAuth2(flagSet *flag.FlagSet) {
433433
lookupEnvOrDefault("oauth2.validate.cel", c.OAuth2.Validate.CEL),
434434
"CEL expression for custom token validation. "+
435435
"The expression must evaluate to a boolean value. "+
436-
"Available variables: openvpnUserCommonName (string), openvpnUserIPAddr (string), oauth2TokenClaims (map). "+
437-
"Example: openvpnUserCommonName == oauth2TokenClaims.preferred_username",
436+
"Example: openVPNUserCommonName == oauth2TokenClaims.preferred_username",
438437
)
439438
flagSet.TextVar(
440439
&c.OAuth2.Scopes,

internal/oauth2/cel.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ import (
77
"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
88
)
99

10+
type CELAuthMode string
11+
12+
const (
13+
CELAuthModeInteractive CELAuthMode = "interactive"
14+
CELAuthModeNonInteractive CELAuthMode = "non-interactive"
15+
)
16+
1017
// CheckTokenCEL checks the provided ID token claims against the configured CEL expression.
11-
func (c *Client) CheckTokenCEL(session state.State, tokens idtoken.IDToken) error {
18+
func (c *Client) CheckTokenCEL(authMode CELAuthMode, session state.State, tokens idtoken.IDToken) error {
1219
if c.celEvalPrg == nil {
1320
return nil
1421
}
@@ -18,8 +25,10 @@ func (c *Client) CheckTokenCEL(session state.State, tokens idtoken.IDToken) erro
1825
}
1926

2027
vars := map[string]any{
21-
"openvpnUserCommonName": session.Client.CommonName,
22-
"openvpnUserIPAddr": session.IPAddr,
28+
"authMode": string(authMode),
29+
"openVPNSessionState": session.SessionState,
30+
"openVPNUserCommonName": session.Client.CommonName,
31+
"openVPNUserIPAddr": session.IPAddr,
2332
"oauth2TokenClaims": tokens.IDTokenClaims.Claims,
2433
}
2534

internal/oauth2/cel_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func TestCheckTokenCEL(t *testing.T) {
141141
conf.OAuth2.Endpoints.Discovery = conf.OAuth2.Issuer
142142
conf.OAuth2.Endpoints.Auth = conf.OAuth2.Issuer
143143
conf.OAuth2.Endpoints.Token = conf.OAuth2.Issuer
144-
conf.OAuth2.Validate.CEL = "openvpnUserCommonName == oauth2TokenClaims.preferred_username"
144+
conf.OAuth2.Validate.CEL = "openVPNUserCommonName == oauth2TokenClaims.preferred_username"
145145

146146
return conf
147147
}(),
@@ -167,7 +167,7 @@ func TestCheckTokenCEL(t *testing.T) {
167167
conf.OAuth2.Endpoints.Discovery = conf.OAuth2.Issuer
168168
conf.OAuth2.Endpoints.Auth = conf.OAuth2.Issuer
169169
conf.OAuth2.Endpoints.Token = conf.OAuth2.Issuer
170-
conf.OAuth2.Validate.CEL = "openvpnUserCommonName != oauth2TokenClaims.preferred_username"
170+
conf.OAuth2.Validate.CEL = "openVPNUserCommonName != oauth2TokenClaims.preferred_username"
171171

172172
return conf
173173
}(),
@@ -194,7 +194,7 @@ func TestCheckTokenCEL(t *testing.T) {
194194
conf.OAuth2.Endpoints.Discovery = conf.OAuth2.Issuer
195195
conf.OAuth2.Endpoints.Auth = conf.OAuth2.Issuer
196196
conf.OAuth2.Endpoints.Token = conf.OAuth2.Issuer
197-
conf.OAuth2.Validate.CEL = "openvpnUserCommonName"
197+
conf.OAuth2.Validate.CEL = "openVPNUserCommonName"
198198

199199
return conf
200200
}(),
@@ -221,7 +221,7 @@ func TestCheckTokenCEL(t *testing.T) {
221221
conf.OAuth2.Endpoints.Discovery = conf.OAuth2.Issuer
222222
conf.OAuth2.Endpoints.Auth = conf.OAuth2.Issuer
223223
conf.OAuth2.Endpoints.Token = conf.OAuth2.Issuer
224-
conf.OAuth2.Validate.CEL = "openvpnUserCommonName.lowerAscii() == string(oauth2TokenClaims.preferred_username).lowerAscii()"
224+
conf.OAuth2.Validate.CEL = "openVPNUserCommonName.lowerAscii() == string(oauth2TokenClaims.preferred_username).lowerAscii()"
225225

226226
return conf
227227
}(),
@@ -255,7 +255,7 @@ func TestCheckTokenCEL(t *testing.T) {
255255

256256
require.NoError(t, err)
257257

258-
err = oAuth2Client.CheckTokenCEL(tc.state, tc.token)
258+
err = oAuth2Client.CheckTokenCEL(oauth2.CELAuthModeInteractive, tc.state, tc.token)
259259
if tc.err != "" {
260260
require.Error(t, err)
261261
require.ErrorContains(t, err, tc.err)

internal/oauth2/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func (c *Client) postCodeExchangeHandler(
285285
return
286286
}
287287

288-
if err = c.CheckTokenCEL(session, tokens); err != nil {
288+
if err = c.CheckTokenCEL(CELAuthModeInteractive, session, tokens); err != nil {
289289
c.openvpn.DenyClient(ctx, logger, session.Client, "client rejected")
290290
c.writeHTTPError(ctx, w, logger, http.StatusForbidden, "user validation", err.Error())
291291

internal/oauth2/handler_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,32 @@ func TestHandler(t *testing.T) {
339339
true,
340340
true,
341341
},
342+
{
343+
"with cel validation result false",
344+
func() config.Config {
345+
conf := config.Defaults
346+
conf.HTTP.Secret = testutils.Secret
347+
conf.HTTP.Check.IPAddr = true
348+
conf.HTTP.EnableProxyHeaders = true
349+
conf.OAuth2.Provider = generic.Name
350+
conf.OAuth2.Endpoints = config.OAuth2Endpoints{}
351+
conf.OAuth2.Scopes = []string{oauth2types.ScopeOpenID, oauth2types.ScopeProfile}
352+
conf.OAuth2.Validate.Groups = make([]string, 0)
353+
conf.OAuth2.Validate.Roles = make([]string, 0)
354+
conf.OAuth2.Validate.Issuer = true
355+
conf.OAuth2.Validate.IPAddr = false
356+
conf.OAuth2.Validate.CEL = "false"
357+
conf.OpenVPN.Bypass.CommonNames = make(types.RegexpSlice, 0)
358+
conf.OpenVPN.AuthTokenUser = true
359+
360+
return conf
361+
}(),
362+
state.New(state.ClientIdentifier{CID: 0, KID: 1, CommonName: "name"}, "127.0.0.2", "12345", ""),
363+
false,
364+
"127.0.0.2, 8.8.8.8",
365+
true,
366+
false,
367+
},
342368
{
343369
"with client config found",
344370
func() config.Config {

internal/oauth2/provider.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,11 @@ func (c *Client) initializeCELValidation() error {
8484
}
8585

8686
env, err := cel.NewEnv(
87-
cel.VariableWithDoc("openvpnUserCommonName", cel.StringType, "The common name of the OpenVPN user"),
88-
cel.VariableWithDoc("openvpnUserIPAddr", cel.StringType, "The IP address of the OpenVPN user"),
87+
cel.VariableWithDoc("authMode", cel.StringType, "The authentication mode used (e.g., 'interactive', 'non-interactive')"),
88+
cel.VariableWithDoc("openVPNSessionState", cel.StringType, "The OpenVPN session state "+
89+
"(e.g., '', 'Empty', 'Initial', 'Authenticated', 'Expired', 'Invalid', 'AuthenticatedEmptyUser', 'ExpiredEmptyUser')"),
90+
cel.VariableWithDoc("openVPNUserCommonName", cel.StringType, "The common name of the OpenVPN user"),
91+
cel.VariableWithDoc("openVPNUserIPAddr", cel.StringType, "The IP address of the OpenVPN user"),
8992
cel.VariableWithDoc("oauth2TokenClaims", cel.MapType(cel.StringType, cel.DynType), "The claims of the OAuth2 ID token"),
9093
ext.Strings(ext.StringsVersion(4)),
9194
)

internal/oauth2/refresh.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ func (c *Client) RefreshClientAuth(ctx context.Context, logger *slog.Logger, cli
7878
return false, fmt.Errorf("error check user data: %w", err)
7979
}
8080

81+
if err = c.CheckTokenCEL(CELAuthModeNonInteractive, session, tokens); err != nil {
82+
return false, fmt.Errorf("error cel validation: %w", err)
83+
}
84+
8185
logger.LogAttrs(ctx, slog.LevelInfo, "successful authenticate via refresh token")
8286

8387
refreshToken, err = c.provider.GetRefreshToken(tokens)

internal/oauth2/refresh_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,23 @@ func TestRefreshReAuth(t *testing.T) {
310310
return res.Result(), nil
311311
}),
312312
},
313+
{
314+
name: "refresh with CEL denying non-interactive auth",
315+
clientCommonName: "test",
316+
nonInteractiveShouldWork: false,
317+
conf: func() config.Config {
318+
conf := config.Defaults
319+
conf.OpenVPN.AuthTokenUser = false
320+
conf.OAuth2.Provider = generic.Name
321+
conf.OAuth2.Refresh.Enabled = true
322+
conf.OAuth2.Refresh.ValidateUser = true
323+
conf.OAuth2.Refresh.UseSessionID = false
324+
conf.OAuth2.Validate.CEL = "authMode == 'interactive'"
325+
326+
return conf
327+
}(),
328+
rt: http.DefaultTransport,
329+
},
313330
} {
314331
t.Run(tc.name, func(t *testing.T) {
315332
t.Parallel()

0 commit comments

Comments
 (0)