@fireemu/darwin-arm64 0.8.0 → 0.9.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/blocking-event.mjs +224 -0
- package/bin/runner-node/blocking-response.mjs +30 -12
- package/bin/runner-node/index.mjs +27 -5
- package/package.json +1 -1
package/README.md
CHANGED
package/bin/fireemu
CHANGED
|
Binary file
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The event a blocking function receives, built as Identity Platform's delivery builds it: the
|
|
2
|
+
// request carries an unsigned blocking token (`{"data":{"jwt":…}}`, the official Auth
|
|
3
|
+
// emulator's form), and the codebase's own firebase-functions parsers turn it into the user
|
|
4
|
+
// record and the event context, exactly as the SDK's wrapped handler does before it calls the
|
|
5
|
+
// handler (firebase-functions `common/providers/identity` `wrapHandler`). The runner keeps its
|
|
6
|
+
// own response and error handling (blocking-response.mjs, blocking-error.mjs).
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
/** A blocking token's payload, decoded without verification as the SDK does under the emulator. */
|
|
11
|
+
export function decodeBlockingToken(jwt) {
|
|
12
|
+
if (typeof jwt !== "string") throw new TypeError("the blocking token is not a string");
|
|
13
|
+
const parts = jwt.split(".");
|
|
14
|
+
if (parts.length !== 3) throw new TypeError("the blocking token is not a JWT");
|
|
15
|
+
let payload;
|
|
16
|
+
try {
|
|
17
|
+
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
throw new TypeError("the blocking token's payload is not JSON");
|
|
20
|
+
}
|
|
21
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
22
|
+
throw new TypeError("the blocking token's payload is not an object");
|
|
23
|
+
}
|
|
24
|
+
// The SDK's unsafeDecodeAuthBlockingToken names the subject as the uid.
|
|
25
|
+
payload.uid = payload.sub;
|
|
26
|
+
return payload;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The handler's `{user, context}` for a request that carries a blocking token, or undefined
|
|
31
|
+
* for one that does not. `parsers` are the SDK's `parseAuthUserRecord` and
|
|
32
|
+
* `parseAuthEventContext`; the user record is parsed for the user events only.
|
|
33
|
+
*/
|
|
34
|
+
export function blockingEvent(body, parsers, projectId) {
|
|
35
|
+
const jwt = body?.data?.jwt;
|
|
36
|
+
if (jwt === undefined) return undefined;
|
|
37
|
+
const decoded = decodeBlockingToken(jwt);
|
|
38
|
+
const user =
|
|
39
|
+
decoded.event_type === "beforeCreate" || decoded.event_type === "beforeSignIn"
|
|
40
|
+
? parsers.parseAuthUserRecord(decoded.user_record)
|
|
41
|
+
: undefined;
|
|
42
|
+
return { user, context: parsers.parseAuthEventContext(decoded, projectId) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function packageRoot(require) {
|
|
46
|
+
let root = dirname(require.resolve("firebase-functions"));
|
|
47
|
+
for (;;) {
|
|
48
|
+
const candidate = join(root, "package.json");
|
|
49
|
+
if (existsSync(candidate) && JSON.parse(readFileSync(candidate, "utf8")).name === "firebase-functions")
|
|
50
|
+
return root;
|
|
51
|
+
const parent = dirname(root);
|
|
52
|
+
if (parent === root) throw new Error("cannot locate the firebase-functions package root");
|
|
53
|
+
root = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The SDK's parsers from the codebase's firebase-functions (`lib/common/providers/identity.js`,
|
|
59
|
+
* not in its public exports), or null when that SDK does not expose both there; the runner then
|
|
60
|
+
* uses [`portedIdentityParsers`].
|
|
61
|
+
*/
|
|
62
|
+
export function loadIdentityParsers(require) {
|
|
63
|
+
try {
|
|
64
|
+
const identity = require(join(packageRoot(require), "lib", "common", "providers", "identity.js"));
|
|
65
|
+
const { parseAuthUserRecord, parseAuthEventContext } = identity;
|
|
66
|
+
if (typeof parseAuthUserRecord !== "function" || typeof parseAuthEventContext !== "function")
|
|
67
|
+
return null;
|
|
68
|
+
return { parseAuthUserRecord, parseAuthEventContext };
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A port of firebase-functions 7.3.2 `lib/common/providers/identity.js` (MIT License, Copyright
|
|
75
|
+
// Google LLC): the event a handler receives when the codebase's firebase-functions does not
|
|
76
|
+
// expose its own parsers. Identity Platform delivers the same token to every SDK version, so
|
|
77
|
+
// the event is built as that SDK builds it rather than refused (an SDK's own parsers are used
|
|
78
|
+
// whenever they can be loaded).
|
|
79
|
+
const EVENT_MAPPING = {
|
|
80
|
+
beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate",
|
|
81
|
+
beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn",
|
|
82
|
+
beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail",
|
|
83
|
+
beforeSendSms: "providers/cloud.auth/eventTypes/user.beforeSendSms",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const utcDate = (value) => (value ? new Date(value).toUTCString() : null);
|
|
87
|
+
|
|
88
|
+
function parseMetadata(metadata) {
|
|
89
|
+
return {
|
|
90
|
+
creationTime: utcDate(metadata?.creation_time),
|
|
91
|
+
lastSignInTime: utcDate(metadata?.last_sign_in_time),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseProviderData(providerData) {
|
|
96
|
+
return providerData.map((provider) => ({
|
|
97
|
+
uid: provider.uid,
|
|
98
|
+
displayName: provider.display_name,
|
|
99
|
+
email: provider.email,
|
|
100
|
+
photoURL: provider.photo_url,
|
|
101
|
+
providerId: provider.provider_id,
|
|
102
|
+
phoneNumber: provider.phone_number,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseDate(seconds) {
|
|
107
|
+
if (!seconds) return null;
|
|
108
|
+
const date = new Date(seconds * 1000);
|
|
109
|
+
return isNaN(date.getTime()) ? null : date.toUTCString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseMultiFactor(multiFactor) {
|
|
113
|
+
if (!multiFactor) return null;
|
|
114
|
+
const enrolledFactors = [];
|
|
115
|
+
for (const factor of multiFactor.enrolled_factors || []) {
|
|
116
|
+
if (!factor.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid multi-factor info response");
|
|
117
|
+
enrolledFactors.push({
|
|
118
|
+
uid: factor.uid,
|
|
119
|
+
factorId: factor.phone_number ? factor.factor_id || "phone" : factor.factor_id,
|
|
120
|
+
displayName: factor.display_name,
|
|
121
|
+
enrollmentTime: factor.enrollment_time ? new Date(factor.enrollment_time).toUTCString() : null,
|
|
122
|
+
phoneNumber: factor.phone_number,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return enrolledFactors.length > 0 ? { enrolledFactors } : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseAuthUserRecord(record) {
|
|
129
|
+
if (!record?.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid user response");
|
|
130
|
+
return {
|
|
131
|
+
uid: record.uid,
|
|
132
|
+
email: record.email,
|
|
133
|
+
emailVerified: record.email_verified,
|
|
134
|
+
displayName: record.display_name,
|
|
135
|
+
photoURL: record.photo_url,
|
|
136
|
+
phoneNumber: record.phone_number,
|
|
137
|
+
disabled: record.disabled || false,
|
|
138
|
+
metadata: parseMetadata(record.metadata),
|
|
139
|
+
providerData: parseProviderData(record.provider_data),
|
|
140
|
+
passwordHash: record.password_hash,
|
|
141
|
+
passwordSalt: record.password_salt,
|
|
142
|
+
customClaims: record.custom_claims,
|
|
143
|
+
tenantId: record.tenant_id,
|
|
144
|
+
tokensValidAfterTime: parseDate(record.tokens_valid_after_time),
|
|
145
|
+
multiFactor: parseMultiFactor(record.multi_factor),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseAdditionalUserInfo(decoded) {
|
|
150
|
+
let profile;
|
|
151
|
+
let username;
|
|
152
|
+
if (decoded.raw_user_info) {
|
|
153
|
+
try {
|
|
154
|
+
profile = JSON.parse(decoded.raw_user_info);
|
|
155
|
+
} catch {
|
|
156
|
+
// The SDK only logs an unparsable profile.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (profile) {
|
|
160
|
+
if (decoded.sign_in_method === "github.com") username = profile.login;
|
|
161
|
+
if (decoded.sign_in_method === "twitter.com") username = profile.screen_name;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
|
|
165
|
+
profile,
|
|
166
|
+
username,
|
|
167
|
+
isNewUser: decoded.event_type === "beforeCreate",
|
|
168
|
+
recaptchaScore: decoded.recaptcha_score,
|
|
169
|
+
email: decoded.email,
|
|
170
|
+
phoneNumber: decoded.phone_number,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseAuthCredential(decoded, time) {
|
|
175
|
+
if (
|
|
176
|
+
!decoded.sign_in_attributes &&
|
|
177
|
+
!decoded.oauth_id_token &&
|
|
178
|
+
!decoded.oauth_access_token &&
|
|
179
|
+
!decoded.oauth_refresh_token
|
|
180
|
+
) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
claims: decoded.sign_in_attributes,
|
|
185
|
+
idToken: decoded.oauth_id_token,
|
|
186
|
+
accessToken: decoded.oauth_access_token,
|
|
187
|
+
refreshToken: decoded.oauth_refresh_token,
|
|
188
|
+
expirationTime: decoded.oauth_expires_in
|
|
189
|
+
? new Date(time + decoded.oauth_expires_in * 1000).toUTCString()
|
|
190
|
+
: undefined,
|
|
191
|
+
secret: decoded.oauth_token_secret,
|
|
192
|
+
providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
|
|
193
|
+
signInMethod: decoded.sign_in_method,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseAuthEventContext(decoded, projectId, time = new Date().getTime()) {
|
|
198
|
+
const eventType =
|
|
199
|
+
(EVENT_MAPPING[decoded.event_type] || decoded.event_type) +
|
|
200
|
+
(decoded.sign_in_method ? `:${decoded.sign_in_method}` : "");
|
|
201
|
+
return {
|
|
202
|
+
locale: decoded.locale,
|
|
203
|
+
ipAddress: decoded.ip_address,
|
|
204
|
+
userAgent: decoded.user_agent,
|
|
205
|
+
eventId: decoded.event_id,
|
|
206
|
+
eventType,
|
|
207
|
+
authType: decoded.user_record ? "USER" : "UNAUTHENTICATED",
|
|
208
|
+
resource: {
|
|
209
|
+
service: "identitytoolkit.googleapis.com",
|
|
210
|
+
name: decoded.tenant_id
|
|
211
|
+
? `projects/${projectId}/tenants/${decoded.tenant_id}`
|
|
212
|
+
: `projects/${projectId}`,
|
|
213
|
+
},
|
|
214
|
+
timestamp: new Date(decoded.iat * 1000).toUTCString(),
|
|
215
|
+
additionalUserInfo: parseAdditionalUserInfo(decoded),
|
|
216
|
+
credential: parseAuthCredential(decoded, time),
|
|
217
|
+
emailType: decoded.email_type,
|
|
218
|
+
smsType: decoded.sms_type,
|
|
219
|
+
params: {},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The ported parsers ([`loadIdentityParsers`] prefers the codebase's own). */
|
|
224
|
+
export const portedIdentityParsers = Object.freeze({ parseAuthUserRecord, parseAuthEventContext });
|
|
@@ -103,6 +103,20 @@ function freezeResponse(snapshot) {
|
|
|
103
103
|
return snapshot;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// The customClaims text as the function's JSON.stringify gives it: what the Functions SDK sends
|
|
107
|
+
// Identity Platform, which production reads back as `customAttributes` with its key order
|
|
108
|
+
// (AUTH-TENANT-BLOCKING recording 2026-09-28). The runner owns this member; the daemon uses the
|
|
109
|
+
// text only when it parses to the claims it applies.
|
|
110
|
+
const CUSTOM_CLAIMS_TEXT = "fireemuCustomClaimsText";
|
|
111
|
+
|
|
112
|
+
function attachCustomClaimsText(snapshot) {
|
|
113
|
+
delete snapshot[CUSTOM_CLAIMS_TEXT];
|
|
114
|
+
// validateBlockingResult has admitted only an object, null or no claims.
|
|
115
|
+
const claims = snapshot.userRecord.customClaims;
|
|
116
|
+
if (claims != null) snapshot[CUSTOM_CLAIMS_TEXT] = JSON.stringify(claims);
|
|
117
|
+
return snapshot;
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
export function blockingResult(value, eventType, HttpsError) {
|
|
107
121
|
// Preserve the runner's established no-result behaviour.
|
|
108
122
|
if (!value || typeof value !== "object") return {};
|
|
@@ -117,21 +131,25 @@ export function blockingResult(value, eventType, HttpsError) {
|
|
|
117
131
|
} else {
|
|
118
132
|
const userRecord = {};
|
|
119
133
|
const updateMask = [];
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
// Each field goes under its public name, as the Functions SDK's generateResponsePayload
|
|
135
|
+
// sends it: Identity Platform does not apply `photoURL` (it reads `photoUrl`), so a
|
|
136
|
+
// function's photoURL is ignored (AUTH-TENANT-BLOCKING recording 2026-09-28,
|
|
137
|
+
// ordering#lookup-profile; the official Auth emulator reads `photoUrl` only too).
|
|
138
|
+
for (const name of [
|
|
139
|
+
"displayName",
|
|
140
|
+
"photoURL",
|
|
141
|
+
"disabled",
|
|
142
|
+
"emailVerified",
|
|
143
|
+
"customClaims",
|
|
144
|
+
"sessionClaims",
|
|
127
145
|
]) {
|
|
128
|
-
if (Object.prototype.hasOwnProperty.call(value,
|
|
129
|
-
const field = value[
|
|
146
|
+
if (Object.prototype.hasOwnProperty.call(value, name)) {
|
|
147
|
+
const field = value[name];
|
|
130
148
|
// Like the Functions SDK's getUpdateMask, undefined means no update;
|
|
131
149
|
// null, false, and an empty string are still explicit updates.
|
|
132
150
|
if (field === undefined) continue;
|
|
133
|
-
userRecord[
|
|
134
|
-
updateMask.push(
|
|
151
|
+
userRecord[name] = field;
|
|
152
|
+
updateMask.push(name);
|
|
135
153
|
}
|
|
136
154
|
}
|
|
137
155
|
if (updateMask.length === 0) return {};
|
|
@@ -142,5 +160,5 @@ export function blockingResult(value, eventType, HttpsError) {
|
|
|
142
160
|
}
|
|
143
161
|
const snapshot = snapshotResponse(candidate, HttpsError);
|
|
144
162
|
validateBlockingResult(snapshot, eventType, HttpsError);
|
|
145
|
-
return freezeResponse(snapshot);
|
|
163
|
+
return freezeResponse(attachCustomClaimsText(snapshot));
|
|
146
164
|
}
|
|
@@ -17,6 +17,7 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
17
17
|
import { url as inspectorUrl } from "node:inspector";
|
|
18
18
|
import { instrumentCallables } from "./callable-app-check.mjs";
|
|
19
19
|
import { blockingFailure } from "./blocking-error.mjs";
|
|
20
|
+
import { blockingEvent, loadIdentityParsers, portedIdentityParsers } from "./blocking-event.mjs";
|
|
20
21
|
import { blockingResult } from "./blocking-response.mjs";
|
|
21
22
|
import { boundLogMessage, createInvocationLogger } from "./log-context.mjs";
|
|
22
23
|
import { invocationFailure } from "./invocation-error.mjs";
|
|
@@ -831,6 +832,15 @@ function callableAppCheck(instrumentation, fn) {
|
|
|
831
832
|
};
|
|
832
833
|
}
|
|
833
834
|
|
|
835
|
+
// Identity Platform's blocking events. The email and SMS events are discovered as well; the
|
|
836
|
+
// daemon serves them only where production parity asks for it (the official emulator serves
|
|
837
|
+
// beforeCreate and beforeSignIn only).
|
|
838
|
+
const BLOCKING_AUTH_EVENTS = ["beforeCreate", "beforeSignIn", "beforeSendEmail", "beforeSendSms"];
|
|
839
|
+
|
|
840
|
+
function isBlockingAuthEvent(eventType) {
|
|
841
|
+
return BLOCKING_AUTH_EVENTS.some((event) => eventType.endsWith(event));
|
|
842
|
+
}
|
|
843
|
+
|
|
834
844
|
function blockingAuthTrigger(eventType, options) {
|
|
835
845
|
return {
|
|
836
846
|
type: "blockingAuth",
|
|
@@ -874,7 +884,7 @@ function describe(name, fn, instrumentation) {
|
|
|
874
884
|
if (ep.callableTrigger) return { ...base, trigger: callable() };
|
|
875
885
|
if (ep.blockingTrigger) {
|
|
876
886
|
const eventType = String(ep.blockingTrigger.eventType || "");
|
|
877
|
-
if (
|
|
887
|
+
if (isBlockingAuthEvent(eventType)) {
|
|
878
888
|
return {
|
|
879
889
|
...base,
|
|
880
890
|
trigger: blockingAuthTrigger(eventType, ep.blockingTrigger.options),
|
|
@@ -911,7 +921,7 @@ function describe(name, fn, instrumentation) {
|
|
|
911
921
|
}
|
|
912
922
|
if (ep.blockingTrigger) {
|
|
913
923
|
const eventType = String(ep.blockingTrigger.eventType || "");
|
|
914
|
-
if (
|
|
924
|
+
if (isBlockingAuthEvent(eventType)) {
|
|
915
925
|
return {
|
|
916
926
|
...base,
|
|
917
927
|
trigger: blockingAuthTrigger(eventType, ep.blockingTrigger.options),
|
|
@@ -1009,7 +1019,7 @@ function describe(name, fn, instrumentation) {
|
|
|
1009
1019
|
}
|
|
1010
1020
|
if (t.blockingTrigger) {
|
|
1011
1021
|
const eventType = String(t.blockingTrigger.eventType || "");
|
|
1012
|
-
if (
|
|
1022
|
+
if (isBlockingAuthEvent(eventType)) {
|
|
1013
1023
|
return {
|
|
1014
1024
|
...base,
|
|
1015
1025
|
trigger: blockingAuthTrigger(eventType, t.blockingTrigger.options),
|
|
@@ -1160,6 +1170,8 @@ async function makeHttpServer(functions, manifest) {
|
|
|
1160
1170
|
}
|
|
1161
1171
|
}
|
|
1162
1172
|
const HttpsErrors = await firebaseHttpsErrorConstructors(require);
|
|
1173
|
+
// The SDK's own parsers build a blocking event from its token (blocking-event.mjs).
|
|
1174
|
+
const identityParsers = loadIdentityParsers(require);
|
|
1163
1175
|
const app = express();
|
|
1164
1176
|
const admission = createHttpAdmission({
|
|
1165
1177
|
secret: process.env.FIREEMU_RUNNER_SECRET || "",
|
|
@@ -1280,8 +1292,18 @@ async function makeHttpServer(functions, manifest) {
|
|
|
1280
1292
|
if (!lifetime.canStart()) return;
|
|
1281
1293
|
try {
|
|
1282
1294
|
if (blocking) {
|
|
1283
|
-
|
|
1284
|
-
|
|
1295
|
+
// A token is parsed by the codebase's own firebase-functions, as Identity Platform's
|
|
1296
|
+
// delivery is, or by the port of its parsers when that SDK does not expose them.
|
|
1297
|
+
const hasToken = req.body?.data?.jwt !== undefined;
|
|
1298
|
+
const parsed = hasToken
|
|
1299
|
+
? blockingEvent(
|
|
1300
|
+
req.body,
|
|
1301
|
+
identityParsers || portedIdentityParsers,
|
|
1302
|
+
process.env.GCLOUD_PROJECT || "",
|
|
1303
|
+
)
|
|
1304
|
+
: undefined;
|
|
1305
|
+
const user = parsed ? parsed.user : req.body?.data?.user;
|
|
1306
|
+
const context = parsed ? parsed.context : req.body?.data?.context || {};
|
|
1285
1307
|
const value = await (spec.generation === 1 ? fn.run(user, context) : fn.run({ ...context, data: user }));
|
|
1286
1308
|
if (lifetime.canStart()) {
|
|
1287
1309
|
// Materialization can call user getters/toJSON. Keep it inside this
|