diff --git a/CHANGELOG.md b/CHANGELOG.md index df6208a8..19edadc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,23 @@ and this project adheres to ## [Unreleased] +### Added + +- Setup guides for the authentication provider and the identity provider + ### Changed - Bump keycloak to 26.7.1 +### Fixed + +- Grant view-realm to the Keycloak service account, needed by the 2FA toggle +- Run make test-keycloak against a freshly imported Keycloak realm + +### Security + +- Turn the direct access grant off on the Keycloak rest-api client + ## [0.9.0] - 2026-07-22 ### Added diff --git a/deploy/env/keycloak.defaults b/deploy/env/keycloak.defaults index 8d540f56..e024dd1f 100644 --- a/deploy/env/keycloak.defaults +++ b/deploy/env/keycloak.defaults @@ -1,5 +1,7 @@ KC_BOOTSTRAP_ADMIN_USERNAME=admin KC_BOOTSTRAP_ADMIN_PASSWORD=admin +KC_BOOTSTRAP_ADMIN_CLIENT_ID=bootstrap-admin +KC_BOOTSTRAP_ADMIN_CLIENT_SECRET=BootstrapAdminClientSecretForDev KC_DB=postgres KC_DB_URL_HOST=postgresql KC_DB_URL_DATABASE=messages @@ -10,4 +12,4 @@ KC_HOSTNAME_STRICT=false KC_HOSTNAME_STRICT_HTTPS=false KC_HTTP_ENABLED=true KC_HEALTH_ENABLED=true -PROXY_ADDRESS_FORWARDING=true \ No newline at end of file +PROXY_ADDRESS_FORWARDING=true diff --git a/docs/authentication-provider.md b/docs/authentication-provider.md new file mode 100644 index 00000000..b0938295 --- /dev/null +++ b/docs/authentication-provider.md @@ -0,0 +1,125 @@ +# Authentication Provider Setup Guide + +Messages uses OpenID Connect in two distinct parts. Do not confuse them. + +1. **Authentication.** Messages delegates each user login to an OIDC + provider. The provider can be ProConnect, Keycloak, or any + other OIDC provider. Every Messages instance needs one, this is + managed by the `OIDC_**` environment vars. +2. **Mailbox identities and Maildomains.** A mail domain decides how a user gets a + mailbox. This part is independent of the authentication provider. + This is managed by the `IDENTITY_PROVIDER` environment variable, + for now Messages only supports Keycloak. + +This guide covers the first part only. If you are looking for the mailbox +and maildomain provisioning, see [identity-provider.md](identity-provider.md). + +## What Messages Expects from the Provider + +Messages acts as a standard OIDC relying party. Any compliant provider +works. Messages needs: + +- the authorization code flow, with a confidential client; +- an `email` claim in the userinfo response. Messages matches a user to a + mailbox by this address; +- the five endpoints listed below. Most providers publish them at + `/.well-known/openid-configuration`. + +## Setup Steps + +1. **Create a client at your provider.** + + Set the redirect URI, for example + `https://messages.example.fr/api/v1.0/callback/`. + + Set the Logout redirect URI, for example + `https://messages.example.fr/api/v1.0/logout-callback/`. + +2. **Set the client variables.** Set `OIDC_RP_CLIENT_ID` and + `OIDC_RP_CLIENT_SECRET` to the credentials of that client. + +3. **Set the endpoint variables.** Read them from the provider's + `/.well-known/openid-configuration` document: + + | Variable | Discovery document key | + |----------|------------------------| + | `OIDC_OP_AUTHORIZATION_ENDPOINT` | `authorization_endpoint` | + | `OIDC_OP_TOKEN_ENDPOINT` | `token_endpoint` | + | `OIDC_OP_USER_ENDPOINT` | `userinfo_endpoint` | + | `OIDC_OP_JWKS_ENDPOINT` | `jwks_uri` | + | `OIDC_OP_LOGOUT_ENDPOINT` | `end_session_endpoint` | + + The browser reaches the authorization endpoint and the logout endpoint. + Give them a public URL. The backend reaches the other three. A private + URL is enough for those. + +4. **Set the redirect hosts.** Add each host that serves Messages to + `OIDC_REDIRECT_ALLOWED_HOSTS`. Set `OIDC_REDIRECT_REQUIRE_HTTPS` to + `True` in production. + +5. **Check the scopes.** `OIDC_RP_SCOPES` must request the claims Messages + needs. The default is `openid email`. + +See the [OIDC Configuration](env.md#oidc-configuration) and +[OIDC Advanced Settings](env.md#oidc-advanced-settings) sections of +`env.md` for the full variable tables. + +### ProConnect as the Authentication Provider + +For ProConnect Integration you can create your application at +[https://partenaires.proconnect.gouv.fr](https://partenaires.proconnect.gouv.fr) +and can use the following variables : + +```env +OIDC_RP_CLIENT_ID=XX +OIDC_RP_CLIENT_SECRET=XX + +OIDC_OP_AUTHORIZATION_ENDPOINT=https://fca.integ01.dev-agentconnect.fr/api/v2/authorize +OIDC_OP_JWKS_ENDPOINT=https://fca.integ01.dev-agentconnect.fr/api/v2/jwks +OIDC_OP_LOGOUT_ENDPOINT=https://fca.integ01.dev-agentconnect.fr/api/v2/session/end +OIDC_OP_TOKEN_ENDPOINT=https://fca.integ01.dev-agentconnect.fr/api/v2/token +OIDC_OP_USER_ENDPOINT=https://fca.integ01.dev-agentconnect.fr/api/v2/userinfo +OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} +``` + +ProConnect requires a Level of Assurance in the authorization request. +`OIDC_AUTH_REQUEST_EXTRA_PARAMS` carries it, for example +`{"acr_values": "eidas1"}`. The mobile application depends on this value +too. See [mobile.md](mobile.md). + +For ProConnect Production, contact the ProConnect team. + +### Keycloak as the Authentication Provider + +You can use Keycloak for this part too. Create a second client, next to the +service-account client of [identity-provider.md](identity-provider.md). + +1. **Create the `messages` client.** Configure it as a confidential + client. Turn the standard flow on. Turn the direct access grants off. + Turn the service accounts off. + + Set the redirect URIs to your own host names. + + The dev realm (`src/keycloak/realm.json`) shows example values. Adapt + the values to your own host names. Do not reuse the example values. + +2. **Set the login variables.** Set `OIDC_RP_CLIENT_ID` and + `OIDC_RP_CLIENT_SECRET` from the `messages` client. Set the five + endpoint variables from the realm, at + `https:///realms//.well-known/openid-configuration`. + +## How a User Gets a Mailbox After the Login + +The login alone does not give a user a mailbox. The mail domain decides +that, and it is a separate mechanism. + +In short: +- a mail domain with `oidc_autojoin` gives each user connected through + the authentication provider a mailbox at login, you don't need to + setup anything else; +- a mail domain with `identity_sync` lets you create the mailboxes as you + need, but you'll then need an `IDENTITY_PROVIDER`, see + [identity-provider.md](identity-provider.md).; +- a mail domain with neither field can manage shared mailboxes only, the + admin must configure the access on every mailbox to an authenticated user + manually. diff --git a/docs/env.md b/docs/env.md index c556f254..6abac8fa 100644 --- a/docs/env.md +++ b/docs/env.md @@ -532,6 +532,8 @@ Used for provisioning-side operations against Keycloak (e.g. toggling mandatory 2FA on a mail domain). Distinct from the OIDC login settings above, which handle end-user authentication. +See [identity-provider.md](identity-provider.md) for the full setup guide. + | Variable | Default | Description | Required | |----------|---------|-------------|----------| | `IDENTITY_PROVIDER` | None | Identity-provider integration to enable (e.g. `keycloak`). Unset disables provisioning-side IdP calls. | Optional | diff --git a/docs/identity-provider.md b/docs/identity-provider.md new file mode 100644 index 00000000..75fa1302 --- /dev/null +++ b/docs/identity-provider.md @@ -0,0 +1,117 @@ +# Identity Provider Setup Guide + +Messages uses OpenID Connect in two distinct parts. Do not confuse them. + +1. **Authentication.** Messages delegates each user login to an OIDC + provider. The provider can be ProConnect, Keycloak, or any + other OIDC provider. Every Messages instance needs one, this is + managed by the `OIDC_**` environment vars. +2. **Mailbox identities and Maildomains.** A mail domain decides how a user gets a + mailbox. This part is independent of the authentication provider. + This is managed by the `IDENTITY_PROVIDER` environment variable, + for now Messages only supports Keycloak. + +This guide covers the second part only. It shows how to set up Keycloak as +the store that holds the mailbox identities. If you are looking for the +user login, see [authentication-provider.md](authentication-provider.md). + +## How a User Gets a Mailbox + +A mail domain has two independent fields. They give three configurations: + +| Mail domain | Who creates the mailbox | IDENTITY_PROVIDER needed | +|-------------|-------------------------|-----------------| +| `oidc_autojoin` on | Messages, at each login | No | +| `identity_sync` on | The administrator. Messages then pushes the mailbox to `IDENTITY_PROVIDER` | Yes | +| Neither field on | The administrator, shared mailbox only | No | + +**With `oidc_autojoin`.** Each user who logs in with an address on the +domain gets a mailbox at once. Messages creates the mailbox locally. You do +_not_ need the `IDENTITY_PROVIDER` for this. + +**With `identity_sync`.** The administrator creates the mailbox +`user1@example.fr`. Messages synchronizes it to Keycloak as a user. The +user then logs in through ProConnect, or through another provider +(the `OIDC_**` vars), with `user1@example.fr`. The user gets access +to the mailbox. + +**With neither field.** The administrator creates shared mailboxes only. +Each user logs in through the OIDC provider. The administrator then gives +the user access to a shared mailboxes manually. + +You therefore need this guide only when you want `identity_sync`. + +## When the Login Address Differs from the Mailbox + +Messages matches a user to a mailbox by the address. The two addresses can +differ. The administrator creates the mailbox `user1@aaa.fr`, but the user +logs in with `user1@bbb.fr`. Messages does not link them. + +In this case, give the user an explicit access to the mailbox. Grant +`user1@bbb.fr` an access to the `user1@aaa.fr` mailbox. + +## Keycloak Setup Steps + +For now, Messages only supports Keycloak as an `IDENTITY_PROVIDER`. + +1. **Get the Keycloak image.** Use the + [ghcr.io/suitenumerique/messages-keycloak](https://ghcr.io/suitenumerique/messages-keycloak) + image that this project publishes to the GitHub container registry. + You can also build the image from `src/keycloak`. + The image bundles providers that + Messages needs: + - the `map-group-attribute` script mapper, which puts group attributes + into the token claims; + - the `bulk-role-membership` admin extension, which makes a bulk + role-membership check faster. + + A plain upstream `quay.io/keycloak/keycloak` image does not have these + providers. Do not deploy a plain upstream image. + +2. **Deploy Keycloak.** Deploy the image with the + [st-cli](https://github.com/suitenumerique/st-ansible/tree/main/cli). + st-cli wraps the same collection in a simpler command-line tool. st-cli + is the recommended tool for a self-hoster. + You can also deploy it with + [st-ansible](https://github.com/suitenumerique/st-ansible) collection or + yourself with the docker compose examples. + +3. **Create a realm.** Create a dedicated Keycloak realm for Messages, for + example `messages`. + +4. **Create the `serviceaccount-messages` client.** This client holds the service account + that Messages uses for the provisioning calls. + Turn "Client authentication" on. Turn "Authorization" on. + Turn "Service accounts roles" on. Turn the "Standard flow" off. + You can setup the Root URL to the Messages URL. + +5. **Assign the realm-management roles.** On the user in the "Service + account roles" tab, assign the client roles `query-users`, + `manage-users`, `view-users` and `view-realm`. + +6. **Set the provisioning variables.** Set `IDENTITY_PROVIDER` to + `keycloak`. Set `KEYCLOAK_URL`, `KEYCLOAK_REALM` and + `KEYCLOAK_GROUP_PATH_PREFIX`. Set `KEYCLOAK_CLIENT_ID` to `serviceaccount-messages`. + Set `KEYCLOAK_CLIENT_SECRET` to the secret of that client. See the + [Identity Provider (Keycloak)](env.md#identity-provider-keycloak) + section of `env.md` for the full variable table. + +7. **Enable `identity_sync` on each mail domain.** Enable this field on + every mail domain that Messages must sync to Keycloak. Set the field in + the Django admin, on the `MailDomain` change form. + +8. **Verify the setup.** Run the resync command: + + ```bash + python manage.py identity resync-all + ``` + + The command syncs each mail domain and each mailbox again. It reports + the number of synced domains and the number of synced mailboxes. + +## The Authentication Provider + +The steps above do not set up a user login. Messages needs an +authentication provider as well. You can use Keycloak for that too, or +keep another provider such as ProConnect. See +[authentication-provider.md](authentication-provider.md). diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a9709609..3089d697 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -181,6 +181,7 @@ Messages uses OpenID Connect (OIDC) for user authentication. This is the only au - Pre-configured with Messages realm and users - Suitable for organizations wanting a self-hosted identity provider - Configure via `deploy/env/production/keycloak.defaults` + - See the [identity provider guide](./identity-provider.md) to create the realm and the clients 2. **External OIDC Provider** - Use any OIDC-compliant identity provider @@ -191,6 +192,8 @@ Messages uses OpenID Connect (OIDC) for user authentication. This is the only au **User Management:** - Users are created automatically when they first log in via OIDC - Mailboxes can be created automatically based on OIDC email addresses +- A mail domain with `oidc_autojoin` gives each user a mailbox at login. This needs no `IDENTITY_PROVIDER`. +- A mail domain with `identity_sync` pushes each mailbox to Keycloak. See the [identity provider guide](./identity-provider.md) ### 7. Production Deployment diff --git a/src/keycloak/realm.json b/src/keycloak/realm.json index 3837058e..958bf4f6 100644 --- a/src/keycloak/realm.json +++ b/src/keycloak/realm.json @@ -430,7 +430,7 @@ "disableableCredentialTypes" : [ ], "requiredActions" : [ ], "clientRoles" : { - "realm-management" : [ "query-users", "manage-users", "view-users" ] + "realm-management" : [ "query-users", "manage-users", "view-users", "view-realm" ] }, "notBefore" : 0, "groups" : [ ] @@ -743,7 +743,7 @@ "consentRequired" : false, "standardFlowEnabled" : false, "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : true, + "directAccessGrantsEnabled" : false, "serviceAccountsEnabled" : true, "authorizationServicesEnabled" : true, "publicClient" : false, diff --git a/src/keycloak/tests/test_bulk_role_membership.py b/src/keycloak/tests/test_bulk_role_membership.py index e5a325c2..c5115399 100644 --- a/src/keycloak/tests/test_bulk_role_membership.py +++ b/src/keycloak/tests/test_bulk_role_membership.py @@ -31,8 +31,10 @@ CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "rest-api") CLIENT_SECRET = os.environ.get( "KEYCLOAK_CLIENT_SECRET", "ServiceAccountClientSecretForDev" ) -MASTER_ADMIN_USER = os.environ.get("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") -MASTER_ADMIN_PASS = os.environ.get("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") +MASTER_CLIENT_ID = os.environ.get("KC_BOOTSTRAP_ADMIN_CLIENT_ID", "bootstrap-admin") +MASTER_CLIENT_SECRET = os.environ.get( + "KC_BOOTSTRAP_ADMIN_CLIENT_SECRET", "BootstrapAdminClientSecretForDev" +) ENDPOINT_PATH = f"/realms/{TARGET_REALM}/bulk-role-membership/check" ENDPOINT_URL = f"{KEYCLOAK_URL}{ENDPOINT_PATH}" @@ -52,7 +54,7 @@ def _post_with_token(token, body, *, raw_data=None, content_type="application/js ) -def _password_token(realm, client_id, username, password, *, client_secret=None): +def _password_token(realm, client_id, username, password): """Fetch an access token via the OIDC password grant.""" data = { "grant_type": "password", @@ -60,8 +62,6 @@ def _password_token(realm, client_id, username, password, *, client_secret=None) "username": username, "password": password, } - if client_secret is not None: - data["client_secret"] = client_secret response = requests.post( f"{KEYCLOAK_URL}/realms/{realm}/protocol/openid-connect/token", data=data, @@ -91,12 +91,27 @@ def main() -> int: client_id=CLIENT_ID, client_secret_key=CLIENT_SECRET, ) - admin_token_payload = openid.token(grant_type="client_credentials") - admin_token = admin_token_payload["access_token"] + admin_token = openid.token(grant_type="client_credentials")["access_token"] + + # Master rights come from the bootstrap admin service account, not the + # bootstrap admin user: that user has an empty profile, so Keycloak asks + # it to fill one in and refuses the password grant. + master_openid = KeycloakOpenID( + server_url=KEYCLOAK_URL, + realm_name="master", + client_id=MASTER_CLIENT_ID, + client_secret_key=MASTER_CLIENT_SECRET, + ) + master_token_payload = master_openid.token(grant_type="client_credentials") + master_admin_token = master_token_payload["access_token"] + + # The fixtures create realm roles and users, which needs more than the + # three user roles the rest-api service account holds. That token stays + # for the endpoint calls below. admin = KeycloakAdmin( server_url=KEYCLOAK_URL, realm_name=TARGET_REALM, - token=admin_token_payload, + token=master_token_payload, verify=False, ) @@ -139,16 +154,10 @@ def main() -> int: ], } ) + # Take this token through admin-cli, the built-in public client, so + # the rest-api client keeps its direct-access grant switched off. lowpriv_token = _password_token( - TARGET_REALM, - CLIENT_ID, - lowpriv_username, - lowpriv_password, - client_secret=CLIENT_SECRET, - ) - - master_admin_token = _password_token( - "master", "admin-cli", MASTER_ADMIN_USER, MASTER_ADMIN_PASS + TARGET_REALM, "admin-cli", lowpriv_username, lowpriv_password ) print("Fixture ready: roles, users, low-priv token, master token") @@ -284,10 +293,8 @@ def main() -> int: # rejects this even if Keycloak's per-realm getRoleById ever leaked. master_admin_client = KeycloakAdmin( server_url=KEYCLOAK_URL, - username=MASTER_ADMIN_USER, - password=MASTER_ADMIN_PASS, realm_name="master", - user_realm_name="master", + token=master_token_payload, verify=False, ) master_admin_role_id = master_admin_client.get_realm_role("admin")["id"]