Identity and sign-in
Every way a person gets into EduSpaze, who sets it up, and what a platform admin can see and change. Last updated 2026-09-18.
1. The four ways in
| Who | Signs in with | Where | Set up by |
|---|---|---|---|
| School staff — admins, teachers, office | Password, always available | /login | the school, or EduSpaze on their behalf |
| School staff | Google Workspace / Microsoft Entra ID, where the school has switched SSO on | /login → provider | the school, or EduSpaze on their behalf |
| Pupils | Username + PIN, no email address | /student/login | the school (roster import or batch issue) |
| Vendors, EduSpaze staff, guardians | Password, plus optional two-factor | /login | EduSpaze (vendors, staff) or the school (guardians) |
Anyone with an email address can also request a one-time sign-in link (docs/email.md §3).
Two rules hold everywhere:
- A school admin always keeps a password. A misconfigured provider must never be able to lock a school out of the settings page where they would fix it.
- Pupils never touch SSO. Children's accounts carry no email, are not in the directory, and are reset by their own school.
2. What the platform is connected to
EduSpaze is the OAuth relying party. It registers *one* application per provider and brokers every school through it — schools never supply a client id or secret, they choose a provider and narrow it to their own domains and directory.
| Variable | Provider |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Google Workspace |
MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET | Microsoft Entra ID |
| *(built in)* | Demo identity provider — test sign-ins and demonstrations only |
Register <app base URL>/api/auth/sso/callback as the redirect URI with each provider, and request openid email profile. The credentials themselves are environment variables on the service and are never rows in this database.
Admin → Settings → Identity & sign-in is where this is done. It carries a four-step checklist that reads the running service rather than describing an ideal one:
| # | Step | Turns green when |
|---|---|---|
| 1 | Tell the service its own public address | APP_BASE_URL is set |
| 2 | Register EduSpaze with Google (§2.1) | GOOGLE_CLIENT_ID / _SECRET are set |
| 3 | Register EduSpaze with Entra (§2.2) | MICROSOFT_CLIENT_ID / _SECRET are set |
| 4 | Switch a school on (§3) | a school is live on Google or Entra |
Two things the order is doing. Steps 2 and 3 read waiting, not *to do*, until step 1 is done: the redirect URI is derived from the public address, so registering it against a guessed address fails every sign-in afterwards with redirect_uri_mismatch — and by then a school has been told it is live. For the same reason the page prints *no* redirect URI while APP_BASE_URL is unset, rather than a plausible one somebody would copy.
And step 4 does not count a school live on the demo provider. That proves the screens and registers nothing with anybody; treating it as done would report the work finished while both consoles are still empty.
The protocol is authorization code + PKCE (lib/identity/oidc.ts); the state and verifier live in a short-lived signed cookie, and the id token is verified against the provider's JWKS (HS256 against the app secret for the demo provider only).
2.1 Registering EduSpaze with Google Workspace
Done once, by EduSpaze, not by each school. The account that owns it can be an ordinary @gmail.com account — a Google Cloud project and an OAuth client do not require Workspace — but two things about that account matter:
- it becomes the owner of the OAuth client, so it should be an account EduSpaze controls permanently, with two-factor on, not a personal address that leaves with a person;
- it cannot supply the authorised domain the consent screen needs (see below).
gmail.comis not accepted there.
1 · Google Cloud console → the eduspaze project → *APIs & Services*
*OAuth consent screen* first:
| Field | Value |
|---|---|
| User type | External |
| App name | EduSpaze |
| User support email | the owning account |
| Authorised domains | the EduSpaze domain — not gmail.com, and not run.app |
| Application home page / privacy policy / terms | URLs on that same domain |
| Scopes | openid, .../auth/userinfo.email, .../auth/userinfo.profile — all non-sensitive |
Then *Credentials* → Create credentials → *OAuth client ID* → Web application:
| Field | Value |
|---|---|
| Authorised redirect URI | <app base URL>/api/auth/sso/callback |
| Authorised JavaScript origins | not needed — the flow is server-side |
2 · The EduSpaze service. Store the two values in Secret Manager and set them on Cloud Run:
GOOGLE_CLIENT_ID the client ID, ending .apps.googleusercontent.com
GOOGLE_CLIENT_SECRET the client secret
3 · Each school, in its own portal. School Settings → Sign-in & identity: provider *Google Workspace*, the school's hosted domain (hd) as the directory restriction, and its email domains. Then test sign-in, then switch on (§3). Nothing is needed from Google at the school's end — a Workspace directory consents per user, and only the school's own hd will pass the check.
What blocks this today. Only the domain. The consent screen will not accept gmail.com as an authorised domain, and an authorised domain must be verified in Google Search Console by the owning account. Until EduSpaze has a custom domain mapped — the same gap that blocks a sensible EMAIL_FROM (docs/integrations-register.md §G) — a Google client can be created in Testing mode and used by named test accounts, but it cannot be published for schools. Unlike Entra, Google gates the registration itself on the domain.
Two further consequences of External + published, once the domain exists:
- a consent screen serving only
openid email profilestays in the non-sensitive tier, so it needs no Google security assessment; - in Testing mode a client is limited to explicitly listed test users and its tokens expire after seven days, which is enough to prove the flow and not enough to run a school on.
2.2 Registering EduSpaze with Microsoft Entra ID
Done once, by EduSpaze, not by each school. Three places, in this order.
An app registration has to live in a directory, so EduSpaze needs a tenant of its own even though no EduSpaze person signs in through it. A consumer address — including the @gmail.com account that owns the Google client — can be made into a Microsoft account and then create a free Entra tenant (*Microsoft Entra admin centre* → *Manage tenants* → *Create*); the default eduspaze.onmicrosoft.com domain is fine, because schools are sent to their own tenant, never to ours. Unlike Google, Entra does not gate the registration on a verified domain, so this half can be finished today.
1 · Microsoft Entra admin centre → *App registrations* → *New registration*
| Field | Value |
|---|---|
| Name | EduSpaze |
| Supported account types | Accounts in any organizational directory (multitenant) |
| Redirect URI | *Web* → <app base URL>/api/auth/sso/callback |
Multitenant is not optional. The platform sends a school to login.microsoftonline.com/<their tenant id>/…, so a single-tenant registration would only ever work for EduSpaze's own directory.
Then, on the same registration:
- *Certificates & secrets* → New client secret. Copy the value immediately; Entra shows it once. Note the expiry — a secret that lapses takes every school's sign-in down at once.
- *Token configuration* → Add optional claim → ID →
email. Without it Entra may omit theemailclaim entirely, and the platform falls back topreferred_username, which is the UPN and is not always the address the school expects (see §3). - *API permissions* — the delegated
openid,email,profilescopes are enough. Nothing needs Microsoft Graph, and nothing needs admin-only permissions.
2 · The EduSpaze service. Store the two values in Secret Manager and set them on Cloud Run:
MICROSOFT_CLIENT_ID the Application (client) ID
MICROSOFT_CLIENT_SECRET the secret value, not its ID
Admin → Settings → Identity & sign-in then shows Microsoft Entra ID as connected, and the redirect URI to register is on that page so it never has to be remembered.
3 · Each school, in its own portal. School Settings → Sign-in & identity: provider *Microsoft Entra ID*, the school's tenant id as the directory restriction, and its verified email domains. Then test sign-in, then switch on (§3).
2.3 What each school's IT lead may still have to do
A multitenant app is not automatically trusted by another organisation's directory. The first time somebody from a school signs in, one of two things happens:
- the school's Entra tenant allows user consent, and that person consents for themselves; or
- consent is restricted, and an Entra administrator at the school must approve EduSpaze once — either by signing in first themselves, or from *Enterprise applications* after the first attempt raises a consent request.
Either way the school's test sign-in (§3) is what surfaces it: a refusal at this point is a consent problem at their end, not a configuration problem at ours. Tell them to have somebody with directory rights run the test.
3. What a school configures
One SsoConfig per school:
| Field | Meaning |
|---|---|
provider | Google, Microsoft, or the demo provider |
allowedDomains | email domains permitted to sign in — an address outside them is refused whatever the provider asserts |
Entra caveat: where a user has no email claim the platform falls back to preferred_username, which is the UPN. A school whose UPNs are name@school.onmicrosoft.com but whose mail is name@school.edu.sg must list the UPN domain here, or add the email optional claim on the app registration (§2.2) so the mail address is asserted instead. | |
directoryId | Google hosted domain or Entra tenant id; sent to the provider *and* re-checked on the claims (hd for Google, tid for Entra) |
autoProvisionRole | what a first-time signer-in becomes, or null = must already have an account |
status | DRAFT → VERIFIED → ACTIVE |
autoProvisionRole is the setting worth arguing about: leaving it null means the directory alone cannot create access, which is the safer default for a school that has not thought about who is in its directory.
The three states
| State | Meaning |
|---|---|
| DRAFT | Saved, never proven. Staff cannot use it. |
| VERIFIED | A test sign-in succeeded. Staff still use passwords until it is switched on. |
| ACTIVE | Staff on a verified domain are sent to the provider. |
Switching on is only reachable from VERIFIED — nothing goes live on a configuration nobody has proved works, whoever is asking. Changing the provider drops it back to DRAFT and clears verifiedAt: the old test proved the old provider and says nothing about the new one.
The gate
Before anyone is sent to a provider (lib/identity/gate.ts), five things are checked: the address has a domain; the domain belongs to a school we signed; that school has a config; the domain is in allowedDomains; the config is ACTIVE and the subscription is valid. Every refusal returns the same neutral sentence, so the login page cannot be used to find out which schools are on the platform or how they are configured. The same checks run again on the claims the provider actually returns.
A lapsed subscription blocks SSO and nothing else: passwords keep working, so a school can still reach its own settings and its own data.
4. What a platform admin can do
Module platform.identity (docs/rbac-spec.md), VIEW and EDIT:
| Where | What |
|---|---|
| Admin → Settings → Identity & sign-in | Which providers the platform is connected to, the redirect URI to register, and every school's provider, domains, status and auto-provision setting in one list |
| **Admin → Schools → *school* → Sign-in & single sign-on** | Set that school's provider, domains, directory and auto-provision on its behalf; switch SSO on or off once verified |
| **Admin → Schools → *school* → User accounts** | The Signs in with column: password (with two-factor state and when it last changed), a linked provider (with when it was last used), or username + PIN |
Shipped defaults: Operations may view and edit (setting a school up is onboarding work, and operations already creates that school's accounts); Support may view only — every "our staff cannot sign in" call starts by reading this, and a support person changing a provider could lock a school out; Analyst has neither. Owner roles hold everything.
These reach an existing environment too: a shipped role nobody has edited absorbs newly registered modules on the next deploy (docs/rbac-spec.md §4.1). A role somebody has retuned keeps what it holds — a deploy must not undo that decision — so there a platform owner grants it in Settings → Roles; surfacing that remaining drift is backlog BL-18.
Every change a platform admin makes is written to that school's own audit chain with the admin named (byPlatformAdmin: true), and the school's admins are notified. Changing how a tenant signs in is the last thing that should ever happen quietly.
The one thing an admin cannot do for a school is the test sign-in. It has to be performed by somebody holding an address on the school's own domains — the school admin from School Settings, or a platform admin inside a support session, acting as them. That is the point of the test: it proves a real account on that domain reaches this platform.
5. Who is told, and when
Switching SSO on is announced to every member of staff, not only the administrators — a teacher given a temporary password months ago has no other way of learning that they can now use their school account, and an unannounced switch reads as a broken login rather than a new one.
| Moment | Who hears | How |
|---|---|---|
| A colleague is given access | that person | in-app notice and the *Welcome — school staff* email; the temporary password is shown to the administrator to pass on separately |
| SSO switched on | every active member of staff | in-app notice and the *Single sign-on switched on* email, which says what to do and that their password still works |
| SSO switched off | every active member of staff | in-app notice: use your password as before |
| A platform admin changes the school's SSO | the school's administrators | in-app notice naming the admin who did it |
| A roster sync fails, or ends pupils | the school's administrators | in-app notice; the run is on the sync history either way |
The staff email matters more than it looks: an in-app notice sits behind the very sign-in it is telling somebody about, so it cannot be the only thing that reaches them.
6. What the school sees
School Settings carries the same controls: domains and provider, run test sign-in, switch on for staff — plus the subscription line that says whether SSO is currently allowed. Staff accounts are managed under School → People, pupil accounts under School → Students (roster import, batch issue of usernames and PINs), and who may do what under the school's own role editor (docs/rbac-spec.md §8).
7. What is recorded
| Event | When |
|---|---|
school.identity_settings_changed | provider, domains, directory or auto-provision changed |
school.sso_status_changed | switched on or off for staff |
school.sso_removed | the provider was removed and the school returned to passwords |
auth.sso_login / auth.sso_refused | a person signed in, or was refused |
All on the school's tenant chain. SsoConfig and SsoIdentity are both covered by the Postgres capture trigger, and SsoIdentity is marked PII-bearing (email, subject), so an erasure request reaches a person's linked identity like anything else.
8. The identifiers, end to end
One question decides every integration: what is the common identifier? The answer is a *pseudonymous subject, per school*, and it is the same idea for everybody.
| Person | Subject | Where it comes from | Who sees it |
|---|---|---|---|
| Pupil | Student.externalId — e.g. RB-4213 | the school's own SIS code, from the roster CSV | the vendor, on launch, roster and telemetry |
| Staff | SchoolMembership.externalId — e.g. STF-0003 | the school's staff code, or minted STF-nnnn on first use | the vendor, on a staff launch and in the roster |
| Class, group | the row id | EduSpaze | the vendor, as an opaque code with a teaching label |
Both subjects are unique within a school (@@unique([tenantId, externalId]) and @@unique([schoolId, externalId])) and live in one namespace, so a vendor keeps one subject column for pupils and teachers alike. A subject is never an email, never a name, and never the provider's sub claim — which stays inside EduSpaze, so a vendor cannot learn which directory a school uses, and a school changing identity provider changes nothing downstream.
Staff subjects are minted lazily, the first time one is actually needed: a school that later imports its own staff codes can set them, and a membership that never reaches a vendor never acquires an identifier it did not need.
The whole chain
Google / Entra EduSpaze Vendor
──────────── ──────── ──────
sub claim ──► SsoIdentity → User → Membership
│
├─ pupil launch ──► subject=RB-4213, tenant, class, groups (signed)
├─ staff launch ──► subject=STF-0003, tenant, role, classes (signed)
└─ GET /roster ──► the same subjects, classes and groups
│
telemetry ◄── heartbeat / sessions, keyed on tenant + subject
The provider's identity stops at the first arrow. Everything to the right of EduSpaze is pseudonymous, and it is the same string in the launch, the roster, the telemetry, the engagement heat map and the audit ledger.
Launch versions
v1 carries subject, tenant, ts, nonce, sig. v2 adds v, role, classes and, for a pupil, class, className, year, groups — and signs over all of them, which is why it is opt in per vendor (Startup.launchRosterClaims). A vendor that hard-coded the v1 four-key canonical string would stop verifying the moment extra parameters appeared, so nothing changes for them until they ask. tests/roster.test.ts asserts the v1 signature is byte-identical to the published contract; if that check ever fails, every live integration is about to break.
9. Phase 2
No SIS sync yet: rosters arrive as a CSV upload, so Student.externalId is only as stable as the school's export. A OneRoster or Google Classroom sync would replace the upload and leave everything downstream unchanged, because the roster endpoint already speaks in codes.
Keycloak or a hosted identity provider can replace the in-app broker behind the same tables: SsoConfig remains the per-school policy and SsoIdentity remains the link. What would change is lib/identity/oidc.ts and nothing above it — which is why the gate is a pure function over rows rather than anything the provider hands back.
