diff --git a/README.md b/README.md index 2d36d29..f9aa463 100644 --- a/README.md +++ b/README.md @@ -354,22 +354,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 1075243..09a1fbe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,7 +16,9 @@ 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 -# 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 f445048..2466fb5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,6 +42,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 8415cdf..e0e53a1 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -351,7 +351,9 @@ 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! """ 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 4bc02b3..a129db6 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 in production when the secret is missing', () => { expect(() => readConfig({ NODE_ENV: 'production' })).toThrow( /SESSION_SECRET is not set/, diff --git a/backend/src/server/config.ts b/backend/src/server/config.ts index cbfeaf8..2ad601e 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'))"`, @@ -62,6 +73,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 d579548..df84e95 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -322,6 +322,18 @@ 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. + ## Gymnasts, lists and routines (`backend/src/server/resolvers/userData.ts`) - **Tool:** Claude Code diff --git a/docs/API.md b/docs/API.md index 1fef6d1..5de6f9f 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 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. - **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. @@ -197,8 +197,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. @@ -223,8 +221,8 @@ 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. -- 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 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 whenever `refreshSession` runs and at each login. 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. @@ -275,19 +273,20 @@ The server is built (issue #28) in `backend/src/server/`. It answers the catalog **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`, `calculator.ts`, `auth.ts` (login, `me`) and `userData.ts` (gymnasts, lists, routines) | -| `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 | +| 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` (gymnasts, lists, routines) | +| `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/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 | 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. @@ -322,6 +321,7 @@ The resolvers for gymnasts, their lists and routines are in `server/resolvers/us - **`Routine.dScore`** is `calculateDScore` of the items, and `null` while the routine has a removed item. - **No N+1 queries.** `Gymnast.routines`, `Gymnast.elements`, `Routine.items` and the gymnast and apparatus of a routine are DataLoaders, so a coach with 25 gymnasts costs the same number of queries as one with 2 (a test checks it). A mutation makes the loaders forget what it changed, so several changes in one request show the right list. - `DateTime` is an ISO 8601 string in the answer. +- **`login`** answers `NOT_IMPLEMENTED` when the server has no `GOOGLE_CLIENT_ID` (see Configuration). ## Tools @@ -419,15 +419,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`. **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). +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`. **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 | -| `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` | +| 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 always required when `NODE_ENV=production`. 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. @@ -442,6 +443,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. - **Gymnasts, lists and routines** (`backend/src/server/userData.test.ts`, with two test coaches): every operation needs a login; a coach never reaches another coach's gymnast, list or routine (`NOT_FOUND`, nothing changes, same message as an unknown id); names (trimmed, 1-70 characters, emoji count as one); 200 gymnasts, 100 list items, 50 routines and 50 items in a routine; `NAME_TAKEN`; a list in order and filtered by apparatus; adding twice and removing a missing item are no-ops, also several changes in one request and four requests at once; `setRoutineItems` order, positions without gaps, duplicates, `updatedAt`, wrong apparatus, two vaults, unknown and odd ids, a failed save changes nothing, two saves at once; removed items (stay, `removed: true`, `dScore` null, cannot be added); `dScore` equals the calculator; the number of queries is the same for 2 and 25 gymnasts. - **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". diff --git a/package-lock.json b/package-lock.json index 1495813..8e2a3b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,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" @@ -5236,6 +5237,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", @@ -5389,6 +5399,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", @@ -5409,6 +5439,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", @@ -5506,6 +5545,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/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6200,7 +6245,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" @@ -6394,6 +6438,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", @@ -6908,6 +6961,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", @@ -7075,7 +7134,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", @@ -7226,7 +7284,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" @@ -7276,6 +7333,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -7420,6 +7505,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", @@ -7672,6 +7783,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/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -8050,6 +8174,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", @@ -8098,6 +8231,27 @@ "node": ">=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", @@ -8880,7 +9034,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", @@ -8900,7 +9053,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", @@ -10939,7 +11091,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"