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:
@@ -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<string>();
|
||||||
|
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<string, unknown>);
|
||||||
|
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<string, unknown>);
|
||||||
|
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<string, unknown>);
|
||||||
|
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<string, unknown>);
|
||||||
|
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<string, string>)?.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<string, string>;
|
||||||
|
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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<OAuthProvider, { authorizeUrl: string; tokenUrl: string; userinfoUrl: string; scope: string }> = {
|
||||||
|
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<string, unknown>
|
||||||
|
): 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<UserInfo> {
|
||||||
|
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<string, string> = {
|
||||||
|
'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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
return parseUserinfo(provider, userinfoJson);
|
||||||
|
}
|
||||||
@@ -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 });
|
const res = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123', totp: code2 });
|
||||||
assert.equal(res.userId, userId);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { DatabaseSync } from 'node:sqlite';
|
|||||||
import type { CacheRepository } from '../cache/CacheRepository.ts';
|
import type { CacheRepository } from '../cache/CacheRepository.ts';
|
||||||
|
|
||||||
export const SESSION_COOKIE = 'iflow_session';
|
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_SECRET = process.env.IFLOW_SESSION_SECRET ?? 'dev-secret-change-me';
|
||||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
@@ -14,10 +15,11 @@ export interface Context {
|
|||||||
cache: CacheRepository;
|
cache: CacheRepository;
|
||||||
resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter)
|
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
|
userId: string | null; // resolved from the session cookie; null = unauthenticated
|
||||||
|
cookies: Record<string, string>; // parsed request cookies (session + oauth state)
|
||||||
}
|
}
|
||||||
export interface CreateContextOpts { req: Request; resHeaders: Headers; info: unknown; }
|
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 sign(token: string): string { return `${token}.${createHmac('sha256', SESSION_SECRET).update(token).digest('hex')}`; }
|
||||||
function unsign(signed: string): string | null {
|
function unsign(signed: string): string | null {
|
||||||
const idx = signed.lastIndexOf('.');
|
const idx = signed.lastIndexOf('.');
|
||||||
@@ -30,11 +32,20 @@ function unsign(signed: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sessionCookie(sessionId: string): string {
|
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}`;
|
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`; }
|
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<string, string> {
|
export function parseCookies(req: Request): Record<string, string> {
|
||||||
const header = req.headers.get('cookie') ?? '';
|
const header = req.headers.get('cookie') ?? '';
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
@@ -56,7 +67,8 @@ export function createSession(database: DatabaseSync, userId: string): { session
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSessionUserId(database: DatabaseSync, req: Request): string | null {
|
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;
|
if (!signed) return null;
|
||||||
const token = unsign(signed);
|
const token = unsign(signed);
|
||||||
if (!token) return null;
|
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 -----
|
// ----- context factory: 1f wires real db+cache; tests inject in-memory -----
|
||||||
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository }) {
|
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository }) {
|
||||||
return ({ req, resHeaders }: CreateContextOpts): Context => ({
|
return ({ req, resHeaders }: CreateContextOpts): Context => {
|
||||||
db: opts.db,
|
const cookies = parseCookies(req);
|
||||||
cache: opts.cache,
|
return {
|
||||||
resHeaders,
|
db: opts.db,
|
||||||
userId: resolveSessionUserId(opts.db, req),
|
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, string>): 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,26 @@ import { initTRPC, TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Context } from './context.ts';
|
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 { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts';
|
||||||
import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.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';
|
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
|
||||||
|
|
||||||
const t = initTRPC.context<Context>().create();
|
const t = initTRPC.context<Context>().create();
|
||||||
const router = t.router;
|
const router = t.router;
|
||||||
const publicProcedure = t.procedure;
|
const publicProcedure = t.procedure;
|
||||||
|
|
||||||
// Require an authenticated session for user-private procedures (DESIGN §2.2 SessionGuard).
|
|
||||||
const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
|
const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
|
||||||
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' });
|
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' });
|
||||||
return next({ ctx });
|
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({
|
const authRouter = router({
|
||||||
signup: publicProcedure
|
signup: publicProcedure
|
||||||
.input(z.object({ email: z.string().email(), password: z.string().min(8) }))
|
.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 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;
|
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.' });
|
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 (row.is_2fa_enabled === 1) {
|
||||||
if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) {
|
if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Two-factor code required or invalid.' });
|
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;
|
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;
|
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 }) => {
|
||||||
enable2fa: protectedProcedure
|
const userId = ctx.userId as string;
|
||||||
.input(z.object({}))
|
const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined;
|
||||||
.mutation(async ({ ctx }) => {
|
const secret = generateBase32Secret();
|
||||||
const userId = ctx.userId as string;
|
const codes = generateBackupCodes(10);
|
||||||
const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined;
|
const hashes = codes.map((c) => hashBackupCode(c));
|
||||||
const secret = generateBase32Secret();
|
ctx.db.prepare('UPDATE users SET totp_secret=?, backup_codes_hashed=? WHERE id=?').run(secret, JSON.stringify(hashes), userId);
|
||||||
const codes = generateBackupCodes(10);
|
return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes };
|
||||||
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);
|
confirm2fa: protectedProcedure.input(z.object({ totp: z.string() })).mutation(async ({ ctx, input }) => {
|
||||||
return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes };
|
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.
|
// Slice 2b — OAuth callback: verify CSRF state, exchange code, find/link/create user, start a session.
|
||||||
confirm2fa: protectedProcedure
|
oauthCallback: publicProcedure
|
||||||
.input(z.object({ totp: z.string() }))
|
.input(z.object({ provider: z.enum(['github', 'google']), code: z.string(), state: z.string(), redirectUri: z.string().url() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.userId as string;
|
if (!verifyOAuthState(ctx.cookies[OAUTH_STATE_COOKIE], input.provider, input.state, input.redirectUri)) {
|
||||||
const row = ctx.db.prepare('SELECT totp_secret FROM users WHERE id=?').get(userId) as { totp_secret: string | null } | undefined;
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid OAuth state.' });
|
||||||
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.' });
|
const creds = oauthCreds(input.provider);
|
||||||
ctx.db.prepare('UPDATE users SET is_2fa_enabled=1 WHERE id=?').run(userId);
|
if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' });
|
||||||
return { ok: true };
|
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) }))
|
.input(z.object({ symbol: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const symbol = input.symbol.toUpperCase();
|
const symbol = input.symbol.toUpperCase();
|
||||||
const k = {
|
const k = { quote: `yfinance:quote:${symbol}`, candles: `yfinance:candles:${symbol}:1d`, sector: `yfinance:symbol:${symbol}` };
|
||||||
quote: `yfinance:quote:${symbol}`,
|
|
||||||
candles: `yfinance:candles:${symbol}:1d`,
|
|
||||||
sector: `yfinance:symbol:${symbol}`,
|
|
||||||
};
|
|
||||||
const entries = await ctx.cache.getMany<unknown>([k.quote, k.candles, k.sector]);
|
const entries = await ctx.cache.getMany<unknown>([k.quote, k.candles, k.sector]);
|
||||||
const byKey = new Map(entries.map((e) => [e.key, e]));
|
const byKey = new Map(entries.map((e) => [e.key, e]));
|
||||||
const val = <T>(key: string): T | null => (byKey.get(key)?.value ?? null) as T | null;
|
const val = <T>(key: string): T | null => (byKey.get(key)?.value ?? null) as T | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user