credential broker: Play tokens from POST /api/google/token (cached, single flight, local key fallback), grants and APIs per package in validate, Play write preflight, apps check and the desktop Accounts view; Google 403 names the API or grant, no retry

This commit is contained in:
CRTkafa 2026-10-25 22:50:52 +07:00
parent 304407e523
commit 2c5e30090f
27 changed files with 1145 additions and 43 deletions

View file

@ -1,5 +1,7 @@
// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency.
import type { ServiceAccount } from './credentials';
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 */
@ -42,3 +44,97 @@ export function tokenSource(sa: ServiceAccount, scope = PLAY_SCOPE): () => Promi
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 } : {}),
};
}