Files
learning-platform/docs/auth-spec.md
RaymondVerhoef 54137122a7
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 38s
On Pull Request to Main / publish (pull_request) Successful in 1m10s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m7s
fix: read Entra claims from id_token + UPN e-mail fallback (#24)
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>
2026-07-12 19:26:25 +02:00

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_members is een PocketBase auth-collection. De record-id blijft de user-identifier die alle voortgangscollecties (test_results, on_demand_attempts, micro_learning_completions, leaderboard, theme_session_completions) als user_id referencen.
  • Sessie = pb.authStore (token in localStorage), niet langer een team_members.id in sessionStorage.

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 in pb_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)

  1. 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)
  2. API permissions / scopes: openid, email, profile.
  3. 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_learnings en theme_sessions worden 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.js reconcilieert 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).