436 lines
16 KiB
JavaScript
436 lines
16 KiB
JavaScript
|
"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 = 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 apps_1 = require("../../apps");
|
||
|
const debug_1 = require("../debug");
|
||
|
const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/;
|
||
|
/**
|
||
|
* 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) { }
|
||
|
}
|
||
|
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.
|
||
|
* @return {CallableTokenStatus} Status of the token verifications.
|
||
|
*/
|
||
|
/** @internal */
|
||
|
async function checkTokens(req, ctx) {
|
||
|
const verifications = {
|
||
|
app: 'INVALID',
|
||
|
auth: 'INVALID',
|
||
|
};
|
||
|
await Promise.all([
|
||
|
Promise.resolve().then(async () => {
|
||
|
verifications.auth = await checkAuthToken(req, ctx);
|
||
|
}),
|
||
|
Promise.resolve().then(async () => {
|
||
|
verifications.app = await checkAppCheckToken(req, ctx);
|
||
|
}),
|
||
|
]);
|
||
|
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.info('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 (.*)$/);
|
||
|
if (match) {
|
||
|
const idToken = match[1];
|
||
|
try {
|
||
|
let authToken;
|
||
|
if ((0, debug_1.isDebugFeatureEnabled)('skipTokenVerification')) {
|
||
|
authToken = unsafeDecodeIdToken(idToken);
|
||
|
}
|
||
|
else {
|
||
|
authToken = await (0, apps_1.apps)()
|
||
|
.admin.auth()
|
||
|
.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) {
|
||
|
const appCheck = req.header('X-Firebase-AppCheck');
|
||
|
if (!appCheck) {
|
||
|
return 'MISSING';
|
||
|
}
|
||
|
try {
|
||
|
if (!(0, apps_1.apps)().admin.appCheck) {
|
||
|
throw new Error('Cannot validate AppCheck token. Please update Firebase Admin SDK to >= v9.8.0');
|
||
|
}
|
||
|
let appCheckData;
|
||
|
if ((0, debug_1.isDebugFeatureEnabled)('skipTokenVerification')) {
|
||
|
const decodedToken = unsafeDecodeAppCheckToken(appCheck);
|
||
|
appCheckData = { appId: decodedToken.app_id, token: decodedToken };
|
||
|
}
|
||
|
else {
|
||
|
appCheckData = await (0, apps_1.apps)()
|
||
|
.admin.appCheck()
|
||
|
.verifyToken(appCheck);
|
||
|
}
|
||
|
ctx.app = appCheckData;
|
||
|
return 'VALID';
|
||
|
}
|
||
|
catch (err) {
|
||
|
logger.warn('Failed to validate AppCheck token.', err);
|
||
|
return 'INVALID';
|
||
|
}
|
||
|
}
|
||
|
/** @internal */
|
||
|
function onCallHandler(options, handler) {
|
||
|
const wrapped = wrapOnCallHandler(options, handler);
|
||
|
return (req, res) => {
|
||
|
return new Promise((resolve) => {
|
||
|
res.on('finish', resolve);
|
||
|
cors(options.cors)(req, res, () => {
|
||
|
resolve(wrapped(req, res));
|
||
|
});
|
||
|
});
|
||
|
};
|
||
|
}
|
||
|
exports.onCallHandler = onCallHandler;
|
||
|
/** @internal */
|
||
|
function wrapOnCallHandler(options, handler) {
|
||
|
return async (req, res) => {
|
||
|
try {
|
||
|
if (!isValidRequest(req)) {
|
||
|
logger.error('Invalid request, unable to process.');
|
||
|
throw new HttpsError('invalid-argument', 'Bad Request');
|
||
|
}
|
||
|
const context = { rawRequest: req };
|
||
|
const tokenStatus = await checkTokens(req, context);
|
||
|
if (tokenStatus.auth === 'INVALID') {
|
||
|
throw new HttpsError('unauthenticated', 'Unauthenticated');
|
||
|
}
|
||
|
if (tokenStatus.app === 'INVALID' && !options.allowInvalidAppCheckToken) {
|
||
|
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 data = decode(req.body.data);
|
||
|
let result;
|
||
|
if (handler.length === 2) {
|
||
|
result = await handler(data, context);
|
||
|
}
|
||
|
else {
|
||
|
const arg = {
|
||
|
...context,
|
||
|
data,
|
||
|
};
|
||
|
// For some reason the type system isn't picking up that the handler
|
||
|
// is a one argument function.
|
||
|
result = await handler(arg);
|
||
|
}
|
||
|
// 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 };
|
||
|
res.status(200).send(responseBody);
|
||
|
}
|
||
|
catch (err) {
|
||
|
if (!(err instanceof HttpsError)) {
|
||
|
// This doesn't count as an 'explicit' error.
|
||
|
logger.error('Unhandled error', err);
|
||
|
err = new HttpsError('internal', 'INTERNAL');
|
||
|
}
|
||
|
const { status } = err.httpErrorCode;
|
||
|
const body = { error: err.toJSON() };
|
||
|
res.status(status).send(body);
|
||
|
}
|
||
|
};
|
||
|
}
|