From 2d07ac6cd497b6f30401bb0fcc76c50cd05e9913 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:13:21 +0800 Subject: [PATCH] feat: add OAuth provider configuration and token exchange utilities --- packages/server/src/lib/oauth-providers.ts | 226 +++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 packages/server/src/lib/oauth-providers.ts diff --git a/packages/server/src/lib/oauth-providers.ts b/packages/server/src/lib/oauth-providers.ts new file mode 100644 index 0000000..712daad --- /dev/null +++ b/packages/server/src/lib/oauth-providers.ts @@ -0,0 +1,226 @@ +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; +}; + +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: string): 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: string): 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: string): string { + const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000'; + return `${base}/api/auth/oauth/${provider}/callback`; +} + +// --- State management (CSRF protection) --- +const stateStore = new Map(); + +setInterval(() => { + const now = Date.now(); + for (const [key, value] of stateStore) { + if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key); + } +}, 5 * 60 * 1000); + +function generateState(provider: string): string { + const state = crypto.randomBytes(32).toString('hex'); + stateStore.set(state, { provider, createdAt: Date.now() }); + return state; +} + +function validateState(state: string, provider: string): 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; +} + +// --- Apple client_secret JWT --- +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')}`; +} + +// --- Public API --- + +export function buildAuthUrl(provider: string): 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: string, 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: string, 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 };