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
+35 -9
View File
@@ -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<string, string>; // 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<string, string> {
const header = req.headers.get('cookie') ?? '';
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 {
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, 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;
}