UNPKG

firebase-functions

Version:
589 lines (587 loc) 17.2 kB
const require_rolldown_runtime = require('../../_virtual/rolldown_runtime.js'); const require_logger_index = require('../../logger/index.js'); const require_common_app = require('../app.js'); const require_common_debug = require('../debug.js'); let firebase_admin_auth = require("firebase-admin/auth"); firebase_admin_auth = require_rolldown_runtime.__toESM(firebase_admin_auth); let cors = require("cors"); cors = require_rolldown_runtime.__toESM(cors); let firebase_admin_app_check = require("firebase-admin/app-check"); firebase_admin_app_check = require_rolldown_runtime.__toESM(firebase_admin_app_check); //#region src/common/providers/https.ts const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/; /** @internal */ const CALLABLE_AUTH_HEADER = "x-callable-context-auth"; /** @internal */ const ORIGINAL_AUTH_HEADER = "x-original-auth"; /** @internal */ const DEFAULT_HEARTBEAT_SECONDS = 30; /** * Standard error codes and HTTP statuses for different ways a request can fail, * as defined by: * https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto * * This map is used primarily to convert from a client error code string to * to the HTTP format error code string and status, and make sure it's in the * supported set. */ const errorCodeMap = { ok: { canonicalName: "OK", status: 200 }, cancelled: { canonicalName: "CANCELLED", status: 499 }, unknown: { canonicalName: "UNKNOWN", status: 500 }, "invalid-argument": { canonicalName: "INVALID_ARGUMENT", status: 400 }, "deadline-exceeded": { canonicalName: "DEADLINE_EXCEEDED", status: 504 }, "not-found": { canonicalName: "NOT_FOUND", status: 404 }, "already-exists": { canonicalName: "ALREADY_EXISTS", status: 409 }, "permission-denied": { canonicalName: "PERMISSION_DENIED", status: 403 }, unauthenticated: { canonicalName: "UNAUTHENTICATED", status: 401 }, "resource-exhausted": { canonicalName: "RESOURCE_EXHAUSTED", status: 429 }, "failed-precondition": { canonicalName: "FAILED_PRECONDITION", status: 400 }, aborted: { canonicalName: "ABORTED", status: 409 }, "out-of-range": { canonicalName: "OUT_OF_RANGE", status: 400 }, unimplemented: { canonicalName: "UNIMPLEMENTED", status: 501 }, internal: { canonicalName: "INTERNAL", status: 500 }, unavailable: { canonicalName: "UNAVAILABLE", status: 503 }, "data-loss": { canonicalName: "DATA_LOSS", status: 500 } }; /** * An explicit error that can be thrown from a handler to send an error to the * client that called the function. */ var HttpsError = class extends Error { constructor(code, message, details) { super(message); if (code in errorCodeMap === false) { throw new Error(`Unknown error code: ${code}.`); } this.code = code; this.details = details; this.httpErrorCode = errorCodeMap[code]; } /** * Returns a JSON-serializable representation of this object. */ toJSON() { const { details, httpErrorCode: { canonicalName: status }, message } = this; return { ...details === undefined ? {} : { details }, message, status }; } }; /** @hidden */ function isValidRequest(req) { if (!req.body) { require_logger_index.warn("Request is missing body."); return false; } if (req.method !== "POST") { require_logger_index.warn("Request has invalid method.", req.method); return false; } let contentType = (req.header("Content-Type") || "").toLowerCase(); const semiColon = contentType.indexOf(";"); if (semiColon >= 0) { contentType = contentType.slice(0, semiColon).trim(); } if (contentType !== "application/json") { require_logger_index.warn("Request has incorrect Content-Type.", contentType); return false; } if (typeof req.body.data === "undefined") { require_logger_index.warn("Request body is missing data.", req.body); return false; } const extraKeys = Object.keys(req.body).filter((field) => field !== "data"); if (extraKeys.length !== 0) { require_logger_index.warn("Request body has extra fields: ", extraKeys.join(", ")); return false; } return true; } /** @hidden */ const LONG_TYPE = "type.googleapis.com/google.protobuf.Int64Value"; /** @hidden */ const UNSIGNED_LONG_TYPE = "type.googleapis.com/google.protobuf.UInt64Value"; /** * Encodes arbitrary data in our special format for JSON. * This is exposed only for testing. */ /** @hidden */ function encode(data) { if (data === null || typeof data === "undefined") { return null; } if (data instanceof Number) { data = data.valueOf(); } if (Number.isFinite(data)) { return data; } if (typeof data === "boolean") { return data; } if (typeof data === "string") { return data; } if (Array.isArray(data)) { return data.map(encode); } if (typeof data === "object" || typeof data === "function") { const obj = {}; for (const [k, v] of Object.entries(data)) { obj[k] = encode(v); } return obj; } require_logger_index.error("Data cannot be encoded in JSON.", data); throw new Error(`Data cannot be encoded in JSON: ${data}`); } /** * Decodes our special format for JSON into native types. * This is exposed only for testing. */ /** @hidden */ function decode(data) { if (data === null) { return data; } if (data["@type"]) { switch (data["@type"]) { case LONG_TYPE: case UNSIGNED_LONG_TYPE: { const value = parseFloat(data.value); if (isNaN(value)) { require_logger_index.error("Data cannot be decoded from JSON.", data); throw new Error(`Data cannot be decoded from JSON: ${data}`); } return value; } default: { require_logger_index.error("Data cannot be decoded from JSON.", data); throw new Error(`Data cannot be decoded from JSON: ${data}`); } } } if (Array.isArray(data)) { return data.map(decode); } if (typeof data === "object") { const obj = {}; for (const [k, v] of Object.entries(data)) { obj[k] = decode(v); } return obj; } return data; } /** @internal */ function unsafeDecodeToken(token) { if (!JWT_REGEX.test(token)) { return {}; } const components = token.split(".").map((s) => Buffer.from(s, "base64").toString()); let payload = components[1]; if (typeof payload === "string") { try { const obj = JSON.parse(payload); if (typeof obj === "object") { payload = obj; } } catch (_e) {} } return payload; } /** * Decode, but not verify, a Auth ID token. * * Do not use in production. Token should always be verified using the Admin SDK. * * This is exposed only for testing. */ /** @internal */ function unsafeDecodeIdToken(token) { const decoded = unsafeDecodeToken(token); decoded.uid = decoded.sub; return decoded; } /** * Decode, but not verify, an App Check token. * * Do not use in production. Token should always be verified using the Admin SDK. * * This is exposed only for testing. */ /** @internal */ function unsafeDecodeAppCheckToken(token) { const decoded = unsafeDecodeToken(token); decoded.app_id = decoded.sub; return decoded; } /** * Check and verify tokens included in the requests. Once verified, tokens * are injected into the callable context. * * @param {Request} req - Request sent to the Callable function. * @param {CallableContext} ctx - Context to be sent to callable function handler. * @returns {CallableTokenStatus} Status of the token verifications. */ /** @internal */ async function checkTokens(req, ctx, options) { const verifications = { app: "INVALID", auth: "INVALID" }; [verifications.auth, verifications.app] = await Promise.all([checkAuthToken(req, ctx), checkAppCheckToken(req, ctx, options)]); const logPayload = { verifications, "logging.googleapis.com/labels": { "firebase-log-type": "callable-request-verification" } }; const errs = []; if (verifications.app === "INVALID") { errs.push("AppCheck token was rejected."); } if (verifications.auth === "INVALID") { errs.push("Auth token was rejected."); } if (errs.length === 0) { require_logger_index.debug("Callable request verification passed", logPayload); } else { require_logger_index.warn(`Callable request verification failed: ${errs.join(" ")}`, logPayload); } return verifications; } /** @interanl */ async function checkAuthToken(req, ctx) { const authorization = req.header("Authorization"); if (!authorization) { return "MISSING"; } const match = authorization.match(/^Bearer (.*)$/i); if (!match) { return "INVALID"; } const idToken = match[1]; try { let authToken; if (require_common_debug.isDebugFeatureEnabled("skipTokenVerification")) { authToken = unsafeDecodeIdToken(idToken); } else { authToken = await (0, firebase_admin_auth.getAuth)(require_common_app.getApp()).verifyIdToken(idToken); } ctx.auth = { uid: authToken.uid, token: authToken, rawToken: idToken }; return "VALID"; } catch (err) { require_logger_index.warn("Failed to validate auth token.", err); return "INVALID"; } } /** @internal */ async function checkAppCheckToken(req, ctx, options) { const appCheckToken = req.header("X-Firebase-AppCheck"); if (!appCheckToken) { return "MISSING"; } try { let appCheckData; if (require_common_debug.isDebugFeatureEnabled("skipTokenVerification")) { const decodedToken = unsafeDecodeAppCheckToken(appCheckToken); appCheckData = { appId: decodedToken.app_id, token: decodedToken }; if (options.consumeAppCheckToken) { appCheckData.alreadyConsumed = false; } } else { const appCheck = (0, firebase_admin_app_check.getAppCheck)(require_common_app.getApp()); if (options.consumeAppCheckToken) { if (appCheck.verifyToken?.length === 1) { const errorMsg = "Unsupported version of the Admin SDK." + " App Check token will not be consumed." + " Please upgrade the firebase-admin to the latest version."; require_logger_index.error(errorMsg); throw new HttpsError("internal", "Internal Error"); } appCheckData = await (0, firebase_admin_app_check.getAppCheck)(require_common_app.getApp()).verifyToken(appCheckToken, { consume: true }); } else { appCheckData = await (0, firebase_admin_app_check.getAppCheck)(require_common_app.getApp()).verifyToken(appCheckToken); } } ctx.app = appCheckData; return "VALID"; } catch (err) { require_logger_index.warn("Failed to validate AppCheck token.", err); if (err instanceof HttpsError) { throw err; } return "INVALID"; } } /** @internal */ function onCallHandler(options, handler, version) { const wrapped = wrapOnCallHandler(options, handler, version); return (req, res) => { return new Promise((resolve) => { res.on("finish", resolve); (0, cors.default)(options.cors)(req, res, () => { resolve(wrapped(req, res)); }); }); }; } function encodeSSE(data) { return `data: ${JSON.stringify(data)}\n\n`; } /** @internal */ function wrapOnCallHandler(options, handler, version) { return async (req, res) => { const abortController = new AbortController(); let heartbeatInterval = null; const heartbeatSeconds = options.heartbeatSeconds === undefined ? DEFAULT_HEARTBEAT_SECONDS : options.heartbeatSeconds; const clearScheduledHeartbeat = () => { if (heartbeatInterval) { clearTimeout(heartbeatInterval); heartbeatInterval = null; } }; const scheduleHeartbeat = () => { clearScheduledHeartbeat(); if (!abortController.signal.aborted) { heartbeatInterval = setTimeout(() => { if (!abortController.signal.aborted) { res.write(": ping\n\n"); scheduleHeartbeat(); } }, heartbeatSeconds * 1e3); } }; res.on("close", () => { clearScheduledHeartbeat(); abortController.abort(); }); try { if (!isValidRequest(req)) { require_logger_index.error("Invalid request, unable to process."); throw new HttpsError("invalid-argument", "Bad Request"); } const context = { rawRequest: req }; if (require_common_debug.isDebugFeatureEnabled("skipTokenVerification") && version === "gcfv1") { const authContext = context.rawRequest.header(CALLABLE_AUTH_HEADER); if (authContext) { require_logger_index.debug("Callable functions auth override", { key: CALLABLE_AUTH_HEADER, value: authContext }); context.auth = JSON.parse(decodeURIComponent(authContext)); delete context.rawRequest.headers[CALLABLE_AUTH_HEADER]; } const originalAuth = context.rawRequest.header(ORIGINAL_AUTH_HEADER); if (originalAuth) { context.rawRequest.headers["authorization"] = originalAuth; delete context.rawRequest.headers[ORIGINAL_AUTH_HEADER]; } } const tokenStatus = await checkTokens(req, context, options); if (tokenStatus.auth === "INVALID") { throw new HttpsError("unauthenticated", "Unauthenticated"); } if (tokenStatus.app === "INVALID") { if (options.enforceAppCheck) { throw new HttpsError("unauthenticated", "Unauthenticated"); } else { require_logger_index.warn("Allowing request with invalid AppCheck token because enforcement is disabled"); } } if (tokenStatus.app === "MISSING" && options.enforceAppCheck) { throw new HttpsError("unauthenticated", "Unauthenticated"); } const instanceId = req.header("Firebase-Instance-ID-Token"); if (instanceId) { context.instanceIdToken = req.header("Firebase-Instance-ID-Token"); } const acceptsStreaming = req.header("accept") === "text/event-stream"; if (acceptsStreaming && version === "gcfv1") { throw new HttpsError("invalid-argument", "Unsupported Accept header 'text/event-stream'"); } const data = decode(req.body.data); if (options.authPolicy) { const authorized = await options.authPolicy(context.auth ?? null, data); if (!authorized) { throw new HttpsError("permission-denied", "Permission Denied"); } } let result; if (version === "gcfv1") { result = await handler(data, context); } else { const arg = { ...context, data, acceptsStreaming }; const responseProxy = { sendChunk(chunk) { if (!acceptsStreaming) { return Promise.resolve(false); } if (abortController.signal.aborted) { return Promise.resolve(false); } const formattedData = encodeSSE({ message: chunk }); let resolve; let reject; const p = new Promise((res$1, rej) => { resolve = res$1; reject = rej; }); const wrote = res.write(formattedData, (error$1) => { if (error$1) { reject(error$1); return; } resolve(wrote); }); if (wrote && heartbeatInterval !== null && heartbeatSeconds > 0) { scheduleHeartbeat(); } return p; }, signal: abortController.signal }; if (acceptsStreaming) { res.status(200); if (heartbeatSeconds !== null && heartbeatSeconds > 0) { scheduleHeartbeat(); } } result = await handler(arg, responseProxy); clearScheduledHeartbeat(); } if (!abortController.signal.aborted) { result = encode(result); const responseBody = { result }; if (acceptsStreaming) { res.write(encodeSSE(responseBody)); res.end(); } else { res.status(200).send(responseBody); } } else { res.end(); } } catch (err) { if (!abortController.signal.aborted) { let httpErr = err; if (!(err instanceof HttpsError)) { require_logger_index.error("Unhandled error", err); httpErr = new HttpsError("internal", "INTERNAL"); } const { status } = httpErr.httpErrorCode; const body = { error: httpErr.toJSON() }; if (version === "gcfv2" && req.header("accept") === "text/event-stream") { res.write(encodeSSE(body)); res.end(); } else { res.status(status).send(body); } } else { res.end(); } } finally { clearScheduledHeartbeat(); } }; } /** * Wraps an HTTP handler with a safety net for unhandled errors. * * This wrapper catches both synchronous errors and rejected Promises from `async` handlers. * Without this, an unhandled error in an `async` handler would cause the request to hang * until the platform timeout, as Express (v4) does not await handlers. * * It logs the error and returns a 500 Internal Server Error to the client if the response * headers have not yet been sent. * * @internal */ function withErrorHandler(handler) { return async (req, res) => { try { await handler(req, res); } catch (err) { require_logger_index.error("Unhandled error", err); if (!res.headersSent) { res.status(500).send("Internal Server Error"); } } }; } //#endregion exports.CALLABLE_AUTH_HEADER = CALLABLE_AUTH_HEADER; exports.DEFAULT_HEARTBEAT_SECONDS = DEFAULT_HEARTBEAT_SECONDS; exports.HttpsError = HttpsError; exports.ORIGINAL_AUTH_HEADER = ORIGINAL_AUTH_HEADER; exports.checkAuthToken = checkAuthToken; exports.decode = decode; exports.encode = encode; exports.isValidRequest = isValidRequest; exports.onCallHandler = onCallHandler; exports.unsafeDecodeAppCheckToken = unsafeDecodeAppCheckToken; exports.unsafeDecodeIdToken = unsafeDecodeIdToken; exports.unsafeDecodeToken = unsafeDecodeToken; exports.withErrorHandler = withErrorHandler;