Skip to Content

Authentication

Amal uses Bearer JWT authentication with no session cookies. A short-lived access token is paired with a long-lived, rotating refresh token.

TokenLifetimeStored whereRotates?
Access token15 minutes (expiresIn: 900)client only; statelessno
Refresh token7 daysserver-sideyes, on every use

Register

POST /api/auth/register is bootstrap only. The first call creates a SUPER-scope admin and returns a token pair. After that, registration is closed: any further call returns 403 with code: "PERMISSION_DENIED".

organizationId is required for every persona except the first SUPER admin, which omits it. All later users are created through Provision Users, not register.

Login

POST /api/auth/login with { email, password }200 AuthTokensResponse ({ accessToken, refreshToken, expiresIn, tokenType }). Bad credentials return 401.

Brute-force protection returns 401 + Retry-After, not 429. After 5 failed attempts per email within 15 minutes the endpoint locks and responds with 401 and a Retry-After header (the seconds to wait). X-RateLimit-* headers appear after the first failed attempt. Do not expect a bare 429 from this API.

A worked login request + response (curl and TypeScript) is in Getting Started → Step 2.

Using the access token

Send the access token on every protected request:

curl https://localhost:3000/api/auth/me \ -H "Authorization: Bearer <accessToken>"

GET /api/auth/me returns your identity plus the permissions[] array that gates every endpoint (MeResponse).

Refresh and rotation

POST /api/auth/refresh with { refreshToken } → a new AuthTokensResponse. Refresh tokens rotate: the old token is invalidated on use. **Reusing a rotated refresh token returns 401 and revokes the session (theft detection). Always store the newest refresh token from the latest response.

Logout

POST /api/auth/logout (requires a valid access token) invalidates the session server-side. Access tokens are not individually revocable; they are short-lived by design, so they simply expire.

Password reset

A two-step, enumeration-safe flow:

  1. POST /api/auth/password-reset with { email }always 200 (it never reveals whether the email exists) → a magic link is sent (1-hour TTL).
  2. POST /api/auth/password-reset/confirm with { token, newPassword } → resets the password and revokes all existing sessions.

Student quickCode login

For Grade-1 usability, students log in without email or password:

curl -X POST https://localhost:3000/api/auth/student/quickcode \ -H "Content-Type: application/json" \ -d '{ "quickCode": "472", "classId": "clx4z2k0u0000xyz1234abcde" }'

POST /api/auth/student/quickcode with a 3-digit quickCode + the class classId (cuid) returns a student-scoped token pair (QuickCodeLoginResponse with tokens, studentId, classId).

  • Codes are per class and regenerate on a 90-day window (see Manage Classes & Rosters).
  • An invalid or expired code returns 401 with code: "QUICKCODE_INVALID_OR_EXPIRED"; too many attempts return 401 with code: "QUICKCODE_RATE_LIMIT".

Google SSO

LMS-imported users can sign in with their Google account. The flow uses PKCE and a short-lived handoff code:

  1. Start: GET /api/auth/google/start?loginHint=<email> returns { redirectUrl }. Send the user’s browser to redirectUrl.
  2. Callback: Google redirects to GET /api/auth/google/callback. The server issues a 60-second single-use ?code= query parameter and redirects the browser to the web app.
  3. Exchange: the web app immediately POSTs { code } to POST /api/auth/google/exchange and receives a standard AuthTokensResponse.

GET /api/auth/google/start returns 503 when GOOGLE_CLIENT_ID is not configured in the environment. A domain allow-list (allowedGoogleDomains on the organisation record) controls which Google accounts are accepted.

What is (and is not) in the token

The JWT carries userId (sub), organizationId (orgId), the role code (role), and the session ID (sid), along with standard iat/exp claims. No name, email, or grade is embedded. Read those from GET /api/auth/me instead. There is no token secret-rotation / sv claim in Wave 1 (deferred to a later wave), so don’t build against one.