import crypto from 'node:crypto'; type ProviderConfig = { authUrl: string; tokenUrl: string; userInfoUrl: string | null; scopes: string[]; }; type ProviderUser = { id: string; email: string; name: string; avatarUrl: string | null; }; export type Provider = 'google' | 'github' | 'apple'; const providers: Record = { google: { authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo', scopes: ['email', 'profile'], }, github: { authUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', scopes: ['user:email'], }, apple: { authUrl: 'https://appleid.apple.com/auth/authorize', tokenUrl: 'https://appleid.apple.com/auth/token', userInfoUrl: null, scopes: ['name', 'email'], }, }; function getClientId(provider: Provider): string { const envKey = provider === 'apple' ? 'APPLE_CLIENT_ID' : `${provider.toUpperCase()}_CLIENT_ID`; const value = process.env[envKey]; if (!value) throw new Error(`Missing env: ${envKey}`); return value; } function getClientSecret(provider: Provider): string { if (provider === 'apple') return buildAppleClientSecret(); const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`; const value = process.env[envKey]; if (!value) throw new Error(`Missing env: ${envKey}`); return value; } function getCallbackUrl(provider: Provider): string { const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000'; return `${base}/api/auth/oauth/${provider}/callback`; } const stateStore = new Map(); const cleanupTimer = setInterval(() => { const now = Date.now(); for (const [key, value] of stateStore) { if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key); } }, 5 * 60 * 1000); cleanupTimer.unref(); function generateState(provider: Provider): string { const state = crypto.randomBytes(32).toString('hex'); stateStore.set(state, { provider, createdAt: Date.now() }); return state; } function validateState(state: string, provider: Provider): boolean { const entry = stateStore.get(state); if (!entry) return false; if (entry.provider !== provider) return false; if (Date.now() - entry.createdAt > 10 * 60 * 1000) { stateStore.delete(state); return false; } stateStore.delete(state); return true; } function buildAppleClientSecret(): string { const teamId = process.env.APPLE_TEAM_ID; const keyId = process.env.APPLE_KEY_ID; const privateKey = process.env.APPLE_PRIVATE_KEY; const clientId = process.env.APPLE_CLIENT_ID; if (!teamId || !keyId || !privateKey || !clientId) { throw new Error('Missing Apple OAuth env vars (APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_CLIENT_ID)'); } const now = Math.floor(Date.now() / 1000); const header = { alg: 'ES256', kid: keyId }; const payload = { iss: teamId, iat: now, exp: now + 15777000, aud: 'https://appleid.apple.com', sub: clientId }; const encode = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url'); const signingInput = `${encode(header)}.${encode(payload)}`; const key = crypto.createPrivateKey(privateKey.replace(/\\n/g, '\n')); const sig = crypto.sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' }); return `${signingInput}.${sig.toString('base64url')}`; } export function buildAuthUrl(provider: Provider): string { const config = providers[provider]; if (!config) throw new Error(`Unknown provider: ${provider}`); const state = generateState(provider); const params = new URLSearchParams({ client_id: getClientId(provider), redirect_uri: getCallbackUrl(provider), response_type: 'code', scope: config.scopes.join(' '), state, }); if (provider === 'apple') { params.set('response_mode', 'form_post'); } return `${config.authUrl}?${params.toString()}`; } export async function exchangeCodeForToken(provider: Provider, code: string): Promise { const config = providers[provider]; if (!config) throw new Error(`Unknown provider: ${provider}`); const body = new URLSearchParams({ client_id: getClientId(provider), client_secret: getClientSecret(provider), code, redirect_uri: getCallbackUrl(provider), grant_type: 'authorization_code', }); const res = await fetch(config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(provider === 'github' ? { Accept: 'application/json' } : {}), }, body: body.toString(), }); if (!res.ok) { const text = await res.text(); throw new Error(`Token exchange failed for ${provider}: ${text}`); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await res.json() as any; if (provider === 'apple') { return data.id_token as string; } return data.access_token as string; } export async function fetchProviderUser(provider: Provider, token: string): Promise { if (provider === 'apple') { return parseAppleIdToken(token); } const config = providers[provider]; if (!config?.userInfoUrl) throw new Error(`No userInfo URL for ${provider}`); const res = await fetch(config.userInfoUrl, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`Failed to fetch user info from ${provider}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await res.json() as any; if (provider === 'google') { return { id: data.id, email: data.email, name: data.name || data.email.split('@')[0], avatarUrl: data.picture || null, }; } if (provider === 'github') { let email = data.email; if (!email) { const emailRes = await fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${token}` }, }); if (emailRes.ok) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const emails = await emailRes.json() as any[]; const primary = emails.find((e: { primary: boolean }) => e.primary); email = primary?.email || emails[0]?.email; } } return { id: String(data.id), email: email || '', name: data.name || data.login, avatarUrl: data.avatar_url || null, }; } throw new Error(`Unknown provider: ${provider}`); } function parseAppleIdToken(idToken: string): ProviderUser { const parts = idToken.split('.'); if (parts.length !== 3) throw new Error('Invalid Apple id_token'); const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); return { id: payload.sub, email: payload.email || '', name: payload.email?.split('@')[0] || 'Apple User', avatarUrl: null, }; } export { validateState };