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
+245
View File
@@ -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/
);
});
});
+151
View File
@@ -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);
}