feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
Release / release (push) Successful in 17s

This commit is contained in:
slawek committed 2026-10-30 01:01:45 +01:00
1 parent 3ed79c7563
commit 3c4c5a8c32
15 files changed
+1572 -240

No files matched your search

+169 -48
View File
@@ -375,7 +375,7 @@ refused request exits with status 2, any other failure with 1.
## Bash completion
Bash completes the commands, options and arguments of `vpn-router-user`, `vpn-router-group`,
`vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca`, `vpn-router-caddy`
`vpn-router-role`, `vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca`, `vpn-router-caddy`
and `vpn-router-site` with the Tab key. The package installs a file per command in
`/usr/share/bash-completion/completions`, which the `bash-completion` package, recommended by
`vpn-router`, loads on the first Tab after the command. For root, who can read them, the
@@ -702,8 +702,9 @@ it is added:
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
system signs in with to use a router service, such as an OpenTelemetry Collector reading
`/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
description, and a service account with redirect URIs is a client of the OpenID Provider (see
OpenID Provider). `add --service` adds one, and the web service manages them at `/service-accounts`,
description, a service account with redirect URIs or grants is a client of the OpenID Provider,
and every one is a resource of it, with its scopes and roles (see OpenID Provider). `add
--service` adds one, and the web service manages them at `/service-accounts`,
not at `/users`. Internal service accounts are those the router creates and manages for its own
services, display name and description included; at present that is the metrics service's, the
account `metrics.service_account` names while metrics are enabled (see Metrics), which
@@ -741,9 +742,9 @@ vpn-router-user otp-remove alice
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider client settings when given; the names are refused |
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider settings when given; the names are refused |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider client settings of a service account; `''` removes one; a repeated client option gives all values of its kind |
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider settings of a service account; `''` removes one; a repeated option gives all values of its kind |
| `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
@@ -855,9 +856,10 @@ A disabled or locked user is refused a passkey sign-in too.
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
optional description of at most 256 printable characters, and at least one member, each a user
of the directory. The group `admins` grants the management of the router, and the allowed groups
of a client of the OpenID Provider admit their members to it; other groups record membership
only. Renaming a group renames it in the clients that allow it, and a group a client allows
cannot be removed. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
of a client of the OpenID Provider admit their members to it, and a role of a resource assigned to
a group is a role of its members; other groups record membership only. Renaming a group renames it
in the clients that allow it and in its roles, a group a client allows cannot be removed, and
removing a group takes it out of its roles. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
```sh
vpn-router-group add ops --member alice --member bob --description 'Operations team'
@@ -1278,22 +1280,33 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
| Request | Body | Answer |
| --- | --- | --- |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?, "grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?, "roles"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles"}`; `""` removes one of the first three, `[]` the values of the lists, `false` the public client | the account |
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
| `POST /service-accounts/<name>/disable` | | the account |
| `POST /service-accounts/<name>/enable` | | the account |
| `POST /service-accounts/<name>/unlock` | | the account |
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
A display name has at most 64 printable characters and a description at most 256. The client
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
groups (see OpenID Provider). The internal
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
router manages them, their display name and description included. A failure is otherwise as for
`/users`, with 404 also for a name that is not a service account.
A display name has at most 64 printable characters and a description at most 256. The settings of
the OpenID Provider (see OpenID Provider) are:
- **URIs and allowed groups:** lists of at most 16 values: URIs of `https`, or of `http` for
`localhost`, `127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names
of existing groups;
- **`grants`:** `client_credentials` and `device_code`; `public_client`, `true` or `false`;
- **`client_scopes`:** at most 16 scopes of resources, `<resource>:<scope>`;
- **`resource_scopes`:** at most 16 scope names of lowercase letters, digits, dots, underscores and
hyphens;
- **`roles`:** `[{"name", "description", "groups", "accounts"}]`, which replaces the account's roles:
role names of letters, digits, dots, underscores and hyphens, and existing groups and accounts.
The internal service accounts, `internal` true, are only listed: every other request on one, and
a role assigned to one, gets 403, as the router manages them, their display name and description
included. A password change, disabling, unlocking and removing revoke the tokens the account was
issued. A failure is otherwise as for `/users`, with 404 also for a name that is not a service
account.
### The /account API
@@ -1521,22 +1534,33 @@ curl https://router.example.com:7444/.well-known/openid-configuration
| Endpoint | Purpose |
| --- | --- |
| `GET /.well-known/openid-configuration` | The discovery document |
| `GET /oidc/jwks` | The public key ID tokens are signed with |
| `GET /oidc/jwks` | The public key ID tokens and access tokens are signed with |
| `GET`, `POST /oidc/authorize` | The authorization code flow; the sign-in page while the browser has no session |
| `POST /oidc/token` | Exchanges a code or a refresh token for tokens |
| `POST /oidc/token` | Exchanges a code, a refresh token, a device code or a client's credentials for tokens |
| `POST /oidc/device_authorization` | Begins the device authorization grant: a device code and a user code |
| `GET`, `POST /oidc/device` | The page where a user enters a device's user code, signs in and allows the device |
| `GET`, `POST /oidc/userinfo` | The claims of the user of an access token |
| `GET /oidc/logout` | Ends the session (RP-initiated logout) |
A client is a service account with at least one redirect URI: its name is the `client_id` and its
**Clients**
A client is a service account with a redirect URI or a grant. Its name is the `client_id` and its
password the `client_secret`, sent with HTTP Basic authentication (`client_secret_basic`) or in
the body of the token request (`client_secret_post`). The password is checked with a bind, so 5
wrong secrets lock the client for 15 minutes. Its settings are:
wrong secrets lock the client for 15 minutes. A public client sends only its `client_id` (`none`).
Its settings are:
- redirect URIs, where the provider sends the browser back with a code, matched exactly; a URI
uses `https`, or `http` for `localhost`, `127.0.0.1` or `::1`;
- post-logout redirect URIs, where `/oidc/logout` may send the browser afterwards;
- allowed groups: only their members may sign in to the client; without any, every user who is
not a service account may.
- **redirect URIs:** where the provider sends the browser back with a code; they give the client
the authorization code grant. A URI uses `https`, or `http` for `localhost`, `127.0.0.1` or
`::1`, and is matched exactly, but the port of an `http` one, which a native program chooses
when it starts (RFC 8252);
- **post-logout redirect URIs:** where `/oidc/logout` may send the browser afterwards;
- **allowed groups:** only their members may sign in to the client; without any, every user who
is not a service account may;
- **grants:** `client_credentials` and `device_code`, besides the authorization code;
- **public client:** the client has no secret, as a native program or a CLI, which cannot keep
one; it cannot have `client_credentials`;
- **client scopes:** the scopes of resources the client may ask for, `<resource>:<scope>`.
```sh
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
@@ -1545,34 +1569,131 @@ echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b
vpn-router-user update 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --allowed-group ''
```
The flow is the authorization code flow with PKCE, whose `code_challenge_method` must be `S256`;
the implicit and hybrid flows, and the password and client credentials grants, are refused. A
registered client is trusted, so a user is not asked for consent. A user signs in on the
provider's page with the name, the password and, when enrolled, the TOTP code, or with a passkey
(see Passkeys); failed sign-ins count towards the lockout, and `POST /oidc/login` takes at most
10 calls a minute from one client address, as `/auth` does. A
disabled, locked or removed user, a service account, and a user outside the client's allowed
groups cannot sign in, and the codes and tokens already issued to such a user stop working. The
sign-in starts a session, a cookie of the issuer's `/oidc/` path valid for 8 hours, so later
requests of any client go on without the page; `prompt=login` and `max_age` ask for a new sign-in,
and `prompt=none` answers `login_required` without a session.
**Grants**
| Grant | Who signs in | Tokens |
| --- | --- | --- |
| `authorization_code` | A user, in the browser, with PKCE (`S256`) | Access, refresh and ID token |
| `urn:ietf:params:oauth:grant-type:device_code` | A user, on another device | Access, refresh and ID token |
| `client_credentials` | The service account itself, with its secret | Access token |
| `refresh_token` | | A new access and refresh token |
The implicit and hybrid flows, the password grant and dynamic registration are refused. A
registered client is trusted, so a user is not asked for consent, except for a device.
**Signing in**
- **The page:** a user signs in on the provider's page with the name, the password and, when
enrolled, the TOTP code, or with a passkey (see Passkeys).
- **Lockout and rate limit:** failed sign-ins count towards the lockout. `POST /oidc/login`,
`/oidc/device` and `/oidc/device_authorization` take at most 10 calls a minute from one client
address, as `/auth` does.
- **Who may not sign in:** a disabled, locked or removed user, a service account, and a user
outside the client's allowed groups. The codes and tokens already issued to such a user stop
working.
- **The session:** the sign-in starts a cookie of the issuer's `/oidc/` path, valid for 8 hours,
so later requests of any client go on without the page. `prompt=login` and `max_age` ask for a
new sign-in, and `prompt=none` answers `login_required` without a session.
**Device authorization (RFC 8628)**
A client with `device_code`, such as a CLI on a host without a browser, signs a user in like this:
1. It posts `client_id` (and its secret, unless it is public), `scope` and optionally `resource` to
`/oidc/device_authorization`. The answer holds `device_code`, `user_code` (8 letters, shown as
`XXXX-XXXX`), `verification_uri`, `verification_uri_complete`, `expires_in` (600 seconds) and
`interval` (5 seconds).
2. It shows the user the code and the URI, then polls `/oidc/token` with
`grant_type=urn:ietf:params:oauth:grant-type:device_code` and `device_code`. Until the user
decides, the answer is `authorization_pending`; a poll sooner than the interval answers
`slow_down` and adds 5 seconds to it.
3. The user opens the URI on any device, enters the code, signs in, and allows or denies the
client, the resource and the scopes shown. The next poll answers the tokens, or
`access_denied`; an expired code answers `expired_token`.
```sh
curl -d client_id=backup-cli -d 'scope=openid profile' \
https://router.example.com:7444/oidc/device_authorization
```
**Client credentials**
A confidential client with `client_credentials` gets an access token as its own service account,
without a user. The scopes are those of a resource, and the profile scopes give the service
account's claims; `openid` is refused, and no refresh token is issued.
```sh
curl -u backup-job:'j0b-s3cret' -d grant_type=client_credentials -d resource=s3 -d scope=s3:read \
https://router.example.com:7444/oidc/token
```
**Resources, roles and scopes**
Every service account is also a resource, an application that checks the router's tokens, as an
API registration of Entra ID:
- **Scopes:** the resource scopes the account defines, such as `read`, which clients ask for as
`<resource>:<scope>` (`s3:read`) when their client scopes allow them.
- **Roles:** named roles, such as `Reader`, each assigned to groups, users and service accounts;
`vpn-router-role` manages them.
```sh
echo 's3-s3cret' | vpn-router-user add s3 --service --resource-scope read --resource-scope write
vpn-router-role add s3 Reader --description 'Reads every bucket'
vpn-router-role member-add s3 Reader --group backup
vpn-router-user update backup-job --grant client_credentials --client-scope s3:read
```
A request is for a resource when it names it with `resource` (RFC 8707), its name or
`<issuer>/<name>`, or asks for its scopes; more than one resource, or one that is not a
resource, is `invalid_target`. A scope the client may not ask for is `invalid_scope`. Without a
resource, the tokens are for the client itself. The resource is kept with the code, the device code
and the refresh token, and a token request naming another one is `invalid_target`.
| Command | What it does |
| --- | --- |
| `vpn-router-role list [<resource>]` | The roles with the groups and accounts they are assigned to |
| `vpn-router-role add <resource> <role> [--description D]` | Add a role |
| `vpn-router-role remove <resource> <role>` | Remove a role |
| `vpn-router-role member-add <resource> <role> --group G\|--account A` | Assign the role |
| `vpn-router-role member-remove <resource> <role> --group G\|--account A` | Take the role away |
A scope a client may ask for cannot be removed from its resource, nor the resource while a client
may ask for its scopes. Removing a service account removes its roles and takes it out of the
others; removing or renaming a group does the same in the roles.
**Tokens**
| Scope | Claims |
| --- | --- |
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
| `openid` (required but by `client_credentials`, which refuses it) | `sub`, the `entryUUID` of the user's entry |
| `profile` | `preferred_username` (the user name), `name`, `given_name`, `family_name` |
| `email` | `email` |
| `groups` | `groups`, the names of the user's groups |
| `<resource>:<scope>` | None; the scope is in the access token's `scope` |
The claims of the scopes granted are in the ID token and in the answer of `/oidc/userinfo`. ID
tokens are RS256 JWTs, valid for 5 minutes, with `auth_time` and `amr` (`pwd`, `pwd` and `otp`, or
`hwk` and `user` for a passkey), signed with an RSA key of 3072 bits that the web service makes on
its first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
thumbprint. A code is valid for 1 minute and used once, an access token for 5 minutes, and a
refresh token for 8 hours and used once: each refresh answers a new one with a new ID token. The
codes, tokens and sessions are kept, as their SHA-256, in the database of the refresh tokens of
`/auth`. Changing a user's password, disabling, unlocking or removing the user ends its sessions
and revokes its codes and tokens.
- **ID tokens:** RS256 JWTs for the client (`aud`), valid for 5 minutes, with the claims of the
scopes, `auth_time`, `amr` (`pwd`, `pwd` and `otp`, or `hwk` and `user` for a passkey), and
`roles`, the user's roles of the client itself. `/oidc/userinfo` answers the same claims.
- **Access tokens:** RS256 JWTs of RFC 9068 (`typ` `at+jwt`), valid for 5 minutes, which a
resource verifies with the JWKS. Their claims:
- `iss`, `sub`, `iat`, `exp` and `jti`;
- `aud`, the resource, or the client without one, and `client_id`, the client;
- `scope`, the scopes granted;
- `roles`, the roles of the resource the user or the service account holds, directly or by a
group;
- the claims of the scopes, and of a user `auth_time` and `amr`.
- **Roles are read at every issue,** refresh included, so a role taken away is gone from the next
token.
- **The key:** tokens are signed with an RSA key of 3072 bits that the web service makes on its
first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
thumbprint.
- **Lifetimes:** a code is valid for 1 minute and used once, a device code for 10 minutes, and a
refresh token for 8 hours and used once: each refresh answers a new one.
- **Storage and revocation:** the codes, device codes, tokens and sessions are kept, as their
SHA-256, in the database of the refresh tokens of `/auth`. Changing the password of a user or a
service account, disabling, unlocking or removing it ends its sessions and revokes its codes and
tokens.
## Web sites