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