// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency. import type { ServiceAccount } from './credentials'; export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'; /** Play Developer Reporting API (vitals): https://developers.google.com/play/developer/reporting/reference/rest */ export const REPORTING_SCOPE = 'https://www.googleapis.com/auth/playdeveloperreporting'; /** Cloud Storage read-only (Play statistics exports in the pubsite_prod_rev_ bucket). */ export const STORAGE_READ_SCOPE = 'https://www.googleapis.com/auth/devstorage.read_only'; const b64u = (b: ArrayBuffer | string): string => Buffer.from(typeof b === 'string' ? b : new Uint8Array(b)).toString('base64url'); function pemToDer(pem: string): ArrayBuffer { const bytes = Buffer.from(pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '').replace(/\s+/g, ''), 'base64'); const out = new ArrayBuffer(bytes.length); new Uint8Array(out).set(bytes); return out; } export async function signAssertion(sa: ServiceAccount, nowS: number, scope = PLAY_SCOPE): Promise { const key = await crypto.subtle.importKey('pkcs8', pemToDer(sa.private_key), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); const head = b64u(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); const claims = b64u(JSON.stringify({ iss: sa.client_email, scope, aud: sa.token_uri || 'https://oauth2.googleapis.com/token', iat: nowS, exp: nowS + 3600 })); const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(`${head}.${claims}`)); return `${head}.${claims}.${b64u(sig)}`; } /** Cached access token getter (refreshes 5 minutes before expiry). */ export function tokenSource(sa: ServiceAccount, scope = PLAY_SCOPE): () => Promise { let token = ''; let until = 0; return async () => { if (token && Date.now() < until) return token; const assertion = await signAssertion(sa, Math.floor(Date.now() / 1000), scope); const r = await fetch(sa.token_uri || '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 }), }); const j = (await r.json()) as { access_token?: string; expires_in?: number; error_description?: string }; if (!j.access_token) throw new Error('Google token: ' + (j.error_description ?? r.status)); token = j.access_token; until = Date.now() + ((j.expires_in ?? 3600) - 300) * 1000; return token; }; }