First Microsoft logins failed with 400 "email: Cannot be blank": Graph's
/oidc/userinfo endpoint omits the email claim for accounts without a
mail attribute, and team_members requires an e-mail (it also drives the
ENTRA_ADMIN_EMAILS role mapping). Reproduced against a mock Entra with
PocketBase v0.30.4; the user-supplied response body matched the
missing-email fingerprint exactly.
- provider config (reconciler + migration fast-path): userInfoURL is now
empty, so PocketBase reads the claims from the Entra id_token, which
always carries preferred_username (UPN) and email when available. The
#18 reconciler flips the already-deployed Labs provider automatically
on the next boot — no migration needed.
- pb_hooks/entra_oidc.pb.js: onRecordAuthWithOAuth2Request hook falls
back to the lowercased UPN when the email claim is absent. Guest UPNs
(ext_user#EXT#@tenant...) are excluded explicitly — "#" is RFC-valid
in an e-mail local part, so both a naive regex AND PocketBase's own
validation would accept them (caught by test V5). The hook also logs
every failed OAuth2 attempt with the underlying error to the container
log, which PocketBase otherwise only writes to its internal logs db.
Verified with a switchable mock-Entra matrix (8/8): no-email→UPN e-mail
+ allow-list role, email claim wins when present, no duplicate on
re-login, guest UPN yields a clean validation error, anonymous REST
create stays rejected, both log lines present. Regression: issue-22
matrix 9/9 (baseline pinned to 1a1351d now that #23 is merged), DoD
harness 15/15, npm test 112/112.
Closes #24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.5 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 |
| 010 | Claims uit het Entra id_token (userInfoURL: "") + UPN-fallback voor e-mail |
Graph /oidc/userinfo geeft géén email-claim voor accounts zonder mail-attribuut → 400 bij eerste login (issue #24). Het id_token bevat altijd preferred_username (UPN = primair e-mailadres bij Respellion); entra_oidc.pb.js gebruikt die als fallback (regex-guard tegen guest-UPN's) en logt gefaalde OAuth2-pogingen naar de containerlog |
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).