interface ServiceAccount { client_email: string private_key: string project_id: string } interface CachedToken { token: string expiresAt: number } let serviceAccount: ServiceAccount | null = null let privateKey: CryptoKey | null = null let cachedToken: CachedToken | null = null export function getServiceAccount(): ServiceAccount { if (serviceAccount) return serviceAccount const credentialsJson = Deno.env.get('GOOGLE_CREDENTIALS_JSON') if (!credentialsJson?.trim()) { throw new Error('GOOGLE_CREDENTIALS_JSON environment variable is required') } serviceAccount = JSON.parse(credentialsJson) return serviceAccount! } async function getPrivateKey(): Promise { if (privateKey) return privateKey const pem = getServiceAccount().private_key const pemContents = pem .replace(/-----BEGIN PRIVATE KEY-----/, '') .replace(/-----END PRIVATE KEY-----/, '') .replace(/\s/g, '') const der = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0)) privateKey = await crypto.subtle.importKey( 'pkcs8', der.buffer, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'] ) return privateKey } function b64url(data: ArrayBuffer | string): string { const str = typeof data === 'string' ? btoa(data) : btoa(String.fromCharCode(...new Uint8Array(data))) return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } export async function getAccessToken(): Promise { const now = Date.now() if (cachedToken && cachedToken.expiresAt > now + 60_000) { return cachedToken.token } const sa = getServiceAccount() const iat = Math.floor(now / 1000) const exp = iat + 3600 const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) const payload = b64url(JSON.stringify({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/firebase.messaging', aud: 'https://oauth2.googleapis.com/token', exp, iat })) const signingInput = `${header}.${payload}` const key = await getPrivateKey() const signature = await crypto.subtle.sign( 'RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(signingInput) ) const jwt = `${signingInput}.${b64url(signature)}` const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }) }) if (!res.ok) { throw new Error(`Failed to get FCM access token: ${await res.text()}`) } const { access_token, expires_in } = await res.json() cachedToken = { token: access_token, expiresAt: now + expires_in * 1000 } return access_token }