From fde11eacaede58cedcd511407d2633bf574ace41 Mon Sep 17 00:00:00 2001 From: charlieww Date: Fri, 27 Mar 2026 01:03:05 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20feat:=20Write=20end-to-end=20auth?= =?UTF-8?q?=20flow=20tests=20=E2=80=94=20src/auth/types.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/types.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/auth/types.ts diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..cbcf97c --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,48 @@ +export interface GitHubUser { + id: number + login: string + email: string | null + avatar_url: string + name: string | null +} + +export interface GitHubTokenResponse { + access_token: string + token_type: string + scope: string + refresh_token?: string + // GitHub Apps with expiring tokens populate these + expires_in?: number + refresh_token_expires_in?: number + error?: string + error_description?: string +} + +export interface Session { + id: string + user_id: string + github_access_token: string + refresh_token: string | null + expires_at: Date + csrf_token: string + revoked_at: Date | null + created_at: Date +} + +export interface User { + id: string + github_id: string + login: string + email: string | null + avatar_url: string + created_at: Date + updated_at: Date +} + +export interface AuthConfig { + github_client_id: string + github_client_secret: string + github_redirect_uri: string + /** Session lifetime in milliseconds. Defaults to 24 hours. */ + session_ttl_ms: number +} From df5c23af039acd77f754ca099cfce658fe53bc59 Mon Sep 17 00:00:00 2001 From: charlieww Date: Fri, 27 Mar 2026 01:03:07 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20feat:=20Write=20end-to-end=20auth?= =?UTF-8?q?=20flow=20tests=20=E2=80=94=20src/auth/github-oauth.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/github-oauth.ts | 129 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/auth/github-oauth.ts diff --git a/src/auth/github-oauth.ts b/src/auth/github-oauth.ts new file mode 100644 index 0000000..1ec605e --- /dev/null +++ b/src/auth/github-oauth.ts @@ -0,0 +1,129 @@ +import type { GitHubTokenResponse, GitHubUser } from './types.js' + +export const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize' +export const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token' +export const GITHUB_API_BASE = 'https://api.github.com' + +export function buildAuthorizeUrl( + clientId: string, + redirectUri: string, + state: string, + scopes = 'read:user user:email', +): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: scopes, + state, + }) + return `${GITHUB_AUTHORIZE_URL}?${params}` +} + +export async function exchangeCodeForToken( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + const res = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }) + + if (!res.ok) { + throw new Error(`GitHub token exchange failed: HTTP ${res.status}`) + } + + const data = await res.json() as GitHubTokenResponse + if (data.error) { + throw new Error(`GitHub OAuth error: ${data.error} — ${data.error_description ?? ''}`) + } + + return data +} + +export async function refreshGitHubToken( + refreshToken: string, + clientId: string, + clientSecret: string, +): Promise { + const res = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + + if (!res.ok) { + throw new Error(`GitHub token refresh failed: HTTP ${res.status}`) + } + + const data = await res.json() as GitHubTokenResponse + if (data.error) { + throw new Error(`GitHub token refresh error: ${data.error} — ${data.error_description ?? ''}`) + } + + return data +} + +export async function fetchGitHubUser(accessToken: string): Promise { + const res = await fetch(`${GITHUB_API_BASE}/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (res.status === 401) { + throw new Error('GitHub token is invalid or has been revoked') + } + if (!res.ok) { + throw new Error(`Failed to fetch GitHub user: HTTP ${res.status}`) + } + + return res.json() as Promise +} + +/** + * Deletes the token via the GitHub Applications API so it can no longer be used. + * Uses HTTP Basic auth with the OAuth app credentials (not a Bearer token). + */ +export async function revokeGitHubToken( + accessToken: string, + clientId: string, + clientSecret: string, +): Promise { + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + const res = await fetch(`${GITHUB_API_BASE}/applications/${clientId}/token`, { + method: 'DELETE', + headers: { + Authorization: `Basic ${credentials}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ access_token: accessToken }), + }) + + // 404 means the token was already invalid — acceptable outcome + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to revoke GitHub token: HTTP ${res.status}`) + } +} From 651b7caeecd89b5d7634fd111e6a20873fb73831 Mon Sep 17 00:00:00 2001 From: charlieww Date: Fri, 27 Mar 2026 01:03:09 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20feat:=20Write=20end-to-end=20auth?= =?UTF-8?q?=20flow=20tests=20=E2=80=94=20src/auth/session-store.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/session-store.ts | 117 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/auth/session-store.ts diff --git a/src/auth/session-store.ts b/src/auth/session-store.ts new file mode 100644 index 0000000..0083a4e --- /dev/null +++ b/src/auth/session-store.ts @@ -0,0 +1,117 @@ +import { randomBytes } from 'crypto' +import type { Pool } from 'pg' +import type { Session, User } from './types.js' + +// DDL run once on startup so the service owns its own schema. +export const SESSION_SCHEMA = ` + CREATE TABLE IF NOT EXISTS auth_users ( + id TEXT PRIMARY KEY, + github_id TEXT UNIQUE NOT NULL, + login TEXT NOT NULL, + email TEXT, + avatar_url TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + github_access_token TEXT NOT NULL, + refresh_token TEXT, + expires_at TIMESTAMPTZ NOT NULL, + csrf_token TEXT NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); +` + +export class SessionStore { + constructor(private readonly db: Pool) {} + + async migrate(): Promise { + await this.db.query(SESSION_SCHEMA) + } + + async upsertUser( + githubId: number, + login: string, + email: string | null, + avatarUrl: string, + ): Promise { + const res = await this.db.query( + `INSERT INTO auth_users (id, github_id, login, email, avatar_url) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4) + ON CONFLICT (github_id) DO UPDATE + SET login = EXCLUDED.login, + email = EXCLUDED.email, + avatar_url = EXCLUDED.avatar_url, + updated_at = NOW() + RETURNING *`, + [String(githubId), login, email, avatarUrl], + ) + return res.rows[0] + } + + async createSession( + userId: string, + githubAccessToken: string, + refreshToken: string | null, + ttlMs: number, + ): Promise { + const id = randomBytes(32).toString('hex') + const csrfToken = randomBytes(32).toString('hex') + const expiresAt = new Date(Date.now() + ttlMs) + + const res = await this.db.query( + `INSERT INTO auth_sessions + (id, user_id, github_access_token, refresh_token, expires_at, csrf_token) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [id, userId, githubAccessToken, refreshToken, expiresAt, csrfToken], + ) + return res.rows[0] + } + + async getSession(id: string): Promise { + const res = await this.db.query( + `SELECT * FROM auth_sessions WHERE id = $1 LIMIT 1`, + [id], + ) + return res.rows[0] ?? null + } + + async revokeSession(id: string): Promise { + await this.db.query( + `UPDATE auth_sessions SET revoked_at = NOW() WHERE id = $1`, + [id], + ) + } + + async updateSessionToken( + id: string, + githubAccessToken: string, + refreshToken: string | null, + ttlMs: number, + ): Promise { + const expiresAt = new Date(Date.now() + ttlMs) + const res = await this.db.query( + `UPDATE auth_sessions + SET github_access_token = $2, + refresh_token = $3, + expires_at = $4 + WHERE id = $1 AND revoked_at IS NULL + RETURNING *`, + [id, githubAccessToken, refreshToken, expiresAt], + ) + return res.rows[0] ?? null + } + + async getUser(id: string): Promise { + const res = await this.db.query( + `SELECT * FROM auth_users WHERE id = $1 LIMIT 1`, + [id], + ) + return res.rows[0] ?? null + } +} From 8e55c530e1f512e6e1a799231de36f576d73b43e Mon Sep 17 00:00:00 2001 From: charlieww Date: Fri, 27 Mar 2026 01:03:10 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20feat:=20Write=20end-to-end=20auth?= =?UTF-8?q?=20flow=20tests=20=E2=80=94=20src/auth/csrf.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/csrf.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/auth/csrf.ts diff --git a/src/auth/csrf.ts b/src/auth/csrf.ts new file mode 100644 index 0000000..fbf0234 --- /dev/null +++ b/src/auth/csrf.ts @@ -0,0 +1,32 @@ +import { randomBytes } from 'crypto' + +const STATE_TTL_MS = 10 * 60 * 1_000 // 10 minutes + +// In-memory store: state token → expiry epoch ms. +// Tokens are single-use: consumed on first validation attempt. +// For a multi-instance deployment, replace this with a Redis-backed store. +const pendingStates = new Map() + +export function generateCsrfState(): string { + const state = randomBytes(32).toString('hex') + pendingStates.set(state, Date.now() + STATE_TTL_MS) + return state +} + +/** + * Returns true and removes the state if it exists and has not expired. + * Always returns false (and removes the state if present) on reuse, + * making states single-use regardless of outcome. + */ +export function validateCsrfState(state: string): boolean { + const expiry = pendingStates.get(state) + // Always delete so replayed states are always rejected + pendingStates.delete(state) + if (expiry === undefined) return false + return Date.now() < expiry +} + +/** Exposed for test teardown only — do not call in production code. */ +export function clearPendingStates(): void { + pendingStates.clear() +} From 50ad7d74767a68b684f1cb728f60ae3c41116901 Mon Sep 17 00:00:00 2001 From: charlieww Date: Fri, 27 Mar 2026 01:03:12 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20feat:=20Write=20end-to-end=20auth?= =?UTF-8?q?=20flow=20tests=20=E2=80=94=20src/auth/server.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/server.ts | 213 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/auth/server.ts diff --git a/src/auth/server.ts b/src/auth/server.ts new file mode 100644 index 0000000..76f3e56 --- /dev/null +++ b/src/auth/server.ts @@ -0,0 +1,213 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import type { Pool } from 'pg' +import { + buildAuthorizeUrl, + exchangeCodeForToken, + fetchGitHubUser, + refreshGitHubToken, +} from './github-oauth.js' +import { SessionStore } from './session-store.js' +import { generateCsrfState, validateCsrfState } from './csrf.js' +import type { AuthConfig } from './types.js' + +const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1_000 // 24 hours + +function sendJson(res: ServerResponse, status: number, data: unknown): void { + const body = JSON.stringify(data) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }) + res.end(body) +} + +function extractBearerToken(req: IncomingMessage): string | null { + const auth = req.headers['authorization'] + if (!auth?.startsWith('Bearer ')) return null + const token = auth.slice(7).trim() + return token.length > 0 ? token : null +} + +export function createAuthServer(db: Pool, config: AuthConfig) { + const store = new SessionStore(db) + const ttl = config.session_ttl_ms ?? DEFAULT_SESSION_TTL_MS + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL( + req.url ?? '/', + `http://${req.headers.host ?? 'localhost'}`, + ) + + try { + // ── GET /auth/github — kick off OAuth flow ────────────────────────────── + if (req.method === 'GET' && url.pathname === '/auth/github') { + const state = generateCsrfState() + const redirectUrl = buildAuthorizeUrl( + config.github_client_id, + config.github_redirect_uri, + state, + ) + res.writeHead(302, { Location: redirectUrl }) + res.end() + return + } + + // ── GET /auth/github/callback — exchange code, create session ─────────── + if (req.method === 'GET' && url.pathname === '/auth/github/callback') { + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + + if (!state || !validateCsrfState(state)) { + sendJson(res, 403, { + error: 'invalid_csrf_state', + message: 'CSRF state is missing, invalid, or has already been used', + }) + return + } + + if (!code) { + sendJson(res, 400, { error: 'missing_code', message: 'OAuth code is required' }) + return + } + + const tokenData = await exchangeCodeForToken( + code, + config.github_client_id, + config.github_client_secret, + config.github_redirect_uri, + ) + + const ghUser = await fetchGitHubUser(tokenData.access_token) + const user = await store.upsertUser( + ghUser.id, + ghUser.login, + ghUser.email, + ghUser.avatar_url, + ) + + const session = await store.createSession( + user.id, + tokenData.access_token, + tokenData.refresh_token ?? null, + ttl, + ) + + sendJson(res, 200, { + session_id: session.id, + csrf_token: session.csrf_token, + expires_at: session.expires_at, + user: { + id: user.id, + login: user.login, + email: user.email, + avatar_url: user.avatar_url, + }, + }) + return + } + + // ── POST /auth/refresh — extend session with a fresh GitHub token ─────── + if (req.method === 'POST' && url.pathname === '/auth/refresh') { + const sessionId = extractBearerToken(req) + if (!sessionId) { + sendJson(res, 401, { error: 'unauthenticated', message: 'Bearer token required' }) + return + } + + const session = await store.getSession(sessionId) + if (!session) { + sendJson(res, 401, { error: 'session_not_found', message: 'Session does not exist' }) + return + } + if (session.revoked_at) { + sendJson(res, 401, { error: 'session_revoked', message: 'Session has been revoked' }) + return + } + if (new Date(session.expires_at) <= new Date()) { + sendJson(res, 401, { error: 'session_expired', message: 'Session has expired' }) + return + } + if (!session.refresh_token) { + sendJson(res, 400, { error: 'no_refresh_token', message: 'No refresh token available for this session' }) + return + } + + const tokenData = await refreshGitHubToken( + session.refresh_token, + config.github_client_id, + config.github_client_secret, + ) + + const updated = await store.updateSessionToken( + sessionId, + tokenData.access_token, + tokenData.refresh_token ?? null, + ttl, + ) + + sendJson(res, 200, { + session_id: sessionId, + expires_at: updated?.expires_at, + }) + return + } + + // ── POST /auth/logout — revoke session ────────────────────────────────── + if (req.method === 'POST' && url.pathname === '/auth/logout') { + const sessionId = extractBearerToken(req) + if (!sessionId) { + sendJson(res, 401, { error: 'unauthenticated', message: 'Bearer token required' }) + return + } + + await store.revokeSession(sessionId) + sendJson(res, 200, { ok: true }) + return + } + + // ── GET /auth/me — return authenticated user ───────────────────────────── + if (req.method === 'GET' && url.pathname === '/auth/me') { + const sessionId = extractBearerToken(req) + if (!sessionId) { + sendJson(res, 401, { error: 'unauthenticated', message: 'Bearer token required' }) + return + } + + const session = await store.getSession(sessionId) + if (!session) { + sendJson(res, 401, { error: 'session_not_found', message: 'Session does not exist' }) + return + } + if (session.revoked_at) { + sendJson(res, 401, { error: 'session_revoked', message: 'Session has been revoked' }) + return + } + if (new Date(session.expires_at) <= new Date()) { + sendJson(res, 401, { error: 'session_expired', message: 'Session has expired' }) + return + } + + const user = await store.getUser(session.user_id) + if (!user) { + sendJson(res, 404, { error: 'user_not_found', message: 'User record not found' }) + return + } + + sendJson(res, 200, { + id: user.id, + login: user.login, + email: user.email, + avatar_url: user.avatar_url, + }) + return + } + + sendJson(res, 404, { error: 'not_found' }) + } catch (err) { + console.error('[auth-server] Unhandled error:', err) + sendJson(res, 500, { error: 'internal_error', message: String(err) }) + } + }) + + return { server, store } +} From 211baa561b5f17aa9812faecc126c2ba9c2538cf Mon Sep 17 00:00:00 2001 From: charlieww Date: Fri, 27 Mar 2026 01:03:14 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20feat:=20Write=20end-to-end=20auth?= =?UTF-8?q?=20flow=20tests=20=E2=80=94=20tests/auth.e2e.test.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/auth.e2e.test.ts | 539 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 tests/auth.e2e.test.ts diff --git a/tests/auth.e2e.test.ts b/tests/auth.e2e.test.ts new file mode 100644 index 0000000..8309e9f --- /dev/null +++ b/tests/auth.e2e.test.ts @@ -0,0 +1,539 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' +import { Pool } from 'pg' +import type { AddressInfo } from 'net' +import { createAuthServer } from '../src/auth/server.js' +import { clearPendingStates } from '../src/auth/csrf.js' +import type { SessionStore } from '../src/auth/session-store.js' + +// --------------------------------------------------------------------------- +// Test database — separate DB from dev to allow destructive DDL +// --------------------------------------------------------------------------- +const TEST_DB_URL = + process.env.TEST_DATABASE_URL ?? + process.env.DATABASE_URL ?? + 'postgres://opendev:opendev_secret@localhost:5432/opendev_test' + +// --------------------------------------------------------------------------- +// GitHub OAuth stub constants +// --------------------------------------------------------------------------- +const STUB_ACCESS_TOKEN = 'ghs_stub_access_token_abc123' +const STUB_REFRESH_TOKEN = 'ghr_stub_refresh_token_xyz789' +const STUB_CLIENT_ID = 'test_github_client_id' +const STUB_CLIENT_SECRET = 'test_github_client_secret' +const STUB_REDIRECT_URI = 'http://localhost:3000/auth/github/callback' + +const STUB_GITHUB_USER = { + id: 123456, + login: 'testuser', + email: 'test@example.com', + avatar_url: 'https://avatars.githubusercontent.com/u/123456', + name: 'Test User', +} + +// --------------------------------------------------------------------------- +// Fetch stubbing +// +// Both the test client and the server share the same process, so stubbing +// globalThis.fetch intercepts all fetch calls. We capture the real fetch +// before any stub is installed and use it to pass through calls to the local +// test server, while returning canned responses for github.com URLs. +// --------------------------------------------------------------------------- +let realFetch: typeof fetch + +type StubMap = Record + +const DEFAULT_GITHUB_STUBS: StubMap = { + 'https://github.com/login/oauth/access_token': { + status: 200, + body: { + access_token: STUB_ACCESS_TOKEN, + refresh_token: STUB_REFRESH_TOKEN, + token_type: 'bearer', + scope: 'read:user,user:email', + expires_in: 28800, + }, + }, + 'https://api.github.com/user': { + status: 200, + body: STUB_GITHUB_USER, + }, +} + +function installFetchStub(overrides: StubMap = {}): void { + const stubs = { ...DEFAULT_GITHUB_STUBS, ...overrides } + + vi.stubGlobal('fetch', async (url: RequestInfo | URL, opts?: RequestInit) => { + const urlStr = url instanceof Request ? url.url : String(url) + + // Pass local test-server calls through to the real network stack + if (urlStr.startsWith('http://127.0.0.1') || urlStr.startsWith('http://localhost')) { + return realFetch(url as RequestInfo, opts) + } + + // Match against stub keys by prefix so query strings are ignored + const matchKey = Object.keys(stubs).find(k => urlStr.startsWith(k)) + if (!matchKey) { + throw new Error(`[fetch-stub] Unexpected external fetch to: ${urlStr}`) + } + + const { status, body } = stubs[matchKey] + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) +} + +// --------------------------------------------------------------------------- +// Suite setup +// --------------------------------------------------------------------------- + +describe('GitHub OAuth E2E auth flow', () => { + let db: Pool + let baseUrl: string + let closeServer: () => Promise + let store: SessionStore + + beforeAll(async () => { + // Must capture before any vi.stubGlobal call replaces it + realFetch = globalThis.fetch + + db = new Pool({ connectionString: TEST_DB_URL }) + + // Ensure tables exist — SessionStore.migrate() is idempotent + const { store: s, server } = createAuthServer(db, { + github_client_id: STUB_CLIENT_ID, + github_client_secret: STUB_CLIENT_SECRET, + github_redirect_uri: STUB_REDIRECT_URI, + session_ttl_ms: 60 * 60 * 1_000, // 1 hour + }) + store = s + await store.migrate() + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const addr = server.address() as AddressInfo + baseUrl = `http://127.0.0.1:${addr.port}` + + closeServer = () => + new Promise((resolve, reject) => + server.close(err => (err ? reject(err) : resolve())) + ) + }) + + afterAll(async () => { + // Drop test tables in dependency order + await db.query('DROP TABLE IF EXISTS auth_sessions') + await db.query('DROP TABLE IF EXISTS auth_users') + await closeServer() + await db.end() + }) + + beforeEach(async () => { + // Isolate every test: clean data, reset CSRF state, restore fetch + await db.query('DELETE FROM auth_sessions') + await db.query('DELETE FROM auth_users') + clearPendingStates() + vi.restoreAllMocks() + }) + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** Hits GET /auth/github and returns the CSRF state embedded in the redirect. */ + async function startOAuthFlow(): Promise { + // Do NOT use the stubbed fetch here — this call is to the local server + const res = await realFetch(`${baseUrl}/auth/github`, { redirect: 'manual' }) + const location = res.headers.get('location') ?? '' + const state = new URL(location).searchParams.get('state') + if (!state) throw new Error('No state in GitHub redirect URL') + return state + } + + /** Completes the OAuth callback with a stubbed GitHub API and returns the session payload. */ + async function completeOAuthCallback( + state: string, + overrides: StubMap = {}, + ): Promise<{ session_id: string; csrf_token: string; user: { id: string; login: string; email: string } }> { + installFetchStub(overrides) + const res = await fetch(`${baseUrl}/auth/github/callback?code=valid_code&state=${state}`) + vi.restoreAllMocks() + if (res.status !== 200) { + const body = await res.json() + throw new Error(`OAuth callback failed ${res.status}: ${JSON.stringify(body)}`) + } + return res.json() + } + + // ── Happy path ──────────────────────────────────────────────────────────── + + describe('happy path: GitHub OAuth login', () => { + it('should redirect to GitHub authorize URL', async () => { + const res = await realFetch(`${baseUrl}/auth/github`, { redirect: 'manual' }) + const location = res.headers.get('location') ?? '' + + expect(res.status).toBe(302) + expect(location).toContain('https://github.com/login/oauth/authorize') + expect(location).toContain(`client_id=${STUB_CLIENT_ID}`) + }) + + it('should include scopes and a CSRF state in the redirect URL', async () => { + const res = await realFetch(`${baseUrl}/auth/github`, { redirect: 'manual' }) + const location = decodeURIComponent(res.headers.get('location') ?? '') + + expect(location).toContain('read:user') + expect(location).toMatch(/state=[a-f0-9]{64}/) + }) + + it('should return session_id, csrf_token, and user on successful callback', async () => { + const state = await startOAuthFlow() + installFetchStub() + const res = await fetch(`${baseUrl}/auth/github/callback?code=valid_code&state=${state}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.session_id).toMatch(/^[a-f0-9]{64}$/) + expect(body.csrf_token).toMatch(/^[a-f0-9]{64}$/) + expect(body.user.login).toBe('testuser') + expect(body.user.email).toBe('test@example.com') + }) + + it('should persist the session in the database', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + const row = await db.query( + 'SELECT * FROM auth_sessions WHERE id = $1', + [session_id], + ) + expect(row.rows).toHaveLength(1) + expect(row.rows[0].github_access_token).toBe(STUB_ACCESS_TOKEN) + expect(row.rows[0].revoked_at).toBeNull() + }) + + it('should upsert the GitHub user into the database', async () => { + const state = await startOAuthFlow() + await completeOAuthCallback(state) + + const row = await db.query( + `SELECT * FROM auth_users WHERE github_id = '123456'`, + ) + expect(row.rows).toHaveLength(1) + expect(row.rows[0].login).toBe('testuser') + }) + + it('should return the current user from GET /auth/me with a valid session', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + const res = await fetch(`${baseUrl}/auth/me`, { + headers: { Authorization: `Bearer ${session_id}` }, + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.login).toBe('testuser') + expect(body.email).toBe('test@example.com') + }) + + it('should re-use the same user record when the same GitHub account logs in twice', async () => { + const state1 = await startOAuthFlow() + await completeOAuthCallback(state1) + + const state2 = await startOAuthFlow() + await completeOAuthCallback(state2) + + const rows = await db.query(`SELECT * FROM auth_users WHERE github_id = '123456'`) + expect(rows.rows).toHaveLength(1) // upsert, not double-insert + }) + }) + + // ── Token refresh ───────────────────────────────────────────────────────── + + describe('token refresh', () => { + it('should issue a refreshed access token and extend the session expiry', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + installFetchStub({ + 'https://github.com/login/oauth/access_token': { + status: 200, + body: { + access_token: 'ghs_refreshed_new_token', + refresh_token: 'ghr_new_refresh_token', + token_type: 'bearer', + scope: 'read:user', + }, + }, + }) + + const res = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.session_id).toBe(session_id) + expect(body.expires_at).toBeTruthy() + }) + + it('should update the stored access token after refresh', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + installFetchStub({ + 'https://github.com/login/oauth/access_token': { + status: 200, + body: { access_token: 'ghs_updated', refresh_token: 'ghr_updated', token_type: 'bearer', scope: '' }, + }, + }) + await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + vi.restoreAllMocks() + + const row = await db.query( + 'SELECT github_access_token FROM auth_sessions WHERE id = $1', + [session_id], + ) + expect(row.rows[0].github_access_token).toBe('ghs_updated') + }) + + it('should return 401 when no Authorization header is provided to refresh', async () => { + const res = await fetch(`${baseUrl}/auth/refresh`, { method: 'POST' }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('unauthenticated') + }) + + it('should return 401 when the session does not exist', async () => { + const res = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { Authorization: 'Bearer completely_unknown_session_id' }, + }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('session_not_found') + }) + }) + + // ── Logout ──────────────────────────────────────────────────────────────── + + describe('logout', () => { + it('should return { ok: true } on successful logout', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + const res = await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + expect(res.status).toBe(200) + expect((await res.json()).ok).toBe(true) + }) + + it('should mark the session as revoked in the database', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + + const row = await db.query( + 'SELECT revoked_at FROM auth_sessions WHERE id = $1', + [session_id], + ) + expect(row.rows[0].revoked_at).not.toBeNull() + }) + + it('should reject /auth/me with 401 session_revoked after logout', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + + const me = await fetch(`${baseUrl}/auth/me`, { + headers: { Authorization: `Bearer ${session_id}` }, + }) + expect(me.status).toBe(401) + expect((await me.json()).error).toBe('session_revoked') + }) + + it('should reject /auth/refresh with 401 session_revoked after logout', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + + const refresh = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + expect(refresh.status).toBe(401) + expect((await refresh.json()).error).toBe('session_revoked') + }) + + it('should return 401 when logging out without Authorization header', async () => { + const res = await fetch(`${baseUrl}/auth/logout`, { method: 'POST' }) + expect(res.status).toBe(401) + }) + }) + + // ── Expired token rejection ─────────────────────────────────────────────── + + describe('expired token rejection', () => { + async function insertExpiredSession(userId: string, sessionId: string): Promise { + const pastDate = new Date(Date.now() - 1_000) // 1 second in the past + await db.query( + `INSERT INTO auth_sessions + (id, user_id, github_access_token, refresh_token, expires_at, csrf_token) + VALUES ($1, $2, 'tok', 'ref', $3, 'csrf')`, + [sessionId, userId, pastDate], + ) + } + + it('should return 401 session_expired on GET /auth/me with an expired session', async () => { + const user = await store.upsertUser(77001, 'expireduser', null, '') + await insertExpiredSession(user.id, 'expired_me_session') + + const res = await fetch(`${baseUrl}/auth/me`, { + headers: { Authorization: 'Bearer expired_me_session' }, + }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('session_expired') + }) + + it('should return 401 session_expired on POST /auth/refresh with an expired session', async () => { + const user = await store.upsertUser(77002, 'expireduser2', null, '') + await insertExpiredSession(user.id, 'expired_refresh_session') + + const res = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { Authorization: 'Bearer expired_refresh_session' }, + }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('session_expired') + }) + + it('should allow logout of an expired session (clean-up is always permitted)', async () => { + const user = await store.upsertUser(77003, 'expireduser3', null, '') + await insertExpiredSession(user.id, 'expired_logout_session') + + // Logout is a fire-and-forget revocation — we do not check expiry + const res = await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Authorization: 'Bearer expired_logout_session' }, + }) + expect(res.status).toBe(200) + }) + }) + + // ── Revoked token rejection ─────────────────────────────────────────────── + + describe('revoked token rejection', () => { + it('should return 401 session_revoked on GET /auth/me when token was revoked server-side', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + // Simulate server-side revocation (e.g. admin action, security event) + await db.query( + 'UPDATE auth_sessions SET revoked_at = NOW() WHERE id = $1', + [session_id], + ) + + const res = await fetch(`${baseUrl}/auth/me`, { + headers: { Authorization: `Bearer ${session_id}` }, + }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('session_revoked') + }) + + it('should return 401 session_revoked on POST /auth/refresh when token was revoked server-side', async () => { + const state = await startOAuthFlow() + const { session_id } = await completeOAuthCallback(state) + + await db.query( + 'UPDATE auth_sessions SET revoked_at = NOW() WHERE id = $1', + [session_id], + ) + + const res = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { Authorization: `Bearer ${session_id}` }, + }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('session_revoked') + }) + + it('should return 401 for a bearer token that was never issued', async () => { + const res = await fetch(`${baseUrl}/auth/me`, { + headers: { Authorization: 'Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + }) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('session_not_found') + }) + }) + + // ── CSRF rejection ──────────────────────────────────────────────────────── + + describe('CSRF rejection', () => { + it('should return 403 invalid_csrf_state when state param is missing', async () => { + const res = await fetch(`${baseUrl}/auth/github/callback?code=valid_code`) + expect(res.status).toBe(403) + expect((await res.json()).error).toBe('invalid_csrf_state') + }) + + it('should return 403 invalid_csrf_state when state is not in pending store', async () => { + const res = await fetch( + `${baseUrl}/auth/github/callback?code=valid_code&state=totally_fabricated_state_value`, + ) + expect(res.status).toBe(403) + expect((await res.json()).error).toBe('invalid_csrf_state') + }) + + it('should return 403 invalid_csrf_state when a valid state is replayed a second time', async () => { + const state = await startOAuthFlow() + // First use — consumes the state + await completeOAuthCallback(state) + + // Second use with the same state — must be rejected even though first succeeded + installFetchStub() + const res = await fetch( + `${baseUrl}/auth/github/callback?code=valid_code&state=${state}`, + ) + expect(res.status).toBe(403) + expect((await res.json()).error).toBe('invalid_csrf_state') + }) + + it('should generate a unique CSRF state for every OAuth flow initiation', async () => { + const res1 = await realFetch(`${baseUrl}/auth/github`, { redirect: 'manual' }) + const res2 = await realFetch(`${baseUrl}/auth/github`, { redirect: 'manual' }) + const state1 = new URL(res1.headers.get('location')!).searchParams.get('state') + const state2 = new URL(res2.headers.get('location')!).searchParams.get('state') + + expect(state1).toBeTruthy() + expect(state2).toBeTruthy() + expect(state1).not.toBe(state2) + }) + + it('should return 403 when a state looks valid but has expired (cleared from store)', async () => { + // Simulate expiry by clearing all pending states after generation + const res = await realFetch(`${baseUrl}/auth/github`, { redirect: 'manual' }) + const state = new URL(res.headers.get('location')!).searchParams.get('state')! + + clearPendingStates() // evict the state, mimicking TTL expiry + + const callback = await fetch( + `${baseUrl}/auth/github/callback?code=valid_code&state=${state}`, + ) + expect(callback.status).toBe(403) + expect((await callback.json()).error).toBe('invalid_csrf_state') + }) + }) +})