// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency. import { playServiceAccount, type ServiceAccount } from './credentials'; import { BROKER_SCOPES, type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, type BrokerScope, brokerToken, BrokerUnavailable, BrokerUnknownAccount, redact } from './broker'; import { warn } from './log'; 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; }; } // ---- Play access through a credential broker (cli/lib/broker.ts) or a local key ---- /** The broker scope a Google scope maps to; null: the broker hands out no such scope (a local key is needed). */ export const BROKER_SCOPE_OF: Record = { [PLAY_SCOPE]: 'androidpublisher', [STORAGE_READ_SCOPE]: 'cloud-platform.read-only' }; export type PlaySource = 'broker' | 'file'; /** A Play access token getter that also says where its tokens come from (no values). */ export interface PlayToken { (): Promise; /** "broker" until the broker failed over to the local key (then "file"). */ source(): PlaySource; /** The service account e-mail the tokens are for, when known. */ principal(): string | null; } const fellBack = new Set(); /** * The Play token getter of a project: the credential broker when it is configured and has the scope (POST * /api/google/token), else the local service account key (env, profile, .storebound/credentials.json). The broker not * answering, or not knowing the account, falls back to the local key once with a warning; a broker that refuses the * API key (401 / 403) fails. `brokerScope: null` forces the local key (writes the broker has no scope for). * Throws at once when neither is there. */ export function playToken(root: string, scope = PLAY_SCOPE, o: { packageName?: string; brokerScope?: BrokerScope | null; warn?: (msg: string) => void } = {}): PlayToken { let sa: ServiceAccount | null = null, saErr: Error | null = null; try { sa = playServiceAccount(root); } catch (e) { saErr = e as Error; } const cfg = brokerConfig(root); const bscope = o.brokerScope === undefined ? BROKER_SCOPE_OF[scope] ?? null : o.brokerScope; const local = sa ? tokenSource(sa, scope) : null; if (!cfg || !bscope) { if (!local) { if (cfg && !bscope) throw new Error(`${scope.replace('https://www.googleapis.com/auth/', '')}: the credential broker hands out no such scope (${BROKER_SCOPES.join(', ')}), and there is no local service account key (PLAY_SERVICE_ACCOUNT) for it`); throw saErr!; } return Object.assign(() => local(), { source: (): PlaySource => 'file', principal: () => sa!.client_email }); } let account = cfg.account ?? sa?.client_email ?? null; let mode: PlaySource = 'broker'; const say = o.warn ?? ((m: string) => warn('credentials', m)); const get = async (): Promise => { if (mode === 'file') return local!(); try { if (!account && o.packageName) { const r = await brokerPlayInfo(cfg, o.packageName); if (r.kind === 'unavailable') throw new BrokerUnavailable(r.message); if (r.kind === 'ok' && r.info.account) account = r.info.account; } if (!account) throw new Error('credential broker: which Google account? Set PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile or .storebound/credentials.json) or "account" in broker.json'); return await brokerToken(cfg, account, bscope); } catch (e) { if (local && (e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)) { const once = `${cfg.url}\n${sa!.client_email}`; if (!fellBack.has(once)) { fellBack.add(once); say(`${redact((e as Error).message)}; using the local service account key instead`); } mode = 'file'; account = sa!.client_email; return local(); } throw e; } }; return Object.assign(get, { source: () => mode, principal: () => account }); } export interface PlayCredentials { /** Where Play tokens come from for this project: the broker, the local key file, or nowhere. */ source: PlaySource | null; /** The service account e-mail when known (masked by the callers that print it). */ principal: string | null; /** A local key is there too (the broker's fallback). */ local: boolean; /** The broker config (its URL and key file path, never the key), and what is wrong with it. */ broker: { url: string; keyFile: string; from: string; error?: string } | null; error?: string; } /** Where this project's Play access comes from, without network calls and without values. */ export function playCredentials(root: string): PlayCredentials { let sa: ServiceAccount | null = null, error: string | undefined; try { sa = playServiceAccount(root); } catch (e) { error = (e as Error).message; } let cfg: BrokerConfig | null = null, brokerError: string | undefined; try { cfg = brokerConfig(root); } catch (e) { brokerError = (e as Error).message; } if (cfg) { try { brokerKey(cfg); } catch (e) { brokerError = (e as Error).message; } } const broker = cfg ? { url: cfg.url, keyFile: cfg.keyFile, from: cfg.from, ...(brokerError ? { error: brokerError } : {}) } : null; const usable = !!cfg && !brokerError; return { source: usable ? 'broker' : sa ? 'file' : null, principal: (usable ? cfg!.account : null) ?? sa?.client_email ?? null, local: !!sa, broker, ...(brokerError || !(usable || sa) ? { error: brokerError ?? error } : {}), }; }