From 3c4c5a8c32202a3828afce09b1f5160ea1151251 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 30 Oct 2026 01:01:45 +0100 Subject: [PATCH] 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. --- ADR.md | 129 ++-- README.md | 217 +++++-- debian-package/debian/changelog | 29 + debian-package/debian/install | 1 + .../src/usr/lib/vpn-router/vpnrouter_ldap.py | 27 +- .../src/usr/lib/vpn-router/vpnrouter_oidc.py | 558 +++++++++++++++--- .../src/usr/lib/vpn-router/vpnrouter_users.py | 356 ++++++++++- .../lib/vpn-router/vpnrouter_web/handler.py | 3 +- .../vpn-router/vpnrouter_web/identities.py | 46 +- debian-package/src/usr/sbin/vpn-router-role | 121 ++++ debian-package/src/usr/sbin/vpn-router-user | 34 +- .../completions/vpn-router-role.bash | 94 +++ .../completions/vpn-router-user.bash | 30 +- .../src/usr/share/vpn-router/sql/oidc.sql | 123 +++- .../vpn-router/templates/oidc-page.html.j2 | 44 +- 15 files changed, 1572 insertions(+), 240 deletions(-) create mode 100755 debian-package/src/usr/sbin/vpn-router-role create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-role.bash diff --git a/ADR.md b/ADR.md index f944000..0922686 100644 --- a/ADR.md +++ b/ADR.md @@ -481,11 +481,14 @@ standard attributes, are the display name and the description of a service accou `account` cannot hold. Manager-only accounts have the class from their creation, so an e-mail address can be added later without changing their classes; VPN users get it once scopes are used. `vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class -reaches an existing directory at the next setup run. +reaches an existing directory at the next setup run. The structural class `vpnRouterRole`, +`MUST cn MAY ( description $ member )`, is a role of a resource of the OpenID Provider (ADR-37), +an entry `cn=:` of `ou=roles`, which provision makes; the manager account manages +that subtree, and every account reads it. -Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their -OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never -be reused for anything else. +Consequences: The schema check stays strict. New attributes of accounts extend +`vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account +(ADR-23). The OID must never be reused for anything else. ## ADR-20: Users manage their own account through /account, proving who they are again @@ -1013,43 +1016,97 @@ Router Manager on another name, or in development on `localhost`, cannot use the sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited. Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys. -## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts +## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients and resources are service accounts Status: Accepted -Context: Other systems, such as dashboards, should sign the router's users in with the directory's -passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no -packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and -resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the -ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe -only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps -tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must -not leave the router, but a relying party verifies an ID token itself. +Context: -Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is -true; its issuer is its HTTPS site, `https://:`, without the port -on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only -the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a -service account with redirect URIs, kept with the account in the new attributes -`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of -`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind. -Allowed groups limit who may sign in; without them every user may. A registered client is -trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2 -template with the password, the code and a passkey, so the provider works without the Router -Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run -(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the -entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in -the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with -procedures and functions the module calls; a process loads the file at its first connection. The -operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or -token checks the user and the client's allowed groups again. +- **What is needed:** other systems, such as dashboards, should sign the router's users in with the + directory's passwords, TOTP codes and passkeys, without a second user store. Programs on hosts + without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway + should learn from a token what its holder may do there, as resources of Entra ID do with app + roles and scopes. +- **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt` + are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe + only. +- **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the + client credentials grant, and leaves the checks, the storage and the signatures to the + application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has + one, as has the current 4.0.0, but it checks no device code and never answers + `authorization_pending` or `slow_down`. +- **What the web service has:** it already checks passwords and passkeys, rate limits sign-ins and + keeps tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 + key must not leave the router, but a relying party or a resource verifies a token itself. -Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs -HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI, -`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming -it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID -token issued. Wrong client secrets lock the client like any account. The implicit and hybrid -flows, dynamic client registration and public clients without a secret are not offered. +Decision: + +- **Where it runs:** the web service is the OpenID Provider, in `vpnrouter_oidc`, while + `oidc.enabled` is true. Its issuer is its HTTPS site, `https://:`, + without the port on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy + passes them. +- **Clients:** + - A client is a service account with redirect URIs or grants, kept with the account in + attributes of `vpnRouterAccount`: `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri`, + `vpnRouterAllowedGroup`, `vpnRouterGrantType`, `vpnRouterPublicClient` and + `vpnRouterClientScope`. + - Its name is the client ID and its password the secret, checked with a bind; a public client has + none. + - Allowed groups limit who may sign in; without them every user may. +- **Grants:** + - the authorization code with PKCE S256, which redirect URIs give; + - `client_credentials`, for confidential clients only; + - the device code; + - the refresh token for the first and the last. + + The device grant is the router's own grant class on oauthlib's token endpoint, with its + authorization endpoint and pages, so it works the same with every oauthlib release. A loopback + redirect URI matches with any port (RFC 8252). +- **Consent:** a registered client is trusted, so there is no consent page, except for a device: + the user who enters its code is asked to allow it, as RFC 8628 advises against remote phishing. +- **The sign-in page:** the web service's own, a Jinja2 template with the password, the code and a + passkey, so the provider works without the Router Manager. +- **Resources, as Entra ID:** + - Every service account is a resource. Its scopes are in `vpnRouterResourceScope`, and its roles + are `vpnRouterRole` entries (ADR-19) whose members are groups and accounts. + - A token request names its resource with `resource` (RFC 8707) or by the resource's scopes, + `:`, which the client's scopes must allow. + - Without a resource, the tokens are for the client. + - The grant keeps the resource, so a code, a device code or a refresh token gives tokens for that + resource only. +- **Tokens:** + - Both kinds are RS256 JWTs, signed with an RSA key the web service makes on its first run + (`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is + the entry's `entryUUID`. + - An ID token is for the client and carries the user's roles of the client. + - An access token follows RFC 9068: it is for the resource and carries the holder's roles there + and the scopes granted, so a resource needs nothing from the router but the JWKS. + - Roles are read at every issue. +- **Storage:** + - Codes, device codes, access and refresh tokens and sessions are kept as their SHA-256 in the + database of the refresh tokens. + - They are defined in `/usr/share/vpn-router/sql/oidc.sql`, with the procedures and functions + the module calls; a process loads the file at its first connection. + - The operations that revoke the refresh tokens of `/auth`, of users and of service accounts, + also revoke them, and every use of a code, a device code or a token checks the user and the + client's allowed groups again. + +Consequences: + +- **Requirements:** the package depends on `python3-oauthlib` and `python3-jwt`. The provider needs + HTTPS, a public name and the web service. +- **Management:** clients, resources and their roles are managed like service accounts, by the + CLI, `vpn-router-role`, `/service-accounts` and the Router Manager. +- **The directory keeps itself consistent:** + - a group a client allows cannot be removed, and renaming it renames it in the clients and the + roles; + - a resource scope a client may ask for cannot be removed. +- **Revocation:** an access token, a JWT, is valid at a resource until it expires, 5 minutes after + issue, whatever the router revokes. The signing key does not rotate; replacing it invalidates + every token issued. +- **Lockout:** wrong client secrets lock the client like any account. +- **Not offered:** the implicit and hybrid flows, the password grant and dynamic client + registration. ## ADR-38: Bash completions are files per command in bash-completion's completions directory diff --git a/README.md b/README.md index 1b019d3..7351b5d 100644 --- a/README.md +++ b/README.md @@ -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 --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate | | `add [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused | -| `add --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 --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 [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | -| `update [--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 [--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 ` | Sets the password | | `disable `, `enable ` | Locks the user out of OpenVPN and `/auth`, and lets the user in again | | `unlock ` | 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/` | 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/` | 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//password` | `{"password": ...}` | the account | | `POST /service-accounts//disable` | | the account | | `POST /service-accounts//enable` | | the account | | `POST /service-accounts//unlock` | | the account | | `DELETE /service-accounts/` | | `{"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_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, `:`. ```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 + `:` (`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 +`/`, 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 []` | The roles with the groups and accounts they are assigned to | +| `vpn-router-role add [--description D]` | Add a role | +| `vpn-router-role remove ` | Remove a role | +| `vpn-router-role member-add --group G\|--account A` | Assign the role | +| `vpn-router-role member-remove --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 | +| `:` | 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 diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 79294ce..999e6cf 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,32 @@ +vpn-router (0.37.0-1) unstable; urgency=medium + + 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. + + * A client may have the grants client_credentials, by which a + confidential client gets an access token as its own service account, + and the device code (RFC 8628): POST /oidc/device_authorization gives + a device code and a user code, the user enters the code on + /oidc/device, signs in and allows or denies the device, and the device + polls /oidc/token. A public client has no secret. + * An http loopback redirect URI matches with any port. + * Every service account is a resource: it defines scopes, which clients + ask for as : when their client scopes allow them, and + roles, assigned to groups, users and service accounts. A token request + names its resource with resource (RFC 8707) or by its scopes. + * Access tokens are RS256 JWTs of RFC 9068 for the resource, or the + client, with client_id, scope, roles and the claims of the scopes; ID + tokens and /oidc/userinfo carry the user's roles of the client. + * vpn-router-user takes --grant, --public-client, --client-scope and + --resource-scope; the new vpn-router-role manages the roles; and + /service-accounts takes and answers grants, public_client, + client_scopes, resource_scopes and roles. + * Changing the password of a service account, disabling, unlocking or + removing it through /service-accounts revokes its tokens. + * POST /oidc/device and /oidc/device_authorization are rate limited as + /oidc/login is. + + -- Slawomir Koszewski Fri, 30 Oct 2026 00:57:11 +0100 + vpn-router (0.36.0-1) unstable; urgency=medium feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code. diff --git a/debian-package/debian/install b/debian-package/debian/install index 2ceabc9..7315f72 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -21,6 +21,7 @@ src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/ src/usr/bin/vpn-router-editor usr/bin/ src/usr/sbin/vpn-router-user usr/sbin/ src/usr/sbin/vpn-router-group usr/sbin/ +src/usr/sbin/vpn-router-role usr/sbin/ src/usr/sbin/vpn-router-token usr/sbin/ src/usr/sbin/vpn-config-edit usr/sbin/ src/usr/sbin/vpn-router-backup usr/sbin/ diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 29d7d1d..d6c178e 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -18,6 +18,9 @@ vpnRouterConsoleLogin TRUE to the system over the local socket. ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per certificate the CA issued. Private keys are readable by root alone, the database's root DN. + +ou=roles holds the roles of the resources of the OpenID Provider: an entry per role, +cn=:, whose members are the groups, users and service accounts it is assigned to. """ import os @@ -39,6 +42,7 @@ GROUPS = f'ou=groups,{BASE}' SERVICES = f'ou=services,{BASE}' POLICIES = f'ou=policies,{BASE}' PKI = f'ou=pki,{BASE}' +ROLES = f'ou=roles,{BASE}' DEFAULT_POLICY = f'cn=default,{POLICIES}' TOTP_PARAMS = f'cn=totp,{POLICIES}' MANAGER = f'cn=user-manager,{SERVICES}' @@ -71,7 +75,12 @@ OVERLAYS = ('ppolicy', 'otp') #: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri #: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in #: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the -#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its +#: groups whose members may sign in to it; vpnRouterGrantType, the further grants it may use, +#: vpnRouterPublicClient, whether it has no secret, and vpnRouterClientScope, the scopes of +#: resources it may ask for, as :. A service account with vpnRouterResourceScope +#: is a resource of the OpenID Provider with those scopes. vpnRouterRole is a role of a resource, +#: named cn=:, with the DNs of the groups and accounts it is assigned to in +#: member. vpnRouterAuthority is a CA of the PKI: its #: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the #: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from, #: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its @@ -112,18 +121,29 @@ SCHEMA_ATTRIBUTE_TYPES = ( "OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )", f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an " "OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )", + f"( {SCHEMA_OID}.1.20 NAME 'vpnRouterGrantType' DESC 'VPN router: a grant an OpenID Connect client may use besides " + "the authorization code' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )", + f"( {SCHEMA_OID}.1.21 NAME 'vpnRouterPublicClient' DESC 'VPN router: whether an OpenID Connect client has no secret' " + "EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )", + f"( {SCHEMA_OID}.1.22 NAME 'vpnRouterClientScope' DESC 'VPN router: a scope of a resource an OpenID Connect client " + "may ask for' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )", + f"( {SCHEMA_OID}.1.23 NAME 'vpnRouterResourceScope' DESC 'VPN router: a scope of a resource of the OpenID Provider' " + "EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )", ) SCHEMA_OBJECT_CLASSES = ( f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' " "SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ " "vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ " - "vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )", + "vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup $ vpnRouterGrantType $ vpnRouterPublicClient $ " + "vpnRouterClientScope $ vpnRouterResourceScope ) )", f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn " "MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ " "vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )", f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top " "STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ " "vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )", + f"( {SCHEMA_OID}.2.4 NAME 'vpnRouterRole' DESC 'VPN router: a role of a resource of the OpenID Provider' SUP top " + "STRUCTURAL MUST cn MAY ( description $ member ) )", ) #: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now. @@ -152,6 +172,7 @@ ACCESS = ( f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none', f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read', f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read', + f'to dn.subtree="{ROLES}" by dn.exact="{MANAGER}" manage by * read', 'to * by * read', ) @@ -333,7 +354,7 @@ def provision(): database = _ensure_database(connection) _ensure_overlays(connection, database) _ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']}) - for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI): + for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI, ROLES): _ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]}) _ensure_entry(connection, DEFAULT_POLICY, { 'objectClass': [b'organizationalRole', b'pwdPolicy'], diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py index c4a7552..461d1f1 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py @@ -3,28 +3,45 @@ While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its -clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its -secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed -groups admits only their members; a service account never signs in as a user. +clients, the service accounts with redirect URIs or grants (vpnrouter_users.client_settings): a client's ID is its name +and its secret its password, sent with HTTP Basic authentication or in the body of the token request; a public client +sends only its ID. A client with allowed groups admits only their members; a service account never signs in as a user. GET /.well-known/openid-configuration the discovery document - GET /oidc/jwks the public key the ID tokens are signed with + GET /oidc/jwks the public key the ID tokens and the access tokens are signed with GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while the browser has no session, or with prompt=login or an older one than max_age POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"} - POST /oidc/token exchange a code or a refresh token for tokens + POST /oidc/token exchange a code, a refresh token or a device code for tokens, or a client's + credentials for an access token + POST /oidc/device_authorization begin the device authorization grant (RFC 8628): a device code and a user code + GET, POST /oidc/device the page where the user enters the user code, signs in and is asked to allow + the device + POST /oidc/device/decide allow or deny the device GET, POST /oidc/userinfo the claims of the user an access token was issued for GET /oidc/logout end the session, then go to a post-logout redirect URI of the client -ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by -the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the -same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups, -the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid -CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session, -the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the -database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A -client is registered by an administrator, so a user is not asked for consent. +The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client +gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but +client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or +/, or by the resource's scopes it asks for, :, of its client scopes; without one the +tokens are for the client itself. + +ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by the +RFC 7638 thumbprint of its public key, for the client. Access tokens are RS256 JWTs of RFC 9068 with the same key, for +the resource or the client: iss, sub, aud, client_id, iat, exp, jti, scope, roles, the claims of the scopes, and of a +user auth_time and amr. sub is the entryUUID of the user's entry, so a user added again under the same name is another +subject. The scopes: openid, required but with client_credentials, which refuses it; profile, preferred_username and +the names; email; groups, the names of the user's groups; their claims are in the ID token, the access token and the +userinfo answer; and the scopes of the resources. roles are the roles of the resource assigned to the user or service +account or a group of it (vpnrouter_users.roles_held), read at every issue; the ID token and the userinfo answer have +those of the client. A code is valid CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token +REFRESH_SECONDS, used once, and a device code DEVICE_SECONDS, polled at most every DEVICE_INTERVAL seconds, 5 more after +each poll that came too soon. A session, the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, +tokens, device codes and sessions are kept in the database of vpnrouter_auth as their SHA-256 +(vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's or a service account's. A client is registered by an +administrator, so a user is not asked for consent, but for a device, which the user allows on the router's page. """ import base64 @@ -45,8 +62,13 @@ import jwt import ldap from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from oauthlib.common import add_params_to_uri -from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error +from oauthlib.common import Request, add_params_to_uri +from oauthlib.oauth2 import (AccessDeniedError, FatalClientError, InvalidClientError, InvalidGrantError, + InvalidRequestError, InvalidScopeError, OAuth2Error, UnauthorizedClientError, + UnsupportedGrantTypeError) +from oauthlib.oauth2.rfc6749 import utils +from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase +from oauthlib.oauth2.rfc6749.tokens import random_token_generator from oauthlib.openid import RequestValidator, Server from oauthlib.openid.connect.core.exceptions import LoginRequired @@ -63,6 +85,14 @@ CODE_SECONDS = 60 ACCESS_SECONDS = 300 REFRESH_SECONDS = 8 * 3600 SESSION_SECONDS = 8 * 3600 +DEVICE_SECONDS = 600 +DEVICE_INTERVAL = 5 + +#: The grant type of the device authorization grant. +DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code' +#: A user code: USER_CODE_LENGTH of these letters, shown in two halves. +USER_CODE_LETTERS = 'BCDFGHJKLMNPQRSTVWXZ' +USER_CODE_LENGTH = 8 COOKIE = 'vpn_router_oidc' @@ -75,10 +105,11 @@ SCOPE_CLAIMS = { SCOPES = ('openid', *SCOPE_CLAIMS) #: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a -#: passkey, which verifies the user. +#: passkey, which verifies the user; a service account's client credentials are its password. PASSWORD_AMR = ['pwd'] OTP_AMR = ['pwd', 'otp'] PASSKEY_AMR = ['hwk', 'user'] +SERVICE_AMR = ['pwd'] TEMPLATE = 'oidc-page.html.j2' @@ -96,6 +127,26 @@ _pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLAT undefined=jinja2.StrictUndefined) +class InvalidTarget(OAuth2Error): + """A resource that is unknown, or more than one (RFC 8707).""" + error = 'invalid_target' + + +class AuthorizationPending(OAuth2Error): + """A device code the user has not yet allowed or denied (RFC 8628).""" + error = 'authorization_pending' + + +class SlowDown(OAuth2Error): + """A device code polled sooner than its interval (RFC 8628).""" + error = 'slow_down' + + +class ExpiredToken(OAuth2Error): + """A device code that has expired (RFC 8628).""" + error = 'expired_token' + + def ensure_key(): """Make the signing key when it is missing.""" if KEY.is_file() and KEY.stat().st_size: @@ -122,30 +173,33 @@ def public_jwk(key): return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'} -def sign(claims): +def sign(claims, headers=None): + """The claims as an RS256 JWT with the key's kid and the headers given.""" key = private_key() - return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']}) + return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid'], **(headers or {})}) -def discovery(issuer): - """The discovery document of OpenID Connect Discovery 1.0.""" +def discovery(issuer, resource_scopes): + """The discovery document of OpenID Connect Discovery 1.0, with the device authorization endpoint of RFC 8628; + resource_scopes are the scopes of the resources.""" return { 'issuer': issuer, 'authorization_endpoint': f'{issuer}/oidc/authorize', 'token_endpoint': f'{issuer}/oidc/token', + 'device_authorization_endpoint': f'{issuer}/oidc/device_authorization', 'userinfo_endpoint': f'{issuer}/oidc/userinfo', 'jwks_uri': f'{issuer}/oidc/jwks', 'end_session_endpoint': f'{issuer}/oidc/logout', - 'scopes_supported': list(SCOPES), + 'scopes_supported': [*SCOPES, *resource_scopes], 'response_types_supported': ['code'], 'response_modes_supported': ['query'], - 'grant_types_supported': ['authorization_code', 'refresh_token'], + 'grant_types_supported': ['authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT], 'subject_types_supported': ['public'], 'id_token_signing_alg_values_supported': ['RS256'], - 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], + 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post', 'none'], 'code_challenge_methods_supported': ['S256'], - 'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', - *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)], + 'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id', + 'scope', 'roles', *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)], } @@ -159,19 +213,52 @@ def directory(): connection.unbind_s() -def client_of(client_id): - """The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service - account with redirect URIs; else None.""" - if not isinstance(client_id, str): +def account_of(name): + """A namespace of the vpnrouter_users.summary of the service account of the name, else None.""" + if not isinstance(name, str): return None with directory() as connection: try: - user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id)) + user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name)) except vpnrouter_users.Failure: return None - if not user['service'] or not user['enabled'] or not user['redirect_uris']: + return types.SimpleNamespace(**user) if user['service'] else None + + +def client_of(client_id): + """The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service + account with redirect URIs or grants; else None.""" + account = account_of(client_id) + if account is None or not account.enabled or not (account.redirect_uris or account.grants): return None - return types.SimpleNamespace(client_id=client_id, **user) + account.client_id = client_id + return account + + +def resource_of(name): + """The resource of the name, a namespace of its vpnrouter_users.summary, when it is an enabled service account that + is not internal; else None.""" + account = account_of(name) + return account if account is not None and account.enabled and not account.internal else None + + +def resource_scopes(): + """The scopes of every resource, :, sorted.""" + with directory() as connection: + users = vpnrouter_users.list_users(connection)['users'] + return sorted(f'{user["name"]}:{scope}' for user in users + if user['service'] and user['enabled'] and not user['internal'] for scope in user['resource_scopes']) + + +def account_claims(connection, name): + """The summary of the entry of the name and its claims of every scope, with sub; Failure when there is none.""" + user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name)) + groups = sorted(vpnrouter_users.groups_of(connection, name)) + uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID'] + full = ' '.join(part for part in (user['first_name'], user['last_name']) if part) + claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'], + 'family_name': user['last_name'], 'email': user['email'], 'groups': groups} + return user, {claim: value for claim, value in claims.items() if value is not None} def subject(name, client): @@ -179,20 +266,26 @@ def subject(name, client): user who is not a service account, a member of one of the client's allowed groups when it has any; else None.""" with directory() as connection: try: - attributes = vpnrouter_users.entry(connection, name) + user, claims = account_claims(connection, name) except vpnrouter_users.Failure: return None - user = vpnrouter_users.summary(attributes) - groups = sorted(vpnrouter_users.groups_of(connection, name)) - uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID'] if user['service'] or not user['enabled'] or user['locked']: return None - if client.allowed_groups and not set(client.allowed_groups) & set(groups): + if client.allowed_groups and not set(client.allowed_groups) & set(claims['groups']): return None - full = ' '.join(part for part in (user['first_name'], user['last_name']) if part) - claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'], - 'family_name': user['last_name'], 'email': user['email'], 'groups': groups} - return {claim: value for claim, value in claims.items() if value is not None} + return claims + + +def service_subject(name): + """The claims of every scope of the service account, with sub.""" + with directory() as connection: + return account_claims(connection, name)[1] + + +def roles_at(resource, name): + """The roles of the resource the user or service account holds.""" + with directory() as connection: + return vpnrouter_users.roles_held(connection, resource, name) def claims_of(person, scopes): @@ -201,6 +294,46 @@ def claims_of(person, scopes): if claim in person}} +def target_of(issuer, resource, scopes): + """The name of the resource a request with the resource parameter, None when absent, and the scopes names, None + for none; InvalidTarget when they name more than one or one that is not a resource.""" + names = {scope.partition(':')[0] for scope in scopes if scope not in SCOPES} + if resource is not None: + names.add(resource[len(issuer) + 1:] if isinstance(resource, str) and resource.startswith(f'{issuer}/') else resource) + if not names: + return None + if len(names) > 1: + raise InvalidTarget(description='the resource and the scopes name more than one resource') + name = names.pop() + if resource_of(name) is None: + raise InvalidTarget(description=f'{name} is not a resource') + return name + + +def scopes_allowed(client, scopes, target, grant_type): + """Whether the client may ask for the scopes: openid, required but by client_credentials, which refuses it, the + other scopes of SCOPES, and those of its client scopes that belong to the target.""" + for scope in scopes: + if scope not in SCOPES and (scope not in client.client_scopes or scope.partition(':')[0] != target): + return False + return ('openid' not in scopes) if grant_type == 'client_credentials' else ('openid' in scopes) + + +def same_redirect(registered, given): + """Whether the redirect URI given is the registered one; an http URI of a loopback host may have any port (RFC 8252 + 7.3).""" + if registered == given: + return True + try: + wanted, found = urllib.parse.urlsplit(registered), urllib.parse.urlsplit(given) + # A port that is not a number makes .port raise ValueError. + wanted.port, found.port + except (ValueError, TypeError, AttributeError): + return False + return wanted.scheme == 'http' and wanted.hostname in vpnrouter_users.LOOPBACK_HOSTS and not found.fragment and \ + (wanted.scheme, wanted.hostname, wanted.path, wanted.query) == (found.scheme, found.hostname, found.path, found.query) + + def cookie_value(cookies): """The value of the session cookie in a Cookie header, or ''.""" try: @@ -220,11 +353,31 @@ def session_of(cfg, cookies): return Session(*row) if row else None +def access_token(cfg, request): + """The access token of a token request: a JWT of RFC 9068 for the request's target, or the client.""" + now = int(time.time()) + client_id = request.client.client_id + service = request.grant_type == 'client_credentials' + name = client_id if service else request.user + person = service_subject(client_id) if service else request.oidc_person + target = getattr(request, 'oidc_target', None) + claims = {**claims_of(person, request.scopes), 'iss': vpnrouter.oidc_issuer(cfg), 'aud': target or client_id, + 'client_id': client_id, 'iat': now, 'exp': now + ACCESS_SECONDS, 'jti': secrets.token_urlsafe(16), + 'scope': ' '.join(request.scopes)} + roles = roles_at(target or client_id, name) + if roles: + claims['roles'] = roles + if not service: + claims.update({'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr}) + return sign(claims, {'typ': 'at+jwt'}) + + class Validator(RequestValidator): """The checks and the storage of oauthlib's server for the router's clients, users and database. - Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a - token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person. + The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a + token, a device code or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person, the + resource the tokens are for as oidc_target. """ def __init__(self, cfg): @@ -243,6 +396,14 @@ class Validator(RequestValidator): def session(self, request): return session_of(self.cfg, request.headers.get('Cookie')) + def check_target(self, request, stored): + """Refuse a resource parameter that names another resource than the one the grant was made for.""" + resource = getattr(request, 'resource', None) + if 'resource' in request.duplicate_params or \ + resource is not None and target_of(vpnrouter.oidc_issuer(self.cfg), resource, []) != stored: + raise InvalidTarget(description='the grant was made for another resource', request=request) + request.oidc_target = stored + # Clients. def validate_client_id(self, client_id, request, *args, **kwargs): @@ -250,7 +411,7 @@ class Validator(RequestValidator): return request.client is not None def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): - return redirect_uri in request.client.redirect_uris + return any(same_redirect(uri, redirect_uri) for uri in request.client.redirect_uris) def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return None @@ -259,17 +420,29 @@ class Validator(RequestValidator): return response_type == 'code' def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): - return 'openid' in scopes and set(scopes) <= set(SCOPES) + """Whether the client may ask for the scopes; the resource they are for is kept as oidc_target.""" + if 'resource' in request.duplicate_params: + raise InvalidTarget(description='give one resource', request=request) + try: + request.oidc_target = target_of(vpnrouter.oidc_issuer(self.cfg), getattr(request, 'resource', None), scopes) + except InvalidTarget as error: + raise InvalidTarget(description=error.description, request=request) + return scopes_allowed(client, scopes, request.oidc_target, request.grant_type) def get_default_scopes(self, client_id, request, *args, **kwargs): - return ['openid'] + return [] if request.grant_type == 'client_credentials' else ['openid'] def is_pkce_required(self, client_id, request): return True + def client_authentication_required(self, request, *args, **kwargs): + """Whether the client must sign the request with its secret: unless it is a public client named by client_id.""" + client = client_of(request.client_id) + return client is None or not client.public_client + def authenticate_client(self, request, *args, **kwargs): """Check the client's secret, the password of its service account, given with HTTP Basic authentication, its - ID and secret form-encoded (RFC 6749 2.3.1), or in the body.""" + ID and secret form-encoded (RFC 6749 2.3.1), or in the body; a public client has none.""" name, secret = request.client_id, request.client_secret header = request.headers.get('Authorization', '') if header[:6].lower() == 'basic ': @@ -279,7 +452,7 @@ class Validator(RequestValidator): return False name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret) client = client_of(name) - if client is None: + if client is None or client.public_client: return False try: vpnrouter_users.login(name, secret, None) @@ -289,10 +462,24 @@ class Validator(RequestValidator): return True def authenticate_client_id(self, client_id, request, *args, **kwargs): - return False + """Accept a public client by its ID.""" + client = client_of(client_id) + if client is None or not client.public_client: + return False + request.client = client + return True def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): - return grant_type in ('authorization_code', 'refresh_token') + """The grants of the client: authorization_code with redirect URIs, client_credentials but for a public client, + the device code, and refresh_token with either of the first and the last.""" + grants = set(client.grants) - ({'client_credentials'} if client.public_client else set()) + if client.redirect_uris: + grants.add('authorization_code') + if 'device_code' in grants: + grants.add(DEVICE_GRANT) + if grants & {'authorization_code', DEVICE_GRANT}: + grants.add('refresh_token') + return grant_type in grants def validate_user(self, username, password, client, request, *args, **kwargs): return False @@ -300,15 +487,15 @@ class Validator(RequestValidator): # Codes. def save_authorization_code(self, client_id, code, request, *args, **kwargs): - self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)', + self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)', (vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes, request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr, - CODE_SECONDS))) + getattr(request, 'oidc_target', None), CODE_SECONDS))) def code(self, code): row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method', - 'auth_time', 'amr'), row)) if row else None + 'auth_time', 'amr', 'resource'), row)) if row else None def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): found = self.code(code) @@ -321,6 +508,7 @@ class Validator(RequestValidator): person = subject(found['name'], client) if person is None: return False + self.check_target(request, found['resource']) request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr'] request.oidc_code, request.oidc_person = found, person return True @@ -340,20 +528,53 @@ class Validator(RequestValidator): def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),))) + # Device codes. + + def poll_device(self, code, request): + """Poll the device code for the request's client: with the user's sign-in on the request when the user allowed + it, else the error RFC 8628 gives.""" + row = self.fetch('SELECT * FROM oidc_device_poll(%s)', (vpnrouter_auth.digest(code),)) \ + if isinstance(code, str) and code else None + found = dict(zip(('client_id', 'scopes', 'resource', 'state', 'name', 'auth_time', 'amr', 'expired', 'too_soon'), + row)) if row else None + if found is None or found['client_id'] != request.client.client_id: + raise InvalidGrantError(description='the device code is unknown', request=request) + if found['expired']: + raise ExpiredToken(description='the device code has expired', request=request) + if found['state'] == 'pending': + raise (SlowDown if found['too_soon'] else AuthorizationPending)(request=request) + if found['state'] == 'denied': + self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),))) + raise AccessDeniedError(description='the user denied the device', request=request) + person = subject(found['name'], request.client) + if person is None: + raise AccessDeniedError(description=f'{found["name"]} may not sign in to this client', request=request) + self.check_target(request, found['resource']) + request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr'] + request.oidc_person = person + + def delete_device(self, code): + self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),))) + # Tokens. def token(self, token, kind): row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \ if isinstance(token, str) and token else None - return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None + return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr', 'resource'), row)) if row else None def save_bearer_token(self, token, request, *args, **kwargs): - """Keep the access token and the refresh token; a refresh token that was exchanged is used up.""" - values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr) - statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)', + """Keep the access token and the refresh token; a refresh token that was exchanged is used up. A token of + client_credentials is kept for its service account.""" + if request.grant_type == 'client_credentials': + name, auth_time, amr = request.client.client_id, datetime.datetime.now(datetime.timezone.utc), SERVICE_AMR + else: + name, auth_time, amr = request.user, request.auth_time, request.amr + values = (request.client.client_id, name, request.scopes, auth_time, amr, getattr(request, 'oidc_target', None)) + statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s, %s)', (vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))] if token.get('refresh_token'): - statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)', + statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s, %s)', (vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS))) if request.grant_type == 'refresh_token': statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),))) @@ -366,6 +587,7 @@ class Validator(RequestValidator): person = subject(found['name'], client) if person is None: return False + self.check_target(request, found['resource']) request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr'] request.oidc_scopes, request.oidc_person = found['scopes'], person return True @@ -385,7 +607,9 @@ class Validator(RequestValidator): return True def get_userinfo_claims(self, request): - return claims_of(request.oidc_person, request.oidc_scopes) + claims = claims_of(request.oidc_person, request.oidc_scopes) + roles = roles_at(request.client.client_id, request.user) + return {**claims, 'roles': roles} if roles else claims # OpenID Connect. @@ -394,6 +618,9 @@ class Validator(RequestValidator): id_token.update(claims_of(request.oidc_person, request.scopes)) id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS, 'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr}) + roles = roles_at(request.client.client_id, request.user) + if roles: + id_token['roles'] = roles return sign(id_token) def validate_silent_login(self, request): @@ -417,10 +644,100 @@ class Validator(RequestValidator): return person is not None and hint.get('sub') == person['sub'] -def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()): - """An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request.""" +class DeviceCodeGrant(GrantTypeBase): + """The token request of the device authorization grant (RFC 8628): a device code the user allowed gives an access + token, a refresh token and, with openid, an ID token; until then the request is answered authorization_pending or + slow_down.""" + + def create_token_response(self, request, token_handler): + headers = self._get_default_headers() + try: + self.validate_token_request(request) + except OAuth2Error as error: + headers.update(error.headers) + return headers, error.json, error.status_code + token = token_handler.create_token(request, refresh_token=True) + if 'openid' in request.scopes: + token['id_token'] = self.request_validator.finalize_id_token( + {'aud': request.client.client_id, 'iat': int(time.time())}, token, token_handler, request) + self.request_validator.save_token(token, request) + self.request_validator.delete_device(request.device_code) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + if request.grant_type != DEVICE_GRANT: + raise UnsupportedGrantTypeError(request=request) + for param in ('grant_type', 'device_code'): + if param in request.duplicate_params: + raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request) + request.device_code = getattr(request, 'device_code', None) + if not request.device_code: + raise InvalidRequestError(description='Missing device_code parameter.', request=request) + if self.request_validator.client_authentication_required(request): + if not self.request_validator.authenticate_client(request): + raise InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + raise InvalidClientError(request=request) + self.validate_grant_type(request) + self.request_validator.poll_device(request.device_code, request) + + +def user_code_of(value): + """The user code a user typed, without the dash and spaces, in capitals; '' when it is not one.""" + code = ''.join(character for character in (value or '').upper() if character.isalnum()) + return code if len(code) == USER_CODE_LENGTH and all(character in USER_CODE_LETTERS for character in code) else '' + + +def device_authorization(validator, cfg, text, headers): + """Answer a device authorization request (RFC 8628 3.1): the client, public by client_id or confidential with its + secret, with the device_code grant, the scopes and the resource; the device code, the user code and the + verification URIs.""" + issuer = vpnrouter.oidc_issuer(cfg) + request = Request(f'{issuer}/oidc/device_authorization', 'POST', text, headers) + for param in ('scope', 'resource'): + if param in request.duplicate_params: + raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request) + if validator.client_authentication_required(request): + if not validator.authenticate_client(request): + raise InvalidClientError(request=request) + elif not validator.authenticate_client_id(request.client_id, request): + raise InvalidClientError(request=request) + if not validator.validate_grant_type(request.client.client_id, DEVICE_GRANT, request.client, request): + raise UnauthorizedClientError(description='the client may not use the device authorization grant', request=request) + scopes = utils.scope_to_list(request.scope) or ['openid'] + target = target_of(issuer, getattr(request, 'resource', None), scopes) + if not scopes_allowed(request.client, scopes, target, DEVICE_GRANT): + raise InvalidScopeError(description='the client may not ask for these scopes; openid is required', request=request) + device_code = secrets.token_urlsafe(32) + user_code = ''.join(secrets.choice(USER_CODE_LETTERS) for _ in range(USER_CODE_LENGTH)) + with vpnrouter_auth.database(cfg) as connection: + connection.execute('CALL oidc_device_save(%s, %s, %s, %s, %s, %s, %s)', + (vpnrouter_auth.digest(device_code), vpnrouter_auth.digest(user_code), request.client.client_id, + scopes, target, DEVICE_INTERVAL, DEVICE_SECONDS)) + shown = f'{user_code[:USER_CODE_LENGTH // 2]}-{user_code[USER_CODE_LENGTH // 2:]}' + return document(200, {'device_code': device_code, 'user_code': shown, 'verification_uri': f'{issuer}/oidc/device', + 'verification_uri_complete': f'{issuer}/oidc/device?' + urllib.parse.urlencode({'user_code': shown}), + 'expires_in': DEVICE_SECONDS, 'interval': DEVICE_INTERVAL}) + + +def device_of(cfg, user_code): + """The waiting device code of a user code, {client_id, scopes, resource}, or None.""" + code = user_code_of(user_code) + if not code: + return None + with vpnrouter_auth.database(cfg) as connection: + row = connection.execute('SELECT * FROM oidc_device_find(%s)', (vpnrouter_auth.digest(code),)).fetchone() + return dict(zip(('client_id', 'scopes', 'resource'), row)) if row else None + + +def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=(), action='/oidc/login', + device=False, user_code='', decide=False, items=()): + """An HTML page of TEMPLATE: a message, an error and items; with form the sign-in form, posted to action with the + authorization request, or with device the user code field, with or without the sign-in fields; with decide the + Allow and Deny buttons of the device of the user code.""" body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request, - name=name, issuer=vpnrouter.oidc_issuer(cfg)) + name=name, issuer=vpnrouter.oidc_issuer(cfg), action=action, device=device, + user_code=user_code, decide=decide, items=list(items)) return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers] @@ -443,6 +760,30 @@ def cookie(value, seconds): return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax' +def sign_in(form): + """The name and the amr of a sign-in with the form's name, password and code, or its passkey; Failure when it is + refused, ValueError when the passkey's answer is not JSON.""" + if form.get('passkey_id'): + with vpnrouter_users.session() as connection: + name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', ''))) + return name, PASSKEY_AMR + name, code = form.get('name', '').strip(), form.get('code', '').strip() + vpnrouter_users.login(name, form.get('password', ''), code or None) + with directory() as connection: + if vpnrouter_users.service(vpnrouter_users.entry(connection, name)): + raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5) + return name, OTP_AMR if code else PASSWORD_AMR + + +def start_session(cfg, name, amr): + """Start a session of the user: a namespace of the Session and the header that sets its cookie.""" + value = secrets.token_urlsafe(32) + with vpnrouter_auth.database(cfg) as connection: + connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS)) + return types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr), + cookie=cookie(value, SESSION_SECONDS)) + + def authorize(server, cfg, params, headers, fresh=None, name='', error=''): """Answer an authorization request, the query or the form params: the sign-in page while the browser has no session, or with prompt=login or max_age older than the session, else a redirect to the client with a code. @@ -491,27 +832,72 @@ def login(server, cfg, form, headers): params = form.get('request', '') name = form.get('name', '').strip() try: - if form.get('passkey_id'): - with vpnrouter_users.session() as connection: - name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', ''))) - amr = PASSKEY_AMR - else: - code = form.get('code', '').strip() - vpnrouter_users.login(name, form.get('password', ''), code or None) - with directory() as connection: - if vpnrouter_users.service(vpnrouter_users.entry(connection, name)): - raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5) - amr = OTP_AMR if code else PASSWORD_AMR + name, amr = sign_in(form) except vpnrouter_users.Failure as failure: return authorize(server, cfg, params, headers, name=name, error=str(failure)) except ValueError: return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted') - value = secrets.token_urlsafe(32) + return authorize(server, cfg, params, headers, fresh=start_session(cfg, name, amr)) + + +def device_page(cfg, status=200, signed_in=False, user_code='', name='', error='', headers=()): + """The page where the user enters the user code of a device, with the sign-in fields unless signed_in.""" + return page(status, cfg, 'Sign in on a device', message='Enter the code the device shows.', error=error, + form=not signed_in, name=name, headers=headers, action='/oidc/device', device=True, user_code=user_code) + + +def device(cfg, form, headers): + """The device page's form: sign in when the browser has no session, then ask the user to allow the device of the + user code.""" + user_code, fresh = form.get('user_code', ''), None + session = session_of(cfg, headers.get('Cookie')) + if session is None: + try: + name, amr = sign_in(form) + except vpnrouter_users.Failure as failure: + return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(), error=str(failure)) + except ValueError: + return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(), + error='the passkey is not accepted') + fresh = start_session(cfg, name, amr) + session = fresh.session + extra = [fresh.cookie] if fresh else [] + found = device_of(cfg, user_code) + client = client_of(found['client_id']) if found else None + if client is None: + return device_page(cfg, 400, signed_in=True, user_code=user_code, error='the code is wrong or has expired', + headers=extra) + shown = client.display_name or client.client_id + if subject(session.name, client) is None: + return device_page(cfg, 403, signed_in=True, user_code=user_code, headers=extra, + error=f'{session.name} may not sign in to {shown}') + items = ([f'resource: {found["resource"]}'] if found['resource'] else []) + [f'scope: {scope}' for scope in found['scopes']] + return page(200, cfg, f'Allow {shown}?', message=f'{shown} asks to sign in as {session.name} on a device.', + items=items, decide=True, user_code=user_code_of(user_code), headers=extra) + + +def decide(cfg, form, headers): + """Allow or deny the device of the user code as the user of the session.""" + session = session_of(cfg, headers.get('Cookie')) + if session is None: + return device_page(cfg, 401, user_code=form.get('user_code', ''), error='sign in first') + found = device_of(cfg, form.get('user_code', '')) + client = client_of(found['client_id']) if found else None + if client is None or subject(session.name, client) is None: + return device_page(cfg, 400, signed_in=True, user_code=form.get('user_code', ''), + error='the code is wrong or has expired') + allowed = form.get('decision') == 'allow' with vpnrouter_auth.database(cfg) as connection: - connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS)) - fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr), - cookie=cookie(value, SESSION_SECONDS)) - return authorize(server, cfg, params, headers, fresh=fresh) + decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)', + (vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name, + session.auth_time, session.amr, allowed)).fetchone()[0] + if not decided: + return device_page(cfg, 400, signed_in=True, error='the code is wrong or has expired') + shown = client.display_name or client.client_id + if allowed: + return page(200, cfg, 'The device is signed in', message=f'{shown} is signed in as {session.name}. You can return ' + 'to the device.') + return page(200, cfg, 'The device is denied', message=f'{shown} is not signed in.') def logout(cfg, query, headers): @@ -552,9 +938,12 @@ def answer(method, target, headers, body): issuer = vpnrouter.oidc_issuer(cfg) headers = dict(headers.items()) text = body.decode('utf-8', 'replace') if body else '' - server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS) + validator = Validator(cfg) + server = Server(validator, token_expires_in=ACCESS_SECONDS, token_generator=lambda request: access_token(cfg, request), + refresh_token_generator=random_token_generator) + server.grant_types[DEVICE_GRANT] = DeviceCodeGrant(validator) if (method, path) == ('GET', '/.well-known/openid-configuration'): - return document(200, discovery(issuer)) + return document(200, discovery(issuer, resource_scopes())) if (method, path) == ('GET', '/oidc/jwks'): return document(200, {'keys': [public_jwk(private_key())]}) if path == '/oidc/authorize' and method in ('GET', 'POST'): @@ -565,6 +954,15 @@ def answer(method, target, headers, body): return document(200, vpnrouter_passkeys.login_begin()) if (method, path) == ('POST', '/oidc/token'): return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers)) + if (method, path) == ('POST', '/oidc/device_authorization'): + return device_authorization(validator, cfg, text, headers) + if (method, path) == ('GET', '/oidc/device'): + signed_in = session_of(cfg, headers.get('Cookie')) is not None + return device_page(cfg, signed_in=signed_in, user_code=dict(urllib.parse.parse_qsl(query)).get('user_code', '')) + if (method, path) == ('POST', '/oidc/device'): + return device(cfg, dict(urllib.parse.parse_qsl(text)), headers) + if (method, path) == ('POST', '/oidc/device/decide'): + return decide(cfg, dict(urllib.parse.parse_qsl(text)), headers) if path == '/oidc/userinfo' and method in ('GET', 'POST'): return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers)) if (method, path) == ('GET', '/oidc/logout'): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 09de200..b0c2a84 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -15,10 +15,18 @@ internal service accounts are those the router creates and manages for its own s included (internal_accounts); vpn-router-setup keeps the metrics service's. Users exist whether or not a P2S system is enabled. -A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and -its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in, -post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every -user who is not a service account when there are none. +A service account with redirect URIs or grants is a client of the OpenID Provider (vpnrouter_oidc), its name the +client ID and its password the client secret (client_settings): redirect_uris, where the provider sends a user back +after a sign-in, post_logout_redirect_uris, after a sign-out, allowed_groups, the groups whose members may sign in to it, +every user who is not a service account when there are none, grants, those of GRANTS it may use besides the +authorization code, public_client, whether it signs no request with its secret, which a client with client_credentials +must, and client_scopes, the scopes of resources it may ask for, as :. + +A service account is also a resource of the OpenID Provider, the audience of the tokens issued for it: resource_scopes +are the scopes it defines, and its roles (roles_of_resource) are entries cn=: of vpnrouter_ldap.ROLES +whose members are the groups and accounts the role is assigned to. A scope a client may ask for cannot be removed, nor +the resource while a client may ask for its scopes; removing an account removes its roles and takes it out of the +others, and removing or renaming a group does the same in the roles. A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an optional description and at least one member; a group cannot be empty, so the last member @@ -105,9 +113,20 @@ LABELS = (('display_name', 'displayName'), ('description', 'description')) ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope', 'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount', 'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri', - 'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup'] + 'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup', 'vpnRouterGrantType', 'vpnRouterPublicClient', + 'vpnRouterClientScope', 'vpnRouterResourceScope'] +#: The grants an OpenID Provider client may have besides the authorization code, which its redirect URIs give it. +GRANTS = ('client_credentials', 'device_code') +#: The name of a role of a resource, and of a scope a resource defines. +ROLE_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9._-]{0,63}$') +SCOPE_NAME = re.compile(r'^[a-z][a-z0-9._-]{0,63}$') +#: The attributes read for a role. +ROLE_ATTRIBUTES = ['cn', 'description', 'member'] #: The keys of the URIs of an OpenID Provider client and their LDAP attributes. CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri')) +#: The keys of the OpenID Provider settings of a service account (client_settings, and roles of set_roles). +CLIENT_KEYS = ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups', 'grants', 'public_client', 'client_scopes', + 'resource_scopes', 'roles') #: The most URIs of each kind and allowed groups of one client, and the longest URI. CLIENT_VALUES_MAX = 16 URI_MAX = 1024 @@ -218,11 +237,13 @@ def labels(values): def client_settings(connection, values): - """The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris, - allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none. + """The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris, + allowed_groups, grants, public_client, client_scopes, resource_scopes}), checked, as {LDAP attribute: [value, ...]}; + a key whose value is None is not given, [] or false means none. - A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each - list has at most CLIENT_VALUES_MAX values. + A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a + grant is one of GRANTS; a client scope is a scope of a resource, :; a resource scope is a + SCOPE_NAME. Each list has at most CLIENT_VALUES_MAX values. """ found = {} for key, attribute in CLIENT_URIS: @@ -249,13 +270,68 @@ def client_settings(connection, values): for group in allowed: group_entry(connection, group) found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))] + grants = values.get('grants') + if grants is not None: + if not isinstance(grants, list) or not all(isinstance(grant, str) and grant in GRANTS for grant in grants): + raise Failure(f'grants must be a list of {", ".join(GRANTS)}', 2) + found['vpnRouterGrantType'] = [grant.encode() for grant in GRANTS if grant in grants] + public = values.get('public_client') + if public is not None: + if not isinstance(public, bool): + raise Failure('public_client must be true or false', 2) + found['vpnRouterPublicClient'] = [b'TRUE'] if public else [] + scopes = values.get('client_scopes') + if scopes is not None: + if not isinstance(scopes, list) or len(scopes) > CLIENT_VALUES_MAX or not all(isinstance(scope, str) for scope in scopes): + raise Failure(f'client_scopes must be a list of at most {CLIENT_VALUES_MAX} scopes', 2) + for scope in scopes: + resource, _, name = scope.partition(':') + if name not in resource_scopes(connection, resource): + raise Failure(f'{scope!r} is not a scope of a resource, :', 2) + found['vpnRouterClientScope'] = [scope.encode() for scope in sorted(set(scopes))] + defined = values.get('resource_scopes') + if defined is not None: + if not isinstance(defined, list) or len(defined) > CLIENT_VALUES_MAX or \ + not all(isinstance(scope, str) and SCOPE_NAME.match(scope) for scope in defined): + raise Failure(f'resource_scopes must be a list of at most {CLIENT_VALUES_MAX} scope names: lowercase letters, ' + 'digits, dots, underscores and hyphens, starting with a letter, at most 64', 2) + found['vpnRouterResourceScope'] = [scope.encode() for scope in sorted(set(defined))] return found def refuse_client_settings(name, values): - """Refuse OpenID Provider client settings for a user who is not a service account.""" - if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')): - raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2) + """Refuse OpenID Provider settings for a user who is not a service account.""" + if any(values.get(key) is not None for key in CLIENT_KEYS): + raise Failure(f'{name} is not a service account; only a service account is a client or a resource of the OpenID ' + 'Provider', 2) + + +def check_client(name, current, changes): + """Refuse the client settings an entry's attributes would have with changes ({LDAP attribute: [value, ...]}) when the + client is public and has client_credentials, which needs its secret.""" + merged = {**current, **changes} + if merged.get('vpnRouterPublicClient') == [b'TRUE'] and b'client_credentials' in merged.get('vpnRouterGrantType', []): + raise Failure(f'{name}: a public client has no secret, so it cannot use client_credentials', 2) + + +def resource_scopes(connection, name): + """The scopes the service account defines as a resource, [] when the name is not that of a service account.""" + if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)): + return [] + try: + attributes = entry(connection, name) + except Failure: + return [] + return [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] if service(attributes) else [] + + +def asking(connection, scopes): + """The names of the clients whose client scopes have any of the scopes, :, sorted.""" + if not scopes: + return [] + condition = ''.join(f'(vpnRouterClientScope={ldap.filter.escape_filter_chars(scope)})' for scope in scopes) + found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, f'(|{condition})', ['uid']) + return sorted(attributes['uid'][0].decode() for _, attributes in found) def dn_of(name): @@ -310,7 +386,7 @@ def summary(attributes): """A user as the answers show it; a person attribute or a label the entry does not have is None. internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID - Provider client settings (client_settings), allowed_groups as group names. + Provider settings (client_settings), allowed_groups as group names; with_roles adds its roles. """ name = attributes['uid'][0].decode() lock = attributes.get('pwdAccountLockedTime', [None])[0] @@ -336,9 +412,18 @@ def summary(attributes): suffix = f',{vpnrouter_ldap.GROUPS}' user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', []) if value.decode().startswith('cn=') and value.decode().endswith(suffix)) + user['grants'] = [value.decode() for value in attributes.get('vpnRouterGrantType', [])] + user['public_client'] = attributes.get('vpnRouterPublicClient') == [b'TRUE'] + user['client_scopes'] = [value.decode() for value in attributes.get('vpnRouterClientScope', [])] + user['resource_scopes'] = [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] return user +def with_roles(connection, user): + """The summary of a service account with roles, its roles as a resource (roles_of_resource).""" + return {**user, 'roles': roles_of_resource(connection, user['name'])} + + def console_of(attributes): """The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local, uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the @@ -536,8 +621,8 @@ def list_users(connection): def add(connection, name, password, values, service=False): """Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the - e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service - account may be named by a GUID.""" + e-mail address, its labels (labels), its OpenID Provider settings (client_settings) and its roles (set_roles); only + a service account may be named by a GUID.""" check_name(name, service) details = person(values, required=False) vpn_user = 'givenName' in details or 'sn' in details @@ -547,6 +632,9 @@ def add(connection, name, password, values, service=False): if service: details.update({attribute: value for attribute, value in labels(values).items() if value}) client = {attribute: found for attribute, found in client_settings(connection, values).items() if found} + check_client(name, {}, client) + if values.get('roles') is not None: + checked_roles(connection, values['roles']) else: refuse_client_settings(name, values) if vpn_user and len(details) != len(PERSON): @@ -574,6 +662,10 @@ def add(connection, name, password, values, service=False): vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail']) except vpnrouter_pki.Failure as failure: raise Failure(f'the certificate cannot be issued: {failure}', failure.status) + if service: + if values.get('roles') is not None: + set_roles(connection, name, values['roles']) + return with_roles(connection, summary(entry(connection, name))) return summary(entry(connection, name)) @@ -628,20 +720,37 @@ def apply_wireguard(): def update_service(connection, name, values): - """Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of - a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting.""" - if not service(entry(connection, name)): + """Change any of the e-mail address, the labels (labels), the OpenID Provider settings (client_settings) and the + roles (set_roles) of a service account that values gives; '' removes the e-mail address or a label, [] the values + of a setting. A resource scope a client may ask for is not removed.""" + current = entry(connection, name) + if not service(current): raise Failure(f'{name} is not a service account', 3) + settings = client_settings(connection, values) + check_client(name, current, settings) + if 'vpnRouterResourceScope' in settings: + removed = {value.decode() for value in current.get('vpnRouterResourceScope', [])} - \ + {value.decode() for value in settings['vpnRouterResourceScope']} + clients = asking(connection, [f'{name}:{scope}' for scope in sorted(removed)]) + if clients: + raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name} being removed; take them out ' + 'of their client scopes first', 2) + roles = values.get('roles') + if roles is not None: + checked_roles(connection, roles) changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()] - changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()] + changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in settings.items()] email = values.get('email') if email is not None: address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail'] changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else [])) - if not changes: - raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2) - connection.modify_s(dn_of(name), changes) - return summary(entry(connection, name)) + if not changes and roles is None: + raise Failure(f'give email, display_name, description or {", ".join(CLIENT_KEYS)}', 2) + if changes: + connection.modify_s(dn_of(name), changes) + if roles is not None: + set_roles(connection, name, roles) + return with_roles(connection, summary(entry(connection, name))) def passwd(connection, name, password): @@ -761,15 +870,24 @@ def remove(connection, name): """Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own posixAccount. - A user who is the last member of a group is not removed. + A user who is the last member of a group is not removed, nor a resource whose scopes a client may ask for. The roles + of a removed service account are removed, and the account is taken out of the other roles. """ current = entry(connection, name) groups = groups_of(connection, name) alone = sorted(group for group, members in groups.items() if members == [name]) if alone: raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2) + clients = sorted(set(asking(connection, [f'{name}:{scope.decode()}' for scope in current.get('vpnRouterResourceScope', [])])) + - {name}) + if clients: + raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name}; take them out of their client ' + 'scopes first', 2) for group in groups: connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])]) + for role in roles_of_resource(connection, name): + connection.delete_s(role_dn(name, role['name'])) + leave_roles(connection, dn_of(name)) try: vpnrouter_pki.revoke_user(vpnrouter.load_config(), name) except vpnrouter_pki.Failure as failure: @@ -977,7 +1095,7 @@ def group_add(connection, name, description, members): def group_update(connection, name, new_name=None, description=None): """Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it - there, and renaming the editors group of web sites renames it in the sites.""" + there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites.""" import vpnrouter_sites if new_name is None and description is None: raise Failure('give a new name or a description', 2) @@ -996,6 +1114,9 @@ def group_update(connection, name, new_name=None, description=None): for client in allowing(connection, name): connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]), (ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])]) + for dn in assigned(connection, group_dn(name)): + connection.modify_s(dn, [(ldap.MOD_DELETE, 'member', [group_dn(name).encode()]), + (ldap.MOD_ADD, 'member', [group_dn(new_name).encode()])]) try: vpnrouter_sites.rename_group(name, new_name) except vpnrouter_sites.DatabaseError as exc: @@ -1007,8 +1128,8 @@ def group_update(connection, name, new_name=None, description=None): def group_remove(connection, name): - """Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites; - removing ADMIN_GROUP switches the console login of its members off.""" + """Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and + take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off.""" import vpnrouter_sites members = member_names(group_entry(connection, name)) clients = allowing(connection, name) @@ -1021,6 +1142,7 @@ def group_remove(connection, name): if sites: raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2) connection.delete_s(group_dn(name)) + leave_roles(connection, group_dn(name)) if name == ADMIN_GROUP: console_off(connection, members) return {'name': name, 'removed': True} @@ -1049,6 +1171,186 @@ def group_member_remove(connection, name, user): return group_summary(group_entry(connection, name)) +def role_dn(resource, role): + return f'cn={resource}:{role},{vpnrouter_ldap.ROLES}' + + +def check_resource(connection, resource): + """Refuse a name that is not that of a service account.""" + if not service(entry(connection, resource)): + raise Failure(f'{resource} is not a service account; only a service account is a resource of the OpenID Provider', 2) + + +def check_role_name(role): + if not isinstance(role, str) or not ROLE_NAME.match(role): + raise Failure('a role name is 1 to 64 letters, digits, dots, underscores and hyphens, starting with a letter', 2) + + +def role_summary(attributes): + """A role as the answers show it: {name, description, groups, accounts}, the groups and accounts it is assigned to.""" + groups, accounts = [], [] + for value in attributes.get('member', []): + dn = value.decode() + if dn.startswith('cn=') and dn.endswith(f',{vpnrouter_ldap.GROUPS}'): + groups.append(dn[3:-len(f',{vpnrouter_ldap.GROUPS}')]) + elif dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'): + accounts.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')]) + values = attributes.get('description') + return {'name': attributes['cn'][0].decode().partition(':')[2], 'description': values[0].decode() if values else '', + 'groups': sorted(groups), 'accounts': sorted(accounts)} + + +def role_search(connection, resource, condition=''): + """The entries of the resource's roles that also match the LDAP filter condition, as [(DN, attributes)].""" + prefix = ldap.filter.escape_filter_chars(f'{resource}:') + found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL, + f'(&(objectClass=vpnRouterRole)(cn={prefix}*){condition})', ROLE_ATTRIBUTES) + return [(dn, attributes) for dn, attributes in found if attributes['cn'][0].decode().startswith(f'{resource}:')] + + +def roles_of_resource(connection, resource): + """The roles of the resource, sorted by name.""" + return sorted((role_summary(attributes) for _, attributes in role_search(connection, resource)), + key=lambda role: role['name']) + + +def role_entry(connection, resource, role): + """The attributes of the role's entry; Failure with status 3 when there is none.""" + check_role_name(role) + try: + return connection.search_s(role_dn(resource, role), ldap.SCOPE_BASE, attrlist=ROLE_ATTRIBUTES)[0][1] + except ldap.NO_SUCH_OBJECT: + raise Failure(f'{resource} has no role {role}', 3) + + +def role_add(connection, resource, role, description=''): + """Add a role to the resource, with a description ('' for none) and no members.""" + check_resource(connection, resource) + check_role_name(role) + description = check_description(description if description is not None else '') + record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{role}'.encode()]} + if description: + record['description'] = [description.encode()] + try: + connection.add_s(role_dn(resource, role), ldap.modlist.addModlist(record)) + except ldap.ALREADY_EXISTS: + raise Failure(f'{resource} has a role {role}', 4) + return role_summary(role_entry(connection, resource, role)) + + +def role_remove(connection, resource, role): + role_entry(connection, resource, role) + connection.delete_s(role_dn(resource, role)) + return {'resource': resource, 'name': role, 'removed': True} + + +def member_dn(connection, group=None, account=None): + """The DN of the existing group or account a role is assigned to; exactly one is given.""" + if (group is None) == (account is None): + raise Failure('give a group or an account', 2) + if group is not None: + group_entry(connection, group) + return group_dn(group) + entry(connection, account) + return dn_of(account) + + +def role_member_add(connection, resource, role, group=None, account=None): + """Assign the role to the group or the account.""" + attributes = role_entry(connection, resource, role) + dn = member_dn(connection, group, account) + if dn.encode() not in attributes.get('member', []): + connection.modify_s(role_dn(resource, role), [(ldap.MOD_ADD, 'member', [dn.encode()])]) + return role_summary(role_entry(connection, resource, role)) + + +def role_member_remove(connection, resource, role, group=None, account=None): + """Take the role from the group or the account.""" + attributes = role_entry(connection, resource, role) + if (group is None) == (account is None): + raise Failure('give a group or an account', 2) + dn = group_dn(group) if group is not None else dn_of(account) + if dn.encode() not in attributes.get('member', []): + raise Failure(f'role {role} of {resource} is not assigned to {group or account}', 3) + connection.modify_s(role_dn(resource, role), [(ldap.MOD_DELETE, 'member', [dn.encode()])]) + return role_summary(role_entry(connection, resource, role)) + + +def checked_roles(connection, roles): + """The roles of set_roles, checked: [{name, description, groups, accounts}], names unique, every group and account + existing; description, groups and accounts may be left out.""" + if not isinstance(roles, list) or not all(isinstance(role, dict) for role in roles): + raise Failure('roles must be a list of {"name", "description", "groups", "accounts"}', 2) + checked = [] + for role in roles: + check_role_name(role.get('name')) + groups, accounts = role.get('groups') or [], role.get('accounts') or [] + if not isinstance(groups, list) or not isinstance(accounts, list): + raise Failure(f'the groups and the accounts of role {role["name"]} must be lists of names', 2) + for group in groups: + group_entry(connection, group) + for account in accounts: + entry(connection, account) + checked.append({'name': role['name'], 'description': check_description(role.get('description') or ''), + 'members': sorted({*(group_dn(group) for group in groups), *(dn_of(account) for account in accounts)})}) + if len({role['name'] for role in checked}) != len(checked): + raise Failure('each role name may appear once', 2) + return checked + + +def set_roles(connection, resource, roles): + """Make the resource's roles those of roles (checked_roles): roles left out are removed, new ones added, and the + others get the description and the members given.""" + check_resource(connection, resource) + wanted = {role['name']: role for role in checked_roles(connection, roles)} + for role in roles_of_resource(connection, resource): + if role['name'] not in wanted: + connection.delete_s(role_dn(resource, role['name'])) + present = {role['name'] for role in roles_of_resource(connection, resource)} + for name, role in wanted.items(): + members = [dn.encode() for dn in role['members']] + description = [role['description'].encode()] if role['description'] else [] + if name in present: + connection.modify_s(role_dn(resource, name), [(ldap.MOD_REPLACE, 'description', description), + (ldap.MOD_REPLACE, 'member', members)]) + continue + record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{name}'.encode()]} + if description: + record['description'] = description + if members: + record['member'] = members + connection.add_s(role_dn(resource, name), ldap.modlist.addModlist(record)) + return roles_of_resource(connection, resource) + + +def assigned(connection, dn): + """The DNs of the roles assigned to the group or account of the DN.""" + found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL, + f'(&(objectClass=vpnRouterRole)(member={ldap.filter.escape_filter_chars(dn)}))', ['1.1']) + return [role for role, _ in found] + + +def leave_roles(connection, dn): + """Take the group or account of the DN out of every role.""" + for role in assigned(connection, dn): + connection.modify_s(role, [(ldap.MOD_DELETE, 'member', [dn.encode()])]) + + +def list_roles(connection, resource=None): + """The roles of every resource, or of one, as {"roles": [{resource, name, description, groups, accounts}]}.""" + found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL, '(objectClass=vpnRouterRole)', ROLE_ATTRIBUTES) + roles = [{'resource': attributes['cn'][0].decode().partition(':')[0], **role_summary(attributes)} for _, attributes in found] + return {'roles': sorted((role for role in roles if resource is None or role['resource'] == resource), + key=lambda role: (role['resource'], role['name']))} + + +def roles_held(connection, resource, name): + """The names of the resource's roles assigned to the account or to a group it is a member of, sorted.""" + members = [dn_of(name), *(group_dn(group) for group in groups_of(connection, name))] + condition = '(|' + ''.join(f'(member={ldap.filter.escape_filter_chars(dn)})' for dn in members) + ')' + return sorted(role_summary(attributes)['name'] for _, attributes in role_search(connection, resource, condition)) + + def extract_pem(text): """The PEM blocks of the certificates in a file that also holds their text form.""" begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----' diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py index 9cb32ae..98b0910 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/handler.py @@ -162,7 +162,8 @@ class Handler(BaseHTTPRequestHandler): code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif oidc: - wait = auth_wait(self.client()) if (self.command, path) == ('POST', '/oidc/login') else 0 + wait = auth_wait(self.client()) if self.command == 'POST' and path in ( + '/oidc/login', '/oidc/device', '/oidc/device_authorization') else 0 if wait: code, kind = 429, 'application/json' body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n' diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/identities.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/identities.py index 7def30c..aaa8d81 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_web/identities.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_web/identities.py @@ -99,22 +99,29 @@ def oidc_settings(): def service_accounts(method, path, body): """Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document. - GET /service-accounts the service accounts: {"service_accounts": [...], "oidc": {"enabled", - "issuer"}}, issuer that of the OpenID Provider, null while it is disabled + GET /service-accounts the service accounts, each with its roles: {"service_accounts": [...], + "oidc": {"enabled", "issuer"}}, issuer that of the OpenID Provider, null + while it is disabled POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?, - "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?} + "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?, + "grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?, + "roles"?} PUT /service-accounts/ change 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 + "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 PUT /service-accounts//password set the password: {"password": ...} POST /service-accounts//disable lock it out POST /service-accounts//enable let it in again POST /service-accounts//unlock end a lockout after failed logins DELETE /service-accounts/ remove it - A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_users.client_settings). A name - that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts), which - the router manages, are only listed: every other request on one is refused with 403. + A service account with redirect URIs or grants is a client of the OpenID Provider, and every service account a + resource (vpnrouter_users.client_settings); roles, [{"name", "description", "groups", "accounts"}], replaces its + roles (vpnrouter_users.set_roles). A name that is not a service account is 404. The internal service accounts + (vpnrouter_users.internal_accounts), which the router manages, are only listed: every other request on one, and a + role assigned to one, is refused with 403. A password change, disabling, unlocking and removing revoke the tokens + the account was issued. """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): return 400, {'error': 'the body must be a JSON object'} @@ -122,11 +129,11 @@ def service_accounts(method, path, body): parts = path.strip('/').split('/') route = (method, len(parts), parts[2] if len(parts) == 3 else None) name = parts[1] if len(parts) > 1 else None - settings = {key: body.get(key) for key in ('email', 'display_name', 'description', 'redirect_uris', - 'post_logout_redirect_uris', 'allowed_groups')} + settings = {key: body.get(key) for key in ('email', 'display_name', 'description', *vpnrouter_users.CLIENT_KEYS)} actions = { ('GET', 1, None): lambda connection: { - 'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']], + 'service_accounts': [vpnrouter_users.with_roles(connection, user) + for user in vpnrouter_users.list_users(connection)['users'] if user['service']], 'oidc': oidc_settings()}, ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings, service=True), @@ -137,19 +144,34 @@ def service_accounts(method, path, body): ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), } + # The vpnrouter_users operation of each route that may revoke tokens. + operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock', + ('DELETE', 2, None): 'remove'} if route not in actions: return 404, {'error': f'no route {method} {path}'} - if name is not None and name in vpnrouter_users.internal_accounts(): + internal = vpnrouter_users.internal_accounts() + if name is not None and name in internal: return 403, {'error': f'{name} is an internal service account, which the router manages'} + roles = settings['roles'] if isinstance(settings['roles'], list) else [] + assigned = sorted(set(internal).intersection(account for role in roles if isinstance(role, dict) + for account in (role.get('accounts') or []) + if isinstance(account, str))) + if assigned: + return 403, {'error': f'{", ".join(assigned)}: internal service accounts, which the router manages, are not ' + 'assigned roles'} try: with vpnrouter_users.session() as connection: if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)): raise vpnrouter_users.Failure(f'{name} is not a service account', 3) document = actions[route](connection) + if operations.get(route) in vpnrouter_auth.REVOKING: + vpnrouter_auth.revoke(vpnrouter.load_config(), name) except vpnrouter_users.Failure as failure: return USER_STATUS.get(failure.status, 500), {'error': str(failure)} except (ldap.LDAPError, OSError) as exc: return 500, {'error': f'the user directory failed: {exc}'} + except vpnrouter_auth.DatabaseError as exc: + return 500, {'error': str(exc)} except SystemExit as exc: return 500, {'error': str(exc.code)} return (201 if route == ('POST', 1, None) else 200), document diff --git a/debian-package/src/usr/sbin/vpn-router-role b/debian-package/src/usr/sbin/vpn-router-role new file mode 100755 index 0000000..9ec5240 --- /dev/null +++ b/debian-package/src/usr/sbin/vpn-router-role @@ -0,0 +1,121 @@ +#!/usr/bin/python3 +"""Manage the roles of the resources of the router's OpenID Provider from the command line, with vpnrouter_users. + +A resource is a service account; a role of it has a name, an optional description, and the groups and accounts it is +assigned to, whose tokens for the resource carry it in their roles claim. The answer is text for people on standard +output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure +is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the resource, the role, the +group or the account does not exist, 4 the role exists, 1 anything else. -h or --help shows the commands and their +options. + + vpn-router-role [--json] list [] + vpn-router-role [--json] add [--description D] + vpn-router-role [--json] remove + vpn-router-role [--json] member-add (--group G | --account A) + vpn-router-role [--json] member-remove (--group G | --account A) +""" + +import argparse +import json +import sys + +sys.path.insert(0, '/usr/lib/vpn-router') + +import ldap + +import vpnrouter_users +from vpnrouter_users import Failure + +#: The help line of each command. +HELP = { + 'list': 'list the roles of every resource, or of one, with the groups and accounts they are assigned to', + 'add': 'add a role to a resource', + 'remove': 'remove a role of a resource', + 'member-add': 'assign the role to a group or an account', + 'member-remove': 'take the role from a group or an account', +} + + +class Parser(argparse.ArgumentParser): + """An argument parser whose errors are Failures with status 2, so that --json answers them as JSON.""" + + def error(self, message): + raise Failure(f'{message}; see {self.prog} --help', 2) + + +def parse(argv): + parser = Parser(prog='vpn-router-role', description='Manage the roles of the resources of the OpenID Provider.') + parser.add_argument('--json', action='store_true', help='answer in JSON') + commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser) + for command, text in HELP.items(): + sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.') + if command == 'list': + sub.add_argument('resource', nargs='?', help='the service account whose roles are listed') + continue + sub.add_argument('resource', help='the service account the role belongs to') + sub.add_argument('role', help='the role name') + if command == 'add': + sub.add_argument('--description', metavar='TEXT', help='the description') + if command in ('member-add', 'member-remove'): + member = sub.add_mutually_exclusive_group(required=True) + member.add_argument('--group', metavar='GROUP', help='the group') + member.add_argument('--account', metavar='USER', help='the user or service account') + return parser.parse_args(argv) + + +def run(connection, options): + """The answer of the command the options name.""" + command = options.command + if command == 'list': + if options.resource is not None: + vpnrouter_users.check_resource(connection, options.resource) + return vpnrouter_users.list_roles(connection, options.resource) + if command == 'add': + role = vpnrouter_users.role_add(connection, options.resource, options.role, options.description) + elif command == 'remove': + return vpnrouter_users.role_remove(connection, options.resource, options.role) + elif command == 'member-add': + role = vpnrouter_users.role_member_add(connection, options.resource, options.role, options.group, options.account) + else: + role = vpnrouter_users.role_member_remove(connection, options.resource, options.role, options.group, options.account) + return {'resource': options.resource, **role} + + +def text_of(answer): + """The answer as text for people.""" + if answer.get('removed'): + return f'role {answer["name"]} of {answer["resource"]} removed' + roles = answer['roles'] if 'roles' in answer else [answer] + if not roles: + return 'no roles' + head = ('RESOURCE', 'ROLE', 'GROUPS', 'ACCOUNTS', 'DESCRIPTION') + rows = [(role['resource'], role['name'], ', '.join(role['groups']) or '-', ', '.join(role['accounts']) or '-', + role['description'] or '-') for role in roles] + widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))] + return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows)) + + +def main(argv): + # A request that cannot be parsed is answered in JSON when --json is among the arguments. + as_json = '--json' in argv + try: + options = parse(argv) + as_json = options.json + with vpnrouter_users.session() as connection: + answer = run(connection, options) + except Failure as failure: + message, status = str(failure), failure.status + except (ldap.LDAPError, OSError) as exc: + message, status = f'the user directory failed: {exc}', 1 + else: + print(json.dumps(answer) if as_json else text_of(answer)) + return 0 + if as_json: + print(json.dumps({'error': message})) + else: + print(f'vpn-router-role: {message}', file=sys.stderr) + return status + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index e661395..1c18c43 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -13,14 +13,17 @@ user exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-user [--json] add [--email E] (a Manager-only account; password on stdin) vpn-router-user [--json] add --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 ...] (a service account; password on stdin) vpn-router-user [--json] update [--first-name F] [--last-name L] [--email E] vpn-router-user [--json] update [--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 ...] (a service account) -A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated, -and replaces all values of its kind; one '' removes them. +A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider, +and one with a resource scope a resource; each of the repeatable options replaces all values of its kind, and one '' +removes them. vpn-router-role manages the roles of a resource. vpn-router-user [--json] passwd (password on stdin) vpn-router-user [--json] disable vpn-router-user [--json] enable @@ -80,6 +83,9 @@ CLIENT_OPTIONS = ( ('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'), ('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'), ('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'), + ('grants', 'grant', 'a grant the client may use besides the authorization code: client_credentials or device_code'), + ('client_scopes', 'client-scope', 'a scope of a resource the client may ask for, :'), + ('resource_scopes', 'resource-scope', 'a scope the service account defines as a resource'), ) @@ -104,6 +110,8 @@ def parse(argv): for key, option, text in CLIENT_OPTIONS: sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE', help=f"{text}, of a service account; repeat it for more, '' for none") + sub.add_argument('--public-client', choices=('yes', 'no'), + help='whether the client of a service account is public, without a secret') if command == 'add': sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service') if command == 'otp-enrol': @@ -131,6 +139,8 @@ def run(connection, options, secret): given = getattr(options, key, None) if given is not None: values[key] = [value for value in given if value] + if getattr(options, 'public_client', None) is not None: + values['public_client'] = options.public_client == 'yes' if command == 'add': return vpnrouter_users.add(connection, options.name, secret, values, service=options.service) if command == 'update': @@ -157,12 +167,20 @@ def console_text(user): def client_text(user): - """The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed - groups.""" - if not user.get('redirect_uris'): - return '-' - groups = ','.join(user['allowed_groups']) or 'all users' - return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}' + """The OpenID Provider settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups, + grants, public, its client scopes and its resource scopes.""" + parts = [] + if user.get('redirect_uris'): + groups = ','.join(user['allowed_groups']) or 'all users' + parts.append(f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}') + parts += user.get('grants') or [] + if user.get('public_client'): + parts.append('public') + if user.get('client_scopes'): + parts.append('asks ' + ','.join(user['client_scopes'])) + if user.get('resource_scopes'): + parts.append('defines ' + ','.join(user['resource_scopes'])) + return '; '.join(parts) or '-' def text_of(answer): diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-role.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-role.bash new file mode 100644 index 0000000..2d5108c --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-role.bash @@ -0,0 +1,94 @@ +# bash completion for vpn-router-role -*- shell-script -*- + +# The service accounts, the users, the groups and the roles of a resource of the router's directory, read as root; +# nothing for other users. +_vpn_router_role_resources() +{ + vpn-router-user --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"] if user["service"]))' 2>/dev/null +} + +_vpn_router_role_accounts() +{ + vpn-router-user --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null +} + +_vpn_router_role_groups() +{ + vpn-router-group --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null +} + +_vpn_router_role_names() +{ + vpn-router-role --json list "$1" 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(role["name"] for role in json.load(sys.stdin)["roles"]))' 2>/dev/null +} + +_vpn_router_role() +{ + local cur prev words cword split + _init_completion -s || return + + # The command is the first word that is not an option; the arguments are the words after it but option values. + local i command='' args=0 resource='' + for ((i = 1; i < cword; i++)); do + if [[ -z $command ]]; then + [[ ${words[i]} != -* ]] && command=${words[i]} + continue + fi + case ${words[i]} in + --description | --group | --account) ((i++)) ;; + -*) ;; + *) + ((args == 0)) && resource=${words[i]} + ((args++)) + ;; + esac + done + + if [[ -z $command ]]; then + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--json --help' -- "$cur")) + else + COMPREPLY=($(compgen -W 'list add remove member-add member-remove' -- "$cur")) + fi + return + fi + + case $prev in + --group) + COMPREPLY=($(compgen -W "$(_vpn_router_role_groups)" -- "$cur")) + return + ;; + --account) + COMPREPLY=($(compgen -W "$(_vpn_router_role_accounts)" -- "$cur")) + return + ;; + --description) + return + ;; + esac + $split && return + + if [[ $cur == -* ]]; then + local options='--help' + case $command in + add) options+=' --description' ;; + member-add | member-remove) options+=' --group --account' ;; + esac + COMPREPLY=($(compgen -W "$options" -- "$cur")) + return + fi + + # The resource, then the role, which add takes new. + if ((args == 0)); then + COMPREPLY=($(compgen -W "$(_vpn_router_role_resources)" -- "$cur")) + elif ((args == 1)) && [[ $command != @(list|add) ]]; then + COMPREPLY=($(compgen -W "$(_vpn_router_role_names "$resource")" -- "$cur")) + fi +} && + complete -F _vpn_router_role vpn-router-role + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash index dae3fb6..2875343 100644 --- a/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash @@ -13,6 +13,14 @@ _vpn_router_user_groups() python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null } +# The scopes of the resources, :, read as root. +_vpn_router_user_scopes() +{ + vpn-router-user --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(user["name"] + ":" + scope for user in json.load(sys.stdin)["users"] + for scope in user.get("resource_scopes", [])))' 2>/dev/null +} + _vpn_router_user() { local cur prev words cword split @@ -30,7 +38,8 @@ _vpn_router_user() fi case ${words[i]} in --first-name | --last-name | --email | --display-name | --description | --redirect-uri | \ - --post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;; + --post-logout-redirect-uri | --allowed-group | --account | --grant | --public-client | --client-scope | \ + --resource-scope) ((i++)) ;; -*) ;; *) ((args++)) ;; esac @@ -50,6 +59,21 @@ _vpn_router_user() COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur")) return ;; + --grant) + COMPREPLY=($(compgen -W 'client_credentials device_code' -- "$cur")) + return + ;; + --public-client) + COMPREPLY=($(compgen -W 'yes no' -- "$cur")) + return + ;; + --client-scope) + COMPREPLY=($(compgen -W "$(_vpn_router_user_scopes)" -- "$cur")) + return + ;; + --resource-scope) + return + ;; --account) # The local accounts console --account takes: a uid from 1000 to 19999, not nobody. COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur")) @@ -65,9 +89,9 @@ _vpn_router_user() local options='--help' case $command in add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri - --post-logout-redirect-uri --allowed-group' ;; + --post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;; update) options+=' --first-name --last-name --email --display-name --description --redirect-uri - --post-logout-redirect-uri --allowed-group' ;; + --post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;; console) options+=' --posix --account --off' ;; esac COMPREPLY=($(compgen -W "$options" -- "$cur")) diff --git a/debian-package/src/usr/share/vpn-router/sql/oidc.sql b/debian-package/src/usr/share/vpn-router/sql/oidc.sql index 79721ca..190aa8c 100644 --- a/debian-package/src/usr/share/vpn-router/sql/oidc.sql +++ b/debian-package/src/usr/share/vpn-router/sql/oidc.sql @@ -1,10 +1,12 @@ -- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database. --- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved. --- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once. +-- Codes, tokens, device codes and sessions are kept as the SHA-256 of their values; expired rows are purged when new +-- ones are saved. The whole file runs as one transaction, serialized by an advisory lock, so that two processes can +-- load it at once. SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema')); --- An authorization code, valid until it is exchanged for tokens or expires. +-- An authorization code, valid until it is exchanged for tokens or expires; resource is the resource the tokens are +-- for, null for the client itself. CREATE TABLE IF NOT EXISTS oidc_codes ( hash text PRIMARY KEY, client_id text NOT NULL, @@ -16,10 +18,13 @@ CREATE TABLE IF NOT EXISTS oidc_codes ( code_challenge_method text NOT NULL, auth_time timestamptz NOT NULL, amr text[] NOT NULL, - expires timestamptz NOT NULL + expires timestamptz NOT NULL, + resource text ); +ALTER TABLE oidc_codes ADD COLUMN IF NOT EXISTS resource text; --- An access token or a refresh token (kind) a client was issued for a user. +-- An access token or a refresh token (kind) a client was issued for a user, or for itself as a service account with +-- client credentials; resource as in oidc_codes. CREATE TABLE IF NOT EXISTS oidc_tokens ( hash text PRIMARY KEY, kind text NOT NULL CHECK (kind IN ('access', 'refresh')), @@ -28,9 +33,30 @@ CREATE TABLE IF NOT EXISTS oidc_tokens ( scopes text[] NOT NULL, auth_time timestamptz NOT NULL, amr text[] NOT NULL, + expires timestamptz NOT NULL, + resource text +); +ALTER TABLE oidc_tokens ADD COLUMN IF NOT EXISTS resource text; +CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name); + +-- A device code of the device authorization grant and its user code (user_hash): pending until the user allows or +-- denies it, then the user, when and how they signed in; poll_seconds is the least time between two polls, polled +-- the last poll. +CREATE TABLE IF NOT EXISTS oidc_devices ( + hash text PRIMARY KEY, + user_hash text NOT NULL UNIQUE, + client_id text NOT NULL, + scopes text[] NOT NULL, + resource text, + state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'allowed', 'denied')), + name text, + auth_time timestamptz, + amr text[], + poll_seconds integer NOT NULL, + polled timestamptz, expires timestamptz NOT NULL ); -CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name); +CREATE INDEX IF NOT EXISTS oidc_devices_name ON oidc_devices (name); -- A user's sign-in at the OpenID Provider, which the session cookie names. CREATE TABLE IF NOT EXISTS oidc_sessions ( @@ -41,22 +67,30 @@ CREATE TABLE IF NOT EXISTS oidc_sessions ( expires timestamptz NOT NULL ); +-- The functions and procedures whose parameters or results differ from those of the same name. +DROP PROCEDURE IF EXISTS oidc_code_save(text, text, text, text, text[], text, text, text, timestamptz, text[], integer); +DROP FUNCTION IF EXISTS oidc_code_get(text); +DROP PROCEDURE IF EXISTS oidc_token_save(text, text, text, text, text[], timestamptz, text[], integer); +DROP FUNCTION IF EXISTS oidc_token_get(text, text); + CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text, p_scopes text[], p_nonce text, p_code_challenge text, p_code_challenge_method text, p_auth_time timestamptz, p_amr text[], - p_seconds integer) + p_resource text, p_seconds integer) LANGUAGE sql AS $$ DELETE FROM oidc_codes WHERE expires < now(); - INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge, - p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds)); + INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, + auth_time, amr, resource, expires) + VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge, p_code_challenge_method, + p_auth_time, p_amr, p_resource, now() + make_interval(secs => p_seconds)); $$; -- The unexpired code. CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text) RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text, - code_challenge_method text, auth_time timestamptz, amr text[]) + code_challenge_method text, auth_time timestamptz, amr text[], resource text) LANGUAGE sql STABLE AS $$ - SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr + SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr, resource FROM oidc_codes WHERE hash = p_hash AND expires > now(); $$; @@ -66,18 +100,19 @@ LANGUAGE sql AS $$ $$; CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[], - p_auth_time timestamptz, p_amr text[], p_seconds integer) + p_auth_time timestamptz, p_amr text[], p_resource text, p_seconds integer) LANGUAGE sql AS $$ DELETE FROM oidc_tokens WHERE expires < now(); - INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, - now() + make_interval(secs => p_seconds)); + INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires) + VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, p_resource, + now() + make_interval(secs => p_seconds)); $$; -- The unexpired token of the kind. CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text) -RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[]) +RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[], resource text) LANGUAGE sql STABLE AS $$ - SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens + SELECT client_id, name, scopes, auth_time, amr, resource FROM oidc_tokens WHERE hash = p_hash AND kind = p_kind AND expires > now(); $$; @@ -86,6 +121,59 @@ LANGUAGE sql AS $$ DELETE FROM oidc_tokens WHERE hash = p_hash; $$; +CREATE OR REPLACE PROCEDURE oidc_device_save(p_hash text, p_user_hash text, p_client_id text, p_scopes text[], + p_resource text, p_poll_seconds integer, p_seconds integer) +LANGUAGE sql AS $$ + DELETE FROM oidc_devices WHERE expires < now(); + INSERT INTO oidc_devices (hash, user_hash, client_id, scopes, resource, poll_seconds, expires) + VALUES (p_hash, p_user_hash, p_client_id, p_scopes, p_resource, p_poll_seconds, + now() + make_interval(secs => p_seconds)); +$$; + +-- The unexpired device code of the user code while it waits for the user. +CREATE OR REPLACE FUNCTION oidc_device_find(p_user_hash text) +RETURNS TABLE (client_id text, scopes text[], resource text) +LANGUAGE sql STABLE AS $$ + SELECT client_id, scopes, resource FROM oidc_devices + WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now(); +$$; + +-- Allow or deny the waiting device code of the user code as the user; whether there was one. +CREATE OR REPLACE FUNCTION oidc_device_decide(p_user_hash text, p_name text, p_auth_time timestamptz, p_amr text[], + p_allowed boolean) +RETURNS boolean +LANGUAGE sql AS $$ + WITH decided AS ( + UPDATE oidc_devices SET state = CASE WHEN p_allowed THEN 'allowed' ELSE 'denied' END, name = p_name, + auth_time = p_auth_time, amr = p_amr + WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now() + RETURNING 1 + ) + SELECT count(*) > 0 FROM decided; +$$; + +-- A poll of the device code: the row, whether it has expired, and whether it came sooner than poll_seconds after the +-- last one, which adds 5 seconds to poll_seconds. +CREATE OR REPLACE FUNCTION oidc_device_poll(p_hash text) +RETURNS TABLE (client_id text, scopes text[], resource text, state text, name text, auth_time timestamptz, amr text[], + expired boolean, too_soon boolean) +LANGUAGE sql AS $$ + UPDATE oidc_devices AS device + SET polled = now(), + poll_seconds = device.poll_seconds + + CASE WHEN device.polled + make_interval(secs => device.poll_seconds) > now() THEN 5 ELSE 0 END + FROM (SELECT hash, polled, poll_seconds FROM oidc_devices WHERE hash = p_hash) AS previous + WHERE device.hash = previous.hash + RETURNING device.client_id, device.scopes, device.resource, device.state, device.name, device.auth_time, device.amr, + device.expires <= now(), + previous.polled IS NOT NULL AND previous.polled + make_interval(secs => previous.poll_seconds) > now(); +$$; + +CREATE OR REPLACE PROCEDURE oidc_device_delete(p_hash text) +LANGUAGE sql AS $$ + DELETE FROM oidc_devices WHERE hash = p_hash; +$$; + CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer) LANGUAGE sql AS $$ DELETE FROM oidc_sessions WHERE expires < now(); @@ -104,10 +192,11 @@ LANGUAGE sql AS $$ DELETE FROM oidc_sessions WHERE hash = p_hash; $$; --- Remove every code, token and session of the user. +-- Remove every code, token, device code and session of the user or service account. CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text) LANGUAGE sql AS $$ DELETE FROM oidc_codes WHERE name = p_name; DELETE FROM oidc_tokens WHERE name = p_name; + DELETE FROM oidc_devices WHERE name = p_name; DELETE FROM oidc_sessions WHERE name = p_name; $$; diff --git a/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 b/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 index dca4b25..4f77838 100644 --- a/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 +++ b/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 @@ -15,6 +15,8 @@ input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; } button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; } .error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; } +ul { margin: 0 0 1rem; padding-left: 1.25rem; color: #4b5563; } +#user_code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, monospace; } .issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; } @@ -27,25 +29,52 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; {%- if error %} {%- endif %} -{%- if form %} -
+{%- if items %} +
    +{%- for item in items %} +
  • {{ item }}
  • +{%- endfor %} +
+{%- endif %} +{%- if decide %} + + + + +
+{%- endif %} +{%- if form or device %} +
+{%- if device %} + + +{%- else %} +{%- endif %} +{%- if form %} - + +{%- else %} + +{%- endif %}
-