Skip to content

Authentication

Audience: Developers working on auth, session, account flows, or admin gates.

Scope: Auth.js (NextAuth v5) configuration, session shape, providers, account flows, and super-admin impersonation. Behaviour is enforced by code in src/auth/ and src/proxy.ts.

The LMS uses Auth.js (NextAuth v5) with a JWT session strategy, layered on top of Kysely queries against PostgreSQL (SQLite locally). Two providers are supported:

  • Credentials — email + bcrypt-hashed password.
  • Google OAuth — host-scoped auto-link (optional, gated by env vars).

Auth.js and Kysely are server-only — never import either from a 'use client' module; never expose AUTH_SECRET or DATABASE_URL to the client bundle.

Building Blocks

File Role
src/auth/config.ts NextAuth v5 providers + JWT/session callbacks. Single source of truth.
src/auth/credentials.ts authorizeCredentials for both password and one-time consumed-token flows.
src/auth/oauth.ts linkOrCreateGoogleUser, isGoogleEnabled.
src/auth/guards.ts requireSession, requireRole, requireCourseAccess, withTenantOverride.
src/auth/actions.ts Server actions for sign-out, email change, force sign-out everywhere.
src/proxy.ts Request-time tenant resolution, locale negotiation, route gates, security headers.
src/db/queries/users/ User CRUD, bumpTokenVersion, profile updates.
src/db/queries/oauth-accounts/ OAuth account links.

Session Shape

The JWT carries:

Claim Source
userId users.id
tenantId users.tenant_id (or null for super_admin)
role users.role
tokenVersion users.token_version (bumped to invalidate sessions)
assignedCourses rows from course_admin_assignments
name, email, locale users columns

On protected-route requests, proxy.ts compares JWT tokenVersion against the current DB value and forces sign-out on mismatch. The jwt callback in src/auth/config.ts performs the same check for Auth.js session reads and refresh paths. Together these checks ensure "sign out everywhere", role changes, deactivation, and force-sign-out invalidate live sessions.

Multi-Tenancy

Tenant is resolved from the request host via resolveTenantFromHost() (see src/lib/tenant/). super_admin has tenantId = null and uses withTenantOverride() for cross-tenant queries. Suspended tenants return a branded 503 before auth routing, per reference/tenant-configuration.md.

Providers

Credentials (email + password)

  • Local development accounts: after npm run db:seed, obtain the account identifiers from scripts/db-seed.ts, set the shared dev password through SEED_DEMO_PASSWORD as described in Development seed data, and sign in on the correct tenant subdomain.
  • Form: /learn/sign-insign-in-form.tsxsignIn('credentials', ...).
  • Server: authorizeCredentials({ email, password, host }) in src/auth/credentials.ts.
  • Validates active tenant, non-deactivated user, non-empty password_hash, and bcrypt match.
  • super_admin rows have tenant_id = null and authenticate only when the request host resolves to apex/public (for example http://lvh.me:3001, not http://demo.lvh.me:3001). On a tenant subdomain, if the email/password match an apex account, signInAction returns apex_account_wrong_host and the form links to the apex sign-in URL.
  • Rate-limited at the auth.signIn policy (10 / 15 min per IP + normalized email).

One-time consumed-token flow

The same credentials provider also accepts { kind: 'consumed_token', userId, proof }. This powers email verification, password reset, magic-link sign-in, invitation acceptance, and super-admin impersonation.

Google OAuth (optional)

Configured in src/auth/config.ts, gated behind AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET. The "Continue with Google" button is hidden when these are unset, and on apex/public hosts (for example http://lvh.me:3001) even when credentials are set.

Local development setup

  1. Set AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET in apps/lms/.env.local.
  2. Set AUTH_URL to the same origin you use in the browser (for example http://demo.lvh.me:3001). If AUTH_URL points at localhost while you sign in on lvh.me, Auth.js error redirects land on localhost and the app returns 404 (unknown host).
  3. Keep AUTH_TRUST_HOST=true.
  4. In Google Cloud ConsoleAPIs & ServicesCredentials → your OAuth Web application client, add for each dev host:
    • Authorized JavaScript origins: http://demo.lvh.me:3001 (and any other tenant subdomain you use)
    • Authorized redirect URIs: http://demo.lvh.me:3001/api/auth/callback/google
  5. Open sign-in on a tenant subdomain, not apex: http://demo.lvh.me:3001/learn/sign-in (see Development seed data).

linkOrCreateGoogleUser({ profile, host }) (in src/auth/oauth.ts):

  1. Resolve tenant from request host. Refuse on unknown / suspended.
  2. Refuse if profile.email_verified !== true or the email is missing.
  3. If a users row exists in (tenant_id, email):
    • Refuse if deactivated_at is set.
    • Insert an oauth_accounts row, set email_verified_at if null, return the user.
  4. Otherwise, insert a new users row (role='student', password_hash=null on Postgres, '' on SQLite) and the oauth_accounts link.

Cross-tenant identity is not allowed. The same Google account on a different subdomain creates an independent user row.

OAuth on the apex host (lvh.me without a tenant slug) is refused with oauth_tenant_unknown — use a tenant subdomain such as demo.lvh.me.

Error reasons surfaced to /learn/sign-in/error

  • oauth_email_unverified
  • oauth_email_missing
  • oauth_tenant_unknown
  • oauth_tenant_suspended
  • oauth_account_deactivated

Account Flows

Sign-in (credentials)

sequenceDiagram
  participant U as User
  participant LMS
  participant DB
  U->>LMS: POST /learn/sign-in
  LMS->>DB: authorizeCredentials(email, host)
  alt apex account on tenant host (correct password)
    LMS-->>U: apex_account_wrong_host + link to main site
  else deactivated / suspended / wrong password / no password
    LMS-->>U: render error
  else success
    LMS-->>U: set JWT cookie, redirect callbackUrl or role landing
  end
sequenceDiagram
  participant U
  participant LMS
  participant Google
  participant DB
  U->>LMS: Click "Continue with Google"
  LMS->>Google: OAuth redirect
  Google-->>LMS: id_token (sub, email, email_verified, name, picture)
  LMS->>LMS: resolveTenantFromHost(host)
  alt invalid input
    LMS-->>U: /learn/sign-in/error?error=oauth_*
  else email exists in tenant
    LMS->>DB: insert oauth_accounts; mark email_verified_at if null
  else email new in tenant
    LMS->>DB: insert users (role=student) + oauth_accounts
  end
  LMS-->>U: JWT issued, redirect /learn/dashboard

Email change

  1. User submits new email in /learn/accountrequestEmailChangeAction issues an email_change verification token, emails it.
  2. User opens the link → /learn/account/email/\[token\] performs a non-mutating token check and renders an explicit confirm CTA. This prevents scanners or prefetchers from consuming the token on GET.
  3. User clicks confirm → confirmEmailChangeAction re-validates ownership, consumes the token, and updates users.email.

Sign-out confirmation page

  • The branded learner sign-out route is /learn/sign-out (src/app/\[locale\]/learn/sign-out/page.tsx), using the same auth shell primitives as other learner auth pages (AuthFlowLayout + AuthResultCard).
  • src/auth/config.ts sets pages.signOut = "/learn/sign-out", so direct visits to /api/auth/signout resolve to the branded page instead of Auth.js default markup.
  • Guard behavior is inverse of sign-in: if no authenticated session exists, the page redirects to / rather than prompting for sign-out.
  • CTA contract:
    • primary destructive action signs out and returns to /,
    • secondary action returns to /learn/dashboard,
    • tertiary cancel action returns to /.
  • Header user-menu sign-out now routes through /learn/sign-out so all entry points share one UX contract.

Sign-out everywhere

signOutEverywhereAction bumps users.token_version. Existing JWTs whose tokenVersion no longer matches are rejected by proxy.ts on protected-route requests (and by the jwt callback on session/read refresh paths), forcing re-sign-in.

Password reset / verification / invitation

All three use the consumed-token credentials path. Tokens live in verification_tokens with TTLs from TOKEN_TTL_SECONDS and are marked consumed atomically.

Forgot-password requests are enumeration-safe: the action returns the same check-inbox response whether or not an email is sent. Delivery occurs only when the request host resolves to an active tenant, the normalized email matches a user in that tenant, and the user has a local password_hash. OAuth-only users do not receive password-reset mail.

Provider rejection also does not change the user-facing response. Support engineers should inspect the server-side email/sent or email/failed structured log and follow Email Delivery. Do not ask users to confirm whether an account exists, and do not expose provider details or reset-token information in a support response.

Passwordless sign-in for learners uses custom magic_link rows in verification_tokensnot the Auth.js Email provider. After the link is consumed, the session is minted through the same consumed_token credentials path as password reset and email verification.

Surface Path / action
Request form /learn/magicMagicLinkRequestFormrequestMagicLinkAction
Consume /learn/magic/\[token\]MagicLinkConsumeconsumeMagicLinkAction
Entry CTA /learn/sign-in — link to /learn/magic when the tenant magicLink flag is enabled
  • TTL: 15 minutes (TOKEN_TTL_SECONDS.magic_link = 900).
  • Rate limit: RATE_LIMIT_POLICIES.magic — 5 requests per 15 minutes per IP + normalized email (see admin/security-and-rate-limiting.md).
  • Enumeration safety: requestMagicLinkAction always returns the same success message whether or not the email exists; email is sent only when a user row matches (tenant_id, email).
  • Tenant flag: magicLink in tenants.feature_flags_json, read server-side via isMagicLinkEnabled / isMagicLinkEnabledForRequestHost. Defaults to enabled when absent. Disabling hides the sign-in CTA, redirects /learn/magic routes, and blocks new requests. Admin invitation emails (/invite/\[token\]) are not gated by this flag.
sequenceDiagram
  participant U as User
  participant LMS
  participant DB
  participant Email
  U->>LMS: POST email at /learn/magic
  LMS->>DB: issueToken(magic_link)
  LMS->>Email: sendEmail (Resend / SMTP / non-production console)
  Email-->>U: dev terminal links[] or inbox
  U->>LMS: GET /learn/magic/token
  LMS->>DB: consumeToken + mintProof
  LMS->>LMS: signIn(credentials, consumed_token)
  LMS-->>U: redirect /learn/dashboard

Local development: leave RESEND_API_KEY and SMTP_HOST unset so the console driver logs the magic URL in the dev server terminal. See Email Delivery.

Admin UI Tour

All /admin/* routes call requireRole(...) server-side. Tenant scoping flows from the JWT claim. super_admin accesses cross-tenant data via withTenantOverride().

Route Roles Purpose
/admin tenant_admin, course_admin, super_admin Landing dashboard with counts + recent audit.
/admin/users tenant_admin, super_admin Paginated user list with search / role / status filters.
/admin/users/\[id\] tenant_admin, super_admin User detail + actions (role change, deactivate, force sign-out, resend verification, password-reset link, impersonate).
/admin/users/invite tenant_admin, super_admin Send an invitation (re-uses invitations + email template).
/admin/courses tenant_admin, course_admin, super_admin Read-only catalogue + links to course-admin manager.
/admin/courses/\[slug\]/dashboard tenant_admin, course_admin (assigned), super_admin Per-course roster + analytics.
/admin/courses/\[slug\]/settings tenant_admin, course_admin (assigned), super_admin Per-course feature flags (e.g. sequential gating). Requires a tenant subdomain (demo.lvh.me, not apex lvh.me); super_admin on apex sees a hint instead of 404.
/admin/courses/\[slug\]/admins tenant_admin, super_admin Assign / revoke course_admin_assignments.
/admin/audit tenant_admin, super_admin Audit log viewer; tenant-scoped or global.
/admin/tenants super_admin Create + suspend tenants.

Mutating actions

Every mutating action:

  • Validates input with Zod at the boundary.
  • Re-checks role / target ownership inside the action.
  • Rate-limits via RATE_LIMIT_POLICIES.adminMutation.
  • Writes an audit row (appendAuditEvent).
  • Bumps target token_version on role change / deactivation / force sign-out.

Super-Admin Impersonation

super_admin users can sign in as any other user, scoped to that user's tenant subdomain.

For the operator workflow, prerequisites, stop path, and audit guidance, see Impersonation.

Mechanics

  • Start: POST /api/admin/impersonate/start validates super_admin, mints a one-time proof for the target user, calls signIn('credentials', { kind: 'consumed_token', userId, proof }), and sets the __impersonator_uid HTTP-only cookie containing the original super-admin's user id.
  • Stop: POST /api/admin/impersonate/stop reads __impersonator_uid, signs out the impersonated session, mints a fresh proof for the original super-admin, signs back in as them, and clears the cookie.
  • UI: AdminShell reads __impersonator_uid and renders the data-testid="admin-impersonation-banner" banner with a "Stop impersonating" button that POSTs to /api/admin/impersonate/stop.

Constraints

  • Super-admin must browse the target tenant's subdomain to impersonate. Cross-tenant impersonation requires switching subdomain first.
  • Both start and stop write auth.impersonate.start / auth.impersonate.stop audit events including actor, target, and tenant.
  • The impersonated session is a normal JWT for the target user — every action they take is correctly attributed (and audit-logged) under the impersonated user, with the impersonator id in the audit context.

Why the consumed-token path?

Reusing the consumed_token credentials flow keeps impersonation in the same code path as password reset, magic-link, and invitation acceptance, so all four surfaces share the same rate-limiting, audit, and session minting logic.