import { encodeBase64url } from '@oslojs/encoding'; const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize'; const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token'; export function getAppleAuthUrl(state: string): string { const params = new URLSearchParams({ client_id: Bun.env.APPLE_ID!, redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`, response_type: 'code', response_mode: 'form_post', scope: 'name email', state }); return `${APPLE_AUTH_URL}?${params.toString()}`; } export async function generateAppleClientSecret(): Promise { const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! }; const now = Math.floor(Date.now() / 1000); const payload = { iss: Bun.env.APPLE_TEAM_ID!, iat: now, exp: now + 86400 * 180, aud: 'https://appleid.apple.com', sub: Bun.env.APPLE_ID! }; const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header))); const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload))); const signingInput = `${encodedHeader}.${encodedPayload}`; // Import PEM private key const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '') .replace(/-----END PRIVATE KEY-----/, '') .replace(/\s/g, ''); const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0)); const key = await crypto.subtle.importKey( 'pkcs8', keyBuffer, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] ); const signatureBuffer = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, key, new TextEncoder().encode(signingInput) ); const signature = new Uint8Array(signatureBuffer); // crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime // Raw format is exactly 64 bytes (32-byte r + 32-byte s) const rawSignature = signature.length === 64 ? signature : derToRaw(signature); const encodedSignature = encodeBase64url(rawSignature); return `${signingInput}.${encodedSignature}`; } /** * Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256) */ function derToRaw(der: Uint8Array): Uint8Array { // DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s] let offset = 2; // skip 0x30 and total length // Read r if (der[offset] !== 0x02) throw new Error('Invalid DER signature'); offset++; const rLen = der[offset]; offset++; let r = der.slice(offset, offset + rLen); offset += rLen; // Read s if (der[offset] !== 0x02) throw new Error('Invalid DER signature'); offset++; const sLen = der[offset]; offset++; let s = der.slice(offset, offset + sLen); // Remove leading zero padding (DER uses it for positive sign) if (r.length === 33 && r[0] === 0) r = r.slice(1); if (s.length === 33 && s[0] === 0) s = s.slice(1); // Pad to 32 bytes each const raw = new Uint8Array(64); raw.set(r, 32 - r.length); raw.set(s, 64 - s.length); return raw; } export async function exchangeAppleCode( code: string, redirectUri: string ): Promise<{ access_token: string; token_type: string; expires_in: number; refresh_token: string; id_token: string; }> { const clientSecret = await generateAppleClientSecret(); const params = new URLSearchParams({ client_id: Bun.env.APPLE_ID!, client_secret: clientSecret, code, grant_type: 'authorization_code', redirect_uri: redirectUri }); const response = await fetch(APPLE_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); if (!response.ok) { const error = await response.text(); throw new Error(`Apple token exchange failed: ${error}`); } return await response.json(); } /** * Decode Apple's id_token JWT payload without signature verification. * Safe because the token is received directly from Apple's token endpoint over TLS. */ export function decodeAppleIdToken(idToken: string): { sub: string; email?: string; email_verified?: string; is_private_email?: string; } { const [, payloadB64] = idToken.split('.'); const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4); const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/'))); return payload; }