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/andsrc/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 fromscripts/db-seed.ts, set the shared dev password throughSEED_DEMO_PASSWORDas described in Development seed data, and sign in on the correct tenant subdomain. - Form:
/learn/sign-in→sign-in-form.tsx→signIn('credentials', ...). - Server:
authorizeCredentials({ email, password, host })insrc/auth/credentials.ts. - Validates active tenant, non-deactivated user, non-empty
password_hash, and bcrypt match. super_adminrows havetenant_id = nulland authenticate only when the request host resolves to apex/public (for examplehttp://lvh.me:3001, nothttp://demo.lvh.me:3001). On a tenant subdomain, if the email/password match an apex account,signInActionreturnsapex_account_wrong_hostand the form links to the apex sign-in URL.- Rate-limited at the
auth.signInpolicy (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¶
- Set
AUTH_GOOGLE_IDandAUTH_GOOGLE_SECRETinapps/lms/.env.local. - Set
AUTH_URLto the same origin you use in the browser (for examplehttp://demo.lvh.me:3001). IfAUTH_URLpoints atlocalhostwhile you sign in onlvh.me, Auth.js error redirects land onlocalhostand the app returns 404 (unknown host). - Keep
AUTH_TRUST_HOST=true. - In Google Cloud Console → APIs & Services → Credentials → 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
- Authorized JavaScript origins:
- Open sign-in on a tenant subdomain, not apex:
http://demo.lvh.me:3001/learn/sign-in(see Development seed data).
Host-scoped auto-link contract¶
linkOrCreateGoogleUser({ profile, host }) (in src/auth/oauth.ts):
- Resolve tenant from request host. Refuse on
unknown/suspended. - Refuse if
profile.email_verified !== trueor the email is missing. - If a
usersrow exists in(tenant_id, email):- Refuse if
deactivated_atis set. - Insert an
oauth_accountsrow, setemail_verified_atif null, return the user.
- Refuse if
- Otherwise, insert a new
usersrow (role='student',password_hash=nullon Postgres,''on SQLite) and theoauth_accountslink.
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_unverifiedoauth_email_missingoauth_tenant_unknownoauth_tenant_suspendedoauth_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
Google sign-in (host-scoped auto-link)¶
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¶
- User submits new email in
/learn/account→requestEmailChangeActionissues anemail_changeverification token, emails it. - 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. - User clicks confirm →
confirmEmailChangeActionre-validates ownership, consumes the token, and updatesusers.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.tssetspages.signOut = "/learn/sign-out", so direct visits to/api/auth/signoutresolve 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
/.
- primary destructive action signs out and returns to
- Header user-menu sign-out now routes through
/learn/sign-outso 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.
Magic-link sign-in¶
Passwordless sign-in for learners uses custom magic_link rows in verification_tokens — not 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/magic — MagicLinkRequestForm → requestMagicLinkAction |
| Consume | /learn/magic/\[token\] — MagicLinkConsume → consumeMagicLinkAction |
| 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 (seeadmin/security-and-rate-limiting.md). - Enumeration safety:
requestMagicLinkActionalways 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:
magicLinkintenants.feature_flags_json, read server-side viaisMagicLinkEnabled/isMagicLinkEnabledForRequestHost. Defaults to enabled when absent. Disabling hides the sign-in CTA, redirects/learn/magicroutes, 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_versionon 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/startvalidatessuper_admin, mints a one-time proof for the target user, callssignIn('credentials', { kind: 'consumed_token', userId, proof }), and sets the__impersonator_uidHTTP-only cookie containing the original super-admin's user id. - Stop:
POST /api/admin/impersonate/stopreads__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:
AdminShellreads__impersonator_uidand renders thedata-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.stopaudit 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.
Related References¶
- User Management & Access — personas, RBAC, permission matrix, and access flows.
- Impersonation — super-admin support workflow.
- Tenant Configuration — feature flags, custom domains, suspension, and host resolution.
- Email System — current event, template, category, and persistence architecture.
- Email Delivery — provider setup and troubleshooting.
- Security And Rate Limiting — auth rate-limit policies and security headers.
- Root rule 080 — Data & Auth Integration.
- Root rule 120 — Security.