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:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user