Skip to content

Commit 42391c0

Browse files
jkroepkeCopilot
andauthored
feat: Add user profile selector feature for client configuration (#707)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a673c6d commit 42391c0

File tree

24 files changed

+1156
-112
lines changed

24 files changed

+1156
-112
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ fmt:
7676
@-go run github.com/catenacyber/perfsprint@v0.10.1 --fix ./...
7777
@-go run github.com/tetafro/godot/cmd/godot@v1.4.20 -w .
7878
@-go run github.com/4meepo/tagalign/cmd/tagalign@v1.4.3 -fix -sort ./...
79+
@-go run github.com/t34-dev/go-field-alignment/v2/cmd/gofield@v2.0.10 --files . -fix
7980
@-go run golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@v0.41.0 -test=false -fix ./...
8081
@-go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 run ./...
8182

docs/Client specific configuration.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,163 @@ The feature must be enabled with `--openvpn.client-config.enabled`.
1313

1414
openvpn-auth-oauth2 looks for a file
1515
named after the token claim or common name with `.conf` suffix in the client config directory.
16+
17+
## Client Profile Selector
18+
19+
The user profile selector feature allows users to choose their client configuration profile through a web UI after OAuth2 authentication. This is useful when:
20+
- Users need access to different VPN configurations (e.g., different network segments, access levels)
21+
- Profile assignments are determined by OAuth2 token claims (e.g., roles, groups, departments)
22+
- You want to provide a self-service experience for profile selection
23+
24+
### Configuration Options
25+
26+
#### Enable Profile Selector
27+
28+
```bash
29+
--openvpn.client-config.user-selector.enabled
30+
```
31+
32+
**Environment Variable:** `CONFIG_OPENVPN_CLIENT__CONFIG_USER__SELECTOR_ENABLED`
33+
34+
**Default:** `false`
35+
36+
When enabled, openvpn-auth-oauth2 will display a profile selection UI after successful OAuth2 authentication. Users can choose from available profiles before connecting to the VPN.
37+
38+
Profile options are populated from:
39+
- Static values configured via `--openvpn.client-config.user-selector.static-values`
40+
- Token claim values from `--openvpn.client-config.token-claim` (if configured)
41+
42+
**Note:** The profile selector only appears when there are 2 or more profiles available. If only one profile is available, it will be automatically selected without showing the UI.
43+
44+
#### Static Profile Values
45+
46+
```bash
47+
--openvpn.client-config.user-selector.static-values value1,value2,value3
48+
```
49+
50+
**Environment Variable:** `CONFIG_OPENVPN_CLIENT__CONFIG_USER__SELECTOR_STATIC__VALUES`
51+
52+
**Default:** (empty)
53+
54+
A comma-separated list of static profile names that are always available in the profile selector UI. These profiles will be displayed as selectable options for all authenticated users, regardless of their token claims.
55+
56+
**Example:**
57+
```bash
58+
--openvpn.client-config.user-selector.static-values corporate,guest,remote
59+
```
60+
61+
This would show three profiles (corporate, guest, remote) to every user.
62+
63+
64+
### How It Works
65+
66+
1. User completes OAuth2 authentication
67+
2. openvpn-auth-oauth2 extracts available profiles from:
68+
- Static values (from `--openvpn.client-config.user-selector.static-values`)
69+
- Token claim values (from `--openvpn.client-config.token-claim`, if configured
70+
- supports both string and array values)
71+
3. Based on the number of profiles:
72+
- **0 profiles:** Falls back to default behavior (uses username or token claim from `--openvpn.client-config.token-claim`)
73+
- **1 profile:** Automatically selects that profile without showing UI
74+
- **2+ profiles:** Displays profile selector UI to the user
75+
4. User selects a profile
76+
5. OpenVPN client configuration is applied based on the selected profile name
77+
78+
### Profile Configuration Files
79+
80+
After a profile is selected, openvpn-auth-oauth2 looks for a configuration file in the client config directory:
81+
82+
```
83+
<client-config-path>/<selected-profile>.conf
84+
```
85+
86+
For example, if a user selects the "corporate" profile, the file would be:
87+
```
88+
/path/to/client-config/corporate.conf
89+
```
90+
91+
### Example Configurations
92+
93+
#### Example 1: Static Profiles Only
94+
95+
Allow all users to choose from three predefined profiles:
96+
97+
```yaml
98+
openvpn:
99+
client-config:
100+
enabled: true
101+
path: /etc/openvpn/client-config
102+
user-selector:
103+
enabled: true
104+
static-values:
105+
- full-access
106+
- limited-access
107+
- guest-access
108+
```
109+
110+
Or via command line:
111+
```bash
112+
--openvpn.client-config.enabled \
113+
--openvpn.client-config.path=/etc/openvpn/client-config \
114+
--openvpn.client-config.user-selector.enabled \
115+
--openvpn.client-config.user-selector.static-values=full-access,limited-access,guest-access
116+
```
117+
118+
#### Example 2: Dynamic Profiles from Token Claims
119+
120+
Profiles are determined by the user's "groups" claim:
121+
122+
```yaml
123+
openvpn:
124+
client-config:
125+
enabled: true
126+
path: /etc/openvpn/client-config
127+
token-claim: groups
128+
user-selector:
129+
enabled: true
130+
```
131+
132+
If a user's ID token contains:
133+
```json
134+
{
135+
"groups": ["engineering", "vpn-users"]
136+
}
137+
```
138+
139+
They will see profiles "engineering" and "vpn-users" in the selector.
140+
141+
#### Example 3: Combined Static and Dynamic Profiles
142+
143+
Provide a "guest" profile to everyone, plus role-based profiles:
144+
145+
```yaml
146+
openvpn:
147+
client-config:
148+
enabled: true
149+
path: /etc/openvpn/client-config
150+
token-claim: roles
151+
user-selector:
152+
enabled: true
153+
static-values:
154+
- guest
155+
```
156+
157+
If a user has `"roles": ["admin", "developer"]` in their token, they will see three profiles:
158+
- guest (static)
159+
- admin (from token)
160+
- developer (from token)
161+
162+
### Security Considerations
163+
164+
- The profile selector validates that the selected profile is in the list of allowed profiles (from static values and/or token claims)
165+
- Invalid profile selections are rejected
166+
- All profile data is encrypted during transmission between the browser and server
167+
- Profile selection requires a valid OAuth2 authentication session
168+
169+
### Interaction with Other Settings
170+
171+
The user profile selector takes precedence over the `--openvpn.client-config.token-claim` setting when enabled. The flow is:
172+
173+
1. If `user-selector.enabled` is true and multiple profiles are available → Show profile selector
174+
2. If `user-selector.enabled` is true and one profile is available → Use that profile automatically
175+
3. Otherwise → Fall back to standard behavior using `--openvpn.client-config.token-claim` if configured

docs/Configuration.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Usage of openvpn-auth-oauth2:
4242
listen addr for client listener (env: CONFIG_HTTP_LISTEN) (default ":9000")
4343
--http.secret value
4444
Random generated secret for cookie encryption. Must be 16, 24 or 32 characters. If argument starts with file:// it reads the secret from a file. (env: CONFIG_HTTP_SECRET)
45+
--http.short-url
46+
Enable short URL. The URL which is used for initial authentication will be reduced to /?s=... instead of /oauth2/start?state=... (env: CONFIG_HTTP_SHORT__URL)
4547
--http.template value
4648
Path to a HTML file which is displayed at the end of the screen. See https://github.com/jkroepke/openvpn-auth-oauth2/wiki/Layout-Customization for more information. (env: CONFIG_HTTP_TEMPLATE)
4749
--http.tls
@@ -76,12 +78,12 @@ Usage of openvpn-auth-oauth2:
7678
oauth2 issuer (env: CONFIG_OAUTH2_ISSUER)
7779
--oauth2.nonce
7880
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)
8181
--oauth2.pkce
8282
If true, Proof Key for Code Exchange (PKCE) RFC 7636 is used for token exchange. (env: CONFIG_OAUTH2_PKCE) (default true)
8383
--oauth2.provider string
8484
oauth2 provider (env: CONFIG_OAUTH2_PROVIDER) (default "generic")
85+
--oauth2.refresh-nonce value
86+
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)
8587
--oauth2.refresh.enabled
8688
If true, openvpn-auth-oauth2 stores refresh tokens and will use it do an non-interaction reauth. (env: CONFIG_OAUTH2_REFRESH_ENABLED)
8789
--oauth2.refresh.expires duration
@@ -124,6 +126,10 @@ Usage of openvpn-auth-oauth2:
124126
Path to the CCD directory. openvpn-auth-oauth2 will look for an file with an .conf suffix and returns the content back. (env: CONFIG_OPENVPN_CLIENT__CONFIG_PATH)
125127
--openvpn.client-config.token-claim string
126128
If non-empty, the value of the token claim is used to lookup the configuration file in the CCD directory. If empty, the common name is used. (env: CONFIG_OPENVPN_CLIENT__CONFIG_TOKEN__CLAIM)
129+
--openvpn.client-config.user-selector.enabled
130+
If true, openvpn-auth-oauth2 will display a profile selection UI after OAuth2 authentication, allowing users to choose their client configuration profile. Profile options are populated from openvpn.client-config.user-selector.static-values and openvpn.client-config.token-claim (if configured). After selection, the chosen profile name is used to lookup the configuration file in the CCD directory. (env: CONFIG_OPENVPN_CLIENT__CONFIG_USER__SELECTOR_ENABLED)
131+
--openvpn.client-config.user-selector.static-values value
132+
Comma-separated list of static profile names that are always available in the profile selector UI. These profiles will be displayed as selectable options for all users. (env: CONFIG_OPENVPN_CLIENT__CONFIG_USER__SELECTOR_STATIC__VALUES)
127133
--openvpn.common-name.environment-variable-name string
128134
Name of the environment variable in the OpenVPN management interface which contains the common name. If username-as-common-name is enabled, this should be set to 'username' to use the username as common name. Other values like 'X509_0_emailAddress' are supported. See https://openvpn.net/community-resources/reference-manual-for-openvpn-2-6/#environmental-variables for more information. (env: CONFIG_OPENVPN_COMMON__NAME_ENVIRONMENT__VARIABLE__NAME) (default "common_name")
129135
--openvpn.common-name.mode value

docs/Layout Customization.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,41 @@ Available variables:
1212
- `{{.title}}`: `Access denied` or `Access granted`
1313
- `{{.message}}`: Potential error message or success message
1414
- `{{.errorID}}`: ErrorID of an error, if present
15+
- `{{.clientConfigProfiles}}`: List of available profiles, used to render the profile selection screen.
16+
- `{{.token}}`: An encrypted token to identify the user session, used for profile selection.
1517

1618
The [go template engine](https://pkg.go.dev/text/template) is used to render the HTML file.
1719

20+
## Client Profile Selector
21+
22+
If the [Client Profile Selector](Client%20specific%20configuration.md#client-profile-selector) is enabled and multiple
23+
profiles are available for the user, a profile selection screen is shown. The custom template must handle this case by itself.
24+
25+
Minimal example to render the profile selector:
26+
27+
```gotemplate
28+
{{- if .clientConfigProfiles }}
29+
<div class="profile-buttons">
30+
<form method="POST" action="./profile-submit" class="profile-form">
31+
<input type="hidden" name="token" value="{{ .token }}">
32+
{{- range .clientConfigProfiles }}
33+
<input class="profile-button" type="submit" name="profile" value="{{ . }}">
34+
{{- end }}
35+
</form>
36+
</div>
37+
{{- end }}
38+
```
39+
40+
The default style uses CSS classes to style the profile selector:
41+
- `profile-buttons`: Container for the profile buttons
42+
- `profile-form`: Form containing the profile buttons
43+
- `profile-button`: Individual profile button
44+
1845
## Overriding the default assets
1946

2047
To override the default assets, you can configure `http.assets-path` with the path to the directory containing the assets.
2148

22-
The default assets are here:
49+
The default assets:
2350

2451
- `style.css`: CSS file to enrich the default layout. By default, it is empty.
2552
- `mvp.css`: [MVP](https://github.com/andybrewer/mvp) CSS framework
@@ -28,6 +55,8 @@ The default assets are here:
2855
- `i18n/<lang>.json`: Language specific localization file. <lang> is the language code, e.g., `en` for English.
2956
See [de.json](https://github.com/jkroepke/openvpn-auth-oauth2/blob/main/internal/ui/static/i18n/de.json) for an example.
3057

58+
Alternatively, you link additional assets via external locations inside your custom template.
59+
3160
## Custom localization
3261

3362
If you want to provide custom localization, you have to configure `http.assets-path` first. In the assets directory,

internal/config/config_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ openvpn:
118118
enabled: true
119119
token-claim: sub
120120
path: "."
121+
user-selector:
122+
enabled: true
123+
static-values:
124+
- "default"
121125
common-name:
122126
environment-variable-name: X509_0_emailAddress
123127
mode: omit
@@ -192,6 +196,10 @@ http:
192196

193197
return dirFS
194198
}(),
199+
UserSelector: config.OpenVPNConfigProfileSelector{
200+
Enabled: true,
201+
StaticValues: []string{"default"},
202+
},
195203
},
196204
Password: "1jd93h5b6s82lf03jh5b2hf9",
197205
AuthTokenUser: true,

internal/config/defaults.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ var Defaults = Config{
5151
ClientConfig: OpenVPNConfig{
5252
Enabled: false,
5353
Path: types.FS{FS: os.DirFS("/etc/openvpn-auth-oauth2/client-config-dir/")},
54+
UserSelector: OpenVPNConfigProfileSelector{
55+
Enabled: false,
56+
StaticValues: make(types.StringSlice, 0),
57+
},
5458
},
5559
CommonName: OpenVPNCommonName{
5660
EnvironmentVariableName: "common_name",

internal/config/flags.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,21 @@ func (c *Config) flagSetOpenVPN(flagSet *flag.FlagSet) {
167167
lookupEnvOrDefault("openvpn.client-config.token-claim", c.OpenVPN.ClientConfig.TokenClaim),
168168
"If non-empty, the value of the token claim is used to lookup the configuration file in the CCD directory. If empty, the common name is used.",
169169
)
170+
flagSet.BoolVar(
171+
&c.OpenVPN.ClientConfig.UserSelector.Enabled,
172+
"openvpn.client-config.user-selector.enabled",
173+
lookupEnvOrDefault("openvpn.client-config.user-selector.enabled", c.OpenVPN.ClientConfig.UserSelector.Enabled),
174+
"If true, openvpn-auth-oauth2 will display a profile selection UI after OAuth2 authentication, allowing users to choose their client configuration profile. "+
175+
"Profile options are populated from openvpn.client-config.user-selector.static-values and openvpn.client-config.token-claim (if configured). "+
176+
"After selection, the chosen profile name is used to lookup the configuration file in the CCD directory.",
177+
)
178+
flagSet.TextVar(
179+
&c.OpenVPN.ClientConfig.UserSelector.StaticValues,
180+
"openvpn.client-config.user-selector.static-values",
181+
lookupEnvOrDefault("openvpn.client-config.user-selector.static-values", c.OpenVPN.ClientConfig.UserSelector.StaticValues),
182+
"Comma-separated list of static profile names that are always available in the profile selector UI. "+
183+
"These profiles will be displayed as selectable options for all users.",
184+
)
170185
flagSet.StringVar(
171186
&c.OpenVPN.CommonName.EnvironmentVariableName,
172187
"openvpn.common-name.environment-variable-name",

internal/config/types.go

Lines changed: 11 additions & 5 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-
OpenVPN OpenVPN `json:"openvpn" yaml:"openvpn"`
2625
OAuth2 OAuth2 `json:"oauth2" yaml:"oauth2"`
26+
OpenVPN OpenVPN `json:"openvpn" yaml:"openvpn"`
2727
}
2828

2929
type HTTP struct {
@@ -53,10 +53,10 @@ type Log struct {
5353
type OpenVPN struct {
5454
Addr types.URL `json:"addr" yaml:"addr"`
5555
Password types.Secret `json:"password" yaml:"password"`
56-
ClientConfig OpenVPNConfig `json:"client-config" yaml:"client-config"`
5756
Bypass OpenVPNBypass `json:"bypass" yaml:"bypass"`
5857
CommonName OpenVPNCommonName `json:"common-name" yaml:"common-name"`
5958
Passthrough OpenVPNPassthrough `json:"pass-through" yaml:"pass-through"`
59+
ClientConfig OpenVPNConfig `json:"client-config" yaml:"client-config"`
6060
AuthPendingTimeout time.Duration `json:"auth-pending-timeout" yaml:"auth-pending-timeout"`
6161
CommandTimeout time.Duration `json:"command-timeout" yaml:"command-timeout"`
6262
AuthTokenUser bool `json:"auth-token-user" yaml:"auth-token-user"`
@@ -68,9 +68,15 @@ type OpenVPNBypass struct {
6868
CommonNames types.RegexpSlice `json:"common-names" yaml:"common-names"`
6969
}
7070
type OpenVPNConfig struct {
71-
Path types.FS `json:"path" yaml:"path"`
72-
TokenClaim string `json:"token-claim" yaml:"token-claim"`
73-
Enabled bool `json:"enabled" yaml:"enabled"`
71+
Path types.FS `json:"path" yaml:"path"`
72+
TokenClaim string `json:"token-claim" yaml:"token-claim"`
73+
UserSelector OpenVPNConfigProfileSelector `json:"user-selector" yaml:"user-selector"`
74+
Enabled bool `json:"enabled" yaml:"enabled"`
75+
}
76+
77+
type OpenVPNConfigProfileSelector struct {
78+
StaticValues types.StringSlice `json:"static-values" yaml:"static-values"`
79+
Enabled bool `json:"enabled" yaml:"enabled"`
7480
}
7581

7682
type OpenVPNCommonName struct {

internal/httphandler/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func New(conf config.Config, oAuth2Client *oauth2.Client) *http.ServeMux {
4444
mux.Handle(fmt.Sprintf("GET %s/assets/", basePath), http.StripPrefix(basePath+"/assets/", http.FileServerFS(conf.HTTP.AssetPath)))
4545
mux.Handle(fmt.Sprintf("GET %s/oauth2/start", basePath), noCacheHeaders(oAuth2Client.OAuth2Start()))
4646
mux.Handle(fmt.Sprintf("GET %s/oauth2/callback", basePath), noCacheHeaders(oAuth2Client.OAuth2Callback()))
47+
mux.Handle(fmt.Sprintf("POST %s/oauth2/profile-submit", basePath), noCacheHeaders(oAuth2Client.OAuth2ProfileSubmit()))
4748

4849
return mux
4950
}

0 commit comments

Comments
 (0)