Authentication¶
Synapse ships with a small, self-contained auth layer that gates the whole API behind a login, with username/password and OpenID Connect single sign-on built in. It is deliberately minimal: a single trusted deployment, server-side sessions, and strict per-user data isolation (see Data model).
How it works¶
- Gate. A global guard (
SessionAuthGuard, registered viaAPP_GUARD) rejects any request without a valid session with401, except routes marked@Public()(the/api/auth/*endpoints). - Sessions. On login the server mints a random 256-bit session id, stores it
in the
sessionstable, and sends it in a signed, HttpOnly cookie (revision_session). The cookie carries only the id; all state (which user, expiry) is authoritative server-side, so logout and expiry can't be bypassed from the client. The cookie isSecurewhenNODE_ENV=production. - Cookie-less clients. Login responses also return the session id as an
opaque
token; clients that can't use cookies (native app shells) send it back as anAuthorization: Bearerheader instead. Same session table, same expiry, same logout. Cross-origin callers additionally need their origin listed inCORS_ORIGINS(comma-separated; empty keeps CORS off). - Users. Accounts live in the
userstable. The built-inlocalprovider stores a bcrypt hash (cost 12). External identities (SSO) also land here, keyed by(provider, external_id), with aNULLpassword. - Bootstrap. The first account is created through
POST /api/auth/register(roleadmin). That endpoint closes with409once any account exists; the frontend shows a one-time "create the first account" screen whileGET /api/auth/statusreportsconfigured: false. - Further accounts. An admin creates them from Settings → Users (or
POST /api/auth/users), choosing the role (useroradmin) and an initial password the owner then changes in Settings → Account. Alternatively, setAUTH_ALLOW_SIGNUP=trueto let people create their own accounts from the login screen. Operator-level actions (user management and the global AI settings) require an admin session (orAUTH_DISABLEDsingle-user mode, where the local operator is the admin); per-course settings stay with each course's owner.
Endpoints¶
| Method | Path | Purpose |
|---|---|---|
| GET | /api/auth/status |
{ configured, authDisabled, providers } |
| GET | /api/auth/me |
{ user \| null } - who's logged in |
| POST | /api/auth/register |
Bootstrap the first admin (then closed) |
| POST | /api/auth/login |
Username/password → sets session cookie |
| POST | /api/auth/logout |
Destroys the session, clears the cookie |
| POST | /api/auth/signup |
Self-registration, role user (only when AUTH_ALLOW_SIGNUP) |
| POST | /api/auth/password |
Change own password ({ currentPassword, newPassword }) |
| GET | /api/auth/users |
List accounts (admin only) |
| POST | /api/auth/users |
Create an account (admin only; { username, password, role? }) |
| POST | /api/auth/users/:id/password |
Reset an account's password (admin only; revokes its sessions) |
| PUT | /api/auth/users/:id/role |
Promote/demote an account (admin only; never your own - lockout guard) |
Configuration¶
Set these in apps/api/.env (see .env.example):
| Variable | Default | Notes |
|---|---|---|
SESSION_SECRET |
(required in prod) | Long random string signing the session cookie. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". In dev an unset value falls back to a random per-boot secret (sessions don't survive restarts). |
AUTH_SESSION_TTL_DAYS |
30 |
Session lifetime. |
AUTH_DISABLED |
false |
Set true to turn the gate off entirely (single-user, localhost). Never on a networked host. |
AUTH_ALLOW_SIGNUP |
false |
Set true to show "Create an account" on the login screen (open self-registration, role user). Only for instances where anyone who can reach the server is welcome. |
HOST |
127.0.0.1 |
Bind address. Keep loopback unless you've fronted it with TLS + auth; set 0.0.0.0 for LAN/remote. |
Passwords: changing and recovery¶
- Change (self-service). A signed-in local user changes their password from
Settings → Account (or
POST /api/auth/passwordwith{ currentPassword, newPassword }). The current password is verified first; on success every other session is revoked and the caller gets a fresh cookie, so a leaked session dies with the old password. - Reset by an admin. An admin resets any local account's password from
Settings → Users (the key icon on the account's row, or
POST /api/auth/users/:id/password). The account's sessions are revoked; hand the new password over out of band. - Recovery (operator). For the case nobody with admin access is left - there is no email infrastructure to send reset links, so the last-resort reset happens at boot: restart the API once with
AUTH_RESET_PASSWORD="username:a-new-password"
The account's password is replaced (min. 8 characters), all its sessions are revoked, and a warning is logged. Sign in, then remove the variable - otherwise every restart resets the password again. Works for any local account, including the admin. External-identity (SSO) accounts have no password to reset; recover access through the IdP instead.
Single sign-on¶
Any OpenID Connect provider (Google, Microsoft Entra, Keycloak, Authentik, Okta and most others) can be configured from the app, without writing any code:
- In your identity provider, register a new client (a "web application").
Give it the redirect URL shown in Synapse under
Settings > Administration > Single sign-on; it has the formhttps://your-server/api/auth/callback/oidc. - In Synapse, open
Settings > Administration > Single sign-onand fill in the issuer URL (the provider's base URL, the one that serves/.well-known/openid-configuration), the client id and the client secret, plus your server's public URL. Enable it and save. - The server fetches the provider's discovery document and registers the login flow. If anything is wrong, the error appears right there in the panel; once active, the login page offers the button (its label is configurable too).
Accounts created through SSO are keyed by the provider's stable subject id, so they survive email or name changes. The first account ever created on the server becomes the admin, the same rule as local registration. SSO accounts have no local password; recover access through the identity provider.
Testing SSO locally¶
You can exercise the whole flow without a real provider or any accounts,
using the throwaway OpenID Connect server in docker-compose.dev.yml:
- Start it next to the app:
It serves discovery at
docker compose -f docker-compose.dev.yml up mock-oidchttp://localhost:8090/synapse/.well-known/openid-configuration. - Run the app with the auth gate on (
AUTH_DISABLEDunset) and create the first admin through the normal login screen, so you have somewhere to configure SSO from. - In
Settings > Administration > Single sign-on, set:- issuer
http://127.0.0.1:8090/synapse - client id
synapse-client(any value; the mock accepts all clients) - client secret any non-empty string
- public URL your app's own origin, e.g.
http://127.0.0.1:3000
- issuer
Enable and save. The panel should turn active with no error, and the login
page starts offering the SSO button. Use 127.0.0.1 rather than localhost
throughout: where localhost resolves to IPv6 first, it can land on a
different listener than the API, and the callback then 404s.
4. Log out, click the SSO button. The mock shows a login form: type any
username (it becomes the account's subject id) and, optionally, a claims
JSON for the rest, e.g.
{"email":"student@example.test","preferred_username":"student","name":"Test Student"}
The mock does not verify client secrets or sign tokens in a checkable way, so it is strictly a local aid; never point a real deployment at it.
Other protocols¶
For a protocol the built-in OIDC client does not cover (SAML, a bespoke
OAuth2 flow), the code seam still exists: a provider is a Passport
strategy plus a one-line descriptor. See apps/api/src/auth/auth-provider.ts
and the OidcService for a worked example; the guard, sessions and cookie
are shared, so a new strategy needs no other change.
Data model¶
Data is strictly isolated per user: courses and lexicon entries carry an
owner_id, everything else is scoped through its course, and every query
filters on the caller. A new account starts with an empty workspace. Under
AUTH_DISABLED there is no scoping (single-user mode).