slice 2b COMPLETE: social OAuth (GitHub/Google) — oauthStart/oauthCallback

oauth.ts by ornith (zero-dep, injectable-fetch, 15 tests; fixed its self-import bug
so OAuthProvider is exported). Router/context integration by orchestrator: CSRF state
cookie (HMAC), code exchange, find/link/create user (sentinel pw_hash for OAuth-only),
session. 74/74 backend tests green. CSRF rejection + reuse-no-duplicate covered.
This commit is contained in:
Investor Flow Build
2026-06-29 21:17:57 -04:00
parent 89bdf0edc4
commit 3b5aa90a89
5 changed files with 538 additions and 37 deletions
@@ -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<string, string> };
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;
}
});