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:
parent
304407e523
commit
2c5e30090f
27 changed files with 1145 additions and 43 deletions
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue