Skip to content

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 via APP_GUARD) rejects any request without a valid session with 401, except routes marked @Public() (the /api/auth/* endpoints).
  • Sessions. On login the server mints a random 256-bit session id, stores it in the sessions table, 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 is Secure when NODE_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 an Authorization: Bearer header instead. Same session table, same expiry, same logout. Cross-origin callers additionally need their origin listed in CORS_ORIGINS (comma-separated; empty keeps CORS off).
  • Users. Accounts live in the users table. The built-in local provider stores a bcrypt hash (cost 12). External identities (SSO) also land here, keyed by (provider, external_id), with a NULL password.
  • Bootstrap. The first account is created through POST /api/auth/register (role admin). That endpoint closes with 409 once any account exists; the frontend shows a one-time "create the first account" screen while GET /api/auth/status reports configured: false.
  • Further accounts. An admin creates them from Settings → Users (or POST /api/auth/users), choosing the role (user or admin) and an initial password the owner then changes in Settings → Account. Alternatively, set AUTH_ALLOW_SIGNUP=true to let people create their own accounts from the login screen. Operator-level actions (user management and the global AI settings) require an admin session (or AUTH_DISABLED single-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/password with { 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:

  1. 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 form https://your-server/api/auth/callback/oidc.
  2. In Synapse, open Settings > Administration > Single sign-on and 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.
  3. 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:

  1. Start it next to the app:
    docker compose -f docker-compose.dev.yml up mock-oidc
    
    It serves discovery at http://localhost:8090/synapse/.well-known/openid-configuration.
  2. Run the app with the auth gate on (AUTH_DISABLED unset) and create the first admin through the normal login screen, so you have somewhere to configure SSO from.
  3. 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

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"}
Submit; you land back in the app, signed in. The first such login (if it is the first account on the server) is the admin.

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).