9.4 KiB
Admin-Panel SSO via Authentik
Phase 09 Task #82. Status: code-complete, awaits Authentik deploy.
This document covers operating the admin-panel SSO flow once Phase 09 #81 has shipped the Authentik instance.
Overview
sequenceDiagram
participant Browser
participant Web as Web (admin host)
participant Authentik
participant DB
Browser->>Web: GET /auth/admin/login
Web->>Web: createAdminSsoChallenge() (PKCE+state+nonce)
Web-->>Browser: 302 -> Authentik authorize, Set-Cookie: admin_sso_challenge
Browser->>Authentik: GET /application/o/authorize/...
Authentik-->>Browser: Login + MFA, then 302 /auth/admin/callback?code=...&state=...
Browser->>Web: GET /auth/admin/callback (with cookie)
Web->>Web: consumeAdminSsoChallenge() -> verify state+nonce+sig
Web->>Authentik: POST /token (PKCE verifier + client_secret_post)
Authentik-->>Web: id_token + access_token
Web->>Web: extractAdminSsoClaims(idToken) + email_verified check
Web->>DB: jitProvisionAdmin() -> upsert User + AdminUser
Web->>DB: createAuditEvent('admin.login.sso.success')
Web-->>Browser: 302 /admin/dashboard + Set-Cookie session + id_token cookie
Note over Browser,Web: Logout (RP-initiated)
Browser->>Web: POST /auth/admin/logout
Web->>Web: readIdTokenFromRequest() + destroyAppSession()
Web-->>Browser: 302 Authentik end_session_endpoint?id_token_hint=...
Browser->>Authentik: end_session
Authentik-->>Browser: 302 post_logout_redirect_uri -> /admin/login?logged_out=1
Required environment
See .env.example for the canonical commented block. Coolify deployment:
| Key | Source |
|---|---|
AUTHENTIK_ISSUER_URL |
https://auth.<base-domain>/application/o/toolctrl-admin-panel/ |
AUTHENTIK_ADMIN_CLIENT_ID |
Blueprint client_id (default toolctrl-admin-panel) |
AUTHENTIK_ADMIN_CLIENT_SECRET |
Authentik admin UI (Coolify vault) |
AUTHENTIK_ADMIN_REDIRECT_URI |
https://admin.<base-domain>/auth/admin/callback |
AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI |
https://admin.<base-domain>/admin/login?logged_out=1 |
ADMIN_FALLBACK_LOGIN_ENABLED |
false (default), flip to true during recovery |
ADMIN_FALLBACK_LOGIN_EMAILS |
Comma-separated bootstrap emails |
Authentik group → role mapping
The OIDC groups claim drives the AdminUser role. Highest priority wins.
| Authentik group | Internal AdminUser role | Notes |
|---|---|---|
toolctrl-admin |
super_admin |
Full admin permissions; if user is in both toolctrl-admin and toolctrl-staff, this wins. |
toolctrl-staff |
staff |
Limited admin permissions. |
| other | login rejected (403) | admin.login.sso.failure audit event with reason=forbidden-group. |
Granular permission checks remain in apps/web/app/utils/permissions/;
the role is the coarse switch.
Onboarding a new admin
- Create the user in Authentik (manual or via SCIM/LDAP if integrated).
- Add the user to the
toolctrl-adminortoolctrl-staffgroup. - Enforce MFA enrollment in Authentik (provider-side).
- User clicks "Sign in with SSO" on
https://admin.<domain>/admin/login. - First login triggers JIT-provisioning — AdminUser row appears in DB
with
ssoProvider='authentik',ssoSubjectId=<sub>,ssoGroups=[...],lastSsoLoginAt=<now>.
Offboarding an admin
- Authentik — remove the user from
toolctrl-admin/toolctrl-staffor deactivate the user. - Next SSO login will be rejected (
admin.login.sso.failurewithreason=forbidden-group). - Force-Logout existing session: until the app session cookie expires,
the admin can still load admin pages. Force-revoke via:
- Operator CLI (planned, Phase 10):
pnpm cli admin:force-logout --userId=<id>. - Or: clear the user's session row directly in DB (search by
userId). - Or: rotate
SESSION_SECRET(invalidates every session — last resort).
- Operator CLI (planned, Phase 10):
Fallback (emergency) login
The email/password fallback bypasses Authentik. It is locked down by two
env-gates plus the existing rate-limit on /login:
ADMIN_FALLBACK_LOGIN_ENABLED=trueADMIN_FALLBACK_LOGIN_EMAILScontains the requesting email (case-insensitive)
Every fallback attempt — success or failure — writes an
admin.login.fallback.* audit event with severity=warn in the metadata
(also visible as a Sentry breadcrumb when the reporter is configured).
Disaster-recovery procedure
- Verify Authentik is genuinely down. Sentry banner +
/auth/admin/loginredirect failing both confirm. - In Coolify, set:
Redeploy the web service.
ADMIN_FALLBACK_LOGIN_ENABLED=true ADMIN_FALLBACK_LOGIN_EMAILS=bootstrap@<base-domain> - Visit
https://admin.<base-domain>/admin/login— expand the disclosure ("Use emergency password login") and sign in with the bootstrap admin. - Resolve the Authentik outage (see
docs/operations/authentik-setup.md). - Immediately revert the env flag back to
ADMIN_FALLBACK_LOGIN_ENABLED=falseand redeploy. Verify the fallback toggle is no longer visible. - Resolve the Sentry alert / Slack-notification (when wired).
The drill MUST be exercised end-to-end at least once on staging before production rollout.
App-MFA gate (Phase 01 #26 TOTP)
requireAdminMfaForAction(request, action, userId) enforces a fresh TOTP
step-up challenge for the closed taxonomy:
const SENSITIVE_ADMIN_ACTIONS = [
"impersonation.start",
"tenant.delete",
"permission.grant",
"audit.export",
"data.export",
] as const;
The cache window matches the existing step-up state (10 minutes — see
MFA_STEP_UP_MAX_AGE_MS). Successful challenges write
admin.mfa.app.challenge.success; redirects to enrollment/challenge write
admin.mfa.app.challenge.failure. Enrollment + challenge UI is the
existing /admin/security/mfa-* route from Phase 01 #26 — no new TOTP
implementation here.
Audit-event reference
| Action key | Severity | Emitted by |
|---|---|---|
admin.login.sso.success |
info | /auth/admin/callback after JIT success |
admin.login.sso.failure |
warn | Any /auth/admin/* early-return path |
admin.login.fallback.success |
warn | /login action (admin host, fallback path) |
admin.login.fallback.failure |
warn | /login action when gate rejects |
admin.logout.sso |
info | /auth/admin/logout |
admin.mfa.app.challenge.success |
info | requireAdminMfaForAction happy path |
admin.mfa.app.challenge.failure |
warn | requireAdminMfaForAction redirect/deny path |
All events carry ip, userAgent, actorAdminUserId (when resolvable)
and an action-specific meta JSON blob. The audit wrapper redacts any
suspicious key (authorization/cookie/password/secret/token) before
persisting to AuditEvent.
Troubleshooting
| Symptom | Cause / Fix |
|---|---|
/auth/admin/login redirects to /admin/login?error=sso_unavailable |
env vars missing or Authentik unreachable. Check Sentry for AdminSsoConfigError (discovery-failed). |
error=sso_state_invalid after Authentik |
Challenge cookie expired, signed under stale secret, or tampered. Have user retry; check CRYPTO_SECRET rotation. |
error=sso_forbidden_group |
User not in toolctrl-admin/toolctrl-staff. Add them in Authentik then retry. |
error=sso_email_not_verified |
Authentik user has not verified their email; enforce verification in Authentik provider settings. |
error=sso_token_exchange_failed |
Client secret mismatch, clock-skew, or redirect-URI drift. Compare .env vs Authentik UI. |
| JWKS-stale after key rotation | openid-client refreshes JWKS lazily; first failure forces a refetch. If repeated, restart the web container. |
| Group claim empty in ID-token | Authentik provider must include groups scope mapping — see blueprint 03-application-toolctrl-admin.yaml. |
Rollback / kill-switch
- Disable SSO entry-point only: unset the Authentik env vars and
enable the fallback.
/auth/admin/loginwill redirect with?error=sso_unavailable; the UI shows only the password form. - Disable the whole flow: revert the commits tagged
09/admin-oidc-login: .... The Prisma migration is additive (AdminUserSSO columns are nullable) so no data-loss. - Compromise (client_secret leak): rotate via Authentik admin UI →
update
AUTHENTIK_ADMIN_CLIENT_SECRETin Coolify vault → redeploy. Existing app sessions remain valid (they are independent of the Authentik secret).