Single sign-on with Authentik
The intranet signs users in through OAuth2/OIDC and maps their provider groups to app roles. The reference provider is Authentik; the configuration is provider-agnostic, so Keycloak, Entra, Google, Okta, and others work the same way.
Two people are involved: an Authentik administrator who creates the client,
and the intranet deployer who sets the environment variables. The callback
path is always <origin>/api/auth/callback/<OAUTH_PROVIDER_ID>; with the
recommended OAUTH_PROVIDER_ID=authentik that is
/api/auth/callback/authentik. The origin is derived from the request Host
when ORIGIN is unset.
1. Choose the origin first
The redirect URI must match the origin the browser actually opens (scheme + host
- port + path).
ORIGINis optional; when unset the app derives the origin from the requestHost, so register each origin users will hit:
| Deployment | Public origin | Redirect URI to register |
|---|---|---|
| Local compose | http://localhost:3000 | http://localhost:3000/api/auth/callback/authentik |
| Staging / production | https://intranet.example | https://intranet.example/api/auth/callback/authentik |
Authentik accepts a newline-separated list; extras are harmless, a missing one
causes invalid_redirect_uri. To pin a single origin, set ORIGIN (compose:
PUBLIC_ORIGIN).
2. Create the provider (Authentik admin)
Applications → Providers → Create → OAuth2/OpenID Provider.
| Field | Value |
|---|---|
| Name | Cloud Exit Intranet |
| Authorization flow | default-provider-authorization-explicit-consent (or implicit) |
| Client type | Confidential |
| Client ID | cloud-exit-intranet (or generated) |
| Client Secret | generate; hand the value to the deployer |
| Redirect URIs | the URI(s) from step 1, one per line |
| Signing Key | select a certificate (required for the OIDC id_token) |
| Scopes | openid, profile, email |
| PKCE | keep enabled |
The profile scope already emits the groups claim — no custom scope mapping is
needed for role mapping.
3. Create the application
Applications → Applications → Create with a name and the slug
cloud-exit-intranet (the slug appears in the discovery URL), provider set to
step 2. Then, under the application's Policy / Group / User bindings, bind the
group(s) allowed to authenticate. Without a binding, users see a "not authorized"
page even though the client is valid.
4. Create role groups
Create Authentik groups whose names map to app roles and add users:
| Authentik group | App role |
|---|---|
cloud-exit-admins | admin |
cloud-exit-finance | finance |
cloud-exit-reviewers | reviewer |
| (any other group) | engineer (default) |
The role is re-synced from the groups claim on every login, so group
membership changes take effect the next time the user signs in.
5. Configure the container (deployer)
Set these in .env (never commit the secret) and rebuild/recreate:
OAUTH_PROVIDER_ID="authentik"
OAUTH_PROVIDER_NAME="Authentik"
OAUTH_DISCOVERY_URL="https://<AUTHENTIK_HOST>/application/o/cloud-exit-intranet/.well-known/openid-configuration"
OAUTH_CLIENT_ID="cloud-exit-intranet"
OAUTH_CLIENT_SECRET="<client secret>"
OAUTH_SCOPES="openid profile email"
OAUTH_PKCE="true"
OAUTH_GROUPS_CLAIM="groups"
OAUTH_ROLE_MAP='{"cloud-exit-admins":"admin","cloud-exit-finance":"finance","cloud-exit-reviewers":"reviewer"}'
OAUTH_DEFAULT_ROLE="engineer"
Leave OAUTH_AUTHORIZATION_URL/OAUTH_TOKEN_URL/OAUTH_USERINFO_URL blank
because the discovery document is used. Then:
docker compose up -d --build --force-recreate app worker
The app container must be able to reach AUTHENTIK_HOST. A hostname that
resolves only on your workstation (e.g. a LAN IP or localhost) may not resolve
inside the container. Use an address the container can reach, and verify:
docker compose exec app node -e "fetch(process.env.OAUTH_DISCOVERY_URL).then(r=>console.log(r.status))"
6. Values to exchange
The Authentik admin sends the deployer: the discovery URL, client ID, client
secret, confirmation of the registered redirect URI(s), and the group names that
should grant admin/finance/reviewer.
Offboarding
Group membership syncing fixes granting, not revoking: an existing session is not re-checked against Authentik, and removing a user from a group does not sign them out. Until session enforcement is added, deprovision by removing the user from the Authentik application binding and having them sign out (or awaiting session expiry).
Troubleshooting
| Symptom | Cause / fix |
|---|---|
invalid_redirect_uri | Received URI doesn't exactly match a registered one. Compare scheme, host, port, path. |
| "not authorized" after login | The user's group isn't bound to the application (step 3). |
| Provider skipped, log shows discovery failure | Authentik unreachable from the container, or wrong discovery URL. Verify with the command above. |
Everyone lands as engineer | Groups aren't in OAUTH_ROLE_MAP, OAUTH_GROUPS_CLAIM/OAUTH_ROLE_MAP didn't reach the container, or the profile scope is off. |
| CSRF / redirect errors after login | The request Origin differs from the origin derived from Host (forward Host/X-Forwarded-Proto, or set ORIGIN). |