diff --git a/apps/api/package.json b/apps/api/package.json index e3ee7ed..361ab98 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "drizzle-orm": "^0.44.4", "express": "^5.1.0", "express-session": "^1.19.0", + "openid-client": "^6.8.5", "pg": "^8.16.3", "zod": "^4.0.17" }, diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 6c53962..1c950c0 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -1,5 +1,6 @@ /** Verifies health responses, credentialed CORS, and safe middleware errors. */ import request from 'supertest'; +import { Router } from 'express'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createApp } from './app.js'; @@ -10,6 +11,7 @@ describe('GET /health', () => { it('reports a connected database', async () => { const response = await request( createApp({ + authRouter: Router(), webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => next(), @@ -24,6 +26,7 @@ describe('GET /health', () => { it('returns a safe unavailable response when the database cannot be reached', async () => { const response = await request( createApp({ + authRouter: Router(), webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => next(), @@ -43,6 +46,7 @@ describe('GET /health', () => { it('allows credentialed requests only from the configured web origin', async () => { const response = await request( createApp({ + authRouter: Router(), webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => next(), @@ -62,6 +66,7 @@ describe('GET /health', () => { const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); const response = await request( createApp({ + authRouter: Router(), webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 32bc306..7413920 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,9 +1,10 @@ /** Composes the Express API and its application-wide middleware. */ import { healthResponseSchema } from '@goforlift/contracts'; import cors from 'cors'; -import express, { type RequestHandler } from 'express'; +import express, { type RequestHandler, type Router } from 'express'; type AppDependencies = { + authRouter: Router; checkDatabaseConnection: () => Promise; sessionMiddleware: RequestHandler; trustProxyHops: number; @@ -11,6 +12,7 @@ type AppDependencies = { }; export function createApp({ + authRouter, checkDatabaseConnection, sessionMiddleware, trustProxyHops, @@ -25,6 +27,7 @@ export function createApp({ app.use(cors({ origin: webOrigin, credentials: true })); app.use(sessionMiddleware); app.use(express.json()); + app.use('/auth', authRouter); app.get('/health', async (_request, response) => { try { diff --git a/apps/api/src/auth/google-oidc.test.ts b/apps/api/src/auth/google-oidc.test.ts new file mode 100644 index 0000000..e84df6d --- /dev/null +++ b/apps/api/src/auth/google-oidc.test.ts @@ -0,0 +1,125 @@ +/** Verifies secure Google authorization redirects and return-path validation. */ +import express from 'express'; +import session from 'express-session'; +import { calculatePKCECodeChallenge, Configuration } from 'openid-client'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { + createGoogleOidcRouter, + type OidcFlowState, + validateReturnTo, +} from './google-oidc.js'; +import { createSessionMiddleware } from './session.js'; + +const redirectUri = 'http://localhost:3000/auth/google/callback'; +const sessionConfiguration = { + SESSION_SECRET: 'test-session-secret-at-least-32-characters', + SESSION_DURATION_SECONDS: 604800, + SESSION_COOKIE: { + name: 'goforlift.sid', + httpOnly: true, + secure: false, + sameSite: 'lax' as const, + path: '/', + maxAgeMs: 604800000, + }, +}; + +function createTestContext() { + const store = new session.MemoryStore(); + const oidc = new Configuration( + { + issuer: 'https://accounts.google.com', + authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + }, + 'google-client-id', + 'google-client-secret', + ); + const app = express(); + app.use(createSessionMiddleware(store, sessionConfiguration)); + app.use('/auth', createGoogleOidcRouter(oidc, redirectUri)); + + return { app, store }; +} + +function readStoredFlow(store: session.MemoryStore) { + const sessions = (store as unknown as { sessions: Record }) + .sessions; + const serializedSession = Object.values(sessions)[0]; + + if (!serializedSession) { + throw new Error('Expected the test session to be stored'); + } + + const storedSession = JSON.parse(serializedSession) as { + oidcFlow?: OidcFlowState; + }; + if (!storedSession.oidcFlow) { + throw new Error('Expected OIDC flow state in the test session'); + } + + return storedSession.oidcFlow; +} + +describe('GET /auth/google', () => { + it('redirects with state, nonce, PKCE, and minimal OIDC scopes', async () => { + const { app, store } = createTestContext(); + const response = await request(app).get( + '/auth/google?returnTo=%2Froutines%3Ftab%3Dmine', + ); + const location = new URL(response.headers.location as string); + const flow = readStoredFlow(store); + + expect(response.status).toBe(302); + expect(location.origin + location.pathname).toBe( + 'https://accounts.google.com/o/oauth2/v2/auth', + ); + expect(location.searchParams.get('client_id')).toBe('google-client-id'); + expect(location.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(location.searchParams.get('response_type')).toBe('code'); + expect(location.searchParams.get('scope')).toBe('openid email profile'); + expect(location.searchParams.get('state')).toBe(flow.state); + expect(location.searchParams.get('nonce')).toBe(flow.nonce); + expect(location.searchParams.get('code_challenge_method')).toBe('S256'); + expect(location.searchParams.get('code_challenge')).toBe( + await calculatePKCECodeChallenge(flow.codeVerifier), + ); + expect(flow.codeVerifier).toBeTruthy(); + expect(flow.returnTo).toBe('/routines?tab=mine'); + }); + + it('generates new protected values for each login attempt', async () => { + const first = createTestContext(); + await request(first.app).get('/auth/google'); + const firstFlow = readStoredFlow(first.store); + + const second = createTestContext(); + await request(second.app).get('/auth/google'); + const secondFlow = readStoredFlow(second.store); + + expect(secondFlow.state).not.toBe(firstFlow.state); + expect(secondFlow.nonce).not.toBe(firstFlow.nonce); + expect(secondFlow.codeVerifier).not.toBe(firstFlow.codeVerifier); + }); +}); + +describe('validateReturnTo', () => { + it.each([ + 'https://attacker.example', + '//attacker.example', + '/\\attacker.example', + '/%2Fattacker.example', + '/%252Fattacker.example', + 'javascript:alert(1)', + '/path%00suffix', + ])('rejects unsafe destination %s', (destination) => { + expect(validateReturnTo(destination)).toBe('/'); + }); + + it('accepts a root-relative application destination', () => { + expect(validateReturnTo('/workouts/history?filter=recent')).toBe( + '/workouts/history?filter=recent', + ); + }); +}); diff --git a/apps/api/src/auth/google-oidc.ts b/apps/api/src/auth/google-oidc.ts new file mode 100644 index 0000000..50de449 --- /dev/null +++ b/apps/api/src/auth/google-oidc.ts @@ -0,0 +1,125 @@ +/** Starts Google OIDC authorization with protected state, nonce, and PKCE data. */ +import { Router } from 'express'; +import { + buildAuthorizationUrl, + calculatePKCECodeChallenge, + discovery, + randomNonce, + randomPKCECodeVerifier, + randomState, + type Configuration, +} from 'openid-client'; + +import type { Env } from '../config/env-schema.js'; + +const GOOGLE_ISSUER = new URL('https://accounts.google.com'); +const DEFAULT_RETURN_PATH = '/'; + +export type OidcFlowState = { + codeVerifier: string; + createdAt: number; + nonce: string; + returnTo: string; + state: string; +}; + +declare module 'express-session' { + interface SessionData { + oidcFlow?: OidcFlowState; + } +} + +type GoogleOidcConfiguration = Pick< + Env, + 'GOOGLE_CLIENT_ID' | 'GOOGLE_CLIENT_SECRET' | 'GOOGLE_OIDC_REDIRECT_URI' +>; + +export function validateReturnTo(value: unknown) { + if (typeof value !== 'string' || !isSafeRelativePath(value)) { + return DEFAULT_RETURN_PATH; + } + + let decoded = value; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + decoded = decodeURIComponent(decoded); + } catch { + return DEFAULT_RETURN_PATH; + } + + if (!isSafeRelativePath(decoded)) { + return DEFAULT_RETURN_PATH; + } + } + + return value; +} + +function isSafeRelativePath(value: string) { + const containsControlCharacter = [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); + + return ( + value.startsWith('/') && + !value.startsWith('//') && + !value.includes('\\') && + !containsControlCharacter + ); +} + +export function discoverGoogleOidc(configuration: GoogleOidcConfiguration) { + return discovery(GOOGLE_ISSUER, configuration.GOOGLE_CLIENT_ID, { + client_secret: configuration.GOOGLE_CLIENT_SECRET, + redirect_uris: [configuration.GOOGLE_OIDC_REDIRECT_URI], + response_types: ['code'], + }); +} + +export function createGoogleOidcRouter( + oidc: Configuration, + redirectUri: string, +) { + const router = Router(); + + router.get('/google', async (request, response, next) => { + try { + const state = randomState(); + const nonce = randomNonce(); + const codeVerifier = randomPKCECodeVerifier(); + const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); + + request.session.oidcFlow = { + state, + nonce, + codeVerifier, + returnTo: validateReturnTo(request.query.returnTo), + createdAt: Date.now(), + }; + + const authorizationUrl = buildAuthorizationUrl(oidc, { + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + request.session.save((error) => { + if (error) { + next(error); + return; + } + + response.redirect(authorizationUrl.href); + }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 761cef5..5c1120b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -2,6 +2,10 @@ import { sql } from 'drizzle-orm'; import { createApp } from './app.js'; +import { + createGoogleOidcRouter, + discoverGoogleOidc, +} from './auth/google-oidc.js'; import { createPostgresSession } from './auth/session.js'; import { env } from './config/env.js'; import { createDatabase } from './db/client.js'; @@ -9,7 +13,9 @@ import { createDatabase } from './db/client.js'; const { db, pool } = createDatabase(env.DATABASE_URL); const { middleware: sessionMiddleware, store: sessionStore } = createPostgresSession(pool, env); +const oidc = await discoverGoogleOidc(env); const app = createApp({ + authRouter: createGoogleOidcRouter(oidc, env.GOOGLE_OIDC_REDIRECT_URI), webOrigin: env.WEB_ORIGIN, trustProxyHops: env.TRUST_PROXY_HOPS, sessionMiddleware, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54da622..98a2302 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: express-session: specifier: ^1.19.0 version: 1.19.0(supports-color@7.2.0) + openid-client: + specifier: ^6.8.5 + version: 6.8.5 pg: specifier: ^8.16.3 version: 8.23.0 @@ -3574,6 +3577,9 @@ packages: nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + oauth4webapi@3.8.7: + resolution: {integrity: sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3613,6 +3619,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openid-client@6.8.5: + resolution: {integrity: sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -7613,6 +7622,8 @@ snapshots: nwsapi@2.2.24: {} + oauth4webapi@3.8.7: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -7652,6 +7663,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openid-client@6.8.5: + dependencies: + jose: 6.2.9 + oauth4webapi: 3.8.7 + optionator@0.9.4: dependencies: deep-is: 0.1.4