@melaya/runner 1.0.117 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistantHost.py +159 -13
- package/dist/browserAuthz.d.ts +78 -0
- package/dist/browserAuthz.js +504 -0
- package/dist/browserBridge.d.ts +50 -0
- package/dist/browserBridge.js +1021 -0
- package/dist/browserGrantVerify.d.ts +91 -0
- package/dist/browserGrantVerify.js +353 -0
- package/dist/browserProvisioner.d.ts +33 -0
- package/dist/browserProvisioner.js +266 -0
- package/dist/codeWorker.d.ts +49 -0
- package/dist/codeWorker.js +538 -0
- package/dist/connection.js +270 -3
- package/dist/sessionManager.d.ts +108 -0
- package/dist/sessionManager.js +290 -0
- package/package.json +4 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
export declare const BROWSER_EFFECT_CLASSES: readonly ["read", "navigate", "message", "publish", "consent", "upload", "download", "purchase", "account_change", "data_export", "destructive"];
|
|
3
|
+
export type BrowserEffectClass = (typeof BROWSER_EFFECT_CLASSES)[number];
|
|
4
|
+
export declare const BROWSER_EFFECT_RANK: Readonly<Record<BrowserEffectClass, number>>;
|
|
5
|
+
export declare const BROWSER_GRANT_ISSUER = "melaya-control";
|
|
6
|
+
export declare const BROWSER_GRANT_ALG: "EdDSA";
|
|
7
|
+
/** The algorithm allowlist is exactly one entry by design. */
|
|
8
|
+
export declare const ALLOWED_ALGS: readonly string[];
|
|
9
|
+
export interface BrowserGrantClaims {
|
|
10
|
+
sub: string;
|
|
11
|
+
aud: string[];
|
|
12
|
+
tenant: string;
|
|
13
|
+
project: string;
|
|
14
|
+
run: string;
|
|
15
|
+
runnerDevice: string;
|
|
16
|
+
socketGeneration: number;
|
|
17
|
+
browserSession: string;
|
|
18
|
+
target: {
|
|
19
|
+
ref: string;
|
|
20
|
+
};
|
|
21
|
+
profile?: string;
|
|
22
|
+
space?: string;
|
|
23
|
+
originScopes: string[];
|
|
24
|
+
actionScopes: BrowserEffectClass[];
|
|
25
|
+
effectCeiling: BrowserEffectClass;
|
|
26
|
+
}
|
|
27
|
+
export interface BrowserGrant extends BrowserGrantClaims {
|
|
28
|
+
iss: typeof BROWSER_GRANT_ISSUER;
|
|
29
|
+
kid: string;
|
|
30
|
+
jti: string;
|
|
31
|
+
iat: number;
|
|
32
|
+
nbf: number;
|
|
33
|
+
exp: number;
|
|
34
|
+
}
|
|
35
|
+
export type BrowserGrantErrorCode = "malformed" | "alg_not_allowed" | "kid_unknown" | "bad_signature" | "iss_mismatch" | "aud_mismatch" | "expired" | "not_yet_valid" | "claims_invalid" | "revoked" | "replayed" | "replay_store_unavailable";
|
|
36
|
+
export declare class BrowserGrantError extends Error {
|
|
37
|
+
readonly code: BrowserGrantErrorCode;
|
|
38
|
+
constructor(code: BrowserGrantErrorCode, message: string);
|
|
39
|
+
}
|
|
40
|
+
export interface ReplayStore {
|
|
41
|
+
/** Atomically register a jti. Returns true exactly once per jti
|
|
42
|
+
* (first caller wins); false means the grant was already used.
|
|
43
|
+
* Implementations MUST throw (not return true) when the store is
|
|
44
|
+
* unreachable: the verifier maps that to replay_store_unavailable
|
|
45
|
+
* and refuses the grant (fail closed). */
|
|
46
|
+
registerOnce(jti: string, ttlMs: number): Promise<boolean>;
|
|
47
|
+
}
|
|
48
|
+
/** In-process replay store for the single-runner case. The runner is
|
|
49
|
+
* one OS process, and every grant is bound to THIS runner device via
|
|
50
|
+
* `aud`, so process-local single-use is sufficient on this side; the
|
|
51
|
+
* server keeps the authoritative Redis store for its own paths. */
|
|
52
|
+
export declare function createInMemoryReplayStore(): ReplayStore;
|
|
53
|
+
/** Accepts base64 of a 32-byte raw Ed25519 public key (the wire form
|
|
54
|
+
* the server ships via getBrowserGrantPublicKeys) or of an SPKI DER
|
|
55
|
+
* key, or an already-built KeyObject. Throws BrowserGrantError
|
|
56
|
+
* ("kid_unknown") on anything that does not decode to Ed25519. */
|
|
57
|
+
export declare function toPublicKeyObject(key: string | KeyObject, kid: string): KeyObject;
|
|
58
|
+
/** Optional operator pinning: parse MEL_BROWSER_GRANT_PUBLIC_KEYS
|
|
59
|
+
* ("<kid>=<base64>,..." in the same format the server env uses) into
|
|
60
|
+
* a publicKeysByKid map. The normal delivery path is the server
|
|
61
|
+
* pushing getBrowserGrantPublicKeys() output over the socket; this
|
|
62
|
+
* env var lets a security-conscious operator pin keys locally so a
|
|
63
|
+
* compromised socket cannot swap them. */
|
|
64
|
+
export declare function loadPinnedPublicKeysFromEnv(): Record<string, string>;
|
|
65
|
+
export declare const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
66
|
+
export interface VerifyBrowserGrantOptions {
|
|
67
|
+
/** This verifier's own identity (runner device id, or extension
|
|
68
|
+
* instance id on the extension path). Must appear in `aud`. */
|
|
69
|
+
expectedAud: string;
|
|
70
|
+
/** kid -> public key (base64 raw 32-byte / base64 SPKI DER /
|
|
71
|
+
* KeyObject). Delivered by the server or pinned via env. */
|
|
72
|
+
publicKeysByKid: Record<string, string | KeyObject>;
|
|
73
|
+
/** Single-use jti store. Use createInMemoryReplayStore() on the
|
|
74
|
+
* runner. REQUIRED: single-use is part of the trust contract. */
|
|
75
|
+
replayStore: ReplayStore;
|
|
76
|
+
/** Optional revocation hook (server RPC or local cache). Returning
|
|
77
|
+
* true, or throwing, rejects the grant. When omitted, revocation
|
|
78
|
+
* is enforced only by expiry (acceptable for 120s grants when the
|
|
79
|
+
* transport also delivers explicit stop signals). */
|
|
80
|
+
isRevoked?: (jti: string) => Promise<boolean>;
|
|
81
|
+
/** Clock tolerance in seconds for exp/nbf/iat. Default 30. */
|
|
82
|
+
clockToleranceSeconds?: number;
|
|
83
|
+
/** Test hook: current time in epoch ms. Default Date.now(). */
|
|
84
|
+
nowMs?: number;
|
|
85
|
+
}
|
|
86
|
+
/** Verify a browserGrant compact JWS. Resolves to the typed, verified
|
|
87
|
+
* BrowserGrant on success; throws BrowserGrantError on EVERY reject
|
|
88
|
+
* path (fail closed). The jti is consumed in the replay store as the
|
|
89
|
+
* final step, so a grant that fails any earlier check is NOT burned
|
|
90
|
+
* and a retried delivery of a valid token still works exactly once. */
|
|
91
|
+
export declare function verifyBrowserGrant(token: string, opts: VerifyBrowserGrantOptions): Promise<BrowserGrant>;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// packages/runner/src/browserGrantVerify.ts
|
|
2
|
+
//
|
|
3
|
+
// Melaya Browser, Phase 0 (plan Section 0.6): runner-side VERIFIER for
|
|
4
|
+
// the signed `browserGrant` capability token minted by the server
|
|
5
|
+
// (server/src/services/browser/browserGrant.ts).
|
|
6
|
+
//
|
|
7
|
+
// Verification order (all checks fail closed with typed errors):
|
|
8
|
+
// 1. compact-JWS shape and JSON decoding -> "malformed"
|
|
9
|
+
// 2. alg allowlist (exactly ["EdDSA"]) -> "alg_not_allowed"
|
|
10
|
+
// 3. kid -> public key lookup -> "kid_unknown"
|
|
11
|
+
// 4. Ed25519 signature over header.payload -> "bad_signature"
|
|
12
|
+
// 5. iss === "melaya-control" -> "iss_mismatch"
|
|
13
|
+
// 6. expectedAud member of aud[] -> "aud_mismatch"
|
|
14
|
+
// 7. exp / nbf / iat with small tolerance (30s) -> "expired" / "not_yet_valid"
|
|
15
|
+
// 8. structural claim checks + scope <= ceiling -> "claims_invalid"
|
|
16
|
+
// 9. revocation hook (if provided) -> "revoked"
|
|
17
|
+
// 10. single-use jti via the replay store -> "replayed"
|
|
18
|
+
//
|
|
19
|
+
// Zero dependencies: node:crypto supports Ed25519 natively on every
|
|
20
|
+
// Node version the runner supports (engines >= 18).
|
|
21
|
+
//
|
|
22
|
+
// -- Keep in sync -------------------------------------------------------
|
|
23
|
+
// `BrowserGrantClaims`, `BrowserGrant`, and the effect-class vocabulary
|
|
24
|
+
// are DUPLICATED from server/src/services/browser/browserGrant.ts
|
|
25
|
+
// because this published package cannot import server sources. Any
|
|
26
|
+
// change on either side MUST be mirrored on the other.
|
|
27
|
+
import { createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
28
|
+
// ---------------------------------------------------------------------
|
|
29
|
+
// Effect-class vocabulary (KEEP IN SYNC with server browserGrant.ts)
|
|
30
|
+
// ---------------------------------------------------------------------
|
|
31
|
+
export const BROWSER_EFFECT_CLASSES = [
|
|
32
|
+
"read",
|
|
33
|
+
"navigate",
|
|
34
|
+
"message",
|
|
35
|
+
"publish",
|
|
36
|
+
"consent",
|
|
37
|
+
"upload",
|
|
38
|
+
"download",
|
|
39
|
+
"purchase",
|
|
40
|
+
"account_change",
|
|
41
|
+
"data_export",
|
|
42
|
+
"destructive",
|
|
43
|
+
];
|
|
44
|
+
export const BROWSER_EFFECT_RANK = Object.fromEntries(BROWSER_EFFECT_CLASSES.map((e, i) => [e, i]));
|
|
45
|
+
// ---------------------------------------------------------------------
|
|
46
|
+
// Types (KEEP IN SYNC with server browserGrant.ts)
|
|
47
|
+
// ---------------------------------------------------------------------
|
|
48
|
+
export const BROWSER_GRANT_ISSUER = "melaya-control";
|
|
49
|
+
export const BROWSER_GRANT_ALG = "EdDSA";
|
|
50
|
+
/** The algorithm allowlist is exactly one entry by design. */
|
|
51
|
+
export const ALLOWED_ALGS = [BROWSER_GRANT_ALG];
|
|
52
|
+
export class BrowserGrantError extends Error {
|
|
53
|
+
code;
|
|
54
|
+
constructor(code, message) {
|
|
55
|
+
super(`[browserGrant:${code}] ${message}`);
|
|
56
|
+
this.name = "BrowserGrantError";
|
|
57
|
+
this.code = code;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** In-process replay store for the single-runner case. The runner is
|
|
61
|
+
* one OS process, and every grant is bound to THIS runner device via
|
|
62
|
+
* `aud`, so process-local single-use is sufficient on this side; the
|
|
63
|
+
* server keeps the authoritative Redis store for its own paths. */
|
|
64
|
+
export function createInMemoryReplayStore() {
|
|
65
|
+
const seen = new Map(); // jti -> expiry epoch ms
|
|
66
|
+
let lastSweep = 0;
|
|
67
|
+
function sweep(now) {
|
|
68
|
+
// Amortized cleanup: at most once per 30s.
|
|
69
|
+
if (now - lastSweep < 30_000)
|
|
70
|
+
return;
|
|
71
|
+
lastSweep = now;
|
|
72
|
+
for (const [jti, until] of seen) {
|
|
73
|
+
if (until <= now)
|
|
74
|
+
seen.delete(jti);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
async registerOnce(jti, ttlMs) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
sweep(now);
|
|
81
|
+
const existing = seen.get(jti);
|
|
82
|
+
if (existing !== undefined && existing > now)
|
|
83
|
+
return false;
|
|
84
|
+
seen.set(jti, now + Math.max(1000, ttlMs));
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------
|
|
90
|
+
// Public-key handling
|
|
91
|
+
// ---------------------------------------------------------------------
|
|
92
|
+
// RFC 8410 fixed DER prefix for an Ed25519 SPKI public key.
|
|
93
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
94
|
+
const RAW_PUB_LEN = 32;
|
|
95
|
+
/** Accepts base64 of a 32-byte raw Ed25519 public key (the wire form
|
|
96
|
+
* the server ships via getBrowserGrantPublicKeys) or of an SPKI DER
|
|
97
|
+
* key, or an already-built KeyObject. Throws BrowserGrantError
|
|
98
|
+
* ("kid_unknown") on anything that does not decode to Ed25519. */
|
|
99
|
+
export function toPublicKeyObject(key, kid) {
|
|
100
|
+
if (typeof key !== "string") {
|
|
101
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
102
|
+
throw new BrowserGrantError("kid_unknown", `public key for kid '${kid}' is not ed25519`);
|
|
103
|
+
}
|
|
104
|
+
return key;
|
|
105
|
+
}
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = Buffer.from(key, "base64");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new BrowserGrantError("kid_unknown", `public key for kid '${kid}' is not valid base64`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const der = raw.length === RAW_PUB_LEN ? Buffer.concat([SPKI_PREFIX, raw]) : raw;
|
|
115
|
+
const obj = createPublicKey({ key: der, format: "der", type: "spki" });
|
|
116
|
+
if (obj.asymmetricKeyType !== "ed25519") {
|
|
117
|
+
throw new Error("not ed25519");
|
|
118
|
+
}
|
|
119
|
+
return obj;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new BrowserGrantError("kid_unknown", `public key for kid '${kid}' must be base64 of a 32-byte raw or SPKI DER Ed25519 key`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Optional operator pinning: parse MEL_BROWSER_GRANT_PUBLIC_KEYS
|
|
126
|
+
* ("<kid>=<base64>,..." in the same format the server env uses) into
|
|
127
|
+
* a publicKeysByKid map. The normal delivery path is the server
|
|
128
|
+
* pushing getBrowserGrantPublicKeys() output over the socket; this
|
|
129
|
+
* env var lets a security-conscious operator pin keys locally so a
|
|
130
|
+
* compromised socket cannot swap them. */
|
|
131
|
+
export function loadPinnedPublicKeysFromEnv() {
|
|
132
|
+
const out = {};
|
|
133
|
+
const raw = process.env.MEL_BROWSER_GRANT_PUBLIC_KEYS || "";
|
|
134
|
+
for (const entry of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
135
|
+
const eq = entry.indexOf("=");
|
|
136
|
+
if (eq <= 0)
|
|
137
|
+
continue;
|
|
138
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------
|
|
143
|
+
// Verifier
|
|
144
|
+
// ---------------------------------------------------------------------
|
|
145
|
+
export const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
146
|
+
/** Sanity ceiling: matches the server's MAX_TTL_SECONDS. A "valid"
|
|
147
|
+
* token claiming a longer life than the issuer can mint is forged or
|
|
148
|
+
* corrupt, so it is rejected regardless of signature. */
|
|
149
|
+
const MAX_LIFETIME_SECONDS = 900;
|
|
150
|
+
function b64urlJson(part, what) {
|
|
151
|
+
let buf;
|
|
152
|
+
try {
|
|
153
|
+
buf = Buffer.from(part, "base64url");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new BrowserGrantError("malformed", `${what} is not valid base64url`);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(buf.toString("utf8"));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new BrowserGrantError("malformed", `${what} is not valid JSON`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function isNonEmptyString(v) {
|
|
166
|
+
return typeof v === "string" && v.length > 0;
|
|
167
|
+
}
|
|
168
|
+
function validateClaimStructure(p) {
|
|
169
|
+
const bad = (name) => {
|
|
170
|
+
throw new BrowserGrantError("claims_invalid", `claim '${name}' is missing or malformed`);
|
|
171
|
+
};
|
|
172
|
+
if (!isNonEmptyString(p.sub))
|
|
173
|
+
bad("sub");
|
|
174
|
+
if (!isNonEmptyString(p.jti))
|
|
175
|
+
bad("jti");
|
|
176
|
+
if (!isNonEmptyString(p.tenant))
|
|
177
|
+
bad("tenant");
|
|
178
|
+
if (!isNonEmptyString(p.project))
|
|
179
|
+
bad("project");
|
|
180
|
+
if (!isNonEmptyString(p.run))
|
|
181
|
+
bad("run");
|
|
182
|
+
if (!isNonEmptyString(p.runnerDevice))
|
|
183
|
+
bad("runnerDevice");
|
|
184
|
+
if (!isNonEmptyString(p.browserSession))
|
|
185
|
+
bad("browserSession");
|
|
186
|
+
if (!Number.isInteger(p.socketGeneration) || p.socketGeneration < 0)
|
|
187
|
+
bad("socketGeneration");
|
|
188
|
+
const target = p.target;
|
|
189
|
+
if (!target || typeof target !== "object" || !isNonEmptyString(target.ref))
|
|
190
|
+
bad("target.ref");
|
|
191
|
+
if (p.profile !== undefined && !isNonEmptyString(p.profile))
|
|
192
|
+
bad("profile");
|
|
193
|
+
if (p.space !== undefined && !isNonEmptyString(p.space))
|
|
194
|
+
bad("space");
|
|
195
|
+
if (!Array.isArray(p.originScopes) || p.originScopes.some((o) => !isNonEmptyString(o))) {
|
|
196
|
+
bad("originScopes");
|
|
197
|
+
}
|
|
198
|
+
if (!isNonEmptyString(p.effectCeiling) || !(p.effectCeiling in BROWSER_EFFECT_RANK))
|
|
199
|
+
bad("effectCeiling");
|
|
200
|
+
const ceiling = BROWSER_EFFECT_RANK[p.effectCeiling];
|
|
201
|
+
if (!Array.isArray(p.actionScopes) || p.actionScopes.length === 0)
|
|
202
|
+
bad("actionScopes");
|
|
203
|
+
for (const scope of p.actionScopes) {
|
|
204
|
+
if (!isNonEmptyString(scope) || !(scope in BROWSER_EFFECT_RANK))
|
|
205
|
+
bad("actionScopes");
|
|
206
|
+
if (BROWSER_EFFECT_RANK[scope] > ceiling) {
|
|
207
|
+
throw new BrowserGrantError("claims_invalid", `actionScope '${scope}' exceeds effectCeiling '${String(p.effectCeiling)}'`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Verify a browserGrant compact JWS. Resolves to the typed, verified
|
|
212
|
+
* BrowserGrant on success; throws BrowserGrantError on EVERY reject
|
|
213
|
+
* path (fail closed). The jti is consumed in the replay store as the
|
|
214
|
+
* final step, so a grant that fails any earlier check is NOT burned
|
|
215
|
+
* and a retried delivery of a valid token still works exactly once. */
|
|
216
|
+
export async function verifyBrowserGrant(token, opts) {
|
|
217
|
+
if (!opts || !isNonEmptyString(opts.expectedAud)) {
|
|
218
|
+
throw new BrowserGrantError("aud_mismatch", "verifier misconfigured: expectedAud is required");
|
|
219
|
+
}
|
|
220
|
+
if (!opts.replayStore) {
|
|
221
|
+
throw new BrowserGrantError("replay_store_unavailable", "verifier misconfigured: replayStore is required");
|
|
222
|
+
}
|
|
223
|
+
// 1. Shape
|
|
224
|
+
if (typeof token !== "string" || token.length === 0 || token.length > 16_384) {
|
|
225
|
+
throw new BrowserGrantError("malformed", "token is empty, not a string, or oversized");
|
|
226
|
+
}
|
|
227
|
+
const parts = token.split(".");
|
|
228
|
+
if (parts.length !== 3) {
|
|
229
|
+
throw new BrowserGrantError("malformed", "token is not a three-part compact JWS");
|
|
230
|
+
}
|
|
231
|
+
const header = b64urlJson(parts[0], "header");
|
|
232
|
+
// 2. Algorithm allowlist (never trust the header beyond routing)
|
|
233
|
+
if (!ALLOWED_ALGS.includes(header.alg)) {
|
|
234
|
+
throw new BrowserGrantError("alg_not_allowed", `alg '${String(header.alg)}' is not in [${ALLOWED_ALGS.join(", ")}]`);
|
|
235
|
+
}
|
|
236
|
+
// 3. kid -> public key
|
|
237
|
+
if (!isNonEmptyString(header.kid)) {
|
|
238
|
+
throw new BrowserGrantError("kid_unknown", "header has no kid");
|
|
239
|
+
}
|
|
240
|
+
const kid = header.kid;
|
|
241
|
+
const keyMaterial = opts.publicKeysByKid[kid];
|
|
242
|
+
if (keyMaterial === undefined) {
|
|
243
|
+
throw new BrowserGrantError("kid_unknown", `no public key for kid '${kid}'`);
|
|
244
|
+
}
|
|
245
|
+
const publicKey = toPublicKeyObject(keyMaterial, kid);
|
|
246
|
+
// 4. Signature over the exact signing input
|
|
247
|
+
let sig;
|
|
248
|
+
try {
|
|
249
|
+
sig = Buffer.from(parts[2], "base64url");
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
throw new BrowserGrantError("malformed", "signature is not valid base64url");
|
|
253
|
+
}
|
|
254
|
+
const signingInput = Buffer.from(parts[0] + "." + parts[1], "utf8");
|
|
255
|
+
let ok = false;
|
|
256
|
+
try {
|
|
257
|
+
ok = cryptoVerify(null, signingInput, publicKey, sig);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
ok = false;
|
|
261
|
+
}
|
|
262
|
+
if (!ok) {
|
|
263
|
+
throw new BrowserGrantError("bad_signature", `signature verification failed under kid '${kid}'`);
|
|
264
|
+
}
|
|
265
|
+
// Only now is the payload trustworthy enough to inspect.
|
|
266
|
+
const p = b64urlJson(parts[1], "payload");
|
|
267
|
+
// 5. Issuer
|
|
268
|
+
if (p.iss !== BROWSER_GRANT_ISSUER) {
|
|
269
|
+
throw new BrowserGrantError("iss_mismatch", `iss '${String(p.iss)}' is not '${BROWSER_GRANT_ISSUER}'`);
|
|
270
|
+
}
|
|
271
|
+
// 6. Audience: our own id must be in the list
|
|
272
|
+
if (!Array.isArray(p.aud) || p.aud.some((a) => !isNonEmptyString(a))) {
|
|
273
|
+
throw new BrowserGrantError("aud_mismatch", "aud is not a string array");
|
|
274
|
+
}
|
|
275
|
+
if (!p.aud.includes(opts.expectedAud)) {
|
|
276
|
+
throw new BrowserGrantError("aud_mismatch", `aud does not include '${opts.expectedAud}'`);
|
|
277
|
+
}
|
|
278
|
+
// 7. Time window with tolerance
|
|
279
|
+
const tol = Math.max(0, opts.clockToleranceSeconds ?? DEFAULT_CLOCK_TOLERANCE_SECONDS);
|
|
280
|
+
const now = Math.floor((opts.nowMs ?? Date.now()) / 1000);
|
|
281
|
+
const { iat, nbf, exp } = p;
|
|
282
|
+
if (!Number.isFinite(iat) || !Number.isFinite(nbf) || !Number.isFinite(exp)) {
|
|
283
|
+
throw new BrowserGrantError("claims_invalid", "iat/nbf/exp must be numeric");
|
|
284
|
+
}
|
|
285
|
+
if (exp <= now - tol) {
|
|
286
|
+
throw new BrowserGrantError("expired", `exp ${exp} is in the past (now ${now}, tolerance ${tol}s)`);
|
|
287
|
+
}
|
|
288
|
+
if (nbf > now + tol) {
|
|
289
|
+
throw new BrowserGrantError("not_yet_valid", `nbf ${nbf} is in the future (now ${now}, tolerance ${tol}s)`);
|
|
290
|
+
}
|
|
291
|
+
if (iat > now + tol) {
|
|
292
|
+
throw new BrowserGrantError("not_yet_valid", `iat ${iat} is in the future (now ${now}, tolerance ${tol}s)`);
|
|
293
|
+
}
|
|
294
|
+
if (exp - iat > MAX_LIFETIME_SECONDS + tol) {
|
|
295
|
+
throw new BrowserGrantError("claims_invalid", `lifetime exceeds ${MAX_LIFETIME_SECONDS}s issuer maximum`);
|
|
296
|
+
}
|
|
297
|
+
if (p.kid !== undefined && p.kid !== kid) {
|
|
298
|
+
throw new BrowserGrantError("claims_invalid", "payload kid disagrees with header kid");
|
|
299
|
+
}
|
|
300
|
+
// 8. Structural claims + scope/ceiling relation
|
|
301
|
+
validateClaimStructure(p);
|
|
302
|
+
const jti = p.jti;
|
|
303
|
+
// 9. Revocation hook (before burning the jti). A throwing hook
|
|
304
|
+
// rejects: unreachable revocation infrastructure means we cannot
|
|
305
|
+
// prove the grant is still live.
|
|
306
|
+
if (opts.isRevoked) {
|
|
307
|
+
let revoked;
|
|
308
|
+
try {
|
|
309
|
+
revoked = await opts.isRevoked(jti);
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
throw new BrowserGrantError("revoked", `revocation check failed, refusing grant: ${e?.message || e}`);
|
|
313
|
+
}
|
|
314
|
+
if (revoked) {
|
|
315
|
+
throw new BrowserGrantError("revoked", `jti '${jti}' is revoked`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// 10. Single-use consumption, last so failed earlier checks never
|
|
319
|
+
// burn a valid token. TTL covers remaining life + tolerance.
|
|
320
|
+
const replayTtlMs = Math.max(1000, (exp - now + tol) * 1000);
|
|
321
|
+
let first;
|
|
322
|
+
try {
|
|
323
|
+
first = await opts.replayStore.registerOnce(jti, replayTtlMs);
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
throw new BrowserGrantError("replay_store_unavailable", `replay store unreachable, refusing grant: ${e?.message || e}`);
|
|
327
|
+
}
|
|
328
|
+
if (!first) {
|
|
329
|
+
throw new BrowserGrantError("replayed", `jti '${jti}' was already used`);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
iss: BROWSER_GRANT_ISSUER,
|
|
333
|
+
kid,
|
|
334
|
+
jti,
|
|
335
|
+
iat: iat,
|
|
336
|
+
nbf: nbf,
|
|
337
|
+
exp: exp,
|
|
338
|
+
sub: p.sub,
|
|
339
|
+
aud: p.aud,
|
|
340
|
+
tenant: p.tenant,
|
|
341
|
+
project: p.project,
|
|
342
|
+
run: p.run,
|
|
343
|
+
runnerDevice: p.runnerDevice,
|
|
344
|
+
socketGeneration: p.socketGeneration,
|
|
345
|
+
browserSession: p.browserSession,
|
|
346
|
+
target: { ref: p.target.ref },
|
|
347
|
+
profile: p.profile,
|
|
348
|
+
space: p.space,
|
|
349
|
+
originScopes: p.originScopes,
|
|
350
|
+
actionScopes: p.actionScopes,
|
|
351
|
+
effectCeiling: p.effectCeiling,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type BrowserEngineId = "chrome" | "edge" | "brave" | "chromium";
|
|
2
|
+
export interface InstalledEngine {
|
|
3
|
+
engine: BrowserEngineId;
|
|
4
|
+
executablePath: string;
|
|
5
|
+
/** Best-effort version ("139.0.7258.67" style); "" when undetectable. */
|
|
6
|
+
version: string;
|
|
7
|
+
/** Playwright channel name when launchable via channel, else null
|
|
8
|
+
* (brave has no Playwright channel: launched via executablePath). */
|
|
9
|
+
channel: "chrome" | "msedge" | null;
|
|
10
|
+
/** Engine-specific caveats the caller should surface to the user. */
|
|
11
|
+
quirks: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface BrowserCapabilityReport {
|
|
14
|
+
engines: InstalledEngine[];
|
|
15
|
+
cdp: boolean;
|
|
16
|
+
provisioner: "v1";
|
|
17
|
+
platform: NodeJS.Platform;
|
|
18
|
+
}
|
|
19
|
+
export declare function discoverEngines(forceRefresh?: boolean): Promise<InstalledEngine[]>;
|
|
20
|
+
export declare function capabilityReport(): Promise<BrowserCapabilityReport>;
|
|
21
|
+
export declare class ProvisionError extends Error {
|
|
22
|
+
readonly code: "engine_not_installed" | "download_not_authorized" | "download_failed" | "engine_unsupported";
|
|
23
|
+
readonly instructions?: string;
|
|
24
|
+
constructor(code: ProvisionError["code"], message: string, instructions?: string);
|
|
25
|
+
}
|
|
26
|
+
/** Resolve an engine to a launchable executable. NEVER downloads unless
|
|
27
|
+
* allowDownload is explicitly true, and even then only the
|
|
28
|
+
* Playwright-bundled Chromium is downloadable (Chrome/Edge/Brave are
|
|
29
|
+
* user-installed products we will not fetch on the user's behalf). */
|
|
30
|
+
export declare function ensureEngine(engine: BrowserEngineId, opts?: {
|
|
31
|
+
allowDownload?: boolean;
|
|
32
|
+
log?: (m: string) => void;
|
|
33
|
+
}): Promise<InstalledEngine>;
|