140 lines
7.7 KiB
TypeScript
140 lines
7.7 KiB
TypeScript
// 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<string> {
|
|
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<string> {
|
|
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<string, BrokerScope> = { [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<string>;
|
|
/** "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<string>();
|
|
|
|
/**
|
|
* 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<string> => {
|
|
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 } : {}),
|
|
};
|
|
}
|