diff --git a/README.md b/README.md index f11fb0e..3af5aa1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ We are building a web application that helps gymnastics coaches plan their gymna ## Status -The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). Login, the user data and the connection from the frontend are the next steps. +The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The user data and the connection from the frontend are the next steps. The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. @@ -312,7 +312,7 @@ To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (o ## GraphQL server -The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login and the user data (gymnasts, routines) are not built yet; those operations answer a clear error, see [The server in docs/API.md](docs/API.md#the-server). +The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login is built (see "Login" below). The user data (gymnasts, routines) is not; those operations answer a clear error, see [The server in docs/API.md](docs/API.md#the-server). **Start it.** The database must be migrated and seeded first (see "Database (local setup)" above): @@ -358,8 +358,17 @@ Other addresses: `/images//.webp` serves the drawings in `data/ | `PORT` | `3001` | The port of the server | | `IMAGE_BASE_URL` | `http://localhost:3001/images` | The address the drawings are served from (`imageUrl` in the API) | | `CORS_ORIGIN` | `http://localhost:5173` | The addresses that may call the API from a browser, comma separated | +| `SESSION_SECRET` | none | Signs the access tokens, at least 32 random characters | -The tests of the server are in `backend/src/server/` and run with `npm test`, like the search tests. They need the seeded database. +The tests of the server are in `backend/src/server/` and run with `npm test`, like the search tests. They need the seeded database. The session tests write a test coach and sessions (the Google id starts with `test-70-`) and delete them again. + +**The session.** A logged-in coach has an access token (15 minutes) and a refresh token (14 days). How it works and why is in "Identifying the user" in [docs/API.md](docs/API.md#identifying-the-user). To turn the tokens on, make a secret and put it in `backend/.env` as `SESSION_SECRET`: + +```bash +node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" +``` + +The server does not start if the secret is shorter than 32 characters. Without it the catalog works and `me` is always `null`. The login with Google (`login`) is a separate pull request, because Sign in with Google needs https and the course server has only http; until it is merged, `login` answers `NOT_IMPLEMENTED`. Send `Authorization: Bearer ` with the queries, or call `refreshSession` with the refresh token. ## Catalog search diff --git a/backend/.env.example b/backend/.env.example index 7886a08..1075243 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,4 +16,7 @@ MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_MIGRATOR_PASSWORD@localho # The addresses that may call the API from a browser, separated by commas. # CORS_ORIGIN=http://localhost:5173 -SESSION_SECRET="your_session_secret_here" +# Signs the access tokens (optional). At least 32 random characters. Without it there +# are no tokens and `me` is always null. +# Make one with: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" +# SESSION_SECRET= diff --git a/backend/package.json b/backend/package.json index d4c2320..80ca971 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,6 +42,7 @@ "dotenv": "^18.0.8", "express": "^5.3.0", "graphql": "^16.14.2", + "jose": "^6.2.12", "tsx": "^4.23.15" } } diff --git a/backend/prisma/migrations/20261017080228_add_sessions/migration.sql b/backend/prisma/migrations/20261017080228_add_sessions/migration.sql new file mode 100644 index 0000000..8bdaa32 --- /dev/null +++ b/backend/prisma/migrations/20261017080228_add_sessions/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "coach_id" TEXT NOT NULL, + "family_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash"); + +-- CreateIndex +CREATE INDEX "sessions_coach_id_idx" ON "sessions"("coach_id"); + +-- CreateIndex +CREATE INDEX "sessions_family_id_idx" ON "sessions"("family_id"); + +-- CreateIndex +CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_coach_id_fkey" FOREIGN KEY ("coach_id") REFERENCES "coaches"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4d78669..c424bf6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -187,10 +187,35 @@ model Coach { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) gymnasts Gymnast[] + sessions Session[] @@map("coaches") } +/// One refresh token of a login. Only a hash of the token is stored. Every use +/// of a token replaces it with a new one in the same family (rotation). A +/// token that is used again after it was replaced means that it was copied, +/// and the whole family is revoked. See "Identifying the user" in docs/API.md. +model Session { + id String @id @default(cuid()) + coachId String @map("coach_id") + /// All the tokens that follow from one login share a family. + familyId String @map("family_id") + /// SHA-256 of the refresh token, as hex. The token itself is never stored. + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + /// Set when the token has been used, or the login was ended. + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade) + + @@index([coachId]) + @@index([familyId]) + @@index([expiresAt]) + @@map("sessions") +} + model Gymnast { id String @id @default(cuid()) coachId String @map("coach_id") diff --git a/backend/src/auth/accessToken.test.ts b/backend/src/auth/accessToken.test.ts new file mode 100644 index 0000000..e3100f6 --- /dev/null +++ b/backend/src/auth/accessToken.test.ts @@ -0,0 +1,84 @@ +// Tests for the access token. No database is needed. + +import { SignJWT } from 'jose' +import { describe, expect, it } from 'vitest' +import { + ACCESS_TOKEN_SECONDS, + signAccessToken, + verifyAccessToken, +} from './accessToken.js' + +const SECRET = 'a-secret-that-is-at-least-32-characters-long' +const NOW = new Date('2026-10-17T10:00:00Z') +const after = (seconds: number) => new Date(NOW.getTime() + seconds * 1000) + +describe('the access token', () => { + it('holds the coach id and gives it back', async () => { + const token = await signAccessToken('coach-1', SECRET, NOW) + expect(await verifyAccessToken(token, SECRET, NOW)).toBe('coach-1') + }) + + it('lives for 15 minutes', async () => { + expect(ACCESS_TOKEN_SECONDS).toBe(15 * 60) + const token = await signAccessToken('coach-1', SECRET, NOW) + expect(await verifyAccessToken(token, SECRET, after(14 * 60 + 59))).toBe( + 'coach-1', + ) + expect( + await verifyAccessToken(token, SECRET, after(15 * 60 + 1)), + ).toBeNull() + }) + + it('is refused when it is signed with another key', async () => { + const token = await signAccessToken('coach-1', 'x'.repeat(40), NOW) + expect(await verifyAccessToken(token, SECRET, NOW)).toBeNull() + }) + + it('is refused when the content has been changed', async () => { + const token = await signAccessToken('coach-1', SECRET, NOW) + const [header, , signature] = token.split('.') + const forged = Buffer.from( + JSON.stringify({ sub: 'coach-2', iss: 'cop-catalog', exp: 9999999999 }), + ).toString('base64url') + expect( + await verifyAccessToken(`${header}.${forged}.${signature}`, SECRET, NOW), + ).toBeNull() + }) + + it('is refused without a signature ("alg": "none")', async () => { + const part = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url') + const unsigned = `${part({ alg: 'none' })}.${part({ + sub: 'coach-1', + iss: 'cop-catalog', + exp: 9999999999, + })}.` + expect(await verifyAccessToken(unsigned, SECRET, NOW)).toBeNull() + }) + + it('is refused when it comes from another issuer or has no coach id', async () => { + const key = new TextEncoder().encode(SECRET) + const issued = Math.floor(NOW.getTime() / 1000) + const other = await new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject('coach-1') + .setIssuer('someone-else') + .setIssuedAt(issued) + .setExpirationTime(issued + 60) + .sign(key) + const noSubject = await new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuer('cop-catalog') + .setIssuedAt(issued) + .setExpirationTime(issued + 60) + .sign(key) + expect(await verifyAccessToken(other, SECRET, NOW)).toBeNull() + expect(await verifyAccessToken(noSubject, SECRET, NOW)).toBeNull() + }) + + it('is refused when it is not a token at all, and never throws', async () => { + for (const text of ['', 'abc', 'a.b.c', '....', 'x'.repeat(5000)]) { + expect(await verifyAccessToken(text, SECRET, NOW)).toBeNull() + } + }) +}) diff --git a/backend/src/auth/accessToken.ts b/backend/src/auth/accessToken.ts new file mode 100644 index 0000000..692395b --- /dev/null +++ b/backend/src/auth/accessToken.ts @@ -0,0 +1,51 @@ +// The access token: a short-lived signed JWT that the client sends in the +// Authorization header. It holds only the coach id (`sub`). It is signed with +// SESSION_SECRET (HS256) and lives for 15 minutes. After that the client +// uses its refresh token (see sessions.ts) to get a new one. + +import { SignJWT, jwtVerify } from 'jose' + +export const ACCESS_TOKEN_SECONDS = 15 * 60 +const ISSUER = 'cop-catalog' +const ALGORITHM = 'HS256' + +const key = (secret: string) => new TextEncoder().encode(secret) + +export async function signAccessToken( + coachId: string, + secret: string, + now: Date, +): Promise { + const issuedAt = Math.floor(now.getTime() / 1000) + return new SignJWT({}) + .setProtectedHeader({ alg: ALGORITHM }) + .setSubject(coachId) + .setIssuer(ISSUER) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + ACCESS_TOKEN_SECONDS) + .sign(key(secret)) +} + +/** + * The coach id in a valid token, or null when the token is wrong, tampered + * with, signed with another key or expired. It never throws and never says + * why, because a missing login is a normal state (see `me` in docs/API.md). + */ +export async function verifyAccessToken( + token: string, + secret: string, + now: Date, +): Promise { + try { + const { payload } = await jwtVerify(token, key(secret), { + issuer: ISSUER, + algorithms: [ALGORITHM], // never "none", and no other key type + currentDate: now, + }) + return typeof payload.sub === 'string' && payload.sub !== '' + ? payload.sub + : null + } catch { + return null + } +} diff --git a/backend/src/auth/sessions.test.ts b/backend/src/auth/sessions.test.ts new file mode 100644 index 0000000..1316ef2 --- /dev/null +++ b/backend/src/auth/sessions.test.ts @@ -0,0 +1,165 @@ +// Tests for the refresh tokens. They run against the local database from +// backend/.env (run `npm run db:deploy` first) and write a test coach with +// sessions, which they delete again. Without DATABASE_URL they are skipped. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createPrismaClient } from '../db.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { UnauthenticatedError } from '../errors.js' +import { + REFRESH_TOKEN_DAYS, + deleteExpiredSessions, + endSession, + hashToken, + rotateSession, + startSession, +} from './sessions.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const NOW = new Date('2026-10-17T10:00:00Z') +const days = (n: number) => new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000) +const TEST_ID = 'test-70-sessions' + +describe.runIf(hasDatabase)('the refresh tokens', () => { + let prisma: PrismaClient + let coachId: string + + const clean = () => + prisma.coach.deleteMany({ where: { googleId: { startsWith: TEST_ID } } }) + + beforeAll(async () => { + prisma = createPrismaClient() + await clean() + coachId = ( + await prisma.coach.create({ + data: { googleId: TEST_ID, email: 'test@example.com', name: 'Test' }, + }) + ).id + }) + + afterAll(async () => { + await clean() + await prisma.$disconnect() + }) + + it('stores a hash of the token and never the token', async () => { + const token = await startSession(prisma, coachId, NOW) + const row = await prisma.session.findUniqueOrThrow({ + where: { tokenHash: hashToken(token) }, + }) + expect(token.length).toBeGreaterThanOrEqual(40) + expect(JSON.stringify(row)).not.toContain(token) + expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/) + expect(row.expiresAt).toEqual(days(REFRESH_TOKEN_DAYS)) + expect(row.revokedAt).toBeNull() + }) + + it('gives a new token for a token, and the old token stops working', async () => { + const first = await startSession(prisma, coachId, NOW) + const second = await rotateSession(prisma, first, NOW) + expect(second.coachId).toBe(coachId) + expect(second.refreshToken).not.toBe(first) + + // The new token works and expires 14 days after it was made. + const later = days(1) + const third = await rotateSession(prisma, second.refreshToken, later) + const row = await prisma.session.findUniqueOrThrow({ + where: { tokenHash: hashToken(third.refreshToken) }, + }) + expect(row.expiresAt).toEqual( + new Date(later.getTime() + REFRESH_TOKEN_DAYS * 24 * 60 * 60 * 1000), + ) + + // The old one does not (and using it again ends the login, see below). + await expect(rotateSession(prisma, first, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + }) + + it('revokes the whole login when an old token is used again', async () => { + const first = await startSession(prisma, coachId, NOW) + const second = await rotateSession(prisma, first, NOW) + const third = await rotateSession(prisma, second.refreshToken, NOW) + + // Someone uses the first token again: it was copied. + await expect(rotateSession(prisma, first, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + // Now the newest token is dead too, so the thief and the coach both log in again. + await expect( + rotateSession(prisma, third.refreshToken, NOW), + ).rejects.toBeInstanceOf(UnauthenticatedError) + }) + + it('does not touch the other logins of the coach when one is revoked', async () => { + const phone = await startSession(prisma, coachId, NOW) + const laptop = await startSession(prisma, coachId, NOW) + await endSession(prisma, phone, NOW) + + await expect(rotateSession(prisma, phone, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + await expect(rotateSession(prisma, laptop, NOW)).resolves.toMatchObject({ + coachId, + }) + }) + + it('refuses an expired token, and a token that does not exist', async () => { + const token = await startSession(prisma, coachId, NOW) + await expect( + rotateSession(prisma, token, days(REFRESH_TOKEN_DAYS)), + ).rejects.toBeInstanceOf(UnauthenticatedError) + // One second before it expires it still works. + const fresh = await startSession(prisma, coachId, NOW) + await expect( + rotateSession( + prisma, + fresh, + new Date(days(REFRESH_TOKEN_DAYS).getTime() - 1000), + ), + ).resolves.toBeDefined() + + for (const text of ['', 'not-a-token', 'x'.repeat(5000)]) { + await expect(rotateSession(prisma, text, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + } + }) + + it('lets only one of two requests with the same token through', async () => { + const token = await startSession(prisma, coachId, NOW) + const results = await Promise.allSettled([ + rotateSession(prisma, token, NOW), + rotateSession(prisma, token, NOW), + ]) + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1) + }) + + it('ends a login on logout, and does nothing for an unknown token', async () => { + const first = await startSession(prisma, coachId, NOW) + const second = await rotateSession(prisma, first, NOW) + await endSession(prisma, second.refreshToken, NOW) + await expect( + rotateSession(prisma, second.refreshToken, NOW), + ).rejects.toBeInstanceOf(UnauthenticatedError) + + await expect(endSession(prisma, 'unknown', NOW)).resolves.toBeUndefined() + await expect(endSession(prisma, '', NOW)).resolves.toBeUndefined() + }) + + it('deletes only the tokens that have expired', async () => { + const old = await startSession(prisma, coachId, NOW) + const used = await startSession(prisma, coachId, days(10)) + await rotateSession(prisma, used, days(10)) // used, but not expired + const current = await startSession(prisma, coachId, days(10)) + + await deleteExpiredSessions(prisma, days(REFRESH_TOKEN_DAYS + 1)) + + const exists = (token: string) => + prisma.session.count({ where: { tokenHash: hashToken(token) } }) + expect(await exists(old)).toBe(0) + expect(await exists(used)).toBe(1) // kept for the reuse check + expect(await exists(current)).toBe(1) + }) +}) diff --git a/backend/src/auth/sessions.ts b/backend/src/auth/sessions.ts new file mode 100644 index 0000000..7da425c --- /dev/null +++ b/backend/src/auth/sessions.ts @@ -0,0 +1,139 @@ +// The refresh tokens. A refresh token is a long random value that lives for 14 +// days. Only its SHA-256 hash is stored (table `sessions`), so a copy of the +// database cannot be used to log in. +// +// Rotation: every use of a refresh token replaces it with a new one in the same +// family (all the tokens that follow from one login). The old token stops +// working. If a token that was already replaced is used again, someone has a +// copy of it, and we cannot tell who is the coach and who is the thief, so the +// whole family is revoked and the coach has to log in again with Google. +// Logging out revokes the family in the same way. +// +// Two tabs that refresh with the same token at the same moment look like a +// reuse, and end the login. The client must therefore refresh in one place at +// a time (see "Identifying the user" in docs/API.md). + +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import type { PrismaClient } from '../generated/prisma/client.js' +import { UnauthenticatedError } from '../errors.js' + +export const REFRESH_TOKEN_DAYS = 14 +/** Longer than any token we make, so a huge string is never hashed. */ +const MAX_TOKEN_LENGTH = 512 + +const DAY_MS = 24 * 60 * 60 * 1000 +const INVALID = 'The refresh token is not valid. Log in again.' + +export const hashToken = (token: string): string => + createHash('sha256').update(token).digest('hex') + +const newToken = (): string => randomBytes(32).toString('base64url') +const expiryFrom = (now: Date): Date => + new Date(now.getTime() + REFRESH_TOKEN_DAYS * DAY_MS) + +type Db = Pick + +/** Starts a new family for a login and returns the refresh token. */ +export async function startSession( + db: Db, + coachId: string, + now: Date, +): Promise { + const token = newToken() + await db.session.create({ + data: { + coachId, + familyId: randomUUID(), + tokenHash: hashToken(token), + expiresAt: expiryFrom(now), + }, + }) + return token +} + +type Rotation = + { ok: true; coachId: string; refreshToken: string } | { ok: false } + +/** + * Uses a refresh token: it stops working, and a new one in the same family is + * returned with the coach id. Throws UnauthenticatedError when the token is + * unknown, expired, already used or revoked. Using a token a second time + * revokes the whole family. + */ +export async function rotateSession( + prisma: PrismaClient, + token: string, + now: Date, +): Promise<{ coachId: string; refreshToken: string }> { + if (token === '' || token.length > MAX_TOKEN_LENGTH) { + throw new UnauthenticatedError(INVALID) + } + const tokenHash = hashToken(token) + + // The result is decided inside the transaction and the error is thrown after + // it, because an error inside would roll back the revoking of the family. + const result = await prisma.$transaction(async (tx): Promise => { + const row = await tx.session.findUnique({ where: { tokenHash } }) + if (!row) return { ok: false } + if (row.expiresAt <= now) return { ok: false } + + // Only one caller can change revokedAt from null. If this update changes + // nothing, the token was used before (or at the same moment). + const claimed = await tx.session.updateMany({ + where: { id: row.id, revokedAt: null }, + data: { revokedAt: now }, + }) + if (claimed.count === 0) { + await tx.session.updateMany({ + where: { familyId: row.familyId, revokedAt: null }, + data: { revokedAt: now }, + }) + return { ok: false } + } + + const refreshToken = newToken() + await tx.session.create({ + data: { + coachId: row.coachId, + familyId: row.familyId, + tokenHash: hashToken(refreshToken), + expiresAt: expiryFrom(now), + }, + }) + return { ok: true, coachId: row.coachId, refreshToken } + }) + + if (!result.ok) throw new UnauthenticatedError(INVALID) + return { coachId: result.coachId, refreshToken: result.refreshToken } +} + +/** Ends the login that the refresh token belongs to. An unknown token does nothing. */ +export async function endSession( + db: Db, + token: string, + now: Date, +): Promise { + if (token === '' || token.length > MAX_TOKEN_LENGTH) return + const row = await db.session.findUnique({ + where: { tokenHash: hashToken(token) }, + }) + if (!row) return + await db.session.updateMany({ + where: { familyId: row.familyId, revokedAt: null }, + data: { revokedAt: now }, + }) +} + +/** + * Deletes the tokens that have expired. Tokens that were used or revoked stay + * until they expire, because the reuse check needs them. + */ +export async function deleteExpiredSessions( + db: Db, + now: Date, +): Promise { + const result = await db.session.deleteMany({ + where: { expiresAt: { lt: now } }, + }) + return result.count +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 0a24ea6..32ca65f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,12 @@ import { createApp } from './server/app.js' import { readConfig } from './server/config.js' const config = readConfig() +if (!config.sessionSecret) { + console.warn( + 'SESSION_SECRET is not set: login is turned off and `me` is always null. ' + + 'Set it in backend/.env (see docs/API.md, Configuration).', + ) +} const prisma = createPrismaClient() const { app, server } = await createApp({ prisma, config }) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 9264682..8415cdf 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -330,9 +330,16 @@ type Routine { type AuthPayload { """ - Session token the client sends in the Authorization header. + Short-lived token (15 minutes). The client sends it in the header + "Authorization: Bearer " with every request. """ - token: String! + accessToken: String! + """ + Lives for 14 days and can be used once: refreshSession gives a new access + token and a new refresh token. Keep it safe, and never send it anywhere + else than to refreshSession and logout. + """ + refreshToken: String! coach: Coach! } @@ -347,6 +354,18 @@ type Mutation { Verifies the Google token and creates the coach on the first login. """ login(googleToken: String!): AuthPayload! + """ + Gives a new access token and a new refresh token for a refresh token, which + then stops working. Throws UNAUTHENTICATED when the token is unknown, + expired or revoked. Using a token that has already been used ends the whole + login, because the token has been copied. + """ + refreshSession(refreshToken: String!): AuthPayload! + """ + Ends the login that the refresh token belongs to. Returns true also for an + unknown token. + """ + logout(refreshToken: String!): Boolean! """ The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 8a27cae..8c52cea 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -60,7 +60,10 @@ export async function createApp(options: { cors({ origin: config.corsOrigins }), express.json({ limit: '100kb' }), expressMiddleware(server, { - context: async () => createContext(prisma, config), + context: async ({ req }) => + createContext(prisma, config, { + authorization: req.headers.authorization, + }), }), ) diff --git a/backend/src/server/auth.test.ts b/backend/src/server/auth.test.ts new file mode 100644 index 0000000..2dc4b5c --- /dev/null +++ b/backend/src/server/auth.test.ts @@ -0,0 +1,327 @@ +// Tests for the access token and the refresh token through GraphQL (me, +// refreshSession, logout). The coach and the first tokens are made directly, +// because the login with Google is another pull request. The time is set by +// the tests. They run against the local database from backend/.env and write +// test coaches and sessions, which they delete again. Without DATABASE_URL they +// are skipped. + +import type { ApolloServer } from '@apollo/server' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { signAccessToken } from '../auth/accessToken.js' +import { hashToken, startSession } from '../auth/sessions.js' +import { createPrismaClient } from '../db.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApolloServer, createApp } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const TEST_ID = 'test-70-session' +const START = new Date('2026-10-17T10:00:00Z') +const minutes = (n: number) => new Date(START.getTime() + n * 60 * 1000) +const days = (n: number) => minutes(n * 24 * 60) + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: ['http://allowed.test'], + sessionSecret: 'a-secret-that-is-at-least-32-characters-long', +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: T | null | undefined + errors: readonly GqlError[] | undefined +} +type Payload = { + accessToken: string + refreshToken: string + coach: { id: string; name: string; email: string } +} + +const REFRESH = `mutation ($t: String!) { refreshSession(refreshToken: $t) { accessToken refreshToken coach { id } } }` +const LOGOUT = `mutation ($t: String!) { logout(refreshToken: $t) }` +const ME = `{ me { id name email } }` + +describe.runIf(hasDatabase)('the session', () => { + let prisma: PrismaClient + let server: ApolloServer + + const clean = () => + prisma.coach.deleteMany({ where: { googleId: { startsWith: TEST_ID } } }) + + async function run( + query: string, + variables?: Record, + options: { + accessToken?: string + now?: Date + config?: Config + } = {}, + ): Promise> { + const now = options.now ?? START + const response = await server.executeOperation( + { query, variables }, + { + contextValue: createContext(prisma, options.config ?? config, { + authorization: options.accessToken + ? `Bearer ${options.accessToken}` + : undefined, + now: () => now, + }), + }, + ) + if (response.body.kind !== 'single') throw new Error('expected one result') + const { data, errors } = response.body.singleResult + return { data: data as T | null | undefined, errors } + } + + const codeOf = (r: Result) => r.errors?.[0]?.extensions?.['code'] + + /** A coach with a first access token and refresh token, made without the login. */ + async function signIn(now = START): Promise { + const coach = await prisma.coach.upsert({ + where: { googleId: TEST_ID }, + create: { googleId: TEST_ID, email: 'anna@example.com', name: 'Anna' }, + update: {}, + }) + return { + accessToken: await signAccessToken(coach.id, config.sessionSecret!, now), + refreshToken: await startSession(prisma, coach.id, now), + coach: { id: coach.id, name: coach.name, email: coach.email }, + } + } + + beforeAll(async () => { + prisma = createPrismaClient() + server = createApolloServer() + await server.start() + await clean() + }) + + afterAll(async () => { + await clean() + await server.stop() + await prisma.$disconnect() + }) + + describe('me and the protected operations', () => { + it('returns the coach for a valid access token', async () => { + const { accessToken, coach } = await signIn() + const result = await run<{ me: { id: string; name: string } }>( + ME, + undefined, + { + accessToken, + }, + ) + expect(result.errors).toBeUndefined() + expect(result.data!.me).toMatchObject({ id: coach.id, name: 'Anna' }) + }) + + it('returns null without a token, with a bad token and after 15 minutes', async () => { + const { accessToken } = await signIn() + const tampered = accessToken.slice(0, -3) + 'abc' + const cases: [string, Parameters[2]][] = [ + ['no token', {}], + ['a bad token', { accessToken: 'not-a-token' }], + ['a changed token', { accessToken: tampered }], + ['an expired token', { accessToken, now: minutes(16) }], + [ + 'a token signed with another key', + { + accessToken, + config: { ...config, sessionSecret: 'x'.repeat(40) }, + }, + ], + ] + for (const [label, options] of cases) { + const result = await run<{ me: null }>(ME, undefined, options) + expect(result.errors, label).toBeUndefined() + expect(result.data!.me, label).toBeNull() + } + // Still valid just before the 15 minutes are over. + const stillValid = await run<{ me: { id: string } | null }>( + ME, + undefined, + { + accessToken, + now: minutes(14), + }, + ) + expect(stillValid.data!.me).not.toBeNull() + }) + + it('returns null when the coach no longer exists', async () => { + const { accessToken } = await signIn() + await clean() + const result = await run<{ me: null }>(ME, undefined, { accessToken }) + expect(result.errors).toBeUndefined() + expect(result.data!.me).toBeNull() + }) + + it('throws UNAUTHENTICATED for a protected operation without a valid token', async () => { + const { accessToken } = await signIn() + const query = 'mutation { createGymnast(name: "A") { id } }' + expect(codeOf(await run(query))).toBe('UNAUTHENTICATED') + expect(codeOf(await run(query, undefined, { accessToken: 'nope' }))).toBe( + 'UNAUTHENTICATED', + ) + expect( + codeOf(await run(query, undefined, { accessToken, now: minutes(20) })), + ).toBe('UNAUTHENTICATED') + // With a valid token the login check passes (the operation itself is built in another issue). + expect(codeOf(await run(query, undefined, { accessToken }))).not.toBe( + 'UNAUTHENTICATED', + ) + }) + }) + + describe('refreshSession', () => { + it('gives new tokens, and the old refresh token stops working', async () => { + const first = await signIn() + const refreshed = await run<{ refreshSession: Payload }>( + REFRESH, + { t: first.refreshToken }, + { now: minutes(20) }, + ) + expect(refreshed.errors).toBeUndefined() + const next = refreshed.data!.refreshSession + expect(next.coach.id).toBe(first.coach.id) + expect(next.refreshToken).not.toBe(first.refreshToken) + expect(next.accessToken).not.toBe(first.accessToken) + + // The new access token works when the first one has expired. + const me = await run<{ me: { id: string } }>(ME, undefined, { + accessToken: next.accessToken, + now: minutes(21), + }) + expect(me.data!.me.id).toBe(first.coach.id) + + const again = await run(REFRESH, { t: first.refreshToken }) + expect(codeOf(again)).toBe('UNAUTHENTICATED') + }) + + it('ends the whole login when an old refresh token is used again', async () => { + const first = await signIn() + const second = await run<{ refreshSession: Payload }>(REFRESH, { + t: first.refreshToken, + }) + const newest = second.data!.refreshSession.refreshToken + + expect(codeOf(await run(REFRESH, { t: first.refreshToken }))).toBe( + 'UNAUTHENTICATED', + ) + expect(codeOf(await run(REFRESH, { t: newest }))).toBe('UNAUTHENTICATED') + }) + + it('refuses an expired, unknown or empty refresh token', async () => { + const { refreshToken } = await signIn() + const expired = await run(REFRESH, { t: refreshToken }, { now: days(15) }) + expect(codeOf(expired)).toBe('UNAUTHENTICATED') + for (const t of ['', 'unknown', 'x'.repeat(5000)]) { + expect(codeOf(await run(REFRESH, { t }))).toBe('UNAUTHENTICATED') + } + }) + + it('removes the sessions that have expired', async () => { + const { refreshToken, coach } = await signIn() + const old = await prisma.session.create({ + data: { + coachId: coach.id, + familyId: 'old-family', + tokenHash: hashToken('an-old-token'), + expiresAt: new Date(START.getTime() - 1000), + }, + }) + await run(REFRESH, { t: refreshToken }) + expect(await prisma.session.findUnique({ where: { id: old.id } })).toBe( + null, + ) + }) + + it('does not accept the access token as a refresh token', async () => { + const { accessToken } = await signIn() + expect(codeOf(await run(REFRESH, { t: accessToken }))).toBe( + 'UNAUTHENTICATED', + ) + }) + }) + + describe('logout', () => { + it('ends the login: the refresh token stops working', async () => { + const { refreshToken } = await signIn() + const result = await run<{ logout: boolean }>(LOGOUT, { t: refreshToken }) + expect(result.errors).toBeUndefined() + expect(result.data!.logout).toBe(true) + expect(codeOf(await run(REFRESH, { t: refreshToken }))).toBe( + 'UNAUTHENTICATED', + ) + }) + + it('is true also for an unknown token and when it is done twice', async () => { + const { refreshToken } = await signIn() + for (const t of [refreshToken, refreshToken, 'unknown', '']) { + const result = await run<{ logout: boolean }>(LOGOUT, { t }) + expect(result.errors).toBeUndefined() + expect(result.data!.logout).toBe(true) + } + }) + }) + + describe('over HTTP', () => { + let base: string + let closeServer: () => Promise + + beforeAll(async () => { + const { app, server: apollo } = await createApp({ + prisma, + config, + }) + const listener = app.listen(0) + await new Promise((resolve) => listener.once('listening', resolve)) + base = `http://127.0.0.1:${(listener.address() as AddressInfo).port}` + closeServer = async () => { + await new Promise((resolve) => listener.close(resolve)) + await apollo.stop() + } + }) + + afterAll(async () => { + await closeServer() + }) + + const post = async ( + query: string, + variables?: object, + authorization?: string, + ) => { + const response = await fetch(`${base}/graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(authorization ? { authorization } : {}), + }, + body: JSON.stringify({ query, variables }), + }) + return (await response.json()) as { + data?: Record + errors?: GqlError[] + } + } + + it('reads me with the Authorization header', async () => { + // The HTTP server checks the token against the real clock, not START. + const { accessToken, coach } = await signIn(new Date()) + + const me = await post(ME, undefined, `Bearer ${accessToken}`) + expect((me.data!['me'] as { id: string }).id).toBe(coach.id) + + const anonymous = await post(ME) + expect(anonymous.data!['me']).toBeNull() + const wrongScheme = await post(ME, undefined, `Basic ${accessToken}`) + expect(wrongScheme.data!['me']).toBeNull() + }) + }) +}) diff --git a/backend/src/server/config.test.ts b/backend/src/server/config.test.ts index 5ca9ef5..4bc02b3 100644 --- a/backend/src/server/config.test.ts +++ b/backend/src/server/config.test.ts @@ -41,4 +41,44 @@ describe('readConfig', () => { ) } }) + + describe('the session secret', () => { + const SECRET = 'a-secret-that-is-at-least-32-characters-long' + + it('reads the session secret', () => { + expect(readConfig({ SESSION_SECRET: SECRET }).sessionSecret).toBe(SECRET) + }) + + it('has no session secret when it is not set', () => { + const config = readConfig({ SESSION_SECRET: ' ' }) + expect(config).not.toHaveProperty('sessionSecret') + }) + + it('stops in production when the secret is missing', () => { + expect(() => readConfig({ NODE_ENV: 'production' })).toThrow( + /SESSION_SECRET is not set/, + ) + expect(() => + readConfig({ NODE_ENV: 'production', SESSION_SECRET: ' ' }), + ).toThrow(/SESSION_SECRET is not set/) + }) + + it('starts in production when the secret is set', () => { + expect( + readConfig({ NODE_ENV: 'production', SESSION_SECRET: SECRET }) + .sessionSecret, + ).toBe(SECRET) + }) + + it('stops when the secret is too short, and does not print it', () => { + const weak = 'password-1234' + try { + readConfig({ SESSION_SECRET: weak }) + expect.unreachable() + } catch (error) { + expect((error as Error).message).toMatch(/at least 32 characters/) + expect((error as Error).message).not.toContain(weak) + } + }) + }) }) diff --git a/backend/src/server/config.ts b/backend/src/server/config.ts index a1b1854..cbfeaf8 100644 --- a/backend/src/server/config.ts +++ b/backend/src/server/config.ts @@ -11,8 +11,15 @@ export type Config = { imageBaseUrl: string /** The addresses (origins) that may call the API from a browser. */ corsOrigins: string[] + /** + * The key that signs the access tokens. At least 32 characters. Without it + * there are no tokens, and `me` is always null. + */ + sessionSecret?: string } +export const MIN_SESSION_SECRET_LENGTH = 32 + export const DEFAULT_PORT = 3001 export const DEFAULT_CORS_ORIGIN = 'http://localhost:5173' // the Vite dev server @@ -39,5 +46,22 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config { .map((origin) => origin.trim()) .filter((origin) => origin !== '') - return { port, imageBaseUrl, corsOrigins } + const sessionSecret = env['SESSION_SECRET']?.trim() || undefined + if (sessionSecret && sessionSecret.length < MIN_SESSION_SECRET_LENGTH) { + throw new Error( + `SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters. Make one with: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"`, + ) + } + + if (!sessionSecret && env['NODE_ENV'] === 'production') { + throw new Error( + 'SESSION_SECRET is not set. In production the server does not start without it, so that a forgotten secret is found at once and not when a coach cannot log in.', + ) + } + return { + port, + imageBaseUrl, + corsOrigins, + ...(sessionSecret ? { sessionSecret } : {}), + } } diff --git a/backend/src/server/context.ts b/backend/src/server/context.ts index b49fc76..11d0c3e 100644 --- a/backend/src/server/context.ts +++ b/backend/src/server/context.ts @@ -1,5 +1,7 @@ // What every resolver can use. A new context is made for each request. +import { verifyAccessToken } from '../auth/accessToken.js' +import { UnauthenticatedError } from '../errors.js' import type { PrismaClient } from '../generated/prisma/client.js' import type { Config } from './config.js' import { type Loaders, createLoaders } from './loaders.js' @@ -8,8 +10,57 @@ export type Context = { prisma: PrismaClient config: Config loaders: Loaders + /** + * The id of the logged-in coach, or null when the request has no valid + * access token. The token is checked once per request, the first time + * something asks. + */ + getCoachId: () => Promise + /** The time now. The tests set it to test expiry. */ + now: () => Date } -export function createContext(prisma: PrismaClient, config: Config): Context { - return { prisma, config, loaders: createLoaders(prisma) } +export type ContextOptions = { + /** The value of the Authorization header, if there is one. */ + authorization?: string | undefined + now?: () => Date +} + +/** The token in "Bearer ", or null. */ +export function bearerToken(header: string | undefined): string | null { + const match = /^Bearer ([^\s]+)$/i.exec(header ?? '') + return match?.[1] ?? null +} + +export function createContext( + prisma: PrismaClient, + config: Config, + options: ContextOptions = {}, +): Context { + const now = options.now ?? (() => new Date()) + const token = bearerToken(options.authorization) + + let coachId: Promise | undefined + const getCoachId = () => { + coachId ??= + token && config.sessionSecret + ? verifyAccessToken(token, config.sessionSecret, now()) + : Promise.resolve(null) + return coachId + } + + return { + prisma, + config, + loaders: createLoaders(prisma), + getCoachId, + now, + } +} + +/** The id of the logged-in coach. Throws UNAUTHENTICATED when nobody is logged in. */ +export async function requireCoachId(context: Context): Promise { + const coachId = await context.getCoachId() + if (!coachId) throw new UnauthenticatedError() + return coachId } diff --git a/backend/src/server/resolvers/auth.ts b/backend/src/server/resolvers/auth.ts new file mode 100644 index 0000000..ecf811e --- /dev/null +++ b/backend/src/server/resolvers/auth.ts @@ -0,0 +1,92 @@ +// Login, the session and the logged-in coach (issue #70). How it works is in +// "Identifying the user" in docs/API.md. + +import { signAccessToken } from '../../auth/accessToken.js' +import { + deleteExpiredSessions, + endSession, + rotateSession, +} from '../../auth/sessions.js' +import { NotImplementedError, UnauthenticatedError } from '../../errors.js' +import type { Resolvers } from '../../generated/graphql.js' +import type { Coach } from '../../generated/prisma/client.js' +import type { Context } from '../context.js' + +/** Longer than any token we make. */ +const MAX_TOKEN_LENGTH = 4096 + +const notImplemented = (operation: string) => (): never => { + throw new NotImplementedError(operation) +} + +const tokenFrom = (value: string, message: string): string => { + if (value === '' || value.length > MAX_TOKEN_LENGTH) { + throw new UnauthenticatedError(message) + } + return value +} + +/** The result of login and refreshSession: new tokens and the coach. */ +async function authPayload( + context: Context, + coach: Coach, + refreshToken: string, +) { + const accessToken = await signAccessToken( + coach.id, + context.config.sessionSecret!, // checked by the caller + context.now(), + ) + return { accessToken, refreshToken, coach } +} + +export const authResolvers = { + Query: { + /** The logged-in coach, or null. Not being logged in is a normal state, so this never throws. */ + me: async (_parent: unknown, _args: unknown, context: Context) => { + const coachId = await context.getCoachId() + if (!coachId) return null + return context.prisma.coach.findUnique({ where: { id: coachId } }) + }, + }, + + Mutation: { + /** The Google login is another pull request (issue #70, part 2). */ + login: notImplemented('login'), + + /** Gives a new access token and a new refresh token for a refresh token. */ + refreshSession: async ( + _parent: unknown, + args: { refreshToken: string }, + context: Context, + ) => { + if (!context.config.sessionSecret) { + throw new NotImplementedError('refreshSession') + } + await deleteExpiredSessions(context.prisma, context.now()) + const rotated = await rotateSession( + context.prisma, + tokenFrom( + args.refreshToken, + 'The refresh token is not valid. Log in again.', + ), + context.now(), + ) + const coach = await context.prisma.coach.findUnique({ + where: { id: rotated.coachId }, + }) + if (!coach) throw new UnauthenticatedError('Log in again.') + return authPayload(context, coach, rotated.refreshToken) + }, + + /** Ends the login. It is true also for an unknown token, so tokens cannot be probed. */ + logout: async ( + _parent: unknown, + args: { refreshToken: string }, + context: Context, + ) => { + await endSession(context.prisma, args.refreshToken, context.now()) + return true + }, + }, +} satisfies Resolvers diff --git a/backend/src/server/resolvers/index.ts b/backend/src/server/resolvers/index.ts index 9c0250c..2a19fc9 100644 --- a/backend/src/server/resolvers/index.ts +++ b/backend/src/server/resolvers/index.ts @@ -1,3 +1,4 @@ +import { authResolvers } from './auth.js' import { calculatorResolvers } from './calculator.js' import { catalogResolvers } from './catalog.js' import { userDataResolvers } from './userData.js' @@ -9,6 +10,11 @@ export const resolvers = { Query: { ...catalogResolvers.Query, ...calculatorResolvers.Query, + ...authResolvers.Query, ...userDataResolvers.Query, }, + Mutation: { + ...authResolvers.Mutation, + ...userDataResolvers.Mutation, + }, } diff --git a/backend/src/server/resolvers/userData.ts b/backend/src/server/resolvers/userData.ts index 5f7a45c..dda25bd 100644 --- a/backend/src/server/resolvers/userData.ts +++ b/backend/src/server/resolvers/userData.ts @@ -1,44 +1,44 @@ -// The user data (coaches, gymnasts, routines) and the login are not built yet. -// Until they are, every operation answers with a clear error, so the schema can -// be used as it is and nothing returns a confusing null: -// - nobody can log in, so the protected operations answer UNAUTHENTICATED, -// which is also what they will answer to a visitor who is not logged in later -// - login and the calculator answer NOT_IMPLEMENTED (temporary) -// Replace these with the real resolvers in the issues for the login and the -// user data. +// The gymnasts, their lists of elements and the routines are not built yet +// (issue #73). Until they are, these operations answer UNAUTHENTICATED when +// nobody is logged in, and NOT_IMPLEMENTED (temporary) when somebody is, so the +// schema can be used as it is and nothing returns a confusing null. The login +// and the session are in auth.ts. -import { NotImplementedError, UnauthenticatedError } from '../../errors.js' +import { NotImplementedError } from '../../errors.js' import type { Resolvers } from '../../generated/graphql.js' -import type { Context } from '../context.js' +import { type Context, requireCoachId } from '../context.js' -const loginRequired = (): never => { - throw new UnauthenticatedError( - 'You must be logged in. Login is not available yet.', - ) -} - -const notImplemented = (operation: string) => (): never => { - throw new NotImplementedError(operation) -} +/** For the operations that are not built yet: log in first, then NOT_IMPLEMENTED. */ +const notBuilt = + (operation: string) => + async ( + _parent: unknown, + _args: unknown, + context: Context, + ): Promise => { + await requireCoachId(context) + throw new NotImplementedError(operation) + } export const userDataResolvers = { Query: { - /** Nobody can log in yet, so there is never a logged-in coach. */ - me: (): null => null, - gymnast: loginRequired, - routine: loginRequired, + gymnast: notBuilt('gymnast'), + routine: notBuilt('routine'), + }, + + Coach: { + gymnasts: notBuilt('Coach.gymnasts'), }, Mutation: { - login: notImplemented('login'), - createGymnast: loginRequired, - renameGymnast: loginRequired, - deleteGymnast: loginRequired, - addGymnastElement: loginRequired, - removeGymnastElement: loginRequired, - createRoutine: loginRequired, - renameRoutine: loginRequired, - deleteRoutine: loginRequired, - setRoutineItems: loginRequired, + createGymnast: notBuilt('createGymnast'), + renameGymnast: notBuilt('renameGymnast'), + deleteGymnast: notBuilt('deleteGymnast'), + addGymnastElement: notBuilt('addGymnastElement'), + removeGymnastElement: notBuilt('removeGymnastElement'), + createRoutine: notBuilt('createRoutine'), + renameRoutine: notBuilt('renameRoutine'), + deleteRoutine: notBuilt('deleteRoutine'), + setRoutineItems: notBuilt('setRoutineItems'), }, } satisfies Resolvers diff --git a/backend/src/server/server.test.ts b/backend/src/server/server.test.ts index 8afd7cf..0773037 100644 --- a/backend/src/server/server.test.ts +++ b/backend/src/server/server.test.ts @@ -16,7 +16,7 @@ import { createApp, createApolloServer } from './app.js' import type { Config } from './config.js' import { type Context, createContext } from './context.js' import { MAX_QUERY_DEPTH } from './depthLimit.js' -import { userDataResolvers } from './resolvers/userData.js' +import { resolvers } from './resolvers/index.js' import { loadTypeDefs } from './schema.js' const hasDatabase = Boolean(process.env['DATABASE_URL']) @@ -527,7 +527,7 @@ describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => { }) describe('what is not built yet', () => { - it('answers null for me, because nobody can log in', async () => { + it('answers null for me when nobody is logged in', async () => { const { data, errors } = await run<{ me: null }>('{ me { id } }') expect(errors).toBeUndefined() expect(data!.me).toBeNull() @@ -548,7 +548,7 @@ describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => { ], [ 'login', - 'mutation { login(googleToken: "x") { token } }', + 'mutation { login(googleToken: "x") { accessToken } }', 'NOT_IMPLEMENTED', ], ])('answers a clear error for %s', async (_name, query, code) => { @@ -561,9 +561,7 @@ describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => { const mutations = Object.keys( buildSchema(loadTypeDefs()).getMutationType()!.getFields(), ) - expect(Object.keys(userDataResolvers.Mutation).sort()).toEqual( - mutations.sort(), - ) + expect(Object.keys(resolvers.Mutation).sort()).toEqual(mutations.sort()) }) }) diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 05fc72d..59dc9ab 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -288,6 +288,26 @@ `totalCount`), which Claude fixed. `npm audit` reports a `braces` problem in the generator's own dependencies (a build tool, not part of the server). +## Access tokens and sessions (`backend/src/auth/`, `backend/src/server/`, `backend/prisma/schema.prisma`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #70 and decided how the session works: a + 15-minute access token and a 14-day refresh token that is replaced at every use + (rotation), `jose` instead of a full auth framework, and writing the other parts by + hand. The team asked what the conventional lifetimes are and chose the two-token + setup over one long-lived token. Because the course server has no https, which + Google sign-in needs, the team asked for the Google check to be a separate pull + request, so that the session does not wait for it. +- **What Claude did:** Researched the usual token lifetimes and the options for + libraries (Better Auth, Auth.js, hosted services) and explained why they were + rejected. Wrote `refreshSession`, `logout` and `me`, the `sessions` table and its + migration, the reuse check that ends a login when an old refresh token is used + again, the settings, the tests (time set by the tests, two requests at once with + the same token) and the documentation, and split the work into this part and the + Google login. Ran the tests against a throwaway PostgreSQL database. One test of + Claude's own was wrong (it used an old token and expected the new one to keep + working), which showed the reuse check doing its job; the test was fixed. + ## Database Infrastructure & Setup Completion (`backend/package.json`, `backend/.env.example`, `docs/VM_SETUP.md`, `docs/AI_USAGE.md`, `src/App.tsx`) diff --git a/docs/API.md b/docs/API.md index f966d76..ba02440 100644 --- a/docs/API.md +++ b/docs/API.md @@ -69,7 +69,7 @@ When a new Code of Points drops an element or vault, it is marked as removed in ### User data (coaches only) - `Coach`, `Gymnast` and `Routine`, with `Routine.items` in the order of the routine and a calculated `Routine.dScore`. A routine also has `createdAt` and `updatedAt`. -- `login(googleToken)` verifies the Google token and returns a session token and the coach. +- `login(googleToken)` verifies the Google token and returns an access token, a refresh token and the coach. `refreshSession(refreshToken)` gives new tokens, and `logout(refreshToken)` ends the login. The Google check and `login` are a separate pull request (issue #70, part 2); until it is merged `login` answers `NOT_IMPLEMENTED`. - Mutations create, rename and delete gymnasts and routines. `setRoutineItems` replaces the whole list of items in a routine, so the client sends the ids in the wanted order and the server does not need separate add, remove and move operations. - **A gymnast's list.** `Gymnast.elements` is the list of elements and vaults the gymnast can do or is practicing, in the order they were added. It is the same list as the table `gymnast_elements` in the database. `elements(apparatusId)` gives only the items on one apparatus, which is what the UI needs when it builds a routine. - `addGymnastElement(gymnastId, itemId)` adds an element or vault, and `removeGymnastElement(gymnastId, itemId)` removes one. Both return the whole `Gymnast`, so the client can update its cache with the new list. @@ -195,7 +195,39 @@ The file names are not exposed in the API. The to-do list for images, the virtua ## Identifying the user -Coaches log in with Google. The client sends the Google token to `login`. The server verifies it, creates the coach on the first login, and returns its own session token. The client sends that token in the `Authorization` header with every request. The Google token itself is not stored, only the Google id (see "Google login and `googleId`" in [DATA_MODEL.md](DATA_MODEL.md)). +Coaches log in with Google, and the server gives them two tokens of its own (issue #70). The code is in `backend/src/auth/`. + +**Status:** the tokens, `refreshSession`, `logout` and `me` are built. The Google check and `login` (steps 1–3 below) are a separate pull request, because Sign in with Google needs https (except on localhost), and the course server has only http. Until that is merged, `login` answers `NOT_IMPLEMENTED`, and the tokens can only be made by the code (the tests do). + +1. The browser gets a Google ID token (Sign in with Google) and the client sends it to `login`. +2. The server checks it with Google (`google-auth-library`): the signature, the expiry, the issuer, and that it was made for our application (`aud` is `GOOGLE_CLIENT_ID`). It creates the coach on the first login from `sub` (stored as `googleId`), `email` and `name`, and keeps the name and email up to date on later logins. The Google token itself is not stored (see "Google login and `googleId`" in [DATA_MODEL.md](DATA_MODEL.md)). +3. `login` returns the coach with an access token and a refresh token. + +| Token | What it is | Lives for | Used for | +| ------------- | -------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------- | +| Access token | A JWT (`jose`, HS256, signed with `SESSION_SECRET`) that only holds the coach id | 15 minutes | Sent in `Authorization: Bearer ` with every request | +| Refresh token | 32 random bytes (base64url), not a JWT. Only its SHA-256 hash is stored | 14 days | Only sent to `refreshSession` and `logout`, to get new tokens or to end the login | + +The access token is short, because the client keeps it and a token that leaks can be used until it expires. The refresh token lives longer, so a coach does not log in with Google again every 15 minutes. These are the usual lifetimes for this setup (see OAuth 2.0 and the OWASP session guidance). + +**Rotation.** `refreshSession(refreshToken)` returns a new access token and a **new** refresh token, and the old refresh token stops working. All the refresh tokens that follow from one login form a family (table `sessions`, see [DATA_MODEL.md](DATA_MODEL.md)). If a token that has already been replaced is used again, someone has a copy of it, and the server cannot tell who is the coach and who is the thief. It then revokes the whole family, and the coach has to log in with Google again. `logout(refreshToken)` revokes the family in the same way, and returns `true` also for an unknown token, so tokens cannot be probed. + +**What the client does:** + +- It keeps the access token in memory. It keeps the refresh token where it survives a reload, and sends it nowhere except to `refreshSession` and `logout`. +- On page load, if `me` is `null` and the client has a refresh token, it calls `refreshSession` once. When a request answers `UNAUTHENTICATED` because the access token has expired, it calls `refreshSession` once and repeats the request. If that fails, the coach logs in with Google again. +- **It must refresh from one place at a time.** Two tabs that use the same refresh token at the same moment look like a stolen token, and the login ends. Use one refresh at a time per browser (for example `navigator.locks`), and store the new refresh token before the next request. + +**Decision for now: the tokens are returned in the response body, not in a cookie.** The refresh token is therefore readable by scripts on the page, so a cross-site scripting hole would leak it. Rotation and the reuse check limit what a stolen token is worth, but they do not remove the risk. An httpOnly, Secure, SameSite cookie for the refresh token is safer, and works when the app and the API are on the same origin (Apache proxy on the virtual machine, the Vite proxy in development). It needs `credentials` in CORS and a change in `login`, `refreshSession` and `logout`, and can be added later without changing the rest. + +**What the server guarantees:** + +- A missing, wrong, tampered or expired access token is the same as not being logged in: `me` returns `null` and every protected operation throws `UNAUTHENTICATED`. The token is checked once per request, and only `Authorization: Bearer` is read. Tokens signed with `alg: none` or another key are refused. +- An invalid Google token (when the login is merged), and an unknown, expired, used or revoked refresh token, all give `UNAUTHENTICATED` with a general message. The reason stays on the server. +- Expired refresh tokens are deleted whenever `refreshSession` runs (and at login, when the Google login is merged). The clean-up has no timer of its own, because the server is one process on one machine and the table is small; a rarely used server simply keeps a few extra rows until the next refresh. Tokens that were used or revoked stay until they expire, because the reuse check needs them. +- **An access token keeps working for up to 15 minutes after `logout`.** `logout` ends the refresh-token family, so no new tokens can be made, but the access token is only checked against its signature and expiry and not against the database. That is what makes it cheap, and it is the usual trade-off for short-lived JWTs. Checking the database on every request would remove the gap, but it would also remove the reason for having a stateless access token, and 15 minutes is short enough for this app, whose data is a coach's own gymnasts and routines. +- Changing `SESSION_SECRET` logs everyone out of their access tokens (the refresh tokens still work and give new ones). +- Not covered yet: limiting how often `login` can be called. The catalog, `searchCatalog` and `calculateDScore` do not need a login. Everything about gymnasts and routines does, and a coach can only reach their own data. @@ -239,20 +271,23 @@ The price of this choice is that the types do not force the client to handle the ## The server -The server is built (issue #28) in `backend/src/server/`. It answers the catalog queries and the search. The login and the user data are not built yet. +The server is built (issue #28) in `backend/src/server/`. It answers the catalog queries and the search. The login is built (issue #70). The resolvers for gymnasts and routines are not. **Run it:** `npm run backend` (restarts on changes) or `npm run start -w backend`, after the database is migrated and seeded. The settings are in `backend/.env` (see Configuration). The code is split so each part can be read and tested on its own: -| File | What it does | -| ------------------------ | ------------------------------------------------------------------------------------------- | -| `index.ts` | Starts the server and shuts it down cleanly on `SIGINT` and `SIGTERM` | -| `server/app.ts` | Builds the Express app and the Apollo server: `/graphql`, `/images`, `/health` | -| `server/config.ts` | Reads and checks the settings; a wrong value stops the start with a clear message | -| `server/resolvers/` | `catalog.ts` (done) and `userData.ts` (answers errors until the login exists) | -| `server/loaders.ts` | The DataLoaders, one set for each request | -| `server/depthLimit.ts` | The depth limit as a validation rule | -| `server/formatError.ts` | How errors look to the client | -| `server/selectsField.ts` | Whether the query asks for a field, so `totalCount` is only calculated when it is asked for | +| File | What it does | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `index.ts` | Starts the server and shuts it down cleanly on `SIGINT` and `SIGTERM` | +| `server/app.ts` | Builds the Express app and the Apollo server: `/graphql`, `/images`, `/health` | +| `server/config.ts` | Reads and checks the settings; a wrong value stops the start with a clear message | +| `server/resolvers/` | `catalog.ts`, `calculator.ts`, `auth.ts` (`me`, `refreshSession`, `logout`, `login`) and `userData.ts` (errors for the gymnasts and routines, which are not built) | +| `server/loaders.ts` | The DataLoaders, one set for each request | +| `server/depthLimit.ts` | The depth limit as a validation rule | +| `server/formatError.ts` | How errors look to the client | +| `server/selectsField.ts` | Whether the query asks for a field, so `totalCount` is only calculated when it is asked for | +| `server/context.ts` | What every resolver can use: the database, the settings, the loaders and the logged-in coach | +| `auth/accessToken.ts` | Signs and checks the access token (`jose`) | +| `auth/sessions.ts` | The refresh tokens: start, rotate, end, delete the expired | The scripts are in `backend/package.json`: `dev` and `start` run `src/index.ts` with `tsx`. The server runs from the source, because `tsconfig.json` has `noEmit` and there is no `dist/` to run. `setup` runs the migrations and then the seed (`prisma migrate deploy` and `prisma db seed`), which is what a new database needs, such as the one on the virtual machine (issue #26). `build` only checks the types. @@ -278,13 +313,12 @@ The scripts are in `backend/package.json`: `dev` and `start` run `src/index.ts` ### What is not built yet -Until the login and the user data exist, every such operation answers a clear error, so the schema can be used as it is and nothing returns a confusing `null`: +The login is built. The resolvers for gymnasts and routines are not, and until they exist every such operation answers a clear error, so the schema can be used as it is and nothing returns a confusing `null`: -| Operation | Answer now | -| --------------------------------------------------------------------- | ----------------- | -| `me` | `null` | -| `gymnast`, `routine`, and every mutation that needs a logged-in coach | `UNAUTHENTICATED` | -| `login` | `NOT_IMPLEMENTED` | +| Operation | Answer now | +| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `gymnast`, `routine`, `Coach.gymnasts` and every mutation that needs a logged-in coach | `UNAUTHENTICATED` without a valid access token, `NOT_IMPLEMENTED` with one | +| `login` | `NOT_IMPLEMENTED` until the Google login is merged (issue #70, part 2) | `NOT_IMPLEMENTED` is temporary and is not in the table of error codes. It goes away when the login and the user data are built. A test checks that every mutation in the schema has a resolver, so a new mutation cannot be forgotten. @@ -384,16 +418,15 @@ We use **DataLoader**, which collects the lookups of one request and sends one q ## Configuration -The settings are read from environment variables, and an example file with all names (but without secrets) is `backend/.env.example`. The first four are in use. The last two come with the login. +The settings are read from environment variables, and an example file with all names (but without secrets) is `backend/.env.example`. `SESSION_SECRET` is only needed for the tokens: without it the catalog works and `me` is always `null`. **Decision: a missing secret gives a warning in development, and stops the server when `NODE_ENV=production`.** In development the catalog and the calculator need no login, so a developer who has not set a secret can still work, and the warning in the log says why `me` is `null`. On the virtual machine a quiet fallback is the dangerous case: the site would start and look fine, and the login would be dead until someone noticed. Stopping at start-up (in `readConfig`, covered by tests) makes a forgotten secret impossible to miss. The price is that the catalog is also down until the secret is set, which is what we want from a failed deploy. The server must be started with `NODE_ENV=production` on the virtual machine for the check to apply; a copy of the placeholder in `docs/VM_SETUP.md` is already refused, because it is shorter than 32 characters. `GOOGLE_CLIENT_ID` comes with the Google login (a separate pull request). -| Variable | Used for | -| ------------------ | -------------------------------------------------------- | -| `PORT` | The port of the server, `3001` | -| `DATABASE_URL` | The PostgreSQL connection for Prisma | -| `IMAGE_BASE_URL` | The address where the drawings are served (see Images) | -| `CORS_ORIGIN` | The addresses that may call the API from a browser | -| `GOOGLE_CLIENT_ID` | Checks that a Google token was made for this application | -| `SESSION_SECRET` | The key that signs the session tokens | +| Variable | Used for | +| ---------------- | -------------------------------------------------------------------------------------------------------------------- | +| `PORT` | The port of the server, `3001` | +| `DATABASE_URL` | The PostgreSQL connection for Prisma | +| `IMAGE_BASE_URL` | The address where the drawings are served (see Images) | +| `CORS_ORIGIN` | The addresses that may call the API from a browser | +| `SESSION_SECRET` | The key that signs the access tokens. At least 32 random characters. The server does not start with a shorter secret, or without one when `NODE_ENV=production` | The course staff must be able to start the backend, so the README (or a file readable with `sudo` on the virtual machine) must list how to set these. @@ -408,6 +441,7 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Filter and sort:** done over the whole catalog, not only the loaded page. - **Errors:** each code in the table is returned for the right case. A coach that asks for another coach's gymnast gets `NOT_FOUND`, and a request without a login gets `UNAUTHENTICATED` (but `me` gives `null`). - **The calculator** (`backend/src/dscore.test.ts`, `backend/src/server/calculator.test.ts`): fewer than, exactly and more than eight elements, the same code twice, a vault, an empty list, sums that would give `0.30000000000000004` as floats; the query needs no login, gives the same score as the function for catalog items, refuses items from two apparatus, a vault with elements, two vaults and a removed item with `INVALID_ITEMS`, refuses 51 ids, an unknown id and an id with SQL in it with `BAD_USER_INPUT`, and uses a fixed number of queries. +- **The session** (`backend/src/server/auth.test.ts`, `backend/src/auth/`; the time is set by the tests): `me` returns the coach for a valid access token and `null` without a token, with a changed token, with a token signed with another key and after 15 minutes; a protected operation without a valid token gives `UNAUTHENTICATED`; a refresh token works once and gives new tokens; using an old one again ends the whole login; an expired, unknown or logged-out refresh token gives `UNAUTHENTICATED`; two requests with the same refresh token let only one through; the database holds a hash and not the token; the `Authorization` header over HTTP. - **Wildcards and case:** a search for `%`, `_` or a word containing them does not match everything, and an uppercase search such as "SAL" finds the same items as "sal". - **Search text limits:** 81 characters or 9 words give `BAD_USER_INPUT`, and a text with no searchable words gives no results, while an empty text or only spaces gives all items. - **Deep pages:** the queries after a cursor contain no `OR` and no `OFFSET` above 0, so each one starts in the index right after the cursor. @@ -423,10 +457,11 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Removed items:** they are not found by `searchCatalog`, but `catalogItem(id)` returns them with `removed: true`. They are left out of `variants`, and a code whose `-a` is removed is still found through its `-b`. `setRoutineItems` accepts a removed item that is already in the routine and rejects one that is not. `calculateDScore` rejects them. - **`updatedAt`:** it changes when a routine is renamed and when `setRoutineItems` changes the items. +**Decision: the test files run one at a time (`fileParallelism: false` in `vite.config.ts`).** The database tests share one local database, and some of them write to it (the login tests) while others check that the row counts do not change, so two files at the same time would break each other. The price is that the frontend tests also run one file at a time. We keep it because the suite is still small and fast (the frontend and non-database tests finish in about a second), so the loss is small for now. Running only the database tests one at a time (for example as a separate Vitest project, or with a database per test file) would remove the price, but it is more setup than the gain is worth now. Revisit it when the suite gets slow. + The end-to-end tests (a long sequence of actions in the browser) are described separately in the README when they exist. ## Open questions - Should the lists of gymnasts and routines become cursor connections? Decided against for now, with the triggers for changing, in [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). -- How long does a session token live, and how does the client renew it? A short life limits the damage if a token is stolen, but makes the coach log in again more often. - What is the `alt` text of the drawings? See [IMAGES.md](IMAGES.md) when it is merged. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 12c4fb9..434b2d1 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -20,6 +20,7 @@ erDiagram VARIANT_GROUP |o--o{ ELEMENT : "has variants" VARIANT_GROUP |o--o{ VAULT : "has variants" COACH ||--o{ GYMNAST : coaches + COACH ||--o{ SESSION : "logs in with" GYMNAST ||--o{ ROUTINE : has APPARATUS ||--o{ ROUTINE : "performed on" GYMNAST ||--o{ GYMNAST_ELEMENT : "can do or practices" @@ -35,6 +36,7 @@ erDiagram ELEMENT { string id string elementGroupId string variantGroupId decimal value string code string description string name string image datetime removedAt } VAULT { string id string elementGroupId string variantGroupId decimal dValue string code string description string name string image datetime removedAt } COACH { string id string googleId string email string name datetime createdAt } + SESSION { string id string coachId string familyId string tokenHash datetime expiresAt datetime revokedAt datetime createdAt } GYMNAST { string id string coachId string name } GYMNAST_ELEMENT { int id string gymnastId string elementId string vaultId } ROUTINE { string id string gymnastId string apparatusId string name datetime createdAt datetime updatedAt } @@ -124,6 +126,22 @@ A user of the website. Coaches log in with Google. | `name` | `"Kari Nilsen"` | Name from the Google account | | `createdAt` | `"2026-10-01T12:00:00Z"` | When the coach first logged in | +### Session + +One refresh token of a login (see "Identifying the user" in [API.md](API.md)). Only a hash of the token is stored. + +| Field | Example | Description | +| ----------- | ------------------------ | ------------------------------------------------------------------------------ | +| `id` | `"cmg2a1…"` | Generated id (cuid) | +| `coachId` | `"cmg1x2…"` | The coach who logged in | +| `familyId` | `"6f1c…"` | Shared by all the tokens that follow from one login | +| `tokenHash` | `"9b74c9…"` | SHA-256 of the refresh token, as hex. Unique. The token itself is never stored | +| `expiresAt` | `"2026-10-31T12:00:00Z"` | 14 days after the token was made | +| `revokedAt` | `null` | Set when the token has been used, or the login was ended | +| `createdAt` | `"2026-10-17T12:00:00Z"` | When the token was made | + +Using a token replaces it with a new row in the same family. A token that is used when `revokedAt` is already set revokes the whole family. Rows are deleted when they have expired. + ### Gymnast A gymnast created by a coach. @@ -318,7 +336,7 @@ The limits on user data (200 gymnasts per coach, 50 routines per gymnast and 50 ### What happens on delete -- **User data cascades.** Deleting a coach deletes their gymnasts. Deleting a gymnast deletes her routines and her element list. Deleting a routine deletes its items. +- **User data cascades.** Deleting a coach deletes their gymnasts and their sessions. Deleting a gymnast deletes her routines and her element list. Deleting a routine deletes its items. - **Catalog rows that are in use cannot be deleted.** The database refuses to delete an element, vault, group or apparatus that something points to. Elements that leave the Code of Points get `removedAt` instead. ## Design decisions @@ -361,6 +379,10 @@ Coaches log in with their Google account, so we do not store usernames or passwo We store `googleId` and not the token itself. The token expires after a short time and is only used to prove who the user is when they log in. The `googleId` never changes, even if the user changes their name or email. +### Two tokens after the login + +After the Google login the server gives the coach a short-lived access token (15 minutes) and a refresh token (14 days). The access token is a signed JWT and is not stored. The refresh token is stored as a hash in `sessions`, so that it can be replaced at every use and revoked, which a JWT alone cannot do. The reasons and the rules are in "Identifying the user" in [API.md](API.md). + ### A gymnast has a list of elements Besides routines, a coach wants an overview of which elements each gymnast can do or is working on. This is stored as rows in `gymnast_elements`, one per element or vault. We do not distinguish between elements the gymnast has mastered and elements she is practicing, since the list is meant as a planning overview for the coach. diff --git a/docs/VM_SETUP.md b/docs/VM_SETUP.md index 0bc66f8..eb548a7 100644 --- a/docs/VM_SETUP.md +++ b/docs/VM_SETUP.md @@ -73,9 +73,18 @@ DATABASE_URL="postgresql://wag_app:YOUR_SECURE_APP_PASSWORD@localhost:5432/wag_c MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_SECURE_MIGRATOR_PASSWORD@localhost:5432/wag_catalog" PORT=3001 +NODE_ENV=production SESSION_SECRET="your_production_session_secret" ``` +Replace the value of `SESSION_SECRET` with a random secret of at least 32 characters. Make one on the server with: + +```bash +node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" +``` + +`NODE_ENV=production` makes the backend refuse to start when `SESSION_SECRET` is missing or shorter than 32 characters. Without it the server starts anyway, and login quietly does not work (`me` is always `null`). A forgotten secret is then found when the server is started, in the log, and not when a coach cannot log in. If the server stops with "SESSION_SECRET is not set", the line is missing or empty in `backend/.env`. The file must be in `backend/`, next to `package.json`, and readable by the user that starts the server. + --- ## 4. Run Migrations & Initial Seed diff --git a/package-lock.json b/package-lock.json index 4da2946..1495813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "dotenv": "^18.0.8", "express": "^5.3.0", "graphql": "^16.14.2", + "jose": "^6.2.12", "tsx": "^4.23.15" }, "devDependencies": { @@ -7997,6 +7998,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/vite.config.ts b/vite.config.ts index 4fa514a..d073c9c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' import { cpSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' // The drawings in data/images are served by Vite in `npm run dev`, but are not // in public/, so a build would leave them out of dist/. Copy them there until @@ -33,4 +33,10 @@ export default defineConfig({ // "@/components/ui/button" means "src/components/ui/button". alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, + test: { + // The backend tests share one local database, and some of them write to it + // (the login tests), while others check that the row counts do not change. + // One test file at a time keeps them apart. + fileParallelism: false, + }, })