feat: add OAuth provider configuration and token exchange utilities

This commit is contained in:
2026-04-03 13:13:21 +08:00
parent dace447a14
commit 2d07ac6cd4

View File

@@ -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<string, ProviderConfig> = {
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<string, { provider: string; createdAt: number }>();
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<string> {
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<ProviderUser> {
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 };