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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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) =>
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
/** 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<void>;
sessionMiddleware: RequestHandler;
trustProxyHops: number;
webOrigin: string;
};

export function createApp({
authRouter,
checkDatabaseConnection,
sessionMiddleware,
trustProxyHops,
Expand All @@ -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 {
Expand Down
125 changes: 125 additions & 0 deletions apps/api/src/auth/google-oidc.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> })
.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',
);
});
});
125 changes: 125 additions & 0 deletions apps/api/src/auth/google-oidc.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@
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';

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,
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.