Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -358,8 +358,17 @@ Other addresses: `/images/<apparatus>/<file>.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 <accessToken>` with the queries, or call `refreshSession` with the refresh token.

## Catalog search

Expand Down
5 changes: 4 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dotenv": "^18.0.8",
"express": "^5.3.0",
"graphql": "^16.14.2",
"jose": "^6.2.12",
"tsx": "^4.23.15"
}
}
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 25 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
84 changes: 84 additions & 0 deletions backend/src/auth/accessToken.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
51 changes: 51 additions & 0 deletions backend/src/auth/accessToken.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string | null> {
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
}
}
Loading