feat: add OAuth provider configuration and token exchange utilities
This commit is contained in:
226
packages/server/src/lib/oauth-providers.ts
Normal file
226
packages/server/src/lib/oauth-providers.ts
Normal 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 };
|
||||||
Reference in New Issue
Block a user