Every first Microsoft login failed with 403 "Only superusers can perform this action": PocketBase applies the collection createRule to the automatic record creation during OAuth2 sign-up, and team_members was created with createRule null (superuser-only) on the wrong assumption that the OAuth2 flow bypasses it. Since the pre-Azure records were deliberately dropped, every user was a first login and nobody could get in. Reproduced locally against a mock OIDC provider (PocketBase v0.30.4). createRule becomes '@request.context = "oauth2"': record creation is allowed exclusively from within the OAuth2 flow. Anonymous REST creates (which could otherwise pre-seed rogue admin profiles) remain rejected — covered by an explicit test. - pb_migrations/1781000002_allow_oauth2_signup.js: applies the rule on already-migrated environments (Labs); idempotent, guarded, with down - pb_migrations/1781000000_team_members_to_auth.js: same rule for fresh environments (filename unchanged — ledger-safe) - scripts/setup-pb-collections.mjs: fallback entry mirrored - docs/auth-spec.md: ADR 009 + files table Verified with a mock-OIDC matrix (9/9): baseline reproduces the 403 on the old rule; upgrade path heals (first login 200, role/enrollment/name defaults, second login no duplicate, anonymous create still rejected); fresh chain + admin allow-list mapping OK. DoD harness regression 15/15, npm test 112/112. Closes #22 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.1 KiB
Authentication — Azure (Entra ID) login
Implemented for issue #16. Replaces the previous client-side PIN login. Hardened for issue #18 (migratie-ledger-sync + env-onafhankelijke provider-reconciliatie).
Doel
Elke gebruiker logt in met zijn Respellion Microsoft (Azure Entra ID)-account.
Bij de eerste login wordt automatisch een team_members-record aangemaakt. Er is
geen PIN, geen handmatige user-aanmaak en geen client-side wachtwoordcontrole meer.
Architectuur in één oogopslag
Browser (SPA) PocketBase (auth) Entra ID
───────────── ───────────────── ────────
"Sign in with Microsoft"
└─ pb.collection('team_members')
.authWithOAuth2({provider:'oidc'})
──────────────────► opens OIDC popup ───► login.microsoftonline.com
token exchange ◄─── (server-side, client secret)
◄────────────────── auth record + token
pb.authStore (token in localStorage)
- PocketBase is de OIDC-client. De token-exchange en het client-secret blijven server-side in PocketBase — er is geen aparte backend en geen client-side key.
team_membersis een PocketBaseauth-collection. De record-idblijft de user-identifier die alle voortgangscollecties (test_results,on_demand_attempts,micro_learning_completions,leaderboard,theme_session_completions) alsuser_idreferencen.- Sessie =
pb.authStore(token inlocalStorage), niet langer eenteam_members.idinsessionStorage.
Belangrijke beslissingen (ADR)
| ADR | Beslissing | Reden |
|---|---|---|
| 001 | OIDC-provider tegen Entra v2-endpoints i.p.v. eigen MSAL-flow of header-trust | Sluit aan op "PocketBase = enige backend"; secret server-side; auto-provisioning gratis; werkt los van de perimeter-implementatie |
| 002 | team_members hergebruikt als auth-collection (zelfde naam) |
Alle user_id-referenties blijven werken; minimale blast-radius |
| 003 | Provisioning + admin-rol via pb_hooks/team_members.pb.js |
Rol-logica server-side, niet in de client |
| 004 | Admin-rol via e-mail-allowlist (ENTRA_ADMIN_EMAILS), re-synced elke login |
Geen handmatig beheer; allowlist-wijziging werkt bij volgende login |
| 005 | API-rules → @request.auth.id != "" op alle app-collecties |
Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd |
| 006 | Baseline-ledger-sync migratie voor out-of-band geprovisionede DB's | Labs/prod draaide historisch zonder --migrationsDir; replay van de historie crashte PB (issue #18). De vroegst-sorterende migratie markeert de historie als applied wanneer schema bestaat maar de ledger leeg is |
| 007 | Migratie = structuur, hook = configuratie | De OIDC-provider wordt bij elke start (en per cron-minuut) gereconcilieerd vanuit ENTRA_* env door pb_hooks/entra_oidc.pb.js; een one-shot migratie die van deploy-time env afhangt kan OAuth2 anders permanent uitschakelen |
| 008 | Hook-helpers via require(${__hooks}/utils.js) |
De JSVM draait elke hook-callback als geïsoleerd programma; top-level functies zijn níet in scope op call-time |
| 009 | createRule: '@request.context = "oauth2"' op team_members |
PocketBase past de createRule toe op de OAuth2-sign-up (eerste login maakt het record aan); null blokkeerde élke eerste login met 403 (issue #22). De context-rule staat alléén de OAuth2-flow toe — anonieme REST-creates blijven geweigerd |
Bestanden
| Bestand | Rol |
|---|---|
pb_migrations/1000000000_baseline_ledger_sync.js |
Detecteert een out-of-band geprovisionede DB (schema bestaat, _migrations-ledger leeg) en markeert de historische migraties als applied, zodat de replay niet crasht (issue #18). |
pb_migrations/1781000000_team_members_to_auth.js |
Converteert relation-velden naar text (data blijft behouden), dropt de oude PIN-collection en maakt team_members als auth-collection. Idempotent; provider wordt alleen als fast-path meegenomen als de env al aanwezig is. |
pb_migrations/1781000001_tighten_api_rules.js |
Zet alle niet-systeem-collecties op @request.auth.id != "". |
pb_migrations/1781000002_allow_oauth2_signup.js |
Zet createRule = '@request.context = "oauth2"' op reeds-gemigreerde omgevingen (issue #22). |
pb_hooks/entra_oidc.pb.js |
Reconcilieert de OIDC-provider vanuit ENTRA_* env bij elke start + cron-tick (compare-before-save). |
pb_hooks/utils.js |
Gedeelde helpers (resolveRole, reconcileEntraOidc) — per callback binnenhalen via require(). |
pb_hooks/team_members.pb.js |
Auto-provisioning (role, enrollment_status, naam-fallback) + admin-rol re-sync. |
src/lib/azureAuth.js |
Canonieke, unit-geteste helpers (OIDC_PROVIDER, resolveRole, parseAdminEmails, deriveName). |
src/store/AppContext.jsx |
loginWithAzure() / logout() op basis van pb.authStore + authRefresh(). |
src/pages/Login.jsx |
"Sign in with Microsoft"-scherm (geen dropdown/PIN). |
src/components/admin/TeamManager.jsx |
Roster-beheer: rol wijzigen + verwijderen (geen aanmaken/PIN). |
De rol-/allowlist-logica bestaat tweemaal: canoniek in
src/lib/azureAuth.js(met tests) en herhaald inpb_hooks/team_members.pb.js(PocketBase JSVM kan de module niet importeren). Houd ze in sync.
Configuratie (environment)
Gerenderd in .env door de Ansible deploy-playbooks en doorgegeven aan de
pocketbase-learning-container:
| Variabele | Betekenis |
|---|---|
ENTRA_TENANT_ID |
Entra tenant-id (of common). |
ENTRA_CLIENT_ID |
App-registration client-id. |
ENTRA_CLIENT_SECRET |
App-registration client-secret. |
ENTRA_ADMIN_EMAILS |
Komma-gescheiden e-mail-allowlist die role=admin krijgt, bv. rve@respellion.nl,admin@respellion.nl. |
Ansible-variabelen (vault): entra_tenant_id, entra_client_id,
entra_client_secret, entra_admin_emails.
Entra App Registration (eenmalig, buiten de codebase)
- Redirect-URI (Web):
https://<host>/api/oauth2-redirect- Labs:
https://learning-platform.labs.respellion.tech/api/oauth2-redirect - Lokaal dev:
http://localhost:8090/api/oauth2-redirect(PB-origin)
- Labs:
- API permissions / scopes:
openid,email,profile. - Genereer een client-secret en zet de waarden in de Ansible-vault.
Uitbreidingspunten
- Silent SSO (geen tweede klik): als bevestigd is dat de perimeter veilige, niet-spoofbare identity-headers doorstuurt, kan een PocketBase-route die headers vertrouwen en direct een token minten (ADR-004-alternatief).
- Least-privilege rules: schrijf/verwijder op de knowledge-authoring-collecties
(
topics,relations,content,sources,question_bank,curriculum_versions) verder beperken tot@request.auth.role = "admin". Let op:micro_learningsentheme_sessionsworden door reguliere users geschreven (generate-then-cache). Vereist runtime-verificatie. - Rol op Entra group-claims i.p.v. e-mail-allowlist (aanpassing in
pb_hooks/team_members.pb.js+src/lib/azureAuth.js).
Bekende aandachtspunten / go-live
- De OIDC-popup is een top-level navigatie naar
login.microsoftonline.com; de bestaande Caddy-CSP (connect-src) raakt dit niet. Verifieer alsnog bij de eerste deploy. - Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel: zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
- Credential-rotatie: update de
ENTRA_*secrets (Gitea → Ansible.env) en herstart/redeploy —pb_hooks/entra_oidc.pb.jsreconcilieert de provider automatisch bij elke start en elke cron-minuut. Geen handmatige re-apply meer. - Migratiebestanden nooit hernoemen nadat ze ergens applied zijn — de
_migrations-ledger is filename-based; hernoemen triggert een re-apply. - De deploy-playbooks bevatten een health-gate: als PocketBase na de deploy niet healthy wordt, faalt de pipeline zichtbaar en print hij de containerlogs (issue #18 bleef 2 weken onzichtbaar doordat de deploy "groen" was).