diff --git a/app/server/src/auth/__tests__/oauth.test.ts b/app/server/src/auth/__tests__/oauth.test.ts new file mode 100644 index 0000000..1324451 --- /dev/null +++ b/app/server/src/auth/__tests__/oauth.test.ts @@ -0,0 +1,245 @@ +// Tests for oauth module. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAuthorizeUrl, + generateState, + exchangeCode, + parseUserinfo, +} from '../oauth.ts'; + +describe('buildAuthorizeUrl', () => { + it('GitHub URL contains client_id, state, and scope', () => { + const url = buildAuthorizeUrl('github', { + clientId: 'gh-client-42', + redirectUri: 'https://app.example/return', + state: 'abc-state-xyz', + }); + assert.match(url, /client_id=gh-client-42/); + assert.match(url, /state=abc-state-xyz/); + assert.match(url, /scope=read%3Auser|scope=read:user/); + assert.ok(url.startsWith('https://github.com/login/oauth/authorize?')); + }); + + it('GitHub URL does not include response_type', () => { + const url = buildAuthorizeUrl('github', { + clientId: 'gh-client-42', + redirectUri: 'https://app.example/return', + state: 's1', + }); + assert.ok(!url.includes('response_type=')); + }); + + it('Google URL contains response_type=code plus the others', () => { + const url = buildAuthorizeUrl('google', { + clientId: 'g-client-7', + redirectUri: 'https://app.example/callback', + state: 'google-s-state', + }); + assert.match(url, /client_id=g-client-7/); + assert.match(url, /state=google-s-state/); + assert.match(url, /response_type=code/); + assert.ok(url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?')); + }); + + it('uses a different authorize base URL from OAuth_PROVIDERS', () => { + const url = buildAuthorizeUrl('google', { + clientId: 'cid', + redirectUri: 'https://x/y', + state: 'z', + }); + assert.ok( + url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'), + `got ${url}` + ); + }); +}); + +describe('generateState', () => { + it('returns exactly 64 hex characters (32 bytes)', () => { + const state = generateState(); + assert.match(state, /^[0-9a-f]{64}$/); + assert.equal(state.length, 64); + }); + + it('multiple calls produce unique values', () => { + const states = new Set(); + for (let i = 0; i < 100; i++) states.add(generateState()); + assert.equal(states.size, 100); + }); +}); + +describe('parseUserinfo', () => { + it('handles a GitHub userinfo payload correctly', () => { + const user = parseUserinfo('github', { + id: 12345, + email: 'ada@example.com', + name: 'Ada Lovelace', + }); + assert.equal(user.providerSubject, '12345'); + assert.equal(user.email, 'ada@example.com'); + assert.equal(user.name, 'Ada Lovelace'); + }); + + it('GitHub userinfo with missing name returns undefined', () => { + const user = parseUserinfo('github', { + id: 99, + email: 'nobody@example.com', + } as Record); + assert.equal(user.providerSubject, '99'); + assert.equal(user.email, 'nobody@example.com'); + assert.equal(user.name, undefined); + }); + + it('handles a Google userinfo payload correctly', () => { + const user = parseUserinfo('google', { + sub: 'google-sub-007', + email: 'grace@example.com', + name: 'Grace Hopper', + } as Record); + assert.equal(user.providerSubject, 'google-sub-007'); + assert.equal(user.email, 'grace@example.com'); + assert.equal(user.name, 'Grace Hopper'); + }); + + it('Google userinfo with missing name returns undefined', () => { + const user = parseUserinfo('google', { + sub: 'sub-1', + email: 'solo@example.com', + } as Record); + assert.equal(user.providerSubject, 'sub-1'); + assert.equal(user.email, 'solo@example.com'); + assert.equal(user.name, undefined); + }); + + it('provides a fallback empty-string subject when id is missing (GitHub)', () => { + const user = parseUserinfo('github', {} as Record); + assert.equal(user.providerSubject, ''); + assert.equal(user.email, ''); + }); +}); + +// --------------------------------------------------------------------------- +// exchangeCode tests — using an injected fake fetchImpl that returns canned +// responses (no real network calls). +// --------------------------------------------------------------------------- + +describe('exchangeCode', () => { + it('exchanges a code for user info with a GitHub mock fetch', async () => { + // Canned token-response body. + const tokenBody = { access_token: 'gh-access-123', scope: 'read:user' }; + // Canned userinfo body. + const userinfoBody = { id: 4096, email: 'neo@matrix.org', name: 'Neo' }; + + // The fake fetch: returns token on the POST call, userinfo on GET. + const mockCalls: Array<{ url: string; init: RequestInit }> = []; + const fakeFetch = async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : ''; + mockCalls.push({ url, init: init ?? {} }); + + if (url.includes('/login/oauth/access_token')) { + return new Response(JSON.stringify(tokenBody), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + // Userinfo lookup. + const authHeader = (init?.headers as Record)?.Authorization; + if (!authHeader) return new Response('bad', { status: 401 }); + if (authHeader !== `Bearer ${tokenBody.access_token}`) return new Response('bad bearer', { status: 403 }); + return new Response(JSON.stringify(userinfoBody), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await exchangeCode('github', 'authcode-99', { + clientId: 'c', + clientSecret: 's', + redirectUri: 'https://localhost/return', + fetchImpl: fakeFetch as unknown as typeof fetch, + }); + + assert.equal(result.providerSubject, '4096'); + assert.equal(result.email, 'neo@matrix.org'); + assert.equal(result.name, 'Neo'); + + // Two HTTP calls expected: one token POST, one userinfo GET. + assert.equal(mockCalls.length, 2); + // First call goes to the GitHub access_token endpoint. + assert.match(mockCalls[0].url, /github\.com\/login\/oauth\/access_token/); + // Second call goes to the GitHub user endpoint with bearer token. + assert.match(mockCalls[1].url, /api\.github\.com\/user/); + }); + + it('exchanges a code for user info with a Google mock fetch', async () => { + const tokenBody = { access_token: 'ya29.google-abc' }; + const userinfoBody = { sub: 'google-uid-55', email: 'ada@computers.com', name: 'Ada' }; + + const fakeFetch = async (input: string | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/oauth2.googleapis.com/token')) { + // Verify the body carries grant_type=authorization_code for Google. + assert.ok((init?.body as string).includes('grant_type=authorization_code'), + 'Google token exchange must POST grant_type=authorization_code'); + return new Response(JSON.stringify(tokenBody), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + const auth = init?.headers as Record; + if (!auth?.Authorization) return new Response('x', { status: 401 }); + return new Response(JSON.stringify(userinfoBody), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await exchangeCode('google', 'google-code', { + clientId: 'c', + clientSecret: 's', + redirectUri: 'https://localhost/return', + fetchImpl: fakeFetch as unknown as typeof fetch, + }); + + assert.equal(result.providerSubject, 'google-uid-55'); + assert.equal(result.email, 'ada@computers.com'); + assert.equal(result.name, 'Ada'); + }); + + it('throws when token exchange returns non-200', async () => { + const fakeFetch = async () => + new Response('', { status: 400, statusText: 'bad request' }); + + await assert.rejects( + () => + exchangeCode('github', 'bad-code', { + clientId: 'c', + clientSecret: 's', + redirectUri: 'https://localhost/return', + fetchImpl: fakeFetch as unknown as typeof fetch, + }), + /Token exchange failed/ + ); + }); + + it('throws when userinfo request returns non-200', async () => { + const tokenBody = { access_token: 'tok-1' }; + let n = 0; + const fakeFetch = async (_input: string) => { + if (++n === 1) return new Response(JSON.stringify(tokenBody), { status: 200, headers: { 'Content-Type': 'application/json' } }); + return new Response('', { status: 500, statusText: 'boom' }); + }; + + await assert.rejects( + () => + exchangeCode('google', 'code', { + clientId: 'c', + clientSecret: 's', + redirectUri: 'https://localhost/return', + fetchImpl: fakeFetch as unknown as typeof fetch, + }), + /Userinfo request failed/ + ); + }); +}); diff --git a/app/server/src/auth/oauth.ts b/app/server/src/auth/oauth.ts new file mode 100644 index 0000000..915db10 --- /dev/null +++ b/app/server/src/auth/oauth.ts @@ -0,0 +1,151 @@ +// OAuth helper utilities for GitHub and Google sign-in flows. +// Zero external dependencies: only node:crypto and global fetch. + +import { randomBytes } from 'node:crypto'; +export type OAuthProvider = "github" | "google"; + +/** Minimal user information returned after a successful OAuth exchange. */ +export interface UserInfo { + providerSubject: string; + email: string; + name?: string | null; +} + +/** Provider metadata: URL endpoints and default scope. */ +export const OAUTH_PROVIDERS: Record = { + github: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userinfoUrl: 'https://api.github.com/user', + scope: 'read:user', + }, + google: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userinfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', + scope: 'openid email', + }, +}; + +/** + * Build an OAuth authorize URL for the given provider. + */ +export function buildAuthorizeUrl( + provider: OAuthProvider, + opts: { clientId: string; redirectUri: string; state: string } +): string { + const config = OAUTH_PROVIDERS[provider]; + const params = new URLSearchParams({ + client_id: opts.clientId, + redirect_uri: opts.redirectUri, + state: opts.state, + scope: config.scope, + }); + + if (provider === 'google') { + params.set('response_type', 'code'); + } + + return `${config.authorizeUrl}?${params.toString()}`; +} + +/** + * Generate a 32-byte random hex string for CSRF state protection. + */ +export function generateState(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Parse userinfo response from a provider into a normalized shape. + * Pure function — no side effects, no network. + */ +export function parseUserinfo( + provider: OAuthProvider, + json: Record +): UserInfo { + if (provider === 'github') { + return { + providerSubject: String(json.id ?? ''), + email: String(json.email ?? ''), + name: json.name !== undefined ? (json.name as string | null) : undefined, + }; + } + + // Google + return { + providerSubject: String(json.sub ?? ''), + email: String(json.email ?? ''), + name: json.name !== undefined ? (json.name as string | null) : undefined, + }; +} + +/** + * Exchange an authorization code for user info by performing the token + * exchange and userinfo lookup on the provider. + * + * The `fetchImpl` argument allows injection of a mock fetch for testing — + * it defaults to global fetch when omitted. + */ +export async function exchangeCode( + provider: OAuthProvider, + code: string, + opts: { clientId: string; clientSecret: string; redirectUri: string; fetchImpl?: typeof fetch } +): Promise { + const config = OAUTH_PROVIDERS[provider]; + const fetchFn = opts.fetchImpl ?? globalThis.fetch; + + // Token exchange + let tokenBody: FormData | URLSearchParams; + if (provider === 'github') { + const body = new URLSearchParams({ + client_id: opts.clientId, + client_secret: opts.clientSecret, + code, + redirect_uri: opts.redirectUri, + }); + tokenBody = body; + } else { + // Google expects grant_type=authorization_code (default POST body) + const form = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: opts.clientId, + client_secret: opts.clientSecret, + code, + redirect_uri: opts.redirectUri, + }); + tokenBody = form; + } + + const tokenHeaders: Record = { + 'Content-Type': provider === 'github' ? 'application/x-www-form-urlencoded' : 'application/x-www-form-urlencoded', + }; + if (provider === 'github') { + tokenHeaders['Accept'] = 'application/json'; + } + + const tokenRes = await fetchFn(config.tokenUrl, { + method: 'POST', + headers: tokenHeaders, + body: provider === 'github' ? (tokenBody as URLSearchParams).toString() : (tokenBody as URLSearchParams).toString(), + }); + + if (!tokenRes.ok) { + throw new Error(`Token exchange failed: ${tokenRes.status} ${tokenRes.statusText}`); + } + + const tokenJson = (await tokenRes.json()) as Record; + const accessToken = String(tokenJson.access_token ?? ''); + + // Userinfo lookup + const userinfoRes = await fetchFn(config.userinfoUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!userinfoRes.ok) { + throw new Error(`Userinfo request failed: ${userinfoRes.status} ${userinfoRes.statusText}`); + } + + const userinfoJson = (await userinfoRes.json()) as Record; + return parseUserinfo(provider, userinfoJson); +} diff --git a/app/server/src/trpc/__tests__/router.test.ts b/app/server/src/trpc/__tests__/router.test.ts index 9a72630..2939c53 100644 --- a/app/server/src/trpc/__tests__/router.test.ts +++ b/app/server/src/trpc/__tests__/router.test.ts @@ -127,3 +127,48 @@ test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => { const res = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123', totp: code2 }); assert.equal(res.userId, userId); }); + +// --- Slice 2b: OAuth (github) --- +test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallback links/creates a user', async () => { + const { db, cache } = setup(); + process.env.GITHUB_CLIENT_ID = 'gh_id'; process.env.GITHUB_CLIENT_SECRET = 'gh_secret'; + type Ctx = { db: typeof db; cache: typeof cache; resHeaders: Headers; userId: string | null; cookies: Record }; + const startCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: {} }; + const start = await appRouter.createCaller(startCtx).auth.oauthStart({ provider: 'github', redirectUri: 'http://localhost/cb' }); + assert.ok(start.redirectUrl.includes('client_id=gh_id')); + assert.ok(start.state.length > 0); + const setCookie = startCtx.resHeaders.get('set-cookie') ?? ''; + const stateVal = setCookie.split(';')[0].slice('iflow_oauth_state='.length); + assert.ok(stateVal, 'oauth state cookie set'); + + const origFetch = global.fetch; + let calls = 0; + global.fetch = (async (url: unknown) => { + calls++; + const u = String(url); + if (u.includes('/access_token')) return new Response(JSON.stringify({ access_token: 'tok' }), { status: 200, headers: { 'content-type': 'application/json' } }); + return new Response(JSON.stringify({ id: 42, email: 'ghuser@x.co', name: 'GH User' }), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + try { + const cbCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: { iflow_oauth_state: stateVal } }; + // wrong state -> rejected (CSRF) + await assert.rejects(() => appRouter.createCaller({ ...cbCtx, cookies: { iflow_oauth_state: 'bogus' } }).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' }), (e: { code: string }) => e.code === 'BAD_REQUEST'); + // correct state -> creates user + session + const res = await appRouter.createCaller(cbCtx).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' }); + assert.ok(res.userId); + const u = db.prepare('SELECT email,oauth_subject,oauth_provider,pw_hash FROM users WHERE id=?').get(res.userId) as { email: string; oauth_subject: string; oauth_provider: string; pw_hash: string }; + assert.equal(u.email, 'ghuser@x.co'); + assert.equal(u.oauth_subject, '42'); + assert.equal(u.oauth_provider, 'github'); + assert.equal(u.pw_hash, 'oauth', 'OAuth-only account has a sentinel pw_hash'); + assert.ok(cbCtx.resHeaders.get('set-cookie'), 'session cookie set'); + assert.equal(calls, 2, 'token + userinfo fetches'); + // second callback with same identity -> reuses the existing linked user (no duplicate) + const res2 = await appRouter.createCaller({ ...cbCtx, resHeaders: new Headers() }).auth.oauthCallback({ provider: 'github', code: 'abc2', state: start.state, redirectUri: 'http://localhost/cb' }); + assert.equal(res2.userId, res.userId, 'reuses existing linked user'); + assert.equal((db.prepare('SELECT COUNT(*) AS c FROM users').get() as { c: number }).c, 1, 'no duplicate users'); + } finally { + global.fetch = origFetch; + delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_SECRET; + } +}); diff --git a/app/server/src/trpc/context.ts b/app/server/src/trpc/context.ts index cd49276..231ee7d 100644 --- a/app/server/src/trpc/context.ts +++ b/app/server/src/trpc/context.ts @@ -6,6 +6,7 @@ import type { DatabaseSync } from 'node:sqlite'; import type { CacheRepository } from '../cache/CacheRepository.ts'; export const SESSION_COOKIE = 'iflow_session'; +export const OAUTH_STATE_COOKIE = 'iflow_oauth_state'; const SESSION_SECRET = process.env.IFLOW_SESSION_SECRET ?? 'dev-secret-change-me'; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -14,10 +15,11 @@ export interface Context { cache: CacheRepository; resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter) userId: string | null; // resolved from the session cookie; null = unauthenticated + cookies: Record; // parsed request cookies (session + oauth state) } export interface CreateContextOpts { req: Request; resHeaders: Headers; info: unknown; } -// ----- signed session cookie (token.mac) ----- +// ----- signed token (HMAC) ----- function sign(token: string): string { return `${token}.${createHmac('sha256', SESSION_SECRET).update(token).digest('hex')}`; } function unsign(signed: string): string | null { const idx = signed.lastIndexOf('.'); @@ -30,11 +32,20 @@ function unsign(signed: string): string | null { } export function sessionCookie(sessionId: string): string { - // HttpOnly + SameSite=Lax. Secure omitted for slice-1 local http; add behind TLS in deployment slice. return `${SESSION_COOKIE}=${sign(sessionId)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`; } export function clearCookie(): string { return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`; } +// OAuth CSRF state cookie (short-lived): signs provider:state:redirectUri. +export function oauthStateCookie(provider: string, state: string, redirectUri: string): string { + return `${OAUTH_STATE_COOKIE}=${sign(`${provider}:${state}:${redirectUri}`)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`; +} +export function verifyOAuthState(cookieValue: string | undefined, provider: string, state: string, redirectUri: string): boolean { + if (!cookieValue) return false; + const token = unsign(cookieValue); + return token !== null && token === `${provider}:${state}:${redirectUri}`; +} + export function parseCookies(req: Request): Record { const header = req.headers.get('cookie') ?? ''; const out: Record = {}; @@ -56,7 +67,8 @@ export function createSession(database: DatabaseSync, userId: string): { session } export function resolveSessionUserId(database: DatabaseSync, req: Request): string | null { - const signed = parseCookies(req)[SESSION_COOKIE]; + const cookies = parseCookies(req); + const signed = cookies[SESSION_COOKIE]; if (!signed) return null; const token = unsign(signed); if (!token) return null; @@ -83,10 +95,24 @@ export function verifyPassword(pw: string, stored: string): boolean { // ----- context factory: 1f wires real db+cache; tests inject in-memory ----- export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository }) { - return ({ req, resHeaders }: CreateContextOpts): Context => ({ - db: opts.db, - cache: opts.cache, - resHeaders, - userId: resolveSessionUserId(opts.db, req), - }); + return ({ req, resHeaders }: CreateContextOpts): Context => { + const cookies = parseCookies(req); + return { + db: opts.db, + cache: opts.cache, + resHeaders, + cookies, + userId: resolveSessionUserId(opts.db, req), + }; + }; +} +// Backward-compat helper for tests that build a context from a cookie map. +export function userIdFromCookies(database: DatabaseSync, cookies: Record): string | null { + const signed = cookies[SESSION_COOKIE]; + if (!signed) return null; + const token = unsign(signed); + if (!token) return null; + const row = database.prepare('SELECT user_id, expires_at FROM sessions WHERE id=?').get(token) as { user_id: string; expires_at: string } | undefined; + if (!row || Date.parse(row.expires_at) < Date.now()) return null; + return row.user_id; } diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index d13e416..c02dce1 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -2,21 +2,26 @@ import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import type { Context } from './context.ts'; -import { hashPassword, verifyPassword, createSession, clearCookie } from './context.ts'; +import { hashPassword, verifyPassword, createSession, clearCookie, oauthStateCookie, verifyOAuthState, OAUTH_STATE_COOKIE } from './context.ts'; import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts'; import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts'; +import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts'; import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts'; const t = initTRPC.context().create(); const router = t.router; const publicProcedure = t.procedure; -// Require an authenticated session for user-private procedures (DESIGN §2.2 SessionGuard). const protectedProcedure = publicProcedure.use(({ ctx, next }) => { if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' }); return next({ ctx }); }); +function oauthCreds(provider: OAuthProvider): { clientId?: string; clientSecret?: string } { + if (provider === 'github') return { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET }; + return { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET }; +} + const authRouter = router({ signup: publicProcedure .input(z.object({ email: z.string().email(), password: z.string().min(8) })) @@ -36,7 +41,6 @@ const authRouter = router({ const email = input.email.toLowerCase(); const row = ctx.db.prepare('SELECT id, pw_hash, is_2fa_enabled, totp_secret FROM users WHERE email=?').get(email) as { id: string; pw_hash: string; is_2fa_enabled: number; totp_secret: string | null } | undefined; if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' }); - // Two-factor gate: if enabled, require a valid TOTP (slice 2). if (row.is_2fa_enabled === 1) { if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Two-factor code required or invalid.' }); @@ -55,28 +59,62 @@ const authRouter = router({ const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined; return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture } : null; }), - // Slice 2 — TOTP 2FA enrollment (does not enable until confirm2fa). - enable2fa: protectedProcedure - .input(z.object({})) - .mutation(async ({ ctx }) => { - const userId = ctx.userId as string; - const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined; - const secret = generateBase32Secret(); - const codes = generateBackupCodes(10); - const hashes = codes.map((c) => hashBackupCode(c)); - ctx.db.prepare('UPDATE users SET totp_secret=?, backup_codes_hashed=? WHERE id=?').run(secret, JSON.stringify(hashes), userId); - return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes }; + enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => { + const userId = ctx.userId as string; + const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined; + const secret = generateBase32Secret(); + const codes = generateBackupCodes(10); + const hashes = codes.map((c) => hashBackupCode(c)); + ctx.db.prepare('UPDATE users SET totp_secret=?, backup_codes_hashed=? WHERE id=?').run(secret, JSON.stringify(hashes), userId); + return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes }; + }), + confirm2fa: protectedProcedure.input(z.object({ totp: z.string() })).mutation(async ({ ctx, input }) => { + const userId = ctx.userId as string; + const row = ctx.db.prepare('SELECT totp_secret FROM users WHERE id=?').get(userId) as { totp_secret: string | null } | undefined; + if (!row?.totp_secret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Enable two-factor first.' }); + if (!verifyTotp(input.totp, row.totp_secret)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid two-factor code.' }); + ctx.db.prepare('UPDATE users SET is_2fa_enabled=1 WHERE id=?').run(userId); + return { ok: true }; + }), + // Slice 2b — OAuth start: return the provider authorize URL + set a CSRF state cookie. + oauthStart: publicProcedure + .input(z.object({ provider: z.enum(['github', 'google']), redirectUri: z.string().url() })) + .mutation(({ ctx, input }) => { + const creds = oauthCreds(input.provider); + if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' }); + const state = generateState(); + ctx.resHeaders.append('Set-Cookie', oauthStateCookie(input.provider, state, input.redirectUri)); + return { redirectUrl: buildAuthorizeUrl(input.provider, { clientId: creds.clientId, redirectUri: input.redirectUri, state }), state }; }), - // Slice 2 — confirm a TOTP to turn on 2FA. - confirm2fa: protectedProcedure - .input(z.object({ totp: z.string() })) + // Slice 2b — OAuth callback: verify CSRF state, exchange code, find/link/create user, start a session. + oauthCallback: publicProcedure + .input(z.object({ provider: z.enum(['github', 'google']), code: z.string(), state: z.string(), redirectUri: z.string().url() })) .mutation(async ({ ctx, input }) => { - const userId = ctx.userId as string; - const row = ctx.db.prepare('SELECT totp_secret FROM users WHERE id=?').get(userId) as { totp_secret: string | null } | undefined; - if (!row?.totp_secret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Enable two-factor first.' }); - if (!verifyTotp(input.totp, row.totp_secret)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid two-factor code.' }); - ctx.db.prepare('UPDATE users SET is_2fa_enabled=1 WHERE id=?').run(userId); - return { ok: true }; + if (!verifyOAuthState(ctx.cookies[OAUTH_STATE_COOKIE], input.provider, input.state, input.redirectUri)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid OAuth state.' }); + } + const creds = oauthCreds(input.provider); + if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' }); + const info = await exchangeCode(input.provider, input.code, { clientId: creds.clientId, clientSecret: creds.clientSecret, redirectUri: input.redirectUri }); + const subject = info.providerSubject; + const email = info.email.toLowerCase(); + if (!subject || !email) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Provider did not return a usable identity.' }); + // existing link? + let row = ctx.db.prepare('SELECT id FROM users WHERE oauth_subject=? AND oauth_provider=?').get(subject, input.provider) as { id: string } | undefined; + if (!row) { + // link existing account by email, else create an OAuth-only account (sentinel pw_hash) + const byEmail = ctx.db.prepare('SELECT id FROM users WHERE email=?').get(email) as { id: string } | undefined; + const userId = byEmail?.id ?? randomUUID(); + if (byEmail) { + ctx.db.prepare('UPDATE users SET oauth_subject=?, oauth_provider=? WHERE id=?').run(subject, input.provider, userId); + } else { + ctx.db.prepare('INSERT INTO users (id,email,pw_hash,oauth_subject,oauth_provider,created_at) VALUES (?,?,?,?,?,?)').run(userId, email, 'oauth', subject, input.provider, new Date().toISOString()); + } + row = { id: userId }; + } + const { cookie } = createSession(ctx.db, row.id); + ctx.resHeaders.append('Set-Cookie', cookie); + return { userId: row.id }; }), }); @@ -85,11 +123,7 @@ const marketRouter = router({ .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); - const k = { - quote: `yfinance:quote:${symbol}`, - candles: `yfinance:candles:${symbol}:1d`, - sector: `yfinance:symbol:${symbol}`, - }; + const k = { quote: `yfinance:quote:${symbol}`, candles: `yfinance:candles:${symbol}:1d`, sector: `yfinance:symbol:${symbol}` }; const entries = await ctx.cache.getMany([k.quote, k.candles, k.sector]); const byKey = new Map(entries.map((e) => [e.key, e])); const val = (key: string): T | null => (byKey.get(key)?.value ?? null) as T | null;