1 | "use strict";
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 | Object.defineProperty(exports, "__esModule", { value: true });
|
24 | exports.onCallHandler = exports.checkAuthToken = exports.unsafeDecodeAppCheckToken = exports.unsafeDecodeIdToken = exports.unsafeDecodeToken = exports.decode = exports.encode = exports.isValidRequest = exports.HttpsError = exports.ORIGINAL_AUTH_HEADER = exports.CALLABLE_AUTH_HEADER = void 0;
|
25 | const cors = require("cors");
|
26 | const logger = require("../../logger");
|
27 |
|
28 |
|
29 | const app_check_1 = require("firebase-admin/app-check");
|
30 | const auth_1 = require("firebase-admin/auth");
|
31 | const app_1 = require("../app");
|
32 | const debug_1 = require("../debug");
|
33 | const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/;
|
34 |
|
35 | exports.CALLABLE_AUTH_HEADER = "x-callable-context-auth";
|
36 |
|
37 | exports.ORIGINAL_AUTH_HEADER = "x-original-auth";
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 | const errorCodeMap = {
|
48 | ok: { canonicalName: "OK", status: 200 },
|
49 | cancelled: { canonicalName: "CANCELLED", status: 499 },
|
50 | unknown: { canonicalName: "UNKNOWN", status: 500 },
|
51 | "invalid-argument": { canonicalName: "INVALID_ARGUMENT", status: 400 },
|
52 | "deadline-exceeded": { canonicalName: "DEADLINE_EXCEEDED", status: 504 },
|
53 | "not-found": { canonicalName: "NOT_FOUND", status: 404 },
|
54 | "already-exists": { canonicalName: "ALREADY_EXISTS", status: 409 },
|
55 | "permission-denied": { canonicalName: "PERMISSION_DENIED", status: 403 },
|
56 | unauthenticated: { canonicalName: "UNAUTHENTICATED", status: 401 },
|
57 | "resource-exhausted": { canonicalName: "RESOURCE_EXHAUSTED", status: 429 },
|
58 | "failed-precondition": { canonicalName: "FAILED_PRECONDITION", status: 400 },
|
59 | aborted: { canonicalName: "ABORTED", status: 409 },
|
60 | "out-of-range": { canonicalName: "OUT_OF_RANGE", status: 400 },
|
61 | unimplemented: { canonicalName: "UNIMPLEMENTED", status: 501 },
|
62 | internal: { canonicalName: "INTERNAL", status: 500 },
|
63 | unavailable: { canonicalName: "UNAVAILABLE", status: 503 },
|
64 | "data-loss": { canonicalName: "DATA_LOSS", status: 500 },
|
65 | };
|
66 |
|
67 |
|
68 |
|
69 |
|
70 | class HttpsError extends Error {
|
71 | constructor(code, message, details) {
|
72 | super(message);
|
73 |
|
74 | if (code in errorCodeMap === false) {
|
75 | throw new Error(`Unknown error code: ${code}.`);
|
76 | }
|
77 | this.code = code;
|
78 | this.details = details;
|
79 | this.httpErrorCode = errorCodeMap[code];
|
80 | }
|
81 | |
82 |
|
83 |
|
84 | toJSON() {
|
85 | const { details, httpErrorCode: { canonicalName: status }, message, } = this;
|
86 | return {
|
87 | ...(details === undefined ? {} : { details }),
|
88 | message,
|
89 | status,
|
90 | };
|
91 | }
|
92 | }
|
93 | exports.HttpsError = HttpsError;
|
94 |
|
95 |
|
96 | function isValidRequest(req) {
|
97 |
|
98 | if (!req.body) {
|
99 | logger.warn("Request is missing body.");
|
100 | return false;
|
101 | }
|
102 |
|
103 | if (req.method !== "POST") {
|
104 | logger.warn("Request has invalid method.", req.method);
|
105 | return false;
|
106 | }
|
107 |
|
108 | let contentType = (req.header("Content-Type") || "").toLowerCase();
|
109 |
|
110 | const semiColon = contentType.indexOf(";");
|
111 | if (semiColon >= 0) {
|
112 | contentType = contentType.slice(0, semiColon).trim();
|
113 | }
|
114 | if (contentType !== "application/json") {
|
115 | logger.warn("Request has incorrect Content-Type.", contentType);
|
116 | return false;
|
117 | }
|
118 |
|
119 | if (typeof req.body.data === "undefined") {
|
120 | logger.warn("Request body is missing data.", req.body);
|
121 | return false;
|
122 | }
|
123 |
|
124 |
|
125 | const extraKeys = Object.keys(req.body).filter((field) => field !== "data");
|
126 | if (extraKeys.length !== 0) {
|
127 | logger.warn("Request body has extra fields: ", extraKeys.join(", "));
|
128 | return false;
|
129 | }
|
130 | return true;
|
131 | }
|
132 | exports.isValidRequest = isValidRequest;
|
133 |
|
134 | const LONG_TYPE = "type.googleapis.com/google.protobuf.Int64Value";
|
135 |
|
136 | const UNSIGNED_LONG_TYPE = "type.googleapis.com/google.protobuf.UInt64Value";
|
137 |
|
138 |
|
139 |
|
140 |
|
141 |
|
142 | function encode(data) {
|
143 | if (data === null || typeof data === "undefined") {
|
144 | return null;
|
145 | }
|
146 | if (data instanceof Number) {
|
147 | data = data.valueOf();
|
148 | }
|
149 | if (Number.isFinite(data)) {
|
150 |
|
151 |
|
152 | return data;
|
153 | }
|
154 | if (typeof data === "boolean") {
|
155 | return data;
|
156 | }
|
157 | if (typeof data === "string") {
|
158 | return data;
|
159 | }
|
160 | if (Array.isArray(data)) {
|
161 | return data.map(encode);
|
162 | }
|
163 | if (typeof data === "object" || typeof data === "function") {
|
164 |
|
165 |
|
166 | const obj = {};
|
167 | for (const [k, v] of Object.entries(data)) {
|
168 | obj[k] = encode(v);
|
169 | }
|
170 | return obj;
|
171 | }
|
172 |
|
173 | logger.error("Data cannot be encoded in JSON.", data);
|
174 | throw new Error(`Data cannot be encoded in JSON: ${data}`);
|
175 | }
|
176 | exports.encode = encode;
|
177 |
|
178 |
|
179 |
|
180 |
|
181 |
|
182 | function decode(data) {
|
183 | if (data === null) {
|
184 | return data;
|
185 | }
|
186 | if (data["@type"]) {
|
187 | switch (data["@type"]) {
|
188 | case LONG_TYPE:
|
189 |
|
190 | case UNSIGNED_LONG_TYPE: {
|
191 |
|
192 |
|
193 |
|
194 | const value = parseFloat(data.value);
|
195 | if (isNaN(value)) {
|
196 | logger.error("Data cannot be decoded from JSON.", data);
|
197 | throw new Error(`Data cannot be decoded from JSON: ${data}`);
|
198 | }
|
199 | return value;
|
200 | }
|
201 | default: {
|
202 | logger.error("Data cannot be decoded from JSON.", data);
|
203 | throw new Error(`Data cannot be decoded from JSON: ${data}`);
|
204 | }
|
205 | }
|
206 | }
|
207 | if (Array.isArray(data)) {
|
208 | return data.map(decode);
|
209 | }
|
210 | if (typeof data === "object") {
|
211 | const obj = {};
|
212 | for (const [k, v] of Object.entries(data)) {
|
213 | obj[k] = decode(v);
|
214 | }
|
215 | return obj;
|
216 | }
|
217 |
|
218 | return data;
|
219 | }
|
220 | exports.decode = decode;
|
221 |
|
222 | function unsafeDecodeToken(token) {
|
223 | if (!JWT_REGEX.test(token)) {
|
224 | return {};
|
225 | }
|
226 | const components = token.split(".").map((s) => Buffer.from(s, "base64").toString());
|
227 | let payload = components[1];
|
228 | if (typeof payload === "string") {
|
229 | try {
|
230 | const obj = JSON.parse(payload);
|
231 | if (typeof obj === "object") {
|
232 | payload = obj;
|
233 | }
|
234 | }
|
235 | catch (e) {
|
236 |
|
237 | }
|
238 | }
|
239 | return payload;
|
240 | }
|
241 | exports.unsafeDecodeToken = unsafeDecodeToken;
|
242 |
|
243 |
|
244 |
|
245 |
|
246 |
|
247 |
|
248 |
|
249 |
|
250 | function unsafeDecodeIdToken(token) {
|
251 | const decoded = unsafeDecodeToken(token);
|
252 | decoded.uid = decoded.sub;
|
253 | return decoded;
|
254 | }
|
255 | exports.unsafeDecodeIdToken = unsafeDecodeIdToken;
|
256 |
|
257 |
|
258 |
|
259 |
|
260 |
|
261 |
|
262 |
|
263 |
|
264 | function unsafeDecodeAppCheckToken(token) {
|
265 | const decoded = unsafeDecodeToken(token);
|
266 | decoded.app_id = decoded.sub;
|
267 | return decoded;
|
268 | }
|
269 | exports.unsafeDecodeAppCheckToken = unsafeDecodeAppCheckToken;
|
270 |
|
271 |
|
272 |
|
273 |
|
274 |
|
275 |
|
276 |
|
277 |
|
278 |
|
279 | async function checkTokens(req, ctx, options) {
|
280 | const verifications = {
|
281 | app: "INVALID",
|
282 | auth: "INVALID",
|
283 | };
|
284 | await Promise.all([
|
285 | Promise.resolve().then(async () => {
|
286 | verifications.auth = await checkAuthToken(req, ctx);
|
287 | }),
|
288 | Promise.resolve().then(async () => {
|
289 | verifications.app = await checkAppCheckToken(req, ctx, options);
|
290 | }),
|
291 | ]);
|
292 | const logPayload = {
|
293 | verifications,
|
294 | "logging.googleapis.com/labels": {
|
295 | "firebase-log-type": "callable-request-verification",
|
296 | },
|
297 | };
|
298 | const errs = [];
|
299 | if (verifications.app === "INVALID") {
|
300 | errs.push("AppCheck token was rejected.");
|
301 | }
|
302 | if (verifications.auth === "INVALID") {
|
303 | errs.push("Auth token was rejected.");
|
304 | }
|
305 | if (errs.length === 0) {
|
306 | logger.debug("Callable request verification passed", logPayload);
|
307 | }
|
308 | else {
|
309 | logger.warn(`Callable request verification failed: ${errs.join(" ")}`, logPayload);
|
310 | }
|
311 | return verifications;
|
312 | }
|
313 |
|
314 | async function checkAuthToken(req, ctx) {
|
315 | const authorization = req.header("Authorization");
|
316 | if (!authorization) {
|
317 | return "MISSING";
|
318 | }
|
319 | const match = authorization.match(/^Bearer (.*)$/i);
|
320 | if (!match) {
|
321 | return "INVALID";
|
322 | }
|
323 | const idToken = match[1];
|
324 | try {
|
325 | let authToken;
|
326 | if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification")) {
|
327 | authToken = unsafeDecodeIdToken(idToken);
|
328 | }
|
329 | else {
|
330 | authToken = await (0, auth_1.getAuth)((0, app_1.getApp)()).verifyIdToken(idToken);
|
331 | }
|
332 | ctx.auth = {
|
333 | uid: authToken.uid,
|
334 | token: authToken,
|
335 | };
|
336 | return "VALID";
|
337 | }
|
338 | catch (err) {
|
339 | logger.warn("Failed to validate auth token.", err);
|
340 | return "INVALID";
|
341 | }
|
342 | }
|
343 | exports.checkAuthToken = checkAuthToken;
|
344 |
|
345 | async function checkAppCheckToken(req, ctx, options) {
|
346 | var _a;
|
347 | const appCheckToken = req.header("X-Firebase-AppCheck");
|
348 | if (!appCheckToken) {
|
349 | return "MISSING";
|
350 | }
|
351 | try {
|
352 | let appCheckData;
|
353 | if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification")) {
|
354 | const decodedToken = unsafeDecodeAppCheckToken(appCheckToken);
|
355 | appCheckData = { appId: decodedToken.app_id, token: decodedToken };
|
356 | if (options.consumeAppCheckToken) {
|
357 | appCheckData.alreadyConsumed = false;
|
358 | }
|
359 | }
|
360 | else {
|
361 | const appCheck = (0, app_check_1.getAppCheck)((0, app_1.getApp)());
|
362 | if (options.consumeAppCheckToken) {
|
363 | if (((_a = appCheck.verifyToken) === null || _a === void 0 ? void 0 : _a.length) === 1) {
|
364 | const errorMsg = "Unsupported version of the Admin SDK." +
|
365 | " App Check token will not be consumed." +
|
366 | " Please upgrade the firebase-admin to the latest version.";
|
367 | logger.error(errorMsg);
|
368 | throw new HttpsError("internal", "Internal Error");
|
369 | }
|
370 | appCheckData = await (0, app_check_1.getAppCheck)((0, app_1.getApp)()).verifyToken(appCheckToken, { consume: true });
|
371 | }
|
372 | else {
|
373 | appCheckData = await (0, app_check_1.getAppCheck)((0, app_1.getApp)()).verifyToken(appCheckToken);
|
374 | }
|
375 | }
|
376 | ctx.app = appCheckData;
|
377 | return "VALID";
|
378 | }
|
379 | catch (err) {
|
380 | logger.warn("Failed to validate AppCheck token.", err);
|
381 | if (err instanceof HttpsError) {
|
382 | throw err;
|
383 | }
|
384 | return "INVALID";
|
385 | }
|
386 | }
|
387 |
|
388 | function onCallHandler(options, handler) {
|
389 | const wrapped = wrapOnCallHandler(options, handler);
|
390 | return (req, res) => {
|
391 | return new Promise((resolve) => {
|
392 | res.on("finish", resolve);
|
393 | cors(options.cors)(req, res, () => {
|
394 | resolve(wrapped(req, res));
|
395 | });
|
396 | });
|
397 | };
|
398 | }
|
399 | exports.onCallHandler = onCallHandler;
|
400 |
|
401 | function wrapOnCallHandler(options, handler) {
|
402 | return async (req, res) => {
|
403 | try {
|
404 | if (!isValidRequest(req)) {
|
405 | logger.error("Invalid request, unable to process.");
|
406 | throw new HttpsError("invalid-argument", "Bad Request");
|
407 | }
|
408 | const context = { rawRequest: req };
|
409 |
|
410 |
|
411 |
|
412 |
|
413 |
|
414 |
|
415 |
|
416 | if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification") && handler.length === 2) {
|
417 | const authContext = context.rawRequest.header(exports.CALLABLE_AUTH_HEADER);
|
418 | if (authContext) {
|
419 | logger.debug("Callable functions auth override", {
|
420 | key: exports.CALLABLE_AUTH_HEADER,
|
421 | value: authContext,
|
422 | });
|
423 | context.auth = JSON.parse(decodeURIComponent(authContext));
|
424 | delete context.rawRequest.headers[exports.CALLABLE_AUTH_HEADER];
|
425 | }
|
426 | const originalAuth = context.rawRequest.header(exports.ORIGINAL_AUTH_HEADER);
|
427 | if (originalAuth) {
|
428 | context.rawRequest.headers["authorization"] = originalAuth;
|
429 | delete context.rawRequest.headers[exports.ORIGINAL_AUTH_HEADER];
|
430 | }
|
431 | }
|
432 | const tokenStatus = await checkTokens(req, context, options);
|
433 | if (tokenStatus.auth === "INVALID") {
|
434 | throw new HttpsError("unauthenticated", "Unauthenticated");
|
435 | }
|
436 | if (tokenStatus.app === "INVALID") {
|
437 | if (options.enforceAppCheck) {
|
438 | throw new HttpsError("unauthenticated", "Unauthenticated");
|
439 | }
|
440 | else {
|
441 | logger.warn("Allowing request with invalid AppCheck token because enforcement is disabled");
|
442 | }
|
443 | }
|
444 | if (tokenStatus.app === "MISSING" && options.enforceAppCheck) {
|
445 | throw new HttpsError("unauthenticated", "Unauthenticated");
|
446 | }
|
447 | const instanceId = req.header("Firebase-Instance-ID-Token");
|
448 | if (instanceId) {
|
449 |
|
450 |
|
451 |
|
452 |
|
453 | context.instanceIdToken = req.header("Firebase-Instance-ID-Token");
|
454 | }
|
455 | const data = decode(req.body.data);
|
456 | let result;
|
457 | if (handler.length === 2) {
|
458 | result = await handler(data, context);
|
459 | }
|
460 | else {
|
461 | const arg = {
|
462 | ...context,
|
463 | data,
|
464 | };
|
465 |
|
466 |
|
467 | result = await handler(arg);
|
468 | }
|
469 |
|
470 | result = encode(result);
|
471 |
|
472 | const responseBody = { result };
|
473 | res.status(200).send(responseBody);
|
474 | }
|
475 | catch (err) {
|
476 | let httpErr = err;
|
477 | if (!(err instanceof HttpsError)) {
|
478 |
|
479 | logger.error("Unhandled error", err);
|
480 | httpErr = new HttpsError("internal", "INTERNAL");
|
481 | }
|
482 | const { status } = httpErr.httpErrorCode;
|
483 | const body = { error: httpErr.toJSON() };
|
484 | res.status(status).send(body);
|
485 | }
|
486 | };
|
487 | }
|