For school administrators

Identity and sign-in

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

WhoSigns in withWhereSet up by
School staff — admins, teachers, officePassword, always available/loginthe school, or EduSpaze on their behalf
School staffGoogle Workspace / Microsoft Entra ID, where the school has switched SSO on/login → providerthe school, or EduSpaze on their behalf
PupilsUsername + PIN, no email address/student/loginthe school (roster import or batch issue)
Vendors, EduSpaze staff, guardiansPassword, plus optional two-factor/loginEduSpaze (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.

VariableProvider
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETGoogle Workspace
MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRETMicrosoft 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:

#StepTurns green when
1Tell the service its own public addressAPP_BASE_URL is set
2Register EduSpaze with Google (§2.1)GOOGLE_CLIENT_ID / _SECRET are set
3Register EduSpaze with Entra (§2.2)MICROSOFT_CLIENT_ID / _SECRET are set
4Switch 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.com is not accepted there.

1 · Google Cloud console → the eduspaze project → *APIs & Services*

*OAuth consent screen* first:

FieldValue
User typeExternal
App nameEduSpaze
User support emailthe owning account
Authorised domainsthe EduSpaze domain — not gmail.com, and not run.app
Application home page / privacy policy / termsURLs on that same domain
Scopesopenid, .../auth/userinfo.email, .../auth/userinfo.profile — all non-sensitive

Then *Credentials* → Create credentials → *OAuth client ID* → Web application:

FieldValue
Authorised redirect URI<app base URL>/api/auth/sso/callback
Authorised JavaScript originsnot 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 profile stays 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*

FieldValue
NameEduSpaze
Supported account typesAccounts 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 the email claim entirely, and the platform falls back to preferred_username, which is the UPN and is not always the address the school expects (see §3).
  • *API permissions* — the delegated openid, email, profile scopes 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:

FieldMeaning
providerGoogle, Microsoft, or the demo provider
allowedDomainsemail 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.
directoryIdGoogle hosted domain or Entra tenant id; sent to the provider *and* re-checked on the claims (hd for Google, tid for Entra)
autoProvisionRolewhat a first-time signer-in becomes, or null = must already have an account
statusDRAFTVERIFIEDACTIVE

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

StateMeaning
DRAFTSaved, never proven. Staff cannot use it.
VERIFIEDA test sign-in succeeded. Staff still use passwords until it is switched on.
ACTIVEStaff 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:

WhereWhat
Admin → Settings → Identity & sign-inWhich 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.

MomentWho hearsHow
A colleague is given accessthat personin-app notice and the *Welcome — school staff* email; the temporary password is shown to the administrator to pass on separately
SSO switched onevery active member of staffin-app notice and the *Single sign-on switched on* email, which says what to do and that their password still works
SSO switched offevery active member of staffin-app notice: use your password as before
A platform admin changes the school's SSOthe school's administratorsin-app notice naming the admin who did it
A roster sync fails, or ends pupilsthe school's administratorsin-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

EventWhen
school.identity_settings_changedprovider, domains, directory or auto-provision changed
school.sso_status_changedswitched on or off for staff
school.sso_removedthe provider was removed and the school returned to passwords
auth.sso_login / auth.sso_refuseda 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.

PersonSubjectWhere it comes fromWho sees it
PupilStudent.externalId — e.g. RB-4213the school's own SIS code, from the roster CSVthe vendor, on launch, roster and telemetry
StaffSchoolMembership.externalId — e.g. STF-0003the school's staff code, or minted STF-nnnn on first usethe vendor, on a staff launch and in the roster
Class, groupthe row idEduSpazethe 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.

Source: docs/identity-and-sso.md in the repository. Last updated with the code it describes.

Identity & single sign-on — EduSpaze