← Pour les communes néerlandaises

Single sign-on (OIDC and SAML)

Each organization can sign its staff in through its own identity provider (IdP): OpenID Connect or SAML 2.0, configured in Admin → Organizations → (org) → SSO tab. Users are found by email domain (home-realm discovery), created at their first sign-in (just-in-time), and can be kept in sync beforehand with SCIM. The org can require SSO (the Security tab), which turns password sign-in off for everyone except a SYSADMIN (break-glass) as long as a usable connection exists.

For Dutch government the authentication standards on the mandatory list are OpenID.NLGov (the NL GOV Assurance profile for OpenID Connect, version 1.0.1) and SAML.

OpenID Connect

Authorization Code flow. What Goooy does, against the relying-party requirements of OpenID.NLGov 1.0.1:

Requirement Section Goooy
PKCE with S256 §4.1, §4.2.1 Always. The verifier stays server-side (Redis, 10 minutes) and never passes through the browser.
state and nonce, ≥ 128 random bits §4.2.1 256-bit nonce; the state is a signed token carrying a 128-bit id, usable once.
response_type=code §4.2.1 Yes.
Confidential client authenticates with private_key_jwt (or mTLS) §4.3.1 private_key_jwt is a choice per connection (recommended); client_secret_post / client_secret_basic remain for IdPs that don’t support it. mTLS is not offered.
id_token signed with RS256 or stronger, nothing weaker §7.2.1 Only RS/PS/ES 256–512 are accepted; HS* and none are refused.
Validate iss, aud, nonce, exp/nbf §4.3.4 Yes (jose).
acr at least the requested level §4.2.1, §4.3.4 Optional acr_values per connection; the returned acr must equal one of them or be stronger within the eIDAS (http://eidas.europa.eu/LoA/…) or eToegang (urn:etoegang:core:assurance-class:…) scale. Other values must match exactly.
Use discovery §4.4 Yes (/.well-known/openid-configuration, cached an hour). Signed metadata is not checked.

private_key_jwt

Choosing private_key_jwt creates an RSA-2048 key with a self-signed certificate (valid three years) for the organization, sealed at rest. Its public half is published at

GET /api/v1/auth/sso/oidc/jwks/<orgId>

which is what most IdPs take. Microsoft Entra ID instead wants the certificate uploaded to the app registration (Certificates & secrets); the admin page shows it for copying. The client assertion carries kid and the certificate’s x5t, aud is the token endpoint, and it lives five minutes.

Rotating: Rotate signing key makes a new key current and keeps the old one in the JWKS until the next rotation. With an uploaded certificate, upload the new one straight after rotating.

SAML 2.0

SP-initiated Web Browser SSO (HTTP-Redirect AuthnRequest, HTTP-POST response). The assertion must be signed.

  • Our metadata: GET /api/v1/auth/sso/saml/metadata/<orgId> (entity id and ACS URL), for the IdP administrator to import. Both URLs are on the admin page.
  • IdP metadata URL (recommended): entry point and signing certificates are read from it at save time and re-read hourly, so when the IdP rolls its certificate nothing needs doing here. Without it, paste the entry point and the certificate; during a rollover paste both certificates (several PEM blocks are accepted). The admin page lists each certificate’s expiry and flags one that ends within 30 days.
  • InResponseTo: a response must answer an AuthnRequest this deployment sent in the last 10 minutes (the request ids live in Redis, so any replica can take the response), and each request id is accepted once.
  • One-time use: every assertion id is remembered for 24 hours; the same assertion posted again is refused.
  • IdP-initiated sign-in (from the IdP’s portal, with no AuthnRequest) is off by default and can be allowed per connection. It is still one-time use.

Group mapping (both protocols)

Optional, per connection:

  • Groups claim / attribute: where the IdP puts the user’s groups, e.g. groups in an Entra ID token, or http://schemas.microsoft.com/ws/2008/06/identity/claims/groups in a SAML assertion from Entra ID or AD FS.
  • Allowed groups: when set, a user in none of them is refused at sign-in (403, and a failure entry in the audit trail).
  • Admin groups: when set, the IdP decides who is organization admin: members become ORGADMIN, anyone else becomes USER at their next sign-in. A SYSADMIN is never changed. Every change is an audit entry (user.role.sso).

Entra ID sends group object ids, not names, unless configured otherwise; use whichever values the IdP actually puts in the claim.

Audit

Every SSO sign-in, success or refusal (bad signature, replay, level of assurance too low, not in an allowed group), is in the organization’s audit trail as auth.login with the method (sso-oidc / sso-saml); see audit.md.

Tested against

Mock IdPs in the smoke suite that behave like a strict provider: the OIDC mock refuses a token request whose PKCE verifier doesn’t match, checks the client secret or verifies the private_key_jwt assertion against the published JWKS, and asserts acr and groups; the SAML mock signs real assertions (xml-crypto), answers the actual AuthnRequest id, and serves IdP metadata. apps/api/test/smoke-sso.mjs, smoke-saml.mjs, test/sso-nlgov.test.ts, apps/web/e2e/admin-sso.spec.ts.

Not yet verified against a live tenant of Entra ID, AD FS or a Dutch government identity broker. Do that with the first pilot municipality’s IdP before promising it.

Code

  • apps/api/src/lib/oidc.ts: discovery, PKCE, client authentication incl. private_key_jwt, id_token + acr verification.
  • apps/api/src/lib/saml.ts: node-saml wrapper, IdP metadata parsing, request cache, replay guard, SP metadata.
  • apps/api/src/modules/auth/auth.routes.ts (/sso/*) and auth.service.ts (connection resolution, roleFromGroups, JIT provisioning).
  • apps/api/src/modules/admin/admin.routes.ts (/orgs/:id/sso, /orgs/:id/sso/oidc/rotate-key).
  • apps/web/src/pages/admin/Organizations.tsx (SsoConfig).