UNPKG

19.1 kBJavaScriptView Raw
1"use strict";
2// The MIT License (MIT)
3//
4// Copyright (c) 2021 Firebase
5//
6// Permission is hereby granted, free of charge, to any person obtaining a copy
7// of this software and associated documentation files (the "Software"), to deal
8// in the Software without restriction, including without limitation the rights
9// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10// copies of the Software, and to permit persons to whom the Software is
11// furnished to do so, subject to the following conditions:
12//
13// The above copyright notice and this permission notice shall be included in all
14// copies or substantial portions of the Software.
15//
16// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22// SOFTWARE.
23Object.defineProperty(exports, "__esModule", { value: true });
24exports.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;
25const cors = require("cors");
26const logger = require("../../logger");
27// TODO(inlined): Decide whether we want to un-version apps or whether we want a
28// different strategy
29const app_check_1 = require("firebase-admin/app-check");
30const auth_1 = require("firebase-admin/auth");
31const app_1 = require("../app");
32const debug_1 = require("../debug");
33const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/;
34/** @internal */
35exports.CALLABLE_AUTH_HEADER = "x-callable-context-auth";
36/** @internal */
37exports.ORIGINAL_AUTH_HEADER = "x-original-auth";
38/**
39 * Standard error codes and HTTP statuses for different ways a request can fail,
40 * as defined by:
41 * https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
42 *
43 * This map is used primarily to convert from a client error code string to
44 * to the HTTP format error code string and status, and make sure it's in the
45 * supported set.
46 */
47const 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 * An explicit error that can be thrown from a handler to send an error to the
68 * client that called the function.
69 */
70class HttpsError extends Error {
71 constructor(code, message, details) {
72 super(message);
73 // A sanity check for non-TypeScript consumers.
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 * Returns a JSON-serializable representation of this object.
83 */
84 toJSON() {
85 const { details, httpErrorCode: { canonicalName: status }, message, } = this;
86 return {
87 ...(details === undefined ? {} : { details }),
88 message,
89 status,
90 };
91 }
92}
93exports.HttpsError = HttpsError;
94/** @hidden */
95// Returns true if req is a properly formatted callable request.
96function isValidRequest(req) {
97 // The body must not be empty.
98 if (!req.body) {
99 logger.warn("Request is missing body.");
100 return false;
101 }
102 // Make sure it's a POST.
103 if (req.method !== "POST") {
104 logger.warn("Request has invalid method.", req.method);
105 return false;
106 }
107 // Check that the Content-Type is JSON.
108 let contentType = (req.header("Content-Type") || "").toLowerCase();
109 // If it has a charset, just ignore it for now.
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 // The body must have data.
119 if (typeof req.body.data === "undefined") {
120 logger.warn("Request body is missing data.", req.body);
121 return false;
122 }
123 // TODO(klimt): Allow only specific http headers.
124 // Verify that the body does not have any extra fields.
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}
132exports.isValidRequest = isValidRequest;
133/** @hidden */
134const LONG_TYPE = "type.googleapis.com/google.protobuf.Int64Value";
135/** @hidden */
136const UNSIGNED_LONG_TYPE = "type.googleapis.com/google.protobuf.UInt64Value";
137/**
138 * Encodes arbitrary data in our special format for JSON.
139 * This is exposed only for testing.
140 */
141/** @hidden */
142function 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 // Any number in JS is safe to put directly in JSON and parse as a double
151 // without any loss of precision.
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 // Sadly we don't have Object.fromEntries in Node 10, so we can't use a single
165 // list comprehension
166 const obj = {};
167 for (const [k, v] of Object.entries(data)) {
168 obj[k] = encode(v);
169 }
170 return obj;
171 }
172 // If we got this far, the data is not encodable.
173 logger.error("Data cannot be encoded in JSON.", data);
174 throw new Error(`Data cannot be encoded in JSON: ${data}`);
175}
176exports.encode = encode;
177/**
178 * Decodes our special format for JSON into native types.
179 * This is exposed only for testing.
180 */
181/** @hidden */
182function decode(data) {
183 if (data === null) {
184 return data;
185 }
186 if (data["@type"]) {
187 switch (data["@type"]) {
188 case LONG_TYPE:
189 // Fall through and handle this the same as unsigned.
190 case UNSIGNED_LONG_TYPE: {
191 // Technically, this could work return a valid number for malformed
192 // data if there was a number followed by garbage. But it's just not
193 // worth all the extra code to detect that case.
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 // Anything else is safe to return.
218 return data;
219}
220exports.decode = decode;
221/** @internal */
222function 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 // ignore error
237 }
238 }
239 return payload;
240}
241exports.unsafeDecodeToken = unsafeDecodeToken;
242/**
243 * Decode, but not verify, a Auth ID token.
244 *
245 * Do not use in production. Token should always be verified using the Admin SDK.
246 *
247 * This is exposed only for testing.
248 */
249/** @internal */
250function unsafeDecodeIdToken(token) {
251 const decoded = unsafeDecodeToken(token);
252 decoded.uid = decoded.sub;
253 return decoded;
254}
255exports.unsafeDecodeIdToken = unsafeDecodeIdToken;
256/**
257 * Decode, but not verify, an App Check token.
258 *
259 * Do not use in production. Token should always be verified using the Admin SDK.
260 *
261 * This is exposed only for testing.
262 */
263/** @internal */
264function unsafeDecodeAppCheckToken(token) {
265 const decoded = unsafeDecodeToken(token);
266 decoded.app_id = decoded.sub;
267 return decoded;
268}
269exports.unsafeDecodeAppCheckToken = unsafeDecodeAppCheckToken;
270/**
271 * Check and verify tokens included in the requests. Once verified, tokens
272 * are injected into the callable context.
273 *
274 * @param {Request} req - Request sent to the Callable function.
275 * @param {CallableContext} ctx - Context to be sent to callable function handler.
276 * @returns {CallableTokenStatus} Status of the token verifications.
277 */
278/** @internal */
279async 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/** @interanl */
314async 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}
343exports.checkAuthToken = checkAuthToken;
344/** @internal */
345async 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/** @internal */
388function 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}
399exports.onCallHandler = onCallHandler;
400/** @internal */
401function 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 // TODO(colerogers): yank this when we release a breaking change of the CLI that removes
410 // our monkey-patching code referenced below and increases the minimum supported SDK version.
411 //
412 // Note: This code is needed to fix v1 callable functions in the emulator with a monorepo setup.
413 // The original monkey-patched code lived in the functionsEmulatorRuntime
414 // (link: https://github.com/firebase/firebase-tools/blob/accea7abda3cc9fa6bb91368e4895faf95281c60/src/emulator/functionsEmulatorRuntime.ts#L480)
415 // and was not compatible with how monorepos separate out packages (see https://github.com/firebase/firebase-tools/issues/5210).
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 // Validating the token requires an http request, so we don't do it.
450 // If the user wants to use it for something, it will be validated then.
451 // Currently, the only real use case for this token is for sending
452 // pushes with FCM. In that case, the FCM APIs will validate the token.
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 // For some reason the type system isn't picking up that the handler
466 // is a one argument function.
467 result = await handler(arg);
468 }
469 // Encode the result as JSON to preserve types like Dates.
470 result = encode(result);
471 // If there was some result, encode it in the body.
472 const responseBody = { result };
473 res.status(200).send(responseBody);
474 }
475 catch (err) {
476 let httpErr = err;
477 if (!(err instanceof HttpsError)) {
478 // This doesn't count as an 'explicit' error.
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}