storebound/cli/lib/google-auth.ts

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 } : {}),
};
}