"use strict";
// The MIT License (MIT)
//
// Copyright (c) 2021 Firebase
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
Object.defineProperty(exports, "__esModule", { value: true });
exports.onCallHandler = exports.checkAuthToken = exports.unsafeDecodeAppCheckToken = exports.unsafeDecodeIdToken = exports.unsafeDecodeToken = exports.decode = exports.encode = exports.isValidRequest = exports.HttpsError = exports.DEFAULT_HEARTBEAT_SECONDS = exports.ORIGINAL_AUTH_HEADER = exports.CALLABLE_AUTH_HEADER = void 0;
const cors = require("cors");
const logger = require("../../logger");
// TODO(inlined): Decide whether we want to un-version apps or whether we want a
// different strategy
const app_check_1 = require("firebase-admin/app-check");
const auth_1 = require("firebase-admin/auth");
const app_1 = require("../app");
const debug_1 = require("../debug");
const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/;
/** @internal */
exports.CALLABLE_AUTH_HEADER = "x-callable-context-auth";
/** @internal */
exports.ORIGINAL_AUTH_HEADER = "x-original-auth";
/** @internal */
exports.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.
 */
class HttpsError extends Error {
    constructor(code, message, details) {
        super(message);
        // A sanity check for non-TypeScript consumers.
        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,
        };
    }
}
exports.HttpsError = HttpsError;
/** @hidden */
// Returns true if req is a properly formatted callable request.
function isValidRequest(req) {
    // The body must not be empty.
    if (!req.body) {
        logger.warn("Request is missing body.");
        return false;
    }
    // Make sure it's a POST.
    if (req.method !== "POST") {
        logger.warn("Request has invalid method.", req.method);
        return false;
    }
    // Check that the Content-Type is JSON.
    let contentType = (req.header("Content-Type") || "").toLowerCase();
    // If it has a charset, just ignore it for now.
    const semiColon = contentType.indexOf(";");
    if (semiColon >= 0) {
        contentType = contentType.slice(0, semiColon).trim();
    }
    if (contentType !== "application/json") {
        logger.warn("Request has incorrect Content-Type.", contentType);
        return false;
    }
    // The body must have data.
    if (typeof req.body.data === "undefined") {
        logger.warn("Request body is missing data.", req.body);
        return false;
    }
    // TODO(klimt): Allow only specific http headers.
    // Verify that the body does not have any extra fields.
    const extraKeys = Object.keys(req.body).filter((field) => field !== "data");
    if (extraKeys.length !== 0) {
        logger.warn("Request body has extra fields: ", extraKeys.join(", "));
        return false;
    }
    return true;
}
exports.isValidRequest = isValidRequest;
/** @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)) {
        // Any number in JS is safe to put directly in JSON and parse as a double
        // without any loss of precision.
        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") {
        // Sadly we don't have Object.fromEntries in Node 10, so we can't use a single
        // list comprehension
        const obj = {};
        for (const [k, v] of Object.entries(data)) {
            obj[k] = encode(v);
        }
        return obj;
    }
    // If we got this far, the data is not encodable.
    logger.error("Data cannot be encoded in JSON.", data);
    throw new Error(`Data cannot be encoded in JSON: ${data}`);
}
exports.encode = encode;
/**
 * 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:
            // Fall through and handle this the same as unsigned.
            case UNSIGNED_LONG_TYPE: {
                // Technically, this could work return a valid number for malformed
                // data if there was a number followed by garbage. But it's just not
                // worth all the extra code to detect that case.
                const value = parseFloat(data.value);
                if (isNaN(value)) {
                    logger.error("Data cannot be decoded from JSON.", data);
                    throw new Error(`Data cannot be decoded from JSON: ${data}`);
                }
                return value;
            }
            default: {
                logger.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;
    }
    // Anything else is safe to return.
    return data;
}
exports.decode = decode;
/** @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) {
            // ignore error
        }
    }
    return payload;
}
exports.unsafeDecodeToken = unsafeDecodeToken;
/**
 * 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;
}
exports.unsafeDecodeIdToken = unsafeDecodeIdToken;
/**
 * 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;
}
exports.unsafeDecodeAppCheckToken = unsafeDecodeAppCheckToken;
/**
 * 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) {
        logger.debug("Callable request verification passed", logPayload);
    }
    else {
        logger.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 ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification")) {
            authToken = unsafeDecodeIdToken(idToken);
        }
        else {
            authToken = await (0, auth_1.getAuth)((0, app_1.getApp)()).verifyIdToken(idToken);
        }
        ctx.auth = {
            uid: authToken.uid,
            token: authToken,
        };
        return "VALID";
    }
    catch (err) {
        logger.warn("Failed to validate auth token.", err);
        return "INVALID";
    }
}
exports.checkAuthToken = checkAuthToken;
/** @internal */
async function checkAppCheckToken(req, ctx, options) {
    var _a;
    const appCheckToken = req.header("X-Firebase-AppCheck");
    if (!appCheckToken) {
        return "MISSING";
    }
    try {
        let appCheckData;
        if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification")) {
            const decodedToken = unsafeDecodeAppCheckToken(appCheckToken);
            appCheckData = { appId: decodedToken.app_id, token: decodedToken };
            if (options.consumeAppCheckToken) {
                appCheckData.alreadyConsumed = false;
            }
        }
        else {
            const appCheck = (0, app_check_1.getAppCheck)((0, app_1.getApp)());
            if (options.consumeAppCheckToken) {
                if (((_a = appCheck.verifyToken) === null || _a === void 0 ? void 0 : _a.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.";
                    logger.error(errorMsg);
                    throw new HttpsError("internal", "Internal Error");
                }
                appCheckData = await (0, app_check_1.getAppCheck)((0, app_1.getApp)()).verifyToken(appCheckToken, { consume: true });
            }
            else {
                appCheckData = await (0, app_check_1.getAppCheck)((0, app_1.getApp)()).verifyToken(appCheckToken);
            }
        }
        ctx.app = appCheckData;
        return "VALID";
    }
    catch (err) {
        logger.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);
            cors(options.cors)(req, res, () => {
                resolve(wrapped(req, res));
            });
        });
    };
}
exports.onCallHandler = onCallHandler;
function encodeSSE(data) {
    return `data: ${JSON.stringify(data)}\n\n`;
}
/** @internal */
function wrapOnCallHandler(options, handler, version) {
    return async (req, res) => {
        var _a;
        const abortController = new AbortController();
        let heartbeatInterval = null;
        const heartbeatSeconds = options.heartbeatSeconds === undefined ? exports.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 * 1000);
            }
        };
        res.on("close", () => {
            clearScheduledHeartbeat();
            abortController.abort();
        });
        try {
            if (!isValidRequest(req)) {
                logger.error("Invalid request, unable to process.");
                throw new HttpsError("invalid-argument", "Bad Request");
            }
            const context = { rawRequest: req };
            // TODO(colerogers): yank this when we release a breaking change of the CLI that removes
            // our monkey-patching code referenced below and increases the minimum supported SDK version.
            //
            // Note: This code is needed to fix v1 callable functions in the emulator with a monorepo setup.
            // The original monkey-patched code lived in the functionsEmulatorRuntime
            // (link: https://github.com/firebase/firebase-tools/blob/accea7abda3cc9fa6bb91368e4895faf95281c60/src/emulator/functionsEmulatorRuntime.ts#L480)
            // and was not compatible with how monorepos separate out packages (see https://github.com/firebase/firebase-tools/issues/5210).
            if ((0, debug_1.isDebugFeatureEnabled)("skipTokenVerification") && version === "gcfv1") {
                const authContext = context.rawRequest.header(exports.CALLABLE_AUTH_HEADER);
                if (authContext) {
                    logger.debug("Callable functions auth override", {
                        key: exports.CALLABLE_AUTH_HEADER,
                        value: authContext,
                    });
                    context.auth = JSON.parse(decodeURIComponent(authContext));
                    delete context.rawRequest.headers[exports.CALLABLE_AUTH_HEADER];
                }
                const originalAuth = context.rawRequest.header(exports.ORIGINAL_AUTH_HEADER);
                if (originalAuth) {
                    context.rawRequest.headers["authorization"] = originalAuth;
                    delete context.rawRequest.headers[exports.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 {
                    logger.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) {
                // Validating the token requires an http request, so we don't do it.
                // If the user wants to use it for something, it will be validated then.
                // Currently, the only real use case for this token is for sending
                // pushes with FCM. In that case, the FCM APIs will validate the token.
                context.instanceIdToken = req.header("Firebase-Instance-ID-Token");
            }
            const acceptsStreaming = req.header("accept") === "text/event-stream";
            if (acceptsStreaming && version === "gcfv1") {
                // streaming responses are not supported in v1 callable
                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((_a = context.auth) !== null && _a !== void 0 ? _a : 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 client doesn't accept sse-protocol, response.write() is no-op.
                        if (!acceptsStreaming) {
                            return Promise.resolve(false);
                        }
                        // if connection is already closed, response.write() is no-op.
                        if (abortController.signal.aborted) {
                            return Promise.resolve(false);
                        }
                        const formattedData = encodeSSE({ message: chunk });
                        let resolve;
                        let reject;
                        const p = new Promise((res, rej) => {
                            resolve = res;
                            reject = rej;
                        });
                        const wrote = res.write(formattedData, (error) => {
                            if (error) {
                                reject(error);
                                return;
                            }
                            resolve(wrote);
                        });
                        // Reset heartbeat timer after successful write
                        if (wrote && heartbeatInterval !== null && heartbeatSeconds > 0) {
                            scheduleHeartbeat();
                        }
                        return p;
                    },
                    signal: abortController.signal,
                };
                if (acceptsStreaming) {
                    // SSE always responds with 200
                    res.status(200);
                    if (heartbeatSeconds !== null && heartbeatSeconds > 0) {
                        scheduleHeartbeat();
                    }
                }
                // For some reason the type system isn't picking up that the handler
                // is a one argument function.
                result = await handler(arg, responseProxy);
                clearScheduledHeartbeat();
            }
            if (!abortController.signal.aborted) {
                // Encode the result as JSON to preserve types like Dates.
                result = encode(result);
                // If there was some result, encode it in the body.
                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)) {
                    // This doesn't count as an 'explicit' error.
                    logger.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();
        }
    };
}
