@fireemu/linux-arm64 0.29.0 → 0.31.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/README.md +1 -1
- package/bin/fireemu +0 -0
- package/bin/runner-node/admin-app-check.mjs +428 -0
- package/bin/runner-node/index.mjs +53 -8
- package/bin/runner-node/protocol.mjs +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @fireemu/linux-arm64
|
|
2
2
|
|
|
3
3
|
The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `linux-arm64`
|
|
4
|
-
(Rust target `aarch64-unknown-linux-musl`), version 0.
|
|
4
|
+
(Rust target `aarch64-unknown-linux-musl`), version 0.31.0.
|
|
5
5
|
|
|
6
6
|
Statically linked against musl: any glibc or musl distribution, including distroless and Alpine.
|
|
7
7
|
|
package/bin/fireemu
CHANGED
|
Binary file
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// Version-bounded instrumentation of `firebase-admin`'s App Check `verifyToken` for the
|
|
2
|
+
// Functions runner (HTTP Functions that verify the `X-Firebase-AppCheck` header themselves).
|
|
3
|
+
//
|
|
4
|
+
// Why it exists. firebase-admin verifies an App Check token offline: it decodes the JWT, checks
|
|
5
|
+
// the claims and then checks the signature against the keys at a Google URL hard-coded in the
|
|
6
|
+
// SDK. A token the emulator minted can never verify there, and an SDK that did not reach the
|
|
7
|
+
// emulator's key could not see a session reset in any case (it caches keys for six hours). So
|
|
8
|
+
// the one step that cannot work, the signature check, is replaced by a question to the daemon,
|
|
9
|
+
// which answers from the same verifier the callable boundary uses, against the project's
|
|
10
|
+
// current epoch and app registry, under the session admission barrier. Nothing is cached here.
|
|
11
|
+
//
|
|
12
|
+
// What stays the SDK's: `safeDecode` and `verifyContent` (so malformed tokens and wrong
|
|
13
|
+
// algorithm, audience, issuer and subject fail with the SDK's own code and message, before the
|
|
14
|
+
// daemon is asked), and every error the daemon's answer becomes: the verdict is turned into
|
|
15
|
+
// the `JwtError` the analogous production condition raises and thrown through the SDK's own
|
|
16
|
+
// `mapJwtErrorToAppCheckError`, so the code, message and (14.x) `cause` shape are the SDK's.
|
|
17
|
+
//
|
|
18
|
+
// What is refused. `verifyToken(token, { consume: true })` would call Google's
|
|
19
|
+
// `verifyAppCheckToken` with the developer's credentials. Limited-use consumption is
|
|
20
|
+
// unimplemented (`APPCHECK-REPLAY-1`), so `verifyReplayProtection` is replaced by a fixed
|
|
21
|
+
// refusal that never builds a request. It is installed together with the signature check, in
|
|
22
|
+
// one step, because once fireemu tokens verify, the consume request becomes reachable.
|
|
23
|
+
//
|
|
24
|
+
// Failing closed. Outside the supported majors, or when any structural probe or the startup
|
|
25
|
+
// self-test fails, nothing stays patched: the stock SDK then fetches Google's keys, which no
|
|
26
|
+
// fireemu token verifies against, so it rejects fireemu tokens and never accepts one it cannot
|
|
27
|
+
// check. A second copy of `firebase-admin` somewhere else in `node_modules` is not the one
|
|
28
|
+
// resolved from the codebase and stays stock (and therefore rejecting).
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { dirname, join, parse as parsePath } from "node:path";
|
|
33
|
+
import https from "node:https";
|
|
34
|
+
import timers from "node:timers";
|
|
35
|
+
|
|
36
|
+
// The natives, captured before the runtime clock can virtualize timers: a verification
|
|
37
|
+
// deadline is a real-time safety bound.
|
|
38
|
+
const { setTimeout, clearTimeout } = timers;
|
|
39
|
+
|
|
40
|
+
/// `firebase-admin` majors whose App Check internals this instrumentation is written for. 14.3.0
|
|
41
|
+
/// and 14.5.0 were compared and are identical on this path; 13.10.0 differs only in how the
|
|
42
|
+
/// SDK constructs its errors, which the instrumentation never does itself.
|
|
43
|
+
export const SUPPORTED_MAJORS = [13, 14];
|
|
44
|
+
|
|
45
|
+
/// How long a question to the daemon may stay unanswered. The daemon waits for a session reset
|
|
46
|
+
/// to finish before it answers, so this is generous.
|
|
47
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
48
|
+
|
|
49
|
+
/// Questions in flight at once; the daemon refuses more than this per runner as well.
|
|
50
|
+
export const MAX_IN_FLIGHT = 64;
|
|
51
|
+
|
|
52
|
+
const SELF_TEST_TIMEOUT_MS = 5_000;
|
|
53
|
+
const SELF_TEST_PROJECT = "fireemu-self-test";
|
|
54
|
+
|
|
55
|
+
/// The text of `AppCheckTokenVerifier.safeDecode`'s failure (identical in 13 and 14).
|
|
56
|
+
const DECODE_FAILED =
|
|
57
|
+
"Decoding App Check token failed. Make sure you passed the entire string JWT which represents the Firebase App Check token.";
|
|
58
|
+
const PROJECT_MATCH =
|
|
59
|
+
" Make sure the App Check token comes from the same Firebase project as the service account used to authenticate this SDK.";
|
|
60
|
+
|
|
61
|
+
/// What each reason of the daemon's verdict becomes. `code` names a `JwtErrorCode`; the SDK's
|
|
62
|
+
/// mapper turns the `JwtError` into the `FirebaseAppCheckError` (or, for the codes it
|
|
63
|
+
/// does not name, an `invalid-argument` carrying this message).
|
|
64
|
+
const REASONS = {
|
|
65
|
+
"no-matching-kid": {
|
|
66
|
+
code: "NO_MATCHING_KID",
|
|
67
|
+
message: "no-matching-kid-error",
|
|
68
|
+
},
|
|
69
|
+
"invalid-signature": { code: "INVALID_SIGNATURE", message: "invalid signature" },
|
|
70
|
+
"token-expired": {
|
|
71
|
+
code: "TOKEN_EXPIRED",
|
|
72
|
+
message: "The provided token has expired. Get a fresh token from your client app and try again.",
|
|
73
|
+
},
|
|
74
|
+
malformed: { code: "INVALID_ARGUMENT", message: DECODE_FAILED },
|
|
75
|
+
claims: {
|
|
76
|
+
code: "INVALID_ARGUMENT",
|
|
77
|
+
message: `The provided App Check token has incorrect "iss" (issuer) or "aud" (audience) claim for this project.${PROJECT_MATCH}`,
|
|
78
|
+
},
|
|
79
|
+
"not-yet-valid": {
|
|
80
|
+
code: "INVALID_ARGUMENT",
|
|
81
|
+
message: 'The provided App Check token has an "iat" (issued-at) claim in the future.',
|
|
82
|
+
},
|
|
83
|
+
"unknown-app": {
|
|
84
|
+
code: "INVALID_ARGUMENT",
|
|
85
|
+
message:
|
|
86
|
+
'The provided App Check token has a "sub" (subject) claim that names no registered, enabled app of this project in fireemu.',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/// The failure of a question the daemon could not answer. This is the text of the SDK's own
|
|
91
|
+
/// JWKS-fetch failure path, so the caller sees the error it would see if the keys were
|
|
92
|
+
/// unreachable.
|
|
93
|
+
function unavailableMessage(why) {
|
|
94
|
+
return `Error fetching Json Web Keys: ${why}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// The runner's side of the pipe to the daemon: the questions in flight and their deadlines.
|
|
98
|
+
/// It exists before the instrumentation does, so that the daemon's answers have somewhere to
|
|
99
|
+
/// go while the startup self-test is still waiting for one.
|
|
100
|
+
export function createAdminChannel(send, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
101
|
+
const pending = new Map();
|
|
102
|
+
let sequence = 0;
|
|
103
|
+
let asked = 0;
|
|
104
|
+
let deadlineMs = timeoutMs;
|
|
105
|
+
const settle = (id, verdict) => {
|
|
106
|
+
const entry = pending.get(id);
|
|
107
|
+
if (!entry) return;
|
|
108
|
+
pending.delete(id);
|
|
109
|
+
clearTimeout(entry.timer);
|
|
110
|
+
entry.resolve(verdict);
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
/// Questions asked so far, answered or not.
|
|
114
|
+
get asked() {
|
|
115
|
+
return asked;
|
|
116
|
+
},
|
|
117
|
+
pending: () => pending.size,
|
|
118
|
+
/// Shortens (or restores) the deadline of the questions asked from now on.
|
|
119
|
+
setDeadline(ms) {
|
|
120
|
+
deadlineMs = ms;
|
|
121
|
+
},
|
|
122
|
+
ask(projectId, token) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
asked += 1;
|
|
125
|
+
if (typeof projectId !== "string" || projectId === "" || typeof token !== "string") {
|
|
126
|
+
resolve({ ok: false, reason: "unavailable" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (pending.size >= MAX_IN_FLIGHT) {
|
|
130
|
+
resolve({ ok: false, reason: "unavailable" });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const id = `admin-${++sequence}`;
|
|
134
|
+
const timer = setTimeout(() => settle(id, { ok: false, reason: "timeout" }), deadlineMs);
|
|
135
|
+
pending.set(id, { resolve, timer });
|
|
136
|
+
try {
|
|
137
|
+
send({ type: "appCheck:verify", requestId: id, projectId, token });
|
|
138
|
+
} catch {
|
|
139
|
+
settle(id, { ok: false, reason: "unavailable" });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
/// Hands over one `appCheck:verdict` frame. Only the right request id settles anything, once.
|
|
144
|
+
deliver(message) {
|
|
145
|
+
if (message?.type !== "appCheck:verdict" || typeof message.requestId !== "string") return;
|
|
146
|
+
settle(
|
|
147
|
+
message.requestId,
|
|
148
|
+
message.ok === true ? { ok: true } : { ok: false, reason: String(message.reason ?? "unavailable") },
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
/// Fails everything in flight: the runner is going away.
|
|
152
|
+
dispose() {
|
|
153
|
+
for (const id of [...pending.keys()]) settle(id, { ok: false, reason: "disposed" });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function unsupported(reason, version = null) {
|
|
159
|
+
return {
|
|
160
|
+
supported: false,
|
|
161
|
+
instrumentation: reason,
|
|
162
|
+
version,
|
|
163
|
+
report: { instrumentation: reason, firebaseAdminVersion: version },
|
|
164
|
+
restore() {},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// The directory of the `firebase-admin` package that owns `entry`.
|
|
169
|
+
function packageRootOf(entry) {
|
|
170
|
+
let dir = dirname(entry);
|
|
171
|
+
const { root: filesystemRoot } = parsePath(dir);
|
|
172
|
+
for (;;) {
|
|
173
|
+
try {
|
|
174
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
175
|
+
if (pkg.name === "firebase-admin") return dir;
|
|
176
|
+
} catch {
|
|
177
|
+
// Not a package directory; keep walking up.
|
|
178
|
+
}
|
|
179
|
+
if (dir === filesystemRoot) break;
|
|
180
|
+
dir = dirname(dir);
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`no firebase-admin package directory above ${entry}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Whether `name` is an own function property of `target` that may be replaced.
|
|
186
|
+
function isPatchable(target, name) {
|
|
187
|
+
if (target === null || target === undefined) return false;
|
|
188
|
+
const d = Object.getOwnPropertyDescriptor(target, name);
|
|
189
|
+
return !!d && typeof d.value === "function" && d.writable !== false && d.configurable !== false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function loadModules(require, root) {
|
|
193
|
+
const load = (relative) => require(join(root, relative));
|
|
194
|
+
return {
|
|
195
|
+
verifier: load("lib/app-check/token-verifier.js"),
|
|
196
|
+
jwt: load("lib/utils/jwt.js"),
|
|
197
|
+
client: load("lib/app-check/app-check-api-client-internal.js"),
|
|
198
|
+
utils: load("lib/utils/index.js"),
|
|
199
|
+
error: (() => {
|
|
200
|
+
try {
|
|
201
|
+
return load("lib/app-check/error.js");
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
})(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Builds `(code, message) => FirebaseAppCheckError` for whichever constructor shape the
|
|
210
|
+
/// installed SDK has (13: positional, 14: info object), checking the result.
|
|
211
|
+
function errorFactory(modules) {
|
|
212
|
+
const Ctor = modules.error?.FirebaseAppCheckError ?? modules.client.FirebaseAppCheckError;
|
|
213
|
+
if (typeof Ctor !== "function") return null;
|
|
214
|
+
const shapes = [
|
|
215
|
+
(code, message) => new Ctor({ code, message }),
|
|
216
|
+
(code, message) => new Ctor(code, message),
|
|
217
|
+
];
|
|
218
|
+
for (const make of shapes) {
|
|
219
|
+
try {
|
|
220
|
+
const probe = make("invalid-argument", "probe");
|
|
221
|
+
if (probe instanceof Error && probe.code === "app-check/invalid-argument" && probe.message === "probe") {
|
|
222
|
+
return make;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Try the other shape.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// An agent that refuses to open any connection: the self-test hands it to the SDK so that a
|
|
232
|
+
/// probe that unexpectedly reaches the stock key fetch cannot leave the process.
|
|
233
|
+
class RefusingAgent extends https.Agent {
|
|
234
|
+
createConnection() {
|
|
235
|
+
throw new Error("fireemu self-test: network access is refused");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function base64url(value) {
|
|
240
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// A well-formed token the SDK's own decode and claim checks accept, signed by nothing.
|
|
244
|
+
function probeToken() {
|
|
245
|
+
return [
|
|
246
|
+
base64url({ alg: "RS256", typ: "JWT", kid: "fireemu-self-test" }),
|
|
247
|
+
base64url({
|
|
248
|
+
iss: "https://firebaseappcheck.googleapis.com/0",
|
|
249
|
+
aud: [`projects/${SELF_TEST_PROJECT}`],
|
|
250
|
+
sub: "fireemu-self-test",
|
|
251
|
+
iat: 0,
|
|
252
|
+
exp: 1,
|
|
253
|
+
}),
|
|
254
|
+
"AAAA",
|
|
255
|
+
].join(".");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Installs the instrumentation for the `firebase-admin` the codebase resolves.
|
|
259
|
+
///
|
|
260
|
+
/// `channel` is [`createAdminChannel`]'s: every `appCheck:verdict` frame the daemon sends must
|
|
261
|
+
/// reach its `deliver` from the moment it is created, the startup self-test included.
|
|
262
|
+
///
|
|
263
|
+
/// `options.enabled` is false unless the daemon asked for the instrumentation
|
|
264
|
+
/// (`FIREEMU_APP_CHECK_ADMIN=1`): without App Check there are no fireemu tokens to verify.
|
|
265
|
+
export async function instrumentAdminAppCheck(sourceDir, channel, options = {}) {
|
|
266
|
+
const enabled = options.enabled ?? process.env.FIREEMU_APP_CHECK_ADMIN === "1";
|
|
267
|
+
if (!enabled) return unsupported("disabled");
|
|
268
|
+
|
|
269
|
+
const require = createRequire(join(sourceDir, "package.json"));
|
|
270
|
+
let root;
|
|
271
|
+
let version;
|
|
272
|
+
try {
|
|
273
|
+
root = packageRootOf(require.resolve("firebase-admin"));
|
|
274
|
+
version = String(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "");
|
|
275
|
+
} catch {
|
|
276
|
+
return unsupported("not-installed");
|
|
277
|
+
}
|
|
278
|
+
const major = Number.parseInt(version.split(".")[0], 10);
|
|
279
|
+
if (!SUPPORTED_MAJORS.includes(major)) {
|
|
280
|
+
return unsupported(
|
|
281
|
+
`firebase-admin ${version} is outside the supported range (major ${SUPPORTED_MAJORS.join(", ")})`,
|
|
282
|
+
version,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
let modules;
|
|
286
|
+
try {
|
|
287
|
+
// By absolute path on purpose: these are internal modules the package's `exports` map does
|
|
288
|
+
// not publish, and the realpath is the module instance the SDK itself holds.
|
|
289
|
+
modules = loadModules(require, root);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return unsupported(`firebase-admin ${version} has no App Check internals: ${e?.message ?? e}`, version);
|
|
292
|
+
}
|
|
293
|
+
const { verifier, jwt, client, utils } = modules;
|
|
294
|
+
const Verifier = verifier.AppCheckTokenVerifier;
|
|
295
|
+
const Client = client.AppCheckApiClient;
|
|
296
|
+
const probes = [
|
|
297
|
+
[Verifier?.prototype, "verifySignature"],
|
|
298
|
+
[Verifier?.prototype, "mapJwtErrorToAppCheckError"],
|
|
299
|
+
[Verifier?.prototype, "verifyContent"],
|
|
300
|
+
[Verifier?.prototype, "safeDecode"],
|
|
301
|
+
[Verifier?.prototype, "verifyToken"],
|
|
302
|
+
[Client?.prototype, "verifyReplayProtection"],
|
|
303
|
+
];
|
|
304
|
+
for (const [target, name] of probes) {
|
|
305
|
+
if (!isPatchable(target, name)) {
|
|
306
|
+
return unsupported(`firebase-admin ${version} has no patchable ${name}`, version);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const codes = jwt.JwtErrorCode;
|
|
310
|
+
if (
|
|
311
|
+
typeof jwt.JwtError !== "function" ||
|
|
312
|
+
typeof utils.findProjectId !== "function" ||
|
|
313
|
+
!codes ||
|
|
314
|
+
["NO_MATCHING_KID", "INVALID_SIGNATURE", "TOKEN_EXPIRED", "INVALID_ARGUMENT", "KEY_FETCH_ERROR"].some(
|
|
315
|
+
(name) => typeof codes[name] !== "string",
|
|
316
|
+
)
|
|
317
|
+
) {
|
|
318
|
+
return unsupported(`firebase-admin ${version} no longer defines the JWT errors this instrumentation maps to`, version);
|
|
319
|
+
}
|
|
320
|
+
const makeError = errorFactory(modules);
|
|
321
|
+
if (!makeError) {
|
|
322
|
+
return unsupported(`firebase-admin ${version} constructs FirebaseAppCheckError in a shape this instrumentation does not know`, version);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const originals = {
|
|
326
|
+
verifySignature: Verifier.prototype.verifySignature,
|
|
327
|
+
verifyReplayProtection: Client.prototype.verifyReplayProtection,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/// The error a verdict becomes, thrown through the SDK's own mapper.
|
|
331
|
+
function failure(self, verdict) {
|
|
332
|
+
const reason = Object.hasOwn(REASONS, verdict.reason) ? REASONS[verdict.reason] : undefined;
|
|
333
|
+
if (reason) {
|
|
334
|
+
return self.mapJwtErrorToAppCheckError(new jwt.JwtError(codes[reason.code], reason.message));
|
|
335
|
+
}
|
|
336
|
+
const why =
|
|
337
|
+
verdict.reason === "timeout"
|
|
338
|
+
? "the daemon did not answer in time"
|
|
339
|
+
: verdict.reason === "disposed"
|
|
340
|
+
? "the Functions runner is shutting down"
|
|
341
|
+
: "the daemon cannot verify App Check tokens";
|
|
342
|
+
return self.mapJwtErrorToAppCheckError(
|
|
343
|
+
new jwt.JwtError(codes.KEY_FETCH_ERROR, unavailableMessage(why)),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function verifySignature(jwtToken) {
|
|
348
|
+
const self = this;
|
|
349
|
+
return utils
|
|
350
|
+
.findProjectId(self.app)
|
|
351
|
+
.then((projectId) => channel.ask(projectId, jwtToken))
|
|
352
|
+
.then((verdict) => {
|
|
353
|
+
if (verdict?.ok === true) return undefined;
|
|
354
|
+
throw failure(self, verdict ?? { ok: false, reason: "unavailable" });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function verifyReplayProtection() {
|
|
358
|
+
return Promise.reject(
|
|
359
|
+
makeError(
|
|
360
|
+
"unimplemented",
|
|
361
|
+
"verifyToken({ consume: true }) is not supported by fireemu: limited-use App Check " +
|
|
362
|
+
"token consumption (APPCHECK-REPLAY-1) is unimplemented, and the production App Check " +
|
|
363
|
+
"API is never called.",
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
Verifier.prototype.verifySignature = verifySignature;
|
|
369
|
+
Client.prototype.verifyReplayProtection = verifyReplayProtection;
|
|
370
|
+
const restore = () => {
|
|
371
|
+
Verifier.prototype.verifySignature = originals.verifySignature;
|
|
372
|
+
Client.prototype.verifyReplayProtection = originals.verifyReplayProtection;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const handle = {
|
|
376
|
+
supported: true,
|
|
377
|
+
instrumentation: "ok",
|
|
378
|
+
version,
|
|
379
|
+
report: { instrumentation: "ok", firebaseAdminVersion: version },
|
|
380
|
+
restore,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Startup self-test. Run the SDK's real `verifyToken` on a probe app and require that the
|
|
384
|
+
// patched step was the one consulted, exactly once, and that it failed the way a verdict
|
|
385
|
+
// does. A supported-range release that routes verification around `verifySignature` fails
|
|
386
|
+
// here instead of silently verifying nothing.
|
|
387
|
+
const app = { options: { projectId: SELF_TEST_PROJECT, httpAgent: new RefusingAgent() } };
|
|
388
|
+
const before = channel.asked;
|
|
389
|
+
channel.setDeadline(
|
|
390
|
+
Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.selfTestTimeoutMs ?? SELF_TEST_TIMEOUT_MS),
|
|
391
|
+
);
|
|
392
|
+
let outcome;
|
|
393
|
+
try {
|
|
394
|
+
const probe = new Verifier(app);
|
|
395
|
+
const expected = probe.mapJwtErrorToAppCheckError(
|
|
396
|
+
new jwt.JwtError(codes.NO_MATCHING_KID, REASONS["no-matching-kid"].message),
|
|
397
|
+
);
|
|
398
|
+
outcome = await probe.verifyToken(probeToken()).then(
|
|
399
|
+
() => ({ kind: "accepted" }),
|
|
400
|
+
(error) => ({ kind: "rejected", error, expected }),
|
|
401
|
+
);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
outcome = { kind: "threw", error: e };
|
|
404
|
+
}
|
|
405
|
+
channel.setDeadline(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
406
|
+
const consultedOnce = channel.asked - before === 1;
|
|
407
|
+
if (outcome.kind === "accepted" || !consultedOnce) {
|
|
408
|
+
channel.dispose();
|
|
409
|
+
restore();
|
|
410
|
+
return unsupported(
|
|
411
|
+
`the startup self-test did not reach the instrumented signature step in firebase-admin ${version}`,
|
|
412
|
+
version,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const answeredByDaemon =
|
|
416
|
+
outcome.kind === "rejected" &&
|
|
417
|
+
outcome.error?.code === outcome.expected.code &&
|
|
418
|
+
outcome.error?.message === outcome.expected.message;
|
|
419
|
+
if (!answeredByDaemon) {
|
|
420
|
+
// The step was reached but the daemon did not answer the probe the way it answers: nothing
|
|
421
|
+
// stays patched. The stock SDK then rejects fireemu tokens, and a daemon that recovers later
|
|
422
|
+
// cannot start accepting tokens through a half-trusted channel.
|
|
423
|
+
channel.dispose();
|
|
424
|
+
restore();
|
|
425
|
+
return unsupported("daemon-unavailable", version);
|
|
426
|
+
}
|
|
427
|
+
return handle;
|
|
428
|
+
}
|
|
@@ -20,6 +20,7 @@ import { url as inspectorUrl } from "node:inspector";
|
|
|
20
20
|
|
|
21
21
|
import { createRuntimeClock } from "./clock.mjs";
|
|
22
22
|
import { instrumentCallables } from "./callable-app-check.mjs";
|
|
23
|
+
import { createAdminChannel, instrumentAdminAppCheck } from "./admin-app-check.mjs";
|
|
23
24
|
import { removeAppCheckToken, withholdsAppCheckToken } from "./app-check-token.mjs";
|
|
24
25
|
import { blockingFailure } from "./blocking-error.mjs";
|
|
25
26
|
import { blockingEvent, loadIdentityParsers, portedIdentityParsers } from "./blocking-event.mjs";
|
|
@@ -726,13 +727,6 @@ function ignoredBlocking(base, eventType) {
|
|
|
726
727
|
// Products the official emulator has a trigger service for and fireemu does not serve.
|
|
727
728
|
// The event-type substring is matched the way `getServiceFromEventType` matches it.
|
|
728
729
|
const DEFERRED_TRIGGER_PRODUCTS = [
|
|
729
|
-
{
|
|
730
|
-
match: (type) =>
|
|
731
|
-
type.includes("firebase.database") || type.includes("google.firebase.database"),
|
|
732
|
-
triggerType: "database",
|
|
733
|
-
scope: "deferred",
|
|
734
|
-
reason: "deferred: the Realtime Database emulator is not in the active supported surface",
|
|
735
|
-
},
|
|
736
730
|
{
|
|
737
731
|
match: (type) => type.includes("remoteconfig"),
|
|
738
732
|
triggerType: "remoteConfig",
|
|
@@ -774,6 +768,16 @@ function deferredProduct(type) {
|
|
|
774
768
|
// firebase-functions v1 triggers: legacy event types and a resource pattern.
|
|
775
769
|
function describeV1Event(base, type, resource, schedule, retry) {
|
|
776
770
|
const v1 = { v1: true };
|
|
771
|
+
const dbMatch = type.match(/^providers\/google\.firebase\.database\/eventTypes\/ref\.(create|update|delete|write)$/);
|
|
772
|
+
if (dbMatch) {
|
|
773
|
+
resource = triggerString(resource, "eventTrigger.resource", { optional: true, allowEmpty: true }) ?? "";
|
|
774
|
+
const match = resource.match(/^projects\/_\/instances\/([^/]+)\/refs(?:\/(.*))?$/s);
|
|
775
|
+
if (!match) return ignored(base, "database", "unsupported", "v1 Database resource has an unsupported shape");
|
|
776
|
+
return { ...base, ...v1, retry, trigger: {
|
|
777
|
+
type: "database", instance: match[1], ref: `/${match[2] || ""}`,
|
|
778
|
+
eventType: `google.firebase.database.ref.v1.${{create:"created",update:"updated",delete:"deleted",write:"written"}[dbMatch[1]]}`,
|
|
779
|
+
}};
|
|
780
|
+
}
|
|
777
781
|
const fsMatch = type.match(
|
|
778
782
|
/^providers\/cloud\.firestore\/eventTypes\/document\.(create|update|delete|write)$/,
|
|
779
783
|
);
|
|
@@ -981,6 +985,17 @@ function describe(name, fn, instrumentation) {
|
|
|
981
985
|
const et = ep.eventTrigger;
|
|
982
986
|
const type = et.eventType || "";
|
|
983
987
|
base.retry = resolvedBoolean(et.retry, "eventTrigger.retry") ?? false;
|
|
988
|
+
if (type.startsWith("google.firebase.database.ref.v1.")) {
|
|
989
|
+
const filters = triggerRecord(et.eventFilters, "eventFilters");
|
|
990
|
+
const patterns = triggerRecord(et.eventFilterPathPatterns, "eventFilterPathPatterns");
|
|
991
|
+
const reference = triggerString(patterns.ref, "eventFilterPathPatterns.ref", { optional: true }) ??
|
|
992
|
+
triggerString(filters.ref, "eventFilters.ref");
|
|
993
|
+
return { ...base, trigger: {
|
|
994
|
+
type: "database", eventType: type, ref: `/${reference.replace(/^\/+|\/+$/g, "")}`,
|
|
995
|
+
instance: triggerString(patterns.instance, "eventFilterPathPatterns.instance", { optional: true }) ??
|
|
996
|
+
triggerString(filters.instance, "eventFilters.instance", { optional: true }) ?? "*",
|
|
997
|
+
}};
|
|
998
|
+
}
|
|
984
999
|
if (type.startsWith("google.cloud.firestore.")) {
|
|
985
1000
|
const filters = triggerRecord(et.eventFilters, "eventFilters");
|
|
986
1001
|
const patterns = triggerRecord(et.eventFilterPathPatterns, "eventFilterPathPatterns");
|
|
@@ -1140,6 +1155,19 @@ const strictProfile = () => process.env.FIREEMU_HTTP_PROFILE === "strict";
|
|
|
1140
1155
|
function v1Context(msg) {
|
|
1141
1156
|
const event = msg.event;
|
|
1142
1157
|
switch (msg.trigger) {
|
|
1158
|
+
case "database": {
|
|
1159
|
+
const operation = {created:"create",updated:"update",deleted:"delete",written:"write"}[event.type.split(".").at(-1)];
|
|
1160
|
+
const context = {
|
|
1161
|
+
eventId: event.id, timestamp: event.time,
|
|
1162
|
+
eventType: `providers/google.firebase.database/eventTypes/ref.${operation}`,
|
|
1163
|
+
resource: `projects/_/instances/${event.instance}/refs/${event.ref}`,
|
|
1164
|
+
domain: event.firebasedatabasehost,
|
|
1165
|
+
authType: {admin:"ADMIN",app_user:"USER",unauthenticated:"UNAUTHENTICATED"}[event.authtype],
|
|
1166
|
+
};
|
|
1167
|
+
if (context.authType === "USER") context.auth = {variable:event.auth};
|
|
1168
|
+
if (context.authType === "ADMIN") context.auth = {admin:true};
|
|
1169
|
+
return context;
|
|
1170
|
+
}
|
|
1143
1171
|
case "firestore": {
|
|
1144
1172
|
const legacy = {
|
|
1145
1173
|
"google.cloud.firestore.document.v1.created":
|
|
@@ -1510,7 +1538,7 @@ async function invoke(functions, manifest, msg) {
|
|
|
1510
1538
|
throw new Error("entry point does not match the function manifest");
|
|
1511
1539
|
}
|
|
1512
1540
|
if (msg.trigger !== spec.trigger?.type ||
|
|
1513
|
-
!["schedule", "firestore", "storage", "pubsub", "eventarc", "auth"].includes(msg.trigger)) {
|
|
1541
|
+
!["schedule", "database", "firestore", "storage", "pubsub", "eventarc", "auth"].includes(msg.trigger)) {
|
|
1514
1542
|
throw new Error("trigger does not match the function manifest");
|
|
1515
1543
|
}
|
|
1516
1544
|
const fn = functions.get(spec.entryPoint);
|
|
@@ -1532,6 +1560,9 @@ async function invoke(functions, manifest, msg) {
|
|
|
1532
1560
|
return;
|
|
1533
1561
|
}
|
|
1534
1562
|
switch (msg.trigger) {
|
|
1563
|
+
case "database":
|
|
1564
|
+
await fn(msg.event);
|
|
1565
|
+
return;
|
|
1535
1566
|
case "schedule": {
|
|
1536
1567
|
// The strict profile calls the function the way production's Scheduler does, through its HTTP wrapper,
|
|
1537
1568
|
// so the SDK builds the event from the scheduler headers (job id, Los Angeles time, `context` getter)
|
|
@@ -1607,14 +1638,23 @@ async function main() {
|
|
|
1607
1638
|
let functions;
|
|
1608
1639
|
let manifest;
|
|
1609
1640
|
let runnerReady = false;
|
|
1641
|
+
// The Admin SDK App Check instrumentation (installed before any user code loads) asks the
|
|
1642
|
+
// daemon through this channel; the daemon's answers arrive on the stdin reader below.
|
|
1643
|
+
const adminChannel = createAdminChannel(send);
|
|
1644
|
+
let adminAppCheck = null;
|
|
1610
1645
|
const activeInvocations = new InvocationBudget();
|
|
1611
1646
|
// Read before loading user code, which may await indefinitely after spawning children.
|
|
1612
1647
|
startupTrace('stdin frame reader start');
|
|
1613
1648
|
readFrames(
|
|
1614
1649
|
process.stdin,
|
|
1615
1650
|
(msg, payloadBytes) => {
|
|
1651
|
+
if (msg.type === "appCheck:verdict") {
|
|
1652
|
+
adminChannel.deliver(msg);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1616
1655
|
if (msg.type === "shutdown") {
|
|
1617
1656
|
explicitShutdown = true;
|
|
1657
|
+
adminChannel.dispose();
|
|
1618
1658
|
// Volta must observe Node exit before its shim is killed, or the daemon
|
|
1619
1659
|
// can finish shutdown while Node still owns an inspector listener.
|
|
1620
1660
|
deferCleanShutdownGroupCleanup();
|
|
@@ -1667,6 +1707,7 @@ async function main() {
|
|
|
1667
1707
|
.finally(release);
|
|
1668
1708
|
},
|
|
1669
1709
|
() => {
|
|
1710
|
+
adminChannel.dispose();
|
|
1670
1711
|
cleanupProcessGroupOnInputClose();
|
|
1671
1712
|
finishOutput();
|
|
1672
1713
|
},
|
|
@@ -1683,6 +1724,9 @@ async function main() {
|
|
|
1683
1724
|
startupTrace('callable instrumentation start');
|
|
1684
1725
|
const instrumentation = instrumentCallables(sourceDir);
|
|
1685
1726
|
startupTrace('callable instrumentation complete');
|
|
1727
|
+
startupTrace('admin app check instrumentation start');
|
|
1728
|
+
adminAppCheck = await instrumentAdminAppCheck(sourceDir, adminChannel);
|
|
1729
|
+
startupTrace('admin app check instrumentation complete');
|
|
1686
1730
|
startupTrace('parameter initialization start');
|
|
1687
1731
|
resolveWhenDeclared(declaredParamsOfCodebase(), process.env);
|
|
1688
1732
|
startupTrace('parameter initialization complete');
|
|
@@ -1804,6 +1848,7 @@ async function main() {
|
|
|
1804
1848
|
authHeaders: instrumentation.authHeaders,
|
|
1805
1849
|
graphs: instrumentation.graphs,
|
|
1806
1850
|
},
|
|
1851
|
+
adminAppCheck: adminAppCheck.report,
|
|
1807
1852
|
});
|
|
1808
1853
|
startupTrace('hello written');
|
|
1809
1854
|
runnerReady = true;
|
|
@@ -44,6 +44,14 @@ function decodeMessage(payload) {
|
|
|
44
44
|
decimal(message.instantNanos, true) && decimal(message.elapsedNanos) && decimal(message.revision)) return message;
|
|
45
45
|
if (message.type === 'clock:runDue' && text(message.invocationId) &&
|
|
46
46
|
(message.budget === undefined || (Number.isSafeInteger(message.budget) && message.budget >= 1 && message.budget <= 10000))) return message;
|
|
47
|
+
// The daemon's answer to an Admin SDK App Check question (tools/runner-node/admin-app-check.mjs).
|
|
48
|
+
if (message.type === 'appCheck:verdict') {
|
|
49
|
+
if (!text(message.requestId) || message.requestId.length > 128 || typeof message.ok !== 'boolean' ||
|
|
50
|
+
(message.reason !== undefined && (typeof message.reason !== 'string' || message.reason.length > 64))) {
|
|
51
|
+
throw new ProtocolError('message fields');
|
|
52
|
+
}
|
|
53
|
+
return message;
|
|
54
|
+
}
|
|
47
55
|
if (message.type !== 'invoke' || !text(message.invocationId) ||
|
|
48
56
|
!text(message.function) || !text(message.trigger) || !object(message.event) ||
|
|
49
57
|
(message.entryPoint !== undefined && !text(message.entryPoint))) {
|