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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,22 +354,27 @@ Other addresses: `/images/<apparatus>/<file>.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 <accessToken>` 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 <accessToken>` with the queries, or call `refreshSession` with the refresh token.

## Catalog search

Expand Down
6 changes: 4 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"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"
Expand Down
33 changes: 33 additions & 0 deletions backend/src/auth/google.ts
Original file line number Diff line number Diff line change
@@ -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<GoogleProfile>

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,
}
}
}
4 changes: 3 additions & 1 deletion backend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
"""
Expand Down
10 changes: 10 additions & 0 deletions backend/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -32,8 +33,16 @@ export function createApolloServer(): ApolloServer<Context> {
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<Context> }> {
const { prisma, config } = options
const verifyGoogleToken =
options.verifyGoogleToken !== undefined
? options.verifyGoogleToken
: config.googleClientId
? createGoogleVerifier(config.googleClientId)
: null
const server = createApolloServer()
await server.start()

Expand Down Expand Up @@ -63,6 +72,7 @@ export async function createApp(options: {
context: async ({ req }) =>
createContext(prisma, config, {
authorization: req.headers.authorization,
verifyGoogleToken,
}),
}),
)
Expand Down
22 changes: 17 additions & 5 deletions backend/src/server/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
12 changes: 12 additions & 0 deletions backend/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'))"`,
Expand All @@ -62,6 +73,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
port,
imageBaseUrl,
corsOrigins,
...(googleClientId ? { googleClientId } : {}),
...(sessionSecret ? { sessionSecret } : {}),
}
}
5 changes: 5 additions & 0 deletions backend/src/server/context.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,13 +17,16 @@ export type Context = {
* something asks.
*/
getCoachId: () => Promise<string | null>
/** 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
}

export type ContextOptions = {
/** The value of the Authorization header, if there is one. */
authorization?: string | undefined
verifyGoogleToken?: GoogleVerifier | null
now?: () => Date
}

Expand Down Expand Up @@ -54,6 +58,7 @@ export function createContext(
config,
loaders: createLoaders(prisma),
getCoachId,
verifyGoogleToken: options.verifyGoogleToken ?? null,
now,
}
}
Expand Down
Loading