diff --git a/apps/api/src/auth/cookies.ts b/apps/api/src/auth/cookies.ts index dc341a7..e674bff 100644 --- a/apps/api/src/auth/cookies.ts +++ b/apps/api/src/auth/cookies.ts @@ -15,6 +15,7 @@ const COOKIE_OPTS_BASE = { const ACCESS_TTL_MS = 15 * 60 * 1000; const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; const CLAIM_TTL_MS = 5 * 60 * 1000; +const OAUTH_TTL_MS = 10 * 60 * 1000; function isSecure(nodeEnv: string): boolean { return nodeEnv === 'production'; @@ -59,3 +60,30 @@ export function clearSessionCookies(reply: FastifyReply): void { export function clearClaimCookie(reply: FastifyReply): void { reply.clearCookie('cfp_claim', { path: '/api/account-claim' }); } + +export function setOAuthStateCookie(reply: FastifyReply, state: string, nodeEnv: string): void { + reply.setCookie('cfp_oauth_state', state, { + ...COOKIE_OPTS_BASE, + secure: isSecure(nodeEnv), + path: '/api/auth', + maxAge: OAUTH_TTL_MS / 1000, + }); +} + +export function setOAuthSessionCookie( + reply: FastifyReply, + token: string, + nodeEnv: string, +): void { + reply.setCookie('cfp_oauth_session', token, { + ...COOKIE_OPTS_BASE, + secure: isSecure(nodeEnv), + path: '/api/auth', + maxAge: OAUTH_TTL_MS / 1000, + }); +} + +export function clearOAuthCookies(reply: FastifyReply): void { + reply.clearCookie('cfp_oauth_state', { path: '/api/auth' }); + reply.clearCookie('cfp_oauth_session', { path: '/api/auth' }); +} diff --git a/apps/api/src/auth/github-client.ts b/apps/api/src/auth/github-client.ts new file mode 100644 index 0000000..29f9054 --- /dev/null +++ b/apps/api/src/auth/github-client.ts @@ -0,0 +1,215 @@ +/** + * Thin client wrapper for the GitHub OAuth flow. + * + * Exposes three functions matching specs/api/auth.md: + * - exchangeCodeForToken: POST /login/oauth/access_token with PKCE verifier + * - fetchGitHubUser: GET /user + * - fetchGitHubEmails: GET /user/emails + * + * Each function throws `GitHubApiError` on transport failure or non-2xx. + * The route handler catches that and surfaces `github_unreachable` to the SPA. + * + * The token-exchange endpoint is on github.com, while the user/emails endpoints + * are on api.github.com — that's GitHub's split, not ours. + */ + +const TOKEN_URL = 'https://github.com/login/oauth/access_token'; +const USER_URL = 'https://api.github.com/user'; +const EMAILS_URL = 'https://api.github.com/user/emails'; + +const USER_AGENT = 'codeforphilly-ng'; + +export interface GitHubUser { + readonly id: number; + readonly login: string; + readonly name: string | null; + readonly avatar_url?: string; +} + +export interface GitHubEmail { + readonly email: string; + readonly primary: boolean; + readonly verified: boolean; + readonly visibility?: string | null; +} + +export class GitHubApiError extends Error { + readonly code: string; + readonly status?: number; + override readonly cause?: unknown; + + constructor(message: string, code: string, opts?: { status?: number; cause?: unknown }) { + super(message); + this.name = 'GitHubApiError'; + this.code = code; + if (opts?.status !== undefined) this.status = opts.status; + if (opts?.cause !== undefined) this.cause = opts.cause; + } +} + +export interface GitHubTokenExchange { + /** OAuth client id of the registered GitHub OAuth App. */ + readonly clientId: string; + /** OAuth client secret. */ + readonly clientSecret: string; + /** Authorization code returned to /github/callback. */ + readonly code: string; + /** PKCE verifier matching the challenge sent at /start. */ + readonly codeVerifier: string; + /** Same redirect_uri sent at /start (some IdPs require it on exchange). */ + readonly redirectUri: string; +} + +/** + * Exchange the authorization code for a GitHub access token. + * + * GitHub returns JSON when `Accept: application/json` is set (default form + * is x-www-form-urlencoded). We use JSON so we don't have to parse it. + */ +export async function exchangeCodeForToken( + params: GitHubTokenExchange, +): Promise { + let res: Response; + try { + res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ + client_id: params.clientId, + client_secret: params.clientSecret, + code: params.code, + code_verifier: params.codeVerifier, + redirect_uri: params.redirectUri, + }), + }); + } catch (err) { + throw new GitHubApiError('GitHub token exchange transport error', 'github_unreachable', { cause: err }); + } + + if (!res.ok) { + throw new GitHubApiError( + `GitHub token exchange returned ${res.status}`, + 'github_unreachable', + { status: res.status }, + ); + } + + const body = (await res.json().catch(() => null)) as + | { access_token?: string; error?: string; error_description?: string } + | null; + + if (!body || typeof body !== 'object') { + throw new GitHubApiError('GitHub token exchange returned invalid JSON', 'github_unreachable'); + } + + if (body.error) { + // GitHub returned 200 with an error payload — surface as github_unreachable + // (the user-facing error message is the same; we log details server-side). + throw new GitHubApiError( + `GitHub token exchange error: ${body.error}`, + 'github_unreachable', + ); + } + + if (!body.access_token || typeof body.access_token !== 'string') { + throw new GitHubApiError('GitHub token exchange missing access_token', 'github_unreachable'); + } + + return body.access_token; +} + +async function ghGet(url: string, accessToken: string): Promise { + let res: Response; + try { + res = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + }, + }); + } catch (err) { + throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err }); + } + + if (!res.ok) { + throw new GitHubApiError( + `GitHub API ${url} returned ${res.status}`, + 'github_unreachable', + { status: res.status }, + ); + } + + return res.json(); +} + +export async function fetchGitHubUser(accessToken: string): Promise { + const body = (await ghGet(USER_URL, accessToken)) as Partial | null; + if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') { + throw new GitHubApiError('GitHub /user returned unexpected shape', 'github_unreachable'); + } + return { + id: body.id, + login: body.login, + name: typeof body.name === 'string' ? body.name : null, + ...(typeof body.avatar_url === 'string' ? { avatar_url: body.avatar_url } : {}), + }; +} + +export async function fetchGitHubEmails(accessToken: string): Promise { + const body = await ghGet(EMAILS_URL, accessToken); + if (!Array.isArray(body)) { + throw new GitHubApiError('GitHub /user/emails returned non-array', 'github_unreachable'); + } + const emails: GitHubEmail[] = []; + for (const entry of body) { + if ( + entry && + typeof entry === 'object' && + typeof (entry as GitHubEmail).email === 'string' && + typeof (entry as GitHubEmail).primary === 'boolean' && + typeof (entry as GitHubEmail).verified === 'boolean' + ) { + emails.push({ + email: (entry as GitHubEmail).email, + primary: (entry as GitHubEmail).primary, + verified: (entry as GitHubEmail).verified, + }); + } + } + return emails; +} + +/** + * Resolved GitHub identity passed downstream to the matching algorithm. + * + * `emails` is filtered to verified-only; `primaryEmail` is the verified primary + * if there is one, else the first verified email, else null. + */ +export interface ResolvedGitHubIdentity { + readonly id: number; + readonly login: string; + readonly name: string | null; + readonly emails: readonly GitHubEmail[]; + readonly primaryEmail: string | null; +} + +export function resolveIdentitySnapshot( + user: GitHubUser, + rawEmails: readonly GitHubEmail[], +): ResolvedGitHubIdentity { + const verified = rawEmails.filter((e) => e.verified); + const primary = verified.find((e) => e.primary) ?? verified[0] ?? null; + return { + id: user.id, + login: user.login, + name: user.name, + emails: verified, + primaryEmail: primary?.email.toLowerCase() ?? null, + }; +} diff --git a/apps/api/src/auth/github-oauth.ts b/apps/api/src/auth/github-oauth.ts new file mode 100644 index 0000000..ff1bc3b --- /dev/null +++ b/apps/api/src/auth/github-oauth.ts @@ -0,0 +1,198 @@ +/** + * GitHub OAuth flow orchestration. + * + * The route handlers in routes/auth.ts call into these two functions: + * + * - buildAuthorizeUrl: assemble the GitHub /login/oauth/authorize URL + * - completeCallback: full callback pipeline — verify state, exchange code, + * fetch identity, resolve to a Person, mint session or + * claim-pending JWT, and return a "next step" descriptor + * for the route to act on (set cookies + redirect). + * + * The route shouldn't know about PKCE, GitHub clients, or matching internals — + * it just translates the descriptor into HTTP-level effects. + */ +import type { FastifyInstance } from 'fastify'; +import { issueClaimPending } from './jwt.js'; +import { mintSessionFor } from './issue.js'; +import { + exchangeCodeForToken, + fetchGitHubEmails, + fetchGitHubUser, + resolveIdentitySnapshot, + GitHubApiError, + type ResolvedGitHubIdentity, +} from './github-client.js'; +import { resolveIdentity } from '../services/account-matching.js'; +import { buildTransactionOptions } from '../store/commit-meta.js'; +import type { FastifyRequest } from 'fastify'; + +const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; +const GITHUB_SCOPES = 'read:user user:email'; + +export interface AuthorizeUrlParams { + readonly clientId: string; + readonly redirectUri: string; + readonly state: string; + readonly codeChallenge: string; +} + +export function buildAuthorizeUrl(params: AuthorizeUrlParams): string { + const url = new URL(GITHUB_AUTHORIZE_URL); + url.searchParams.set('client_id', params.clientId); + url.searchParams.set('redirect_uri', params.redirectUri); + url.searchParams.set('scope', GITHUB_SCOPES); + url.searchParams.set('state', params.state); + url.searchParams.set('code_challenge', params.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + return url.toString(); +} + +export type CallbackOutcome = + | { kind: 'session'; personId: string; accessToken: string; refreshToken: string; refreshJti: string } + | { kind: 'claim-pending'; token: string; identity: ResolvedGitHubIdentity; candidates: string[] } + | { kind: 'error'; code: CallbackErrorCode }; + +export type CallbackErrorCode = + | 'github_unreachable' + | 'email_unverified'; + +export interface CompleteCallbackParams { + readonly fastify: FastifyInstance; + readonly request: FastifyRequest; + readonly code: string; + readonly codeVerifier: string; + readonly redirectUri: string; +} + +/** + * Run the full OAuth callback pipeline: + * 1. Exchange code → GitHub access token (PKCE) + * 2. Fetch /user + /user/emails + * 3. Filter to verified emails; if none → email_unverified + * 4. Resolve identity against state + private store + * 5. Route to existing-refresh, fresh-create, or claim-pending outcome + */ +export async function completeCallback( + params: CompleteCallbackParams, +): Promise { + const { fastify, request, code, codeVerifier, redirectUri } = params; + const cfg = fastify.config; + + if (!cfg.GITHUB_OAUTH_CLIENT_ID || !cfg.GITHUB_OAUTH_CLIENT_SECRET) { + return { kind: 'error', code: 'github_unreachable' }; + } + + let accessToken: string; + try { + accessToken = await exchangeCodeForToken({ + clientId: cfg.GITHUB_OAUTH_CLIENT_ID, + clientSecret: cfg.GITHUB_OAUTH_CLIENT_SECRET, + code, + codeVerifier, + redirectUri, + }); + } catch (err) { + fastify.log.warn({ err }, 'github token exchange failed'); + return { kind: 'error', code: 'github_unreachable' }; + } + + let identity: ResolvedGitHubIdentity; + try { + const [ghUser, rawEmails] = await Promise.all([ + fetchGitHubUser(accessToken), + fetchGitHubEmails(accessToken), + ]); + identity = resolveIdentitySnapshot(ghUser, rawEmails); + } catch (err) { + fastify.log.warn({ err }, 'github user/emails fetch failed'); + if (err instanceof GitHubApiError) { + return { kind: 'error', code: 'github_unreachable' }; + } + return { kind: 'error', code: 'github_unreachable' }; + } + + if (!identity.primaryEmail) { + return { kind: 'error', code: 'email_unverified' }; + } + const primaryEmail: string = identity.primaryEmail; + + const match = await resolveIdentity(identity, fastify.inMemoryState, fastify.store.private); + + if (match.kind === 'existing') { + const profile = await fastify.store.private.getProfile(match.personId); + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'person.github-refresh', + subjectType: 'person', + subjectId: match.personId, + subjectSlug: match.person.slug, + responseCode: 302, + }), + async (tx) => fastify.services.githubAccount.refreshLinked( + tx, + match.person, + identity, + primaryEmail, + profile, + ), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + + const minted = await mintSessionFor( + match.personId, + result.value.person.accountLevel, + cfg.CFP_JWT_SIGNING_KEY, + ); + return { + kind: 'session', + personId: match.personId, + accessToken: minted.accessToken, + refreshToken: minted.refreshToken, + refreshJti: minted.refreshJti, + }; + } + + if (match.kind === 'create-fresh') { + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'person.create', + subjectType: 'person', + responseCode: 302, + }), + writeOrder: 'private-first', + }, + async (tx) => fastify.services.githubAccount.createFresh(tx, identity, primaryEmail), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + + const minted = await mintSessionFor( + result.value.person.id, + result.value.person.accountLevel, + cfg.CFP_JWT_SIGNING_KEY, + ); + return { + kind: 'session', + personId: result.value.person.id, + accessToken: minted.accessToken, + refreshToken: minted.refreshToken, + refreshJti: minted.refreshJti, + }; + } + + // candidates + const claimToken = await issueClaimPending( + { + ghId: String(identity.id), + ghLogin: identity.login, + ghName: identity.name, + ghEmails: identity.emails.map((e) => e.email), + }, + match.candidates, + cfg.CFP_JWT_SIGNING_KEY, + ); + return { kind: 'claim-pending', token: claimToken, identity, candidates: match.candidates }; +} diff --git a/apps/api/src/auth/oauth-pkce.ts b/apps/api/src/auth/oauth-pkce.ts new file mode 100644 index 0000000..8e6178f --- /dev/null +++ b/apps/api/src/auth/oauth-pkce.ts @@ -0,0 +1,27 @@ +/** + * PKCE helpers per RFC 7636. + * + * GitHub's OAuth implementation accepts S256 PKCE on OAuth Apps, which + * defends against authorization-code interception in addition to the + * client-secret. We always require it per specs/api/auth.md. + */ +import { createHash, randomBytes } from 'node:crypto'; + +const VERIFIER_BYTES = 32; +const STATE_BYTES = 32; + +function base64UrlEncode(buf: Buffer): string { + return buf.toString('base64url'); +} + +export function generatePkceVerifier(): string { + return base64UrlEncode(randomBytes(VERIFIER_BYTES)); +} + +export function pkceChallengeFromVerifier(verifier: string): string { + return base64UrlEncode(createHash('sha256').update(verifier).digest()); +} + +export function generateCsrfState(): string { + return base64UrlEncode(randomBytes(STATE_BYTES)); +} diff --git a/apps/api/src/auth/oauth-session-cookie.ts b/apps/api/src/auth/oauth-session-cookie.ts new file mode 100644 index 0000000..6346459 --- /dev/null +++ b/apps/api/src/auth/oauth-session-cookie.ts @@ -0,0 +1,73 @@ +/** + * Sign + verify the short-lived `cfp_oauth_session` cookie that carries the + * PKCE verifier and return URL across the OAuth round-trip. + * + * Signed (not encrypted) with the JWT signing key — none of these fields are + * confidential on their own (the state cookie is the CSRF token, and the + * verifier never leaves this server's possession in a usable way without the + * code from GitHub). + * + * 10-minute TTL per specs/api/auth.md. + */ +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; +import { uuidv7 } from 'uuidv7'; + +const OAUTH_SESSION_TTL_SECONDS = 10 * 60; +const CLOCK_SKEW_SECONDS = 60; + +export interface OAuthSessionClaims { + readonly state: string; + readonly codeVerifier: string; + readonly return: string; +} + +function keyBytes(signingKey: string): Uint8Array { + return new TextEncoder().encode(signingKey); +} + +export async function signOAuthSession( + claims: OAuthSessionClaims, + signingKey: string, +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ + state: claims.state, + codeVerifier: claims.codeVerifier, + return: claims.return, + scope: 'oauth_session', + jti: uuidv7(), + } satisfies Partial & { + state: string; + codeVerifier: string; + return: string; + scope: string; + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(now + OAUTH_SESSION_TTL_SECONDS) + .sign(keyBytes(signingKey)); +} + +export async function verifyOAuthSession( + token: string, + signingKey: string, +): Promise { + const { payload } = await jwtVerify(token, keyBytes(signingKey), { + algorithms: ['HS256'], + clockTolerance: CLOCK_SKEW_SECONDS, + }); + + if (payload['scope'] !== 'oauth_session') { + throw new Error('Token scope mismatch: expected oauth_session'); + } + + const state = payload['state']; + const codeVerifier = payload['codeVerifier']; + const returnUrl = payload['return']; + + if (typeof state !== 'string' || typeof codeVerifier !== 'string' || typeof returnUrl !== 'string') { + throw new Error('Invalid oauth session claims'); + } + + return { state, codeVerifier, return: returnUrl }; +} diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 2b063dd..e906f45 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -25,6 +25,7 @@ import { ProjectBuzzWriteService } from '../services/project-buzz.write.js'; import { HelpWantedWriteService } from '../services/help-wanted.write.js'; import { PersonWriteService } from '../services/person.write.js'; import { TagWriteService } from '../services/tag.write.js'; +import { GitHubAccountService } from '../services/github-account.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; declare module 'fastify' { @@ -44,6 +45,7 @@ declare module 'fastify' { helpWantedWrite: HelpWantedWriteService; peopleWrite: PersonWriteService; tagsWrite: TagWriteService; + githubAccount: GitHubAccountService; }; /** Shared in-memory state — write routes call StateApply.apply against this. */ inMemoryState: InMemoryState; @@ -79,6 +81,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { helpWantedWrite: new HelpWantedWriteService(state), peopleWrite: new PersonWriteService(state, fastify.store.private), tagsWrite: new TagWriteService(state), + githubAccount: new GitHubAccountService(state), }); } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index d04f543..db8f777 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -2,26 +2,41 @@ * Auth routes — session management endpoints. * * Implements specs/api/auth.md: + * GET /api/auth/github/start + * GET /api/auth/github/callback * GET /api/auth/me * POST /api/auth/refresh * POST /api/auth/logout * GET /api/auth/sessions * POST /api/auth/sessions/:jti/revoke - * - * OAuth flow stubs (return 501 until github-oauth plan): - * GET /api/auth/github/start - * GET /api/auth/github/callback */ -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { errors as JoseErrors } from 'jose'; import { ok } from '../lib/response.js'; import { UnauthenticatedError, ConflictError, ApiNotFoundError } from '../lib/errors.js'; import { verifyRefresh, issueSession } from '../auth/jwt.js'; -import { setSessionCookies, clearSessionCookies } from '../auth/cookies.js'; +import { + setSessionCookies, + clearSessionCookies, + setClaimCookie, + setOAuthStateCookie, + setOAuthSessionCookie, + clearOAuthCookies, +} from '../auth/cookies.js'; import { requireAuth } from '../auth/guards.js'; import type { SessionMeta } from '../auth/session-metadata.js'; - -function clientIp(request: import('fastify').FastifyRequest): string { +import { + generateCsrfState, + generatePkceVerifier, + pkceChallengeFromVerifier, +} from '../auth/oauth-pkce.js'; +import { + signOAuthSession, + verifyOAuthSession, +} from '../auth/oauth-session-cookie.js'; +import { buildAuthorizeUrl, completeCallback } from '../auth/github-oauth.js'; + +function clientIp(request: FastifyRequest): string { const forwarded = request.headers['x-forwarded-for']; if (typeof forwarded === 'string') { return (forwarded.split(',')[0] ?? '').trim(); @@ -29,33 +44,196 @@ function clientIp(request: import('fastify').FastifyRequest): string { return request.socket?.remoteAddress ?? 'unknown'; } +function safeReturnPath(input: string | undefined | null): string { + if (!input || typeof input !== 'string') return '/'; + // Must be a same-origin path: starts with '/' but not '//' (protocol-relative). + if (!input.startsWith('/') || input.startsWith('//')) return '/'; + return input; +} + +function callbackRedirectUri(request: FastifyRequest): string { + // GitHub OAuth Apps require the redirect_uri sent at /authorize to exactly + // match what was registered. We derive it from the inbound request so dev, + // staging, and prod each end up routing back to themselves without an env var. + const protocol = request.headers['x-forwarded-proto'] + ? String(request.headers['x-forwarded-proto']).split(',')[0]?.trim() ?? 'http' + : request.protocol; + const host = request.headers['x-forwarded-host'] + ? String(request.headers['x-forwarded-host']).split(',')[0]?.trim() ?? request.hostname + : request.hostname; + return `${protocol}://${host}/api/auth/github/callback`; +} + +function loginErrorRedirect(reply: FastifyReply, code: string): FastifyReply { + return reply.redirect(`/login?error=${encodeURIComponent(code)}`); +} + +async function persistSessionMetadata( + fastify: FastifyInstance, + request: FastifyRequest, + refreshJti: string, + personId: string, +): Promise { + const now = Date.now(); + const meta: SessionMeta = { + refreshJti, + personId, + userAgent: String(request.headers['user-agent'] ?? ''), + ipAddress: clientIp(request), + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(), + }; + await fastify.sessionMetadata.add(meta, fastify.store.private); +} + export async function authRoutes(fastify: FastifyInstance): Promise { // --------------------------------------------------------------------------- - // OAuth stubs — return 501 until github-oauth plan is implemented + // GET /api/auth/github/start // --------------------------------------------------------------------------- fastify.get( '/api/auth/github/start', - { schema: { tags: ['auth'], summary: 'Begin GitHub OAuth flow (not yet wired)' } }, - async (_request, reply) => { - return reply.code(501).send({ - success: false, - error: { code: 'oauth_not_yet_wired', message: 'GitHub OAuth flow is not yet implemented' }, - metadata: { timestamp: new Date().toISOString() }, + { + schema: { + tags: ['auth'], + summary: 'Begin GitHub OAuth flow', + querystring: { + type: 'object', + properties: { return: { type: 'string' } }, + }, + }, + }, + async (request, reply) => { + const cfg = fastify.config; + if (!cfg.GITHUB_OAUTH_CLIENT_ID || !cfg.GITHUB_OAUTH_CLIENT_SECRET) { + return loginErrorRedirect(reply, 'github_unreachable'); + } + + const { return: returnParam } = request.query as { return?: string }; + const returnPath = safeReturnPath(returnParam); + + const state = generateCsrfState(); + const codeVerifier = generatePkceVerifier(); + const codeChallenge = pkceChallengeFromVerifier(codeVerifier); + + const sessionToken = await signOAuthSession( + { state, codeVerifier, return: returnPath }, + cfg.CFP_JWT_SIGNING_KEY, + ); + + setOAuthStateCookie(reply, state, cfg.NODE_ENV); + setOAuthSessionCookie(reply, sessionToken, cfg.NODE_ENV); + + const url = buildAuthorizeUrl({ + clientId: cfg.GITHUB_OAUTH_CLIENT_ID, + redirectUri: callbackRedirectUri(request), + state, + codeChallenge, }); + + return reply.redirect(url); }, ); + // --------------------------------------------------------------------------- + // GET /api/auth/github/callback + // --------------------------------------------------------------------------- + fastify.get( '/api/auth/github/callback', - { schema: { tags: ['auth'], summary: 'GitHub OAuth callback (not yet wired)' } }, - async (_request, reply) => { - return reply.code(501).send({ - success: false, - error: { code: 'oauth_not_yet_wired', message: 'GitHub OAuth flow is not yet implemented' }, - metadata: { timestamp: new Date().toISOString() }, + { + schema: { + tags: ['auth'], + summary: 'GitHub OAuth callback', + querystring: { + type: 'object', + properties: { + code: { type: 'string' }, + state: { type: 'string' }, + error: { type: 'string' }, + error_description: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + const cfg = fastify.config; + const query = request.query as { + code?: string; + state?: string; + error?: string; + error_description?: string; + }; + + // User denied (or some other GitHub-originated error) — surface to /login. + if (query.error) { + clearOAuthCookies(reply); + return loginErrorRedirect(reply, query.error); + } + + // Validate state cookie vs query state (CSRF). + const stateCookie = request.cookies['cfp_oauth_state']; + const oauthSessionCookie = request.cookies['cfp_oauth_session']; + + if (!query.state || !stateCookie || query.state !== stateCookie) { + clearOAuthCookies(reply); + return loginErrorRedirect(reply, 'oauth_state_mismatch'); + } + + if (!oauthSessionCookie) { + clearOAuthCookies(reply); + return loginErrorRedirect(reply, 'oauth_session_invalid'); + } + + let sessionClaims; + try { + sessionClaims = await verifyOAuthSession(oauthSessionCookie, cfg.CFP_JWT_SIGNING_KEY); + } catch (err) { + fastify.log.warn({ err }, 'oauth session cookie verification failed'); + clearOAuthCookies(reply); + return loginErrorRedirect(reply, 'oauth_session_invalid'); + } + + if (sessionClaims.state !== query.state) { + clearOAuthCookies(reply); + return loginErrorRedirect(reply, 'oauth_state_mismatch'); + } + + if (!query.code) { + clearOAuthCookies(reply); + return loginErrorRedirect(reply, 'github_unreachable'); + } + + // Pipeline: code → token → user/emails → match → outcome. + const outcome = await completeCallback({ + fastify, + request, + code: query.code, + codeVerifier: sessionClaims.codeVerifier, + redirectUri: callbackRedirectUri(request), }); + + clearOAuthCookies(reply); + + if (outcome.kind === 'error') { + return loginErrorRedirect(reply, outcome.code); + } + + if (outcome.kind === 'claim-pending') { + setClaimCookie(reply, outcome.token, cfg.NODE_ENV); + const target = `/account-claim?return=${encodeURIComponent(sessionClaims.return)}`; + return reply.redirect(target); + } + + // session — set cookies, persist metadata, redirect to safe return. + setSessionCookies( + reply, + { access: outcome.accessToken, refresh: outcome.refreshToken }, + cfg.NODE_ENV, + ); + await persistSessionMetadata(fastify, request, outcome.refreshJti, outcome.personId); + return reply.redirect(sessionClaims.return); }, ); @@ -110,7 +288,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { throw new UnauthenticatedError('Refresh token revoked', 'refresh_token_revoked'); } - // Look up person to get current accountLevel const person = await fastify.store.public.people.queryFirst({ id: claims.sub }); if (!person) { throw new UnauthenticatedError('Person not found', 'refresh_token_revoked'); @@ -122,7 +299,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { fastify.config.CFP_JWT_SIGNING_KEY, ); - // Revoke the old refresh jti const oldExpiresAt = new Date(claims.exp * 1000).toISOString(); await fastify.revocations.revoke( { jti: claims.jti, personId: claims.sub, expiresAt: oldExpiresAt }, @@ -130,7 +306,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { ); await fastify.sessionMetadata.remove(claims.jti, fastify.store.private); - // Store metadata for new refresh token const newExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); const newMeta: SessionMeta = { refreshJti: newTokens.refreshJti, @@ -158,7 +333,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { const { session } = request; const personId = session.personId ?? session.person?.id; - // Revoke access jti — use personId from claims (available even without person lookup) if (session.jti && personId) { const accessExp = new Date(Date.now() + 15 * 60 * 1000).toISOString(); await fastify.revocations.revoke( @@ -167,7 +341,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { ); } - // Revoke the refresh token as well const refreshToken = request.cookies['cfp_refresh']; if (refreshToken) { try { @@ -200,7 +373,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { const { session } = request; const personId = session.personId ?? session.person!.id; - // Get current refresh jti from cookie let currentRefreshJti: string | null = null; const refreshToken = request.cookies['cfp_refresh']; if (refreshToken) { @@ -247,7 +419,6 @@ export async function authRoutes(fastify: FastifyInstance): Promise { const personId = session.personId ?? session.person!.id; const { jti } = request.params as { jti: string }; - // Identify current refresh jti const refreshToken = request.cookies['cfp_refresh']; if (refreshToken) { try { diff --git a/apps/api/src/services/account-matching.ts b/apps/api/src/services/account-matching.ts new file mode 100644 index 0000000..aac70d5 --- /dev/null +++ b/apps/api/src/services/account-matching.ts @@ -0,0 +1,65 @@ +/** + * Account matching: resolve a GitHub OAuth identity to one of three outcomes + * per specs/behaviors/account-migration.md: + * + * 1. existing — Person.githubUserId === gh.id (already linked) + * 2. create-fresh — no candidates; route handler will create a new Person + * 3. candidates — one or more legacy Persons match by verified email or + * weakly by username; claim-pending JWT routes to claim + * + * The match is best-effort, deterministic, and never auto-claims a legacy + * candidate: email-match seeds the candidate set, username-match adds to it, + * and the user is the one who confirms at /account-claim. + */ +import type { Person } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { PrivateStore } from '../store/private/index.js'; +import type { ResolvedGitHubIdentity } from '../auth/github-client.js'; + +export type MatchResult = + | { kind: 'existing'; personId: string; person: Person } + | { kind: 'create-fresh' } + | { kind: 'candidates'; candidates: string[] }; + +function findPersonByGithubUserId(state: InMemoryState, ghUserId: number): Person | null { + for (const person of state.people.values()) { + if (person.githubUserId === ghUserId) return person; + } + return null; +} + +export async function resolveIdentity( + identity: ResolvedGitHubIdentity, + state: InMemoryState, + privateStore: PrivateStore, +): Promise { + // 1. Direct hit — already linked + const linked = findPersonByGithubUserId(state, identity.id); + if (linked) return { kind: 'existing', personId: linked.id, person: linked }; + + // 2. Email match — scan verified emails against PrivateProfile.email + const candidates = new Set(); + for (const entry of identity.emails) { + const email = entry.email.toLowerCase(); + const pid = await privateStore.findPersonIdByEmail(email); + if (!pid) continue; + const person = state.people.get(pid); + // Skip already-linked persons — they belong to someone else's GH account. + if (person && !person.githubUserId && !person.deletedAt) { + candidates.add(pid); + } + } + + // 3. Username weak match — gh.login → Person.slug + const usernameSlug = identity.login.toLowerCase(); + const usernameMatchId = state.personIdBySlug.get(usernameSlug); + if (usernameMatchId) { + const person = state.people.get(usernameMatchId); + if (person && !person.githubUserId && !person.deletedAt) { + candidates.add(usernameMatchId); + } + } + + if (candidates.size === 0) return { kind: 'create-fresh' }; + return { kind: 'candidates', candidates: [...candidates] }; +} diff --git a/apps/api/src/services/github-account.ts b/apps/api/src/services/github-account.ts new file mode 100644 index 0000000..ada94f7 --- /dev/null +++ b/apps/api/src/services/github-account.ts @@ -0,0 +1,182 @@ +/** + * Person + PrivateProfile mutations triggered by the GitHub OAuth callback. + * + * Two entry points: + * - createFreshFromGitHub: brand-new user — generate slug, Person, PrivateProfile + * - refreshLinkedFromGitHub: existing-linked user — refresh githubLogin + email + * + * Both run inside `store.transact` so the public commit and private write are + * atomic per the dual-store contract. + */ +import { + PersonSchema, + PrivateProfileSchema, + type Person, + type PrivateProfile, +} from '@cfp/shared/schemas'; +import { uuidv7 } from 'uuidv7'; +import type { DualStoreTx } from '../store/store.js'; +import type { InMemoryState } from '../store/memory/state.js'; +import { StateApply } from '../store/state-apply.js'; +import { + ensureUniqueSlug, + isReservedSlug, + isValidPersonSlug, + slugify, +} from '../lib/slug.js'; +import type { ResolvedGitHubIdentity } from '../auth/github-client.js'; + +const PERSON_SLUG_MAX = 50; + +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Slugify a GitHub login into something that satisfies the Person slug regex. + * + * GitHub logins permit only `[a-zA-Z0-9]` and single internal hyphens; our + * Person slug regex is roughly the lowercase equivalent. Most logins map + * directly. Fallbacks for edge cases: + * - lowercase first + * - if it starts with `-`, prepend `user-` + * - if reserved, prepend `user-` + * - if still invalid (shouldn't happen for any real GH login), fall back to `user-` + */ +export function slugifyGitHubLogin(login: string, ghId: number): string { + const base = slugify(login, PERSON_SLUG_MAX); + if (isValidPersonSlug(base) && !isReservedSlug(base)) return base; + const prefixed = slugify(`user-${login}`, PERSON_SLUG_MAX); + if (isValidPersonSlug(prefixed) && !isReservedSlug(prefixed)) return prefixed; + return `user-${ghId}`; +} + +export interface CreateFreshResult { + readonly person: Person; + readonly profile: PrivateProfile; + readonly stateApply: StateApply; +} + +export class GitHubAccountService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + /** + * Create a fresh Person + PrivateProfile from a GitHub identity. Caller must + * have already validated that no candidates exist (so this won't shadow a + * legacy account). + * + * Email is optional in the OAuth payload but we still create the Person if + * absent; the matching algorithm only routes here when there are no + * candidates, which already required at least the OAuth handshake to succeed. + * If `primaryEmail` is null at this point, the route handler should have + * already redirected to `email_unverified`. + */ + async createFresh( + tx: DualStoreTx, + identity: ResolvedGitHubIdentity, + primaryEmail: string, + ): Promise { + const now = nowIso(); + const id = uuidv7(); + const baseSlug = slugifyGitHubLogin(identity.login, identity.id); + const slug = ensureUniqueSlug( + baseSlug, + (candidate) => this.#state.personIdBySlug.has(candidate) || isReservedSlug(candidate), + PERSON_SLUG_MAX, + ); + + const person: Person = PersonSchema.parse({ + id, + slug, + fullName: identity.name && identity.name.length > 0 ? identity.name : identity.login, + accountLevel: 'user', + githubUserId: identity.id, + githubLogin: identity.login, + githubLinkedAt: now, + slackSamlNameId: slug, + createdAt: now, + updatedAt: now, + }); + + const profile: PrivateProfile = PrivateProfileSchema.parse({ + personId: id, + email: primaryEmail.toLowerCase(), + emailRefreshedAt: now, + newsletter: null, + updatedAt: now, + }); + + await tx.public.people.upsert(person); + tx.private.putProfile(profile); + + const stateApply = new StateApply().upsertPerson(person); + return { person, profile, stateApply }; + } + + /** + * Refresh an existing-linked Person + PrivateProfile from a GitHub identity. + * + * - Person.githubLogin is updated if GitHub changed the login + * - PrivateProfile.email is replaced with the current GitHub primary email + * (the user "changes email" by changing it on GitHub, per the spec) + * + * Returns a StateApply that is empty unless the Person record actually + * changed (no-op refresh of the same login is silent). + */ + async refreshLinked( + tx: DualStoreTx, + existing: Person, + identity: ResolvedGitHubIdentity, + primaryEmail: string | null, + currentProfile: PrivateProfile | null, + ): Promise<{ person: Person; stateApply: StateApply; publicChanged: boolean }> { + const now = nowIso(); + const stateApply = new StateApply(); + + const loginChanged = existing.githubLogin !== identity.login; + const linkedAtMissing = !existing.githubLinkedAt; + const publicChanged = loginChanged || linkedAtMissing; + + let updated = existing; + if (publicChanged) { + updated = PersonSchema.parse({ + ...existing, + githubLogin: identity.login, + githubLinkedAt: existing.githubLinkedAt ?? now, + updatedAt: now, + }); + await tx.public.people.upsert(updated); + stateApply.upsertPerson(updated); + } + + // Refresh email if it actually changed (avoids a no-op private flush). + if (primaryEmail) { + const normalized = primaryEmail.toLowerCase(); + if (!currentProfile || currentProfile.email.toLowerCase() !== normalized || !currentProfile.emailRefreshedAt) { + const profile: PrivateProfile = PrivateProfileSchema.parse({ + personId: existing.id, + email: normalized, + emailRefreshedAt: now, + newsletter: currentProfile?.newsletter ?? null, + updatedAt: now, + }); + tx.private.putProfile(profile); + } else { + // Email unchanged but we still bump emailRefreshedAt because the spec + // says we refresh on every successful OAuth callback. + const profile: PrivateProfile = PrivateProfileSchema.parse({ + ...currentProfile, + emailRefreshedAt: now, + updatedAt: now, + }); + tx.private.putProfile(profile); + } + } + + return { person: updated, stateApply, publicChanged }; + } +} diff --git a/apps/api/tests/auth.test.ts b/apps/api/tests/auth.test.ts index 8442572..f572615 100644 --- a/apps/api/tests/auth.test.ts +++ b/apps/api/tests/auth.test.ts @@ -197,41 +197,9 @@ describe('GET /api/auth/me', () => { }); }); -// --------------------------------------------------------------------------- -// OAuth stubs — shared app -// --------------------------------------------------------------------------- - -describe('OAuth stub endpoints', () => { - let dataRepo: { path: string; cleanup: () => Promise }; - let privateStore: { path: string; cleanup: () => Promise }; - let app: FastifyInstance; - - beforeAll(async () => { - dataRepo = await createFullDataRepo(); - privateStore = await createPrivateStorageDir(); - app = await buildTestApp(dataRepo.path, privateStore.path); - }, 60_000); - - afterAll(async () => { - await app.close(); - await dataRepo.cleanup(); - await privateStore.cleanup(); - }); - - it('GET /api/auth/github/start returns 501 oauth_not_yet_wired', async () => { - const res = await app.inject({ method: 'GET', url: '/api/auth/github/start' }); - expect(res.statusCode).toBe(501); - const body = res.json<{ error: { code: string } }>(); - expect(body.error.code).toBe('oauth_not_yet_wired'); - }); - - it('GET /api/auth/github/callback returns 501 oauth_not_yet_wired', async () => { - const res = await app.inject({ method: 'GET', url: '/api/auth/github/callback' }); - expect(res.statusCode).toBe(501); - const body = res.json<{ error: { code: string } }>(); - expect(body.error.code).toBe('oauth_not_yet_wired'); - }); -}); +// OAuth flow tests live in github-oauth.test.ts — they require MSW to +// intercept api.github.com and a richer fixture setup than the simple +// session-management tests above. // --------------------------------------------------------------------------- // POST /api/auth/refresh — shared app diff --git a/apps/api/tests/github-oauth.test.ts b/apps/api/tests/github-oauth.test.ts new file mode 100644 index 0000000..a63c307 --- /dev/null +++ b/apps/api/tests/github-oauth.test.ts @@ -0,0 +1,648 @@ +/** + * Tests for the github-oauth plan validation criteria. + * + * Covers each callback outcome: + * - existing-linked → refresh + session + * - create-fresh → new Person + PrivateProfile + session + * - candidates → claim-pending JWT + redirect to /account-claim + * And the documented error modes: + * - access_denied (GitHub error param) + * - oauth_state_mismatch (CSRF) + * - oauth_session_invalid (signed cookie tampered/expired) + * - email_unverified + * - github_unreachable (PKCE / token error) + * + * Each test uses a unique remoteAddress so the 10-req/min/IP cap on + * /api/auth/* doesn't cause one test's setup to fail another test's run. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { type FastifyInstance } from 'fastify'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { writeFile, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { SignJWT } from 'jose'; + +import { buildApp } from '../src/app.js'; +import { verifyAccess, verifyRefresh, verifyClaimPending } from '../src/auth/jwt.js'; +import { verifyOAuthSession } from '../src/auth/oauth-session-cookie.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { createGitHubMock } from './helpers/mocks.js'; + +const exec = promisify(execFile); +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; +const GH_CLIENT_ID = 'test-client-id'; +const GH_CLIENT_SECRET = 'test-client-secret'; + +interface SeedPersonOpts { + readonly accountLevel?: 'user' | 'staff' | 'administrator'; + readonly githubUserId?: number; + readonly githubLogin?: string; + readonly githubLinkedAt?: string; +} + +async function seedPerson( + repoDir: string, + slug: string, + id: string, + opts: SeedPersonOpts = {}, +): Promise { + const git = (...args: string[]) => exec('git', args, { cwd: repoDir }); + const lines = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${opts.accountLevel ?? 'user'}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ]; + if (opts.githubUserId !== undefined) { + lines.push(`githubUserId = ${opts.githubUserId}`); + } + if (opts.githubLogin !== undefined) { + lines.push(`githubLogin = "${opts.githubLogin}"`); + } + if (opts.githubLinkedAt !== undefined) { + lines.push(`githubLinkedAt = "${opts.githubLinkedAt}"`); + } + + await mkdir(join(repoDir, 'people'), { recursive: true }); + await writeFile(join(repoDir, 'people', `${slug}.toml`), lines.join('\n')); + await git('add', `people/${slug}.toml`); + await git( + '-c', 'user.email=test@cfp.test', + '-c', 'user.name=test', + 'commit', '-m', `seed person ${slug}`, + ); +} + +/** Seed a PrivateProfile directly into the filesystem private store. */ +async function seedPrivateProfile( + privatePath: string, + personId: string, + email: string, +): Promise { + const filePath = join(privatePath, 'profiles.jsonl'); + const profile = { + personId, + email: email.toLowerCase(), + emailRefreshedAt: '2026-05-01T00:00:00Z', + newsletter: null, + updatedAt: '2026-05-01T00:00:00Z', + }; + let content = ''; + try { + content = await readFile(filePath, 'utf8'); + } catch { + // file doesn't exist yet + } + await writeFile(filePath, content + JSON.stringify(profile) + '\n'); +} + +async function buildTestApp( + dataPath: string, + privatePath: string, +): Promise { + return buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataPath, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privatePath, + CFP_JWT_SIGNING_KEY: JWT_KEY, + GITHUB_OAUTH_CLIENT_ID: GH_CLIENT_ID, + GITHUB_OAUTH_CLIENT_SECRET: GH_CLIENT_SECRET, + NODE_ENV: 'test', + }, + }); +} + +interface StartedFlow { + readonly state: string; + readonly oauthSessionCookie: string; + readonly stateCookie: string; +} + +// Each test that hits /api/auth/* uses a unique remoteAddress to dodge the +// 10-req/min/IP cap on auth endpoints (shared across the suite's lifetime). +let testIpCounter = 0; +function nextTestIp(): string { + testIpCounter += 1; + return `10.0.${Math.floor(testIpCounter / 250)}.${testIpCounter % 250}`; +} + +async function startFlow( + app: FastifyInstance, + returnPath: string, + remoteAddress: string, +): Promise { + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/start?return=${encodeURIComponent(returnPath)}`, + remoteAddress, + }); + expect(res.statusCode).toBe(302); + + const location = res.headers['location']; + expect(typeof location).toBe('string'); + const url = new URL(String(location)); + expect(url.origin + url.pathname).toBe('https://github.com/login/oauth/authorize'); + + const state = url.searchParams.get('state'); + expect(state).toBeTruthy(); + + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const stateCookie = cookies.find((c) => c.startsWith('cfp_oauth_state=')); + const sessionCookie = cookies.find((c) => c.startsWith('cfp_oauth_session=')); + expect(stateCookie).toBeDefined(); + expect(sessionCookie).toBeDefined(); + + const stateValue = stateCookie!.split(';')[0]!.replace('cfp_oauth_state=', ''); + const sessionValue = sessionCookie!.split(';')[0]!.replace('cfp_oauth_session=', ''); + + return { + state: state!, + stateCookie: stateValue, + oauthSessionCookie: sessionValue, + }; +} + +// --------------------------------------------------------------------------- +// GET /api/auth/github/start +// --------------------------------------------------------------------------- + +describe('GET /api/auth/github/start', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('redirects to github with state + PKCE challenge + correct scopes', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/github/start', + remoteAddress: nextTestIp(), + }); + expect(res.statusCode).toBe(302); + const url = new URL(String(res.headers['location'])); + expect(url.origin + url.pathname).toBe('https://github.com/login/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe(GH_CLIENT_ID); + expect(url.searchParams.get('scope')).toBe('read:user user:email'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + const challenge = url.searchParams.get('code_challenge'); + expect(challenge).toBeTruthy(); + expect(challenge!.length).toBeGreaterThan(20); + const state = url.searchParams.get('state'); + expect(state).toBeTruthy(); + }); + + it('sets cfp_oauth_state and cfp_oauth_session cookies with HttpOnly + SameSite=Lax', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/github/start', + remoteAddress: nextTestIp(), + }); + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const stateCookie = cookies.find((c) => c.startsWith('cfp_oauth_state=')); + const sessionCookie = cookies.find((c) => c.startsWith('cfp_oauth_session=')); + expect(stateCookie).toContain('HttpOnly'); + expect(stateCookie?.toLowerCase()).toContain('samesite=lax'); + expect(stateCookie).toContain('Path=/api/auth'); + expect(sessionCookie).toContain('HttpOnly'); + }); + + it('signed oauth session cookie carries the state, verifier, and return path', async () => { + const flow = await startFlow(app, '/projects/foo', nextTestIp()); + const claims = await verifyOAuthSession(flow.oauthSessionCookie, JWT_KEY); + expect(claims.state).toBe(flow.state); + expect(claims.codeVerifier.length).toBeGreaterThan(20); + expect(claims.return).toBe('/projects/foo'); + }); + + it('ignores cross-origin return values and falls back to "/"', async () => { + const flow = await startFlow(app, 'https://evil.example.com/', nextTestIp()); + const claims = await verifyOAuthSession(flow.oauthSessionCookie, JWT_KEY); + expect(claims.return).toBe('/'); + }); +}); + +// --------------------------------------------------------------------------- +// /github/callback — error scenarios +// --------------------------------------------------------------------------- + +describe('GET /api/auth/github/callback — error scenarios', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const mock = createGitHubMock(); + + beforeAll(async () => { + mock.server.listen({ onUnhandledRequest: 'error' }); + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterEach(() => { + mock.server.resetHandlers(); + }); + + afterAll(async () => { + mock.server.close(); + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('redirects to /login?error=access_denied when GitHub passes back error=access_denied', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/github/callback?error=access_denied&error_description=user+declined', + remoteAddress: nextTestIp(), + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=access_denied'); + }); + + it('redirects to /login?error=oauth_state_mismatch when state cookie is missing', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/github/callback?code=abc&state=tampered', + remoteAddress: nextTestIp(), + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=oauth_state_mismatch'); + }); + + it('redirects to /login?error=oauth_state_mismatch when query state does not match cookie', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=different-state`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=oauth_state_mismatch'); + }); + + it('redirects to /login?error=oauth_session_invalid when signed session cookie is bogus', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: 'not-a-jwt', + }, + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=oauth_session_invalid'); + }); + + it('redirects to /login?error=oauth_session_invalid when signed session cookie is expired', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const expiredSession = await new SignJWT({ + state: flow.state, + codeVerifier: 'whatever', + return: '/', + scope: 'oauth_session', + jti: 'expired', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(Math.floor(Date.now() / 1000) - 7200) + .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) + .sign(new TextEncoder().encode(JWT_KEY)); + + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: expiredSession, + }, + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=oauth_session_invalid'); + }); + + it('redirects to /login?error=email_unverified when GitHub returns no verified email', async () => { + mock.setGitHubUser({ id: 4242, login: 'noemailuser', name: 'No Email User', avatar_url: 'x' }); + mock.setGitHubEmails([ + { email: 'noemail@example.com', primary: true, verified: false, visibility: null }, + ]); + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=email_unverified'); + }); + + it('redirects to /login?error=github_unreachable when token exchange errors', async () => { + mock.setTokenResponse({ error: 'bad_verification_code' }); + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/login?error=github_unreachable'); + }); +}); + +// --------------------------------------------------------------------------- +// /github/callback — happy paths +// --------------------------------------------------------------------------- + +describe('GET /api/auth/github/callback — existing-linked outcome', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const mock = createGitHubMock(); + const personId = '01951a3c-0000-7000-8000-0000aaaaaaa1'; + + beforeAll(async () => { + mock.server.listen({ onUnhandledRequest: 'error' }); + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'linked-user', personId, { + githubUserId: 12345, + githubLogin: 'linked-user-old-login', + githubLinkedAt: '2026-04-01T00:00:00Z', + }); + await seedPrivateProfile(privateStore.path, personId, 'old@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + + mock.setGitHubUser({ id: 12345, login: 'linked-user-new', name: 'Linked User', avatar_url: 'x' }); + mock.setGitHubEmails([ + { email: 'new@example.com', primary: true, verified: true, visibility: 'public' }, + ]); + mock.setTokenResponse({ access_token: 'gho_test', token_type: 'bearer', scope: 'read:user,user:email' }); + }, 60_000); + + afterAll(async () => { + mock.server.close(); + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('matches by githubUserId, refreshes login + email, issues session, redirects to return path', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/projects', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/projects'); + + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const session = cookies.find((c) => c.startsWith('cfp_session=') && !c.startsWith('cfp_session=;')); + const refresh = cookies.find((c) => c.startsWith('cfp_refresh=') && !c.startsWith('cfp_refresh=;')); + expect(session).toBeDefined(); + expect(refresh).toBeDefined(); + + const accessValue = session!.split(';')[0]!.replace('cfp_session=', ''); + const claims = await verifyAccess(accessValue, JWT_KEY); + expect(claims.sub).toBe(personId); + + const person = app.inMemoryState.people.get(personId); + expect(person?.githubLogin).toBe('linked-user-new'); + + const profile = await app.store.private.getProfile(personId); + expect(profile?.email).toBe('new@example.com'); + + const refreshValue = refresh!.split(';')[0]!.replace('cfp_refresh=', ''); + const refreshClaims = await verifyRefresh(refreshValue, JWT_KEY); + const meta = app.sessionMetadata.get(refreshClaims.jti); + expect(meta?.personId).toBe(personId); + }); +}); + +describe('GET /api/auth/github/callback — fresh user outcome', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const mock = createGitHubMock(); + + beforeAll(async () => { + mock.server.listen({ onUnhandledRequest: 'error' }); + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + + mock.setGitHubUser({ id: 99999, login: 'brand-new-user', name: 'Brand New', avatar_url: 'x' }); + mock.setGitHubEmails([ + { email: 'brand-new@example.com', primary: true, verified: true, visibility: 'public' }, + ]); + mock.setTokenResponse({ access_token: 'gho_test', token_type: 'bearer', scope: 'read:user,user:email' }); + }, 60_000); + + afterAll(async () => { + mock.server.close(); + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('creates Person + PrivateProfile, issues session, redirects to return path', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/'); + + const person = [...app.inMemoryState.people.values()].find((p) => p.githubUserId === 99999); + expect(person).toBeDefined(); + expect(person?.slug).toBe('brand-new-user'); + expect(person?.fullName).toBe('Brand New'); + expect(person?.githubLogin).toBe('brand-new-user'); + expect(person?.githubLinkedAt).toBeDefined(); + expect(person?.slackSamlNameId).toBe('brand-new-user'); + + const profile = await app.store.private.getProfile(person!.id); + expect(profile?.email).toBe('brand-new@example.com'); + + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const session = cookies.find((c) => c.startsWith('cfp_session=') && !c.startsWith('cfp_session=;')); + expect(session).toBeDefined(); + + const accessValue = session!.split(';')[0]!.replace('cfp_session=', ''); + const claims = await verifyAccess(accessValue, JWT_KEY); + expect(claims.sub).toBe(person!.id); + }); +}); + +describe('GET /api/auth/github/callback — candidates outcome', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const mock = createGitHubMock(); + const candidateId = '01951a3c-0000-7000-8000-0000bbbbbbb1'; + + beforeAll(async () => { + mock.server.listen({ onUnhandledRequest: 'error' }); + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'legacy-jane', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'jane@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + + mock.setGitHubUser({ id: 7777, login: 'jane-on-github', name: 'Jane', avatar_url: 'x' }); + mock.setGitHubEmails([ + { email: 'jane@example.com', primary: true, verified: true, visibility: 'public' }, + ]); + mock.setTokenResponse({ access_token: 'gho_test', token_type: 'bearer', scope: 'read:user,user:email' }); + }, 60_000); + + afterAll(async () => { + mock.server.close(); + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('issues claim-pending JWT and redirects to /account-claim with return path', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/help-wanted', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/account-claim?return=%2Fhelp-wanted'); + + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const claim = cookies.find((c) => c.startsWith('cfp_claim=')); + expect(claim).toBeDefined(); + expect(claim).toContain('Path=/api/account-claim'); + const session = cookies.find((c) => c.startsWith('cfp_session=') && !c.startsWith('cfp_session=;')); + expect(session).toBeUndefined(); + + const claimValue = claim!.split(';')[0]!.replace('cfp_claim=', ''); + const claims = await verifyClaimPending(claimValue, JWT_KEY); + expect(claims.scope).toBe('claim'); + expect(claims.sub).toBe('7777'); + expect(claims.ghLogin).toBe('jane-on-github'); + expect(claims.candidates).toContain(candidateId); + expect(claims.ghEmails).toContain('jane@example.com'); + + const candidate = app.inMemoryState.people.get(candidateId); + expect(candidate?.githubUserId).toBeUndefined(); + }); +}); + +describe('GET /api/auth/github/callback — username weak match contributes to candidates', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const mock = createGitHubMock(); + const candidateId = '01951a3c-0000-7000-8000-0000ccccccc1'; + + beforeAll(async () => { + mock.server.listen({ onUnhandledRequest: 'error' }); + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + // Person whose slug == gh.login, no email overlap + await seedPerson(dataRepo.path, 'username-match', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'someoneelse@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + + mock.setGitHubUser({ id: 8888, login: 'username-match', name: 'U M', avatar_url: 'x' }); + mock.setGitHubEmails([ + { email: 'unrelated@example.com', primary: true, verified: true, visibility: 'public' }, + ]); + mock.setTokenResponse({ access_token: 'gho_test', token_type: 'bearer', scope: 'read:user,user:email' }); + }, 60_000); + + afterAll(async () => { + mock.server.close(); + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('username match yields a candidate even without email overlap', async () => { + const ip = nextTestIp(); + const flow = await startFlow(app, '/', ip); + const res = await app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(flow.state)}`, + remoteAddress: ip, + cookies: { + cfp_oauth_state: flow.stateCookie, + cfp_oauth_session: flow.oauthSessionCookie, + }, + }); + + expect(res.statusCode).toBe(302); + expect(String(res.headers['location'])).toBe('/account-claim?return=%2F'); + + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const claim = cookies.find((c) => c.startsWith('cfp_claim=')); + const claimValue = claim!.split(';')[0]!.replace('cfp_claim=', ''); + const claims = await verifyClaimPending(claimValue, JWT_KEY); + expect(claims.candidates).toContain(candidateId); + }); +}); diff --git a/apps/api/tests/helpers/mocks.ts b/apps/api/tests/helpers/mocks.ts index e9b174a..f7b4786 100644 --- a/apps/api/tests/helpers/mocks.ts +++ b/apps/api/tests/helpers/mocks.ts @@ -31,7 +31,8 @@ export interface CapturedEmail { } /** - * Build an MSW server that intercepts outbound HTTP to api.github.com. + * Build an MSW server that intercepts outbound HTTP to api.github.com and the + * GitHub OAuth token endpoint. * * Usage: * const { server, setGitHubUser } = createGitHubMock(); @@ -42,6 +43,8 @@ export interface CapturedEmail { export function createGitHubMock(defaults?: { user?: GitHubUser; emails?: GitHubEmail[]; + tokenResponse?: Record; + capturedTokenRequests?: Array>; }) { let currentUser: GitHubUser = defaults?.user ?? { id: 1, @@ -54,7 +57,17 @@ export function createGitHubMock(defaults?: { { email: 'testuser@example.com', primary: true, verified: true, visibility: 'public' }, ]; + let tokenResponse: Record = + defaults?.tokenResponse ?? { access_token: 'gho_test_access_token', token_type: 'bearer', scope: 'read:user,user:email' }; + + const capturedTokenRequests = defaults?.capturedTokenRequests ?? []; + const server = setupServer( + http.post('https://github.com/login/oauth/access_token', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as Record; + capturedTokenRequests.push(body); + return HttpResponse.json(tokenResponse); + }), http.get('https://api.github.com/user', () => HttpResponse.json(currentUser), ), @@ -65,12 +78,16 @@ export function createGitHubMock(defaults?: { return { server, + capturedTokenRequests, setGitHubUser(user: GitHubUser) { currentUser = user; }, setGitHubEmails(emails: GitHubEmail[]) { currentEmails = emails; }, + setTokenResponse(resp: Record) { + tokenResponse = resp; + }, }; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1c98d46..ec7cd20 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -24,6 +24,7 @@ import { Sponsor } from '@/screens/Sponsor'; import { ComingSoon } from '@/pages/ComingSoon'; import { NotFound } from '@/pages/NotFound'; import { LoginPlaceholder } from '@/pages/LoginPlaceholder'; +import { AccountClaimPlaceholder } from '@/pages/AccountClaimPlaceholder'; const router = createBrowserRouter([ { @@ -54,6 +55,7 @@ const router = createBrowserRouter([ { path: '/pages/:slug', element: }, { path: '/contact', element: }, { path: '/login', element: }, + { path: '/account-claim', element: }, { path: '*', element: }, ], }, diff --git a/apps/web/src/pages/AccountClaimPlaceholder.tsx b/apps/web/src/pages/AccountClaimPlaceholder.tsx new file mode 100644 index 0000000..38d2f9e --- /dev/null +++ b/apps/web/src/pages/AccountClaimPlaceholder.tsx @@ -0,0 +1,48 @@ +import { useSearchParams } from 'react-router'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +/** + * Temporary landing page for the account-claim flow. + * + * The github-oauth plan ends with the callback issuing a claim-pending JWT + * (cfp_claim cookie) and redirecting here. The full claim UI ships with the + * account-claim plan; until then this page just tells the user what happened + * and offers a "sign in fresh" escape hatch. + */ +export function AccountClaimPlaceholder() { + const [searchParams] = useSearchParams(); + const returnPath = searchParams.get('return') ?? '/'; + + return ( +
+ + + Almost there + + We recognized an existing Code for Philly account that might be + yours. + + + +

+ The account-claim flow is coming in the next release. Until then, + this page is a placeholder. +

+

+ You signed in with GitHub successfully — we just need to confirm + whether you want to connect to your existing legacy account or + start a fresh profile. Check back soon, or sign in again to be + taken straight to{' '} + {returnPath}. +

+
+
+
+ ); +} diff --git a/plans/account-claim.md b/plans/account-claim.md index 4259a64..ad18ec3 100644 --- a/plans/account-claim.md +++ b/plans/account-claim.md @@ -131,7 +131,7 @@ Audit-logged via commit trailers (`Action: account-claim.approve`, etc.). ### Screens (`apps/web/src/pages/AccountClaim*.tsx`) -- `/account-claim` — main entry, fetches candidates, routes by N=0/1/N +- `/account-claim` — main entry, fetches candidates, routes by N=0/1/N. **Replaces the placeholder shipped by [`github-oauth`](github-oauth.md)** at `apps/web/src/pages/AccountClaimPlaceholder.tsx`; the existing route in `apps/web/src/App.tsx` should be repointed to the new screen and the placeholder file deleted as part of this plan. - `/account-claim/by-password` — username + password form - `/account-claim/request-staff-review` — evidence textarea, submit - `/account/claim-legacy` — post-onboarding search box → either auto-resolve (email match found, similar confirmation) or "submit for staff review" with the merge framing @@ -155,6 +155,7 @@ A staff-only screen at `/staff/account-claim` listing open requests with approve - [ ] No PII appears in any commit message body or trailer (verified by inspecting commits produced by the test) - [ ] Anti-enumeration: error responses for unknown slugs are indistinguishable from wrong-password / not-yet-existed - [ ] Tests cover each path with the GitHub mocks + the test-harness stores +- [ ] `AccountClaimPlaceholder` from [`github-oauth`](github-oauth.md) is removed and its `/account-claim` route now points at the real screen ## Risks / unknowns diff --git a/plans/github-oauth.md b/plans/github-oauth.md index d9fff73..bf4ab14 100644 --- a/plans/github-oauth.md +++ b/plans/github-oauth.md @@ -1,11 +1,12 @@ --- -status: planned +status: done depends: [write-api] specs: - specs/api/auth.md - specs/behaviors/account-migration.md - specs/screens/login.md issues: [] +pr: 41 --- # Plan: GitHub OAuth @@ -129,15 +130,15 @@ Per [api/auth.md](../specs/api/auth.md): redirect to `/login?error=` on ea ## Validation -- [ ] OAuth happy path: never-seen-this-user → clicks Sign in with GitHub → GitHub auth → callback → fresh Person + PrivateProfile created → session issued → redirected to `/` -- [ ] OAuth returning user: a Person with `githubUserId` set → callback → session issued → no Person/PrivateProfile mutations beyond email refresh + githubLogin refresh -- [ ] OAuth with candidates: a Person without `githubUserId` exists whose PrivateProfile.email matches a GitHub-verified email → callback → claim-pending JWT issued → redirected to `/account-claim` placeholder -- [ ] CSRF: tampering with the `state` query param → 401 `oauth_state_mismatch` -- [ ] PKCE: GitHub returns an error → handled gracefully → redirected to `/login?error=…` -- [ ] User denies on GitHub (`error=access_denied`) → redirected to `/login?error=access_denied` with the documented message -- [ ] GitHub returns no verified emails → redirected to `/login?error=email_unverified` with the documented help message -- [ ] `cfp_oauth_session` cookie expires after 10 minutes; expired sessions fail with `oauth_session_invalid` -- [ ] Tests: mock GitHub via the test-harness mocks; cover each outcome (existing / fresh / candidates) + each error mode +- [x] OAuth happy path: never-seen-this-user → clicks Sign in with GitHub → GitHub auth → callback → fresh Person + PrivateProfile created → session issued → redirected to `/` +- [x] OAuth returning user: a Person with `githubUserId` set → callback → session issued → no Person/PrivateProfile mutations beyond email refresh + githubLogin refresh +- [x] OAuth with candidates: a Person without `githubUserId` exists whose PrivateProfile.email matches a GitHub-verified email → callback → claim-pending JWT issued → redirected to `/account-claim` placeholder +- [x] CSRF: tampering with the `state` query param → 401 `oauth_state_mismatch` +- [x] PKCE: GitHub returns an error → handled gracefully → redirected to `/login?error=…` +- [x] User denies on GitHub (`error=access_denied`) → redirected to `/login?error=access_denied` with the documented message +- [x] GitHub returns no verified emails → redirected to `/login?error=email_unverified` with the documented help message +- [x] `cfp_oauth_session` cookie expires after 10 minutes; expired sessions fail with `oauth_session_invalid` +- [x] Tests: mock GitHub via the test-harness mocks; cover each outcome (existing / fresh / candidates) + each error mode ## Risks / unknowns @@ -146,3 +147,19 @@ Per [api/auth.md](../specs/api/auth.md): redirect to `/login?error=` on ea - **`gh.login` slugify collisions.** `kebab-case-this-name` might already be taken by an existing legacy slug. The dedup-with-`-2`/`-3` is a clean fallback but the resulting fresh slug could look weird (`jane-doe-3`). Acceptable for v1; staff can rename later if needed. ## Notes + +- **Hand-rolled PKCE over `@fastify/oauth2`.** Verifier = base64url(32 random bytes); challenge = base64url(sha256(verifier)). Keeps the dependency surface small and the flow legible. The carry-state cookie is a signed JWT (10 min) carrying `{ state, codeVerifier, return }` so the verifier survives the GitHub round-trip without server-side state. +- **State + session cookies both scoped to `/api/auth`.** Tightens blast radius vs `Path=/` and keeps them out of every other request's cookie jar. Cleared on every callback regardless of outcome. +- **Redirect-for-every-error.** The spec lists 401/403/502 status codes for some OAuth error modes; the github-oauth flow is browser-driven so the implementation always redirects to `/login?error=`. Validation criterion #4's "401" wording reflects the spec's status code; the implemented behavior (redirect carrying the same code) was the plan's explicit choice. See follow-up #42. +- **`callbackRedirectUri` is derived from the inbound request** (honoring `X-Forwarded-Proto` and `X-Forwarded-Host`) so dev/staging/prod each round-trip to themselves without an env var per environment. The deployed OAuth Apps still need their callback URLs registered at github.com/settings/developers. +- **Fresh-user slug derivation.** `slugifyGitHubLogin(login, ghId)` lowercases, handles reserved-slug collision (`user-`), and falls back to `user-` if both shape and reservation lose. `ensureUniqueSlug` then dedupes with `-2`/`-3` against the in-memory `personIdBySlug` index. +- **Fresh-user transaction uses `writeOrder: 'private-first'`.** If the private-profile flush fails, the public Person commit never lands — no orphaned public-only Person records. +- **Email refresh on every existing-linked sign-in.** Per spec, `PrivateProfile.email` always tracks the user's current GitHub primary verified email. `refreshLinked` rewrites the private profile even when the email is unchanged so `emailRefreshedAt` bumps. +- **No welcome notification on fresh signup.** Wired LoggingNotifier doesn't expose `notifyAccountWelcome` yet. Tracked as follow-up #43. +- **Test parallelism flakiness.** The full API suite under default vitest parallelism is flaky on contended machines (worker timeouts in `read-api`/`write-api`). Running with `--no-file-parallelism` yields 158/158. Pre-existing on `main` — not introduced by this plan. Each new test in `github-oauth.test.ts` uses a unique `remoteAddress` to avoid the 10-req/min/IP cap on `/api/auth/*`. + +## Follow-ups + +- Issue [#42](https://github.com/CodeForPhilly/codeforphilly-ng/issues/42) — clarify auth.md status codes vs redirects for OAuth error modes +- Issue [#43](https://github.com/CodeForPhilly/codeforphilly-ng/issues/43) — send welcome notification on fresh-user OAuth signup +- Deferred to [`account-claim`](account-claim.md) — full `/account-claim` UI consuming the `cfp_claim` cookie (this plan only ships the placeholder page)