From 241cdca4733320172a024871a298e6b2128a4760 Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 11:36:50 +0200 Subject: [PATCH] feat: add login with Google login verifies the Google token (google-auth-library) and creates the coach on the first login, then starts a session with the tokens from the session part. It answers NOT_IMPLEMENTED when GOOGLE_CLIENT_ID is not set. Sign in with Google needs https except on localhost, so this is kept separate from the session. --- README.md | 23 ++- backend/.env.example | 6 +- backend/package.json | 1 + backend/src/auth/google.ts | 33 ++++ backend/src/schema.graphql | 6 +- backend/src/server/app.ts | 10 + backend/src/server/config.test.ts | 22 ++- backend/src/server/config.ts | 12 ++ backend/src/server/context.ts | 5 + backend/src/server/login.test.ts | 269 +++++++++++++++++++++++++++ backend/src/server/resolvers/auth.ts | 39 +++- docs/AI_USAGE.md | 12 ++ docs/API.md | 27 +-- package-lock.json | 163 +++++++++++++++- 14 files changed, 585 insertions(+), 43 deletions(-) create mode 100644 backend/src/auth/google.ts create mode 100644 backend/src/server/login.test.ts diff --git a/README.md b/README.md index f1c9617..1cac5e6 100644 --- a/README.md +++ b/README.md @@ -353,22 +353,27 @@ Other addresses: `/images//.webp` serves the drawings in `data/ **Settings** (all optional, in `backend/.env`, see `backend/.env.example`): -| Variable | Default | Used for | -| ---------------- | ------------------------------ | ------------------------------------------------------------------- | -| `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 | +| Variable | Default | Used for | +| ------------------ | ------------------------------ | ---------------------------------------------------------------------------------------- | +| `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 | +| `GOOGLE_CLIENT_ID` | none | The Google OAuth client id. Setting it turns the login on | +| `SESSION_SECRET` | none | Signs the access tokens, at least 32 random characters. Required with `GOOGLE_CLIENT_ID` | -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 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 and the login tests write a test coach and sessions (the Google id starts with `test-70-`) and delete them again. The login tests use a fake instead of Google. -**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`: +**Login.** The coach logs in with Google and gets 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 it on: + +1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an OAuth client id of the type "Web application". Add `http://localhost:5173` under "Authorized JavaScript origins". +2. Put the client id in `backend/.env` as `GOOGLE_CLIENT_ID`. +3. 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. +Without `GOOGLE_CLIENT_ID` the catalog works and `login` answers `NOT_IMPLEMENTED`. The server does not start if `GOOGLE_CLIENT_ID` is set without `SESSION_SECRET`, or if the secret is shorter than 32 characters. **Google only allows http for localhost: on a server without https, like the course VM, Sign in with Google does not work.** To try `login` before the frontend has a login button, get a Google ID token for your client id from the [OAuth 2.0 Playground](https://developers.google.com/oauthplayground) (use your own OAuth credentials in its settings, and the scopes `openid email profile`), and send the `id_token` as `googleToken`. Then 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 f1bc7a9..01808d2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,7 +13,9 @@ DATABASE_URL="postgresql://cop:your-password@localhost:5432/cop?schema=public" # The addresses that may call the API from a browser, separated by commas. # CORS_ORIGIN=http://localhost:5173 -# Signs the access tokens (optional). At least 32 random characters. Without it there -# are no tokens and `me` is always null. +# Login with Google (optional). Without GOOGLE_CLIENT_ID the catalog works and login is off. +# The OAuth client id from the Google Cloud Console (type "Web application"). +# GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com +# Signs the access tokens. At least 32 random characters, required with GOOGLE_CLIENT_ID. # 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 accd962..d92fc93 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,6 +38,7 @@ "dataloader": "^2.2.3", "dotenv": "^18.0.8", "express": "^5.3.0", + "google-auth-library": "^11.3.1", "graphql": "^16.14.2", "jose": "^6.2.12", "tsx": "^4.23.15" diff --git a/backend/src/auth/google.ts b/backend/src/auth/google.ts new file mode 100644 index 0000000..fc980d9 --- /dev/null +++ b/backend/src/auth/google.ts @@ -0,0 +1,33 @@ +// Checks the token that Google gives the browser when a coach logs in. The +// library fetches Google's public keys and checks the signature, the expiry, +// the issuer and that the token was made for our application (`aud` is +// GOOGLE_CLIENT_ID). The token itself is never stored: only `sub` is kept, as +// Coach.googleId (see "Google login and googleId" in docs/DATA_MODEL.md). + +import { OAuth2Client } from 'google-auth-library' + +export type GoogleProfile = { + /** Google's unique id for the user (the `sub` claim). Never changes. */ + googleId: string + email: string + name: string +} + +/** Throws when the token is not valid. The tests replace it with a fake. */ +export type GoogleVerifier = (idToken: string) => Promise + +export function createGoogleVerifier(clientId: string): GoogleVerifier { + const client = new OAuth2Client() + return async (idToken) => { + const ticket = await client.verifyIdToken({ idToken, audience: clientId }) + const payload = ticket.getPayload() + if (!payload?.sub || !payload.email || payload.email_verified !== true) { + throw new Error('The Google token has no verified email address.') + } + return { + googleId: payload.sub, + email: payload.email, + name: payload.name?.trim() || payload.email, + } + } +} diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 1c932f6..0b70e2b 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -278,7 +278,11 @@ input CreateRoutineInput { } type Mutation { - """Verifies the Google token and creates the coach on the first login.""" + """ + Verifies the Google token and creates the coach on the first login. Throws + UNAUTHENTICATED when the token is not valid, and NOT_IMPLEMENTED when the + server has no GOOGLE_CLIENT_ID. + """ login(googleToken: String!): AuthPayload! """ Gives a new access token and a new refresh token for a refresh token, which diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 8c52cea..40febd2 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -7,6 +7,7 @@ import { expressMiddleware } from '@as-integrations/express5' import cors from 'cors' import express, { type Express } from 'express' import { fileURLToPath } from 'node:url' +import { type GoogleVerifier, createGoogleVerifier } from '../auth/google.js' import type { PrismaClient } from '../generated/prisma/client.js' import type { Config } from './config.js' import { type Context, createContext } from './context.js' @@ -32,8 +33,16 @@ export function createApolloServer(): ApolloServer { export async function createApp(options: { prisma: PrismaClient config: Config + /** For the tests. By default it checks the token with Google, when GOOGLE_CLIENT_ID is set. */ + verifyGoogleToken?: GoogleVerifier | null }): Promise<{ app: Express; server: ApolloServer }> { const { prisma, config } = options + const verifyGoogleToken = + options.verifyGoogleToken !== undefined + ? options.verifyGoogleToken + : config.googleClientId + ? createGoogleVerifier(config.googleClientId) + : null const server = createApolloServer() await server.start() @@ -63,6 +72,7 @@ export async function createApp(options: { context: async ({ req }) => createContext(prisma, config, { authorization: req.headers.authorization, + verifyGoogleToken, }), }), ) diff --git a/backend/src/server/config.test.ts b/backend/src/server/config.test.ts index 0d6e680..a57e9e2 100644 --- a/backend/src/server/config.test.ts +++ b/backend/src/server/config.test.ts @@ -42,18 +42,30 @@ describe('readConfig', () => { } }) - describe('the session secret', () => { + describe('login', () => { 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('reads the Google client id and the session secret', () => { + const config = readConfig({ + GOOGLE_CLIENT_ID: 'id.apps.googleusercontent.com', + SESSION_SECRET: SECRET, + }) + expect(config.googleClientId).toBe('id.apps.googleusercontent.com') + expect(config.sessionSecret).toBe(SECRET) }) - it('has no session secret when it is not set', () => { - const config = readConfig({ SESSION_SECRET: ' ' }) + it('has no login settings when they are not set', () => { + const config = readConfig({ GOOGLE_CLIENT_ID: '', SESSION_SECRET: ' ' }) + expect(config).not.toHaveProperty('googleClientId') expect(config).not.toHaveProperty('sessionSecret') }) + it('stops when there is a client id but no secret to sign the tokens with', () => { + expect(() => readConfig({ GOOGLE_CLIENT_ID: 'id' })).toThrow( + /SESSION_SECRET must be set/, + ) + }) + it('stops when the secret is too short, and does not print it', () => { const weak = 'password-1234' try { diff --git a/backend/src/server/config.ts b/backend/src/server/config.ts index 794d41c..ec9b91b 100644 --- a/backend/src/server/config.ts +++ b/backend/src/server/config.ts @@ -11,6 +11,11 @@ export type Config = { imageBaseUrl: string /** The addresses (origins) that may call the API from a browser. */ corsOrigins: string[] + /** + * The Google OAuth client id that a login token must be made for. Without + * it nobody can log in (`login` answers NOT_IMPLEMENTED). + */ + googleClientId?: string /** * The key that signs the access tokens. At least 32 characters. Without it * there are no tokens, and `me` is always null. @@ -46,7 +51,13 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config { .map((origin) => origin.trim()) .filter((origin) => origin !== '') + const googleClientId = env['GOOGLE_CLIENT_ID']?.trim() || undefined const sessionSecret = env['SESSION_SECRET']?.trim() || undefined + if (googleClientId && !sessionSecret) { + throw new Error( + 'SESSION_SECRET must be set when GOOGLE_CLIENT_ID is set, because login gives out tokens signed with it.', + ) + } 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'))"`, @@ -57,6 +68,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config { port, imageBaseUrl, corsOrigins, + ...(googleClientId ? { googleClientId } : {}), ...(sessionSecret ? { sessionSecret } : {}), } } diff --git a/backend/src/server/context.ts b/backend/src/server/context.ts index 11d0c3e..c788a60 100644 --- a/backend/src/server/context.ts +++ b/backend/src/server/context.ts @@ -1,6 +1,7 @@ // What every resolver can use. A new context is made for each request. import { verifyAccessToken } from '../auth/accessToken.js' +import type { GoogleVerifier } from '../auth/google.js' import { UnauthenticatedError } from '../errors.js' import type { PrismaClient } from '../generated/prisma/client.js' import type { Config } from './config.js' @@ -16,6 +17,8 @@ export type Context = { * something asks. */ getCoachId: () => Promise + /** Checks a Google token. Null when login is not set up (no GOOGLE_CLIENT_ID). */ + verifyGoogleToken: GoogleVerifier | null /** The time now. The tests set it to test expiry. */ now: () => Date } @@ -23,6 +26,7 @@ export type Context = { export type ContextOptions = { /** The value of the Authorization header, if there is one. */ authorization?: string | undefined + verifyGoogleToken?: GoogleVerifier | null now?: () => Date } @@ -54,6 +58,7 @@ export function createContext( config, loaders: createLoaders(prisma), getCoachId, + verifyGoogleToken: options.verifyGoogleToken ?? null, now, } } diff --git a/backend/src/server/login.test.ts b/backend/src/server/login.test.ts new file mode 100644 index 0000000..0ab711f --- /dev/null +++ b/backend/src/server/login.test.ts @@ -0,0 +1,269 @@ +// Tests for the login with Google through GraphQL (the check of the Google token +// is replaced by a fake; the session itself is tested in auth.test.ts). Google is replaced by a +// fake, and 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 type { GoogleVerifier } from '../auth/google.js' +import { hashToken } 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-login' +const START = new Date('2026-10-17T10:00:00Z') +const minutes = (n: number) => new Date(START.getTime() + n * 60 * 1000) + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: ['http://allowed.test'], + googleClientId: 'client-id.apps.googleusercontent.com', + sessionSecret: 'a-secret-that-is-at-least-32-characters-long', +} + +/** Stands in for Google: one good token, and an error for anything else. */ +const fakeGoogle = + (profile = { googleId: TEST_ID, email: 'anna@example.com', name: 'Anna' }) => + (token: string) => + token === 'good-google-token' + ? Promise.resolve(profile) + : Promise.reject( + new Error('token expired, wrong audience, bad signature'), + ) + +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 LOGIN = `mutation ($t: String!) { login(googleToken: $t) { accessToken refreshToken coach { id name email } } }` +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)('login with Google', () => { + 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 + verify?: GoogleVerifier | null + 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, + verifyGoogleToken: + options.verify === undefined ? fakeGoogle() : options.verify, + 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'] + + async function login(now = START): Promise { + const result = await run<{ login: Payload }>( + LOGIN, + { t: 'good-google-token' }, + { now }, + ) + expect(result.errors).toBeUndefined() + return result.data!.login + } + + beforeAll(async () => { + prisma = createPrismaClient() + server = createApolloServer() + await server.start() + await clean() + }) + + afterAll(async () => { + await clean() + await server.stop() + await prisma.$disconnect() + }) + + describe('login', () => { + it('creates the coach on the first login and finds the same coach the next time', async () => { + await clean() + const first = await login() + expect(first.coach).toMatchObject({ + name: 'Anna', + email: 'anna@example.com', + }) + expect(first.accessToken.split('.')).toHaveLength(3) + expect(first.refreshToken.length).toBeGreaterThanOrEqual(40) + + const second = await login() + expect(second.coach.id).toBe(first.coach.id) + expect(await prisma.coach.count({ where: { googleId: TEST_ID } })).toBe(1) + expect(second.refreshToken).not.toBe(first.refreshToken) + }) + + it('keeps the name and email up to date', async () => { + await login() + const changed = fakeGoogle({ + googleId: TEST_ID, + email: 'anna.new@example.com', + name: 'Anna Ny', + }) + const result = await run<{ login: Payload }>( + LOGIN, + { t: 'good-google-token' }, + { verify: changed }, + ) + expect(result.data!.login.coach).toMatchObject({ + name: 'Anna Ny', + email: 'anna.new@example.com', + }) + }) + + it('answers UNAUTHENTICATED for a Google token that is not valid, without saying why', async () => { + const before = await prisma.session.count() + const result = await run(LOGIN, { t: 'a-bad-token' }) + expect(codeOf(result)).toBe('UNAUTHENTICATED') + expect(result.errors![0]!.message).toBe('The Google token is not valid.') + expect(JSON.stringify(result)).not.toMatch(/expired|audience|signature/) + expect(await prisma.session.count()).toBe(before) + + for (const t of ['', 'x'.repeat(5000)]) { + expect(codeOf(await run(LOGIN, { t }))).toBe('UNAUTHENTICATED') + } + }) + + it('is not available when the server has no Google client id', async () => { + const result = await run( + LOGIN, + { t: 'good-google-token' }, + { verify: null }, + ) + expect(codeOf(result)).toBe('NOT_IMPLEMENTED') + }) + + it('gives tokens that work: me, refreshSession and logout', async () => { + const first = await login() + const me = await run<{ me: { id: string } }>(ME, undefined, { + accessToken: first.accessToken, + }) + expect(me.data!.me.id).toBe(first.coach.id) + + const refreshed = await run<{ refreshSession: Payload }>( + REFRESH, + { t: first.refreshToken }, + { now: minutes(20) }, + ) + expect(refreshed.errors).toBeUndefined() + + const out = await run<{ logout: boolean }>(LOGOUT, { + t: refreshed.data!.refreshSession.refreshToken, + }) + expect(out.data!.logout).toBe(true) + }) + + it('removes expired sessions', async () => { + const coach = await prisma.coach.findUniqueOrThrow({ + where: { googleId: TEST_ID }, + }) + 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 login() + expect(await prisma.session.findUnique({ where: { id: old.id } })).toBe( + null, + ) + }) + }) + + describe('over HTTP', () => { + let base: string + let closeServer: () => Promise + + beforeAll(async () => { + const { app, server: apollo } = await createApp({ + prisma, + config, + verifyGoogleToken: fakeGoogle(), + }) + 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('logs in and reads me with the Authorization header', async () => { + const loggedIn = await post(LOGIN, { t: 'good-google-token' }) + const { accessToken, coach } = loggedIn.data!['login'] as Payload + + 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/resolvers/auth.ts b/backend/src/server/resolvers/auth.ts index ecf811e..000e468 100644 --- a/backend/src/server/resolvers/auth.ts +++ b/backend/src/server/resolvers/auth.ts @@ -6,19 +6,16 @@ import { deleteExpiredSessions, endSession, rotateSession, + startSession, } 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. */ +/** Longer than any token we or Google 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) @@ -51,8 +48,36 @@ export const authResolvers = { }, Mutation: { - /** The Google login is another pull request (issue #70, part 2). */ - login: notImplemented('login'), + /** Checks the Google token, finds or creates the coach, and starts a login. */ + login: async ( + _parent: unknown, + args: { googleToken: string }, + context: Context, + ) => { + if (!context.verifyGoogleToken || !context.config.sessionSecret) { + throw new NotImplementedError('login') + } + const googleToken = tokenFrom( + args.googleToken, + 'The Google token is not valid.', + ) + let profile + try { + profile = await context.verifyGoogleToken(googleToken) + } catch { + // The reason (expired, wrong audience, bad signature) stays on the server. + throw new UnauthenticatedError('The Google token is not valid.') + } + const coach = await context.prisma.coach.upsert({ + where: { googleId: profile.googleId }, + create: profile, + update: { email: profile.email, name: profile.name }, + }) + const now = context.now() + const refreshToken = await startSession(context.prisma, coach.id, now) + await deleteExpiredSessions(context.prisma, now) + return authPayload(context, coach, refreshToken) + }, /** Gives a new access token and a new refresh token for a refresh token. */ refreshSession: async ( diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 8b45ef9..ec0ae67 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -244,3 +244,15 @@ 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. + +## Login with Google (`backend/src/auth/google.ts`, `backend/src/server/resolvers/auth.ts`) + +- **Tool:** Claude Code +- **What the team did:** Chose `google-auth-library` for the check of the Google + token. Found that Sign in with Google needs https (except on localhost) and that + the course server has only http, so this part was split from the session and + kept as a draft until the team has decided how the login works on the server. +- **What Claude did:** Wrote the Google check, `login` (creates the coach on the + first login and keeps the name and email up to date), its tests with a fake + Google, and the documentation. Read Google's own documentation for the rules + for authorized origins, and split the work from the session part. diff --git a/docs/API.md b/docs/API.md index c298615..2ea01b1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -56,7 +56,7 @@ Fields that only exist on one type are fetched with a fragment: ### User data (coaches only) - `Coach`, `Gymnast` and `Routine`, with `Routine.items` in the order of the routine and a calculated `Routine.dScore`. -- `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`. +- `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. - 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. - `calculateDScore(itemIds)` is for the calculator, which works without logging in. It calculates the D-score and saves nothing. The rule is the same as for a saved routine (constraint 13 in [DATA_MODEL.md](DATA_MODEL.md)): the sum of the eight highest values, each code counted once, and on vault the D-value of the single vault. The sum is made in tenths (whole numbers), so there are no rounding errors. An empty list gives `0`, and the same id twice counts once. It throws `INVALID_ITEMS` when the items do not fit together (items from more than one apparatus, a vault with elements, or more than one vault) or when an item has been removed from the Code of Points, and `BAD_USER_INPUT` for more than 50 ids, an id that does not exist or an id with odd characters. The function is in `backend/src/dscore.ts` and `Routine.dScore` will use the same one. - `Gymnast.elements` is the gymnast's list of elements and vaults she can do or is practicing. `addGymnastElement` and `removeGymnastElement` change it one item at a time, because the coach adds items from the search one by one. Adding an item that is already in the list, or removing one that is not, does nothing. @@ -177,8 +177,6 @@ The file names are not exposed in the API. The to-do list for images, the virtua 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. @@ -203,7 +201,7 @@ The access token is short, because the client keeps it and a token that leaks ca **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. +- An invalid Google token, 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 at each login. Tokens that were used or revoked stay until they expire, because the reuse check needs them. - 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. @@ -263,6 +261,7 @@ The server is built (issue #28) in `backend/src/server/`. It answers the catalog | `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/google.ts` | Checks the Google token (`google-auth-library`) | | `auth/accessToken.ts` | Signs and checks the access token (`jose`) | | `auth/sessions.ts` | The refresh tokens: start, rotate, end, delete the expired | @@ -295,7 +294,7 @@ The login is built. The resolvers for gymnasts and routines are not, and until t | 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) | +| `login` | `NOT_IMPLEMENTED` when the server has no `GOOGLE_CLIENT_ID` | `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. @@ -395,15 +394,16 @@ 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`. `SESSION_SECRET` is only needed for the tokens: without it the catalog works and `me` is always `null`. `GOOGLE_CLIENT_ID` comes with the Google login (a separate pull request). +The settings are read from environment variables, and an example file with all names (but without secrets) is `backend/.env.example`. All six are in use. `GOOGLE_CLIENT_ID` and `SESSION_SECRET` are only needed for the login: without them the catalog works and `login` answers `NOT_IMPLEMENTED`. -| 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 | +| 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. Setting it turns the login on | +| `SESSION_SECRET` | The key that signs the access tokens. At least 32 random characters. Required when `GOOGLE_CLIENT_ID` is set, and the server does not start without it | 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. @@ -418,6 +418,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. +- **Login with Google** (`backend/src/server/login.test.ts`; Google is replaced by a fake): the first login creates the coach and the next one finds it; the name and email are kept up to date; a wrong Google token, a wrong audience and an expired token give `UNAUTHENTICATED` without saying why; `login` is not available without a Google client id; expired sessions are removed; the tokens from `login` work for `me`, `refreshSession` and `logout`; `login` over HTTP. - **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. diff --git a/package-lock.json b/package-lock.json index ad54831..51d43fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "dataloader": "^2.2.3", "dotenv": "^18.0.8", "express": "^5.3.0", + "google-auth-library": "^11.3.1", "graphql": "^16.14.2", "jose": "^6.2.12", "tsx": "^4.23.15" @@ -5837,6 +5838,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -6075,6 +6085,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.26", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", @@ -6095,6 +6125,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -6192,6 +6231,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bundle-name": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", @@ -7042,7 +7087,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -7346,6 +7390,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7958,6 +8011,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -8125,7 +8184,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -8292,7 +8350,6 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -8364,6 +8421,34 @@ "dev": true, "license": "MIT" }, + "node_modules/gaxios": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-8.3.1.tgz", + "integrity": "sha512-h4/R7ay7JFXU5kmAkp/Y4Y4Vmm7m83tD579GUgfQQsRRGQ4TzSedyHla6XgTWohQuVffT3ldN4FF6uCqlRGaEA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/gcp-metadata": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.2.1.tgz", + "integrity": "sha512-5/LvxarRKbGCA5LJ+ha2aV1iVm7/aALIW4PzeXnQR5dyXiNMWbeA4UOBnqERInLfH/xSFp0ZREcxodUzxhEw2Q==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^8.3.0", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -8538,6 +8623,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.3.1.tgz", + "integrity": "sha512-mQS0R2Ev/DKkzuWSYtDeq15uhDeaWuT2qzLYqJ41LmooxVKHmn5ut3pc6dlHY6wfHW/D7FBmOYjAtkjaD1A7qA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^8.3.0", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.1.0.tgz", + "integrity": "sha512-1R6uiq6XhXW2ZrcGtZpGgVDsYGzSmveVOn8Pp37qe1fbThHZ34e3t3eXj5mVu6PFSVx9fvB1KNVnYgVtq/BRcA==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8800,6 +8911,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -9324,6 +9448,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -9392,6 +9525,27 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -10241,7 +10395,6 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -10261,7 +10414,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -12909,7 +13061,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8"