Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions scripts/emulator-tests/functionsEmulatorRuntime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@
reject(new Error("Timeout - runtime server not ready"));
}, 10_000);
});
while (true) {

Check warning on line 62 in scripts/emulator-tests/functionsEmulatorRuntime.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
try {
await Promise.race([isSocketReady(socketPath), timeout]);
break;
} catch (err: any) {

Check warning on line 66 in scripts/emulator-tests/functionsEmulatorRuntime.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
// Allow us to wait until the server is listening.
if (["ECONNREFUSED", "ENOENT"].includes(err?.code)) {

Check warning on line 68 in scripts/emulator-tests/functionsEmulatorRuntime.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .code on an `any` value

Check warning on line 68 in scripts/emulator-tests/functionsEmulatorRuntime.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `string`
await sleep(100);
continue;
}
Expand Down Expand Up @@ -398,36 +398,6 @@
});
});
});
describe("_InitializeFunctionsConfigHelper()", () => {
const cfgPath = path.join(FUNCTIONS_DIR, ".runtimeconfig.json");

before(async () => {
await fs.writeFile(cfgPath, '{"real":{"exist":"already exists" }}');
});

after(async () => {
await fs.unlink(cfgPath);
});

it("should tell the user if they've accessed a non-existent function field", async () => {
runtime = await startRuntime("functionId", "event", () => {
require("firebase-admin").initializeApp();
return {
functionId: require("firebase-functions")
.firestore.document("test/test")
.onCreate(() => {
// Exists
console.log(require("firebase-functions").config().real);
// Does not exist
console.log(require("firebase-functions").config().foo);
console.log(require("firebase-functions").config().bar);
}),
};
});
await sendEvent(runtime, FunctionRuntimeBundles.onCreate.proto);
expect(runtime.sysMsg["functions-config-missing-value"]?.length).to.eq(2);
});
});
describe("Runtime", () => {
describe("HTTPS", () => {
it("should handle a GET request", async () => {
Expand Down
58 changes: 0 additions & 58 deletions src/emulator/functionsEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as path from "path";
import * as express from "express";
import * as clc from "colorette";
import * as http from "http";
import * as jwt from "jsonwebtoken";
import * as cors from "cors";
import * as semver from "semver";
import { URL } from "url";
Expand All @@ -28,7 +27,6 @@ import {
FunctionsRuntimeFeatures,
getFunctionService,
getSignatureType,
HttpConstants,
ParsedTriggerDefinition,
emulatedFunctionsFromEndpoints,
emulatedFunctionsByRegion,
Expand Down Expand Up @@ -1732,41 +1730,6 @@ export class FunctionsEmulator implements EmulatorInstance {
return EmulatorRegistry.getInfo(emulator);
}

private tokenFromAuthHeader(authHeader: string) {
const match = /^Bearer (.*)$/.exec(authHeader);
if (!match) {
return;
}

let idToken = match[1];
logger.debug(`ID Token: ${idToken}`);

// The @firebase/testing library sometimes produces JWTs with invalid padding, so we
// remove that via regex. This is the spec that says trailing = should be removed:
// https://tools.ietf.org/html/rfc7515#section-2
if (idToken && idToken.includes("=")) {
idToken = idToken.replace(/[=]+?\./g, ".");
logger.debug(`ID Token contained invalid padding, new value: ${idToken}`);
}

try {
const decoded = jwt.decode(idToken, { complete: true }) as any;
if (!decoded || typeof decoded !== "object") {
logger.debug(`Failed to decode ID Token: ${decoded}`);
return;
}

// In firebase-functions we manually copy 'sub' to 'uid'
// https://github.com/firebase/firebase-admin-node/blob/0b2082f1576f651e75069e38ce87e639c25289af/src/auth/token-verifier.ts#L249
const claims = decoded.payload as jwt.JwtPayload;
claims.uid = claims.sub;

return claims;
} catch (e: any) {
return;
}
}

private async handleHttpsTrigger(req: express.Request, res: express.Response) {
const method = req.method;
let triggerId: string = req.params.trigger_name;
Expand Down Expand Up @@ -1804,27 +1767,6 @@ export class FunctionsEmulator implements EmulatorInstance {
}
}

// For callable functions we want to accept tokens without actually calling verifyIdToken
const isCallable = trigger.labels && trigger.labels["deployment-callable"] === "true";
const authHeader = req.header("Authorization");
if (authHeader && isCallable && trigger.platform !== "gcfv2") {
const token = this.tokenFromAuthHeader(authHeader);
if (token) {
const contextAuth = {
uid: token.uid,
token: token,
};

// Stash the "Authorization" header in a temporary place, we will replace it
// when invoking the callable handler
req.headers[HttpConstants.ORIGINAL_AUTH_HEADER] = req.headers["authorization"];
delete req.headers["authorization"];

req.headers[HttpConstants.CALLABLE_AUTH_HEADER] = encodeURIComponent(
JSON.stringify(contextAuth),
);
}
}
// For analytics, track the invoked service
void trackEmulator(EVENT_INVOKE_GA4, {
function_service: getFunctionService(trigger),
Expand Down
162 changes: 3 additions & 159 deletions src/emulator/functionsEmulatorRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import * as fs from "fs";

import { CloudFunction, DeploymentOptions, https } from "firebase-functions";
import { CloudFunction, DeploymentOptions } from "firebase-functions";
import * as express from "express";
import * as path from "path";
import * as admin from "firebase-admin";
Expand All @@ -10,12 +8,7 @@ import * as _ from "lodash";

import { EmulatorLog } from "./types";
import { Constants } from "./constants";
import {
findModuleRoot,
FunctionsRuntimeBundle,
HttpConstants,
SignatureType,
} from "./functionsEmulatorShared";
import { findModuleRoot, FunctionsRuntimeBundle, SignatureType } from "./functionsEmulatorShared";
import { compareVersionStrings, isLocalHost } from "./functionsEmulatorUtils";
import { EventUtils } from "./events/types";

Expand Down Expand Up @@ -252,7 +245,7 @@ async function assertResolveDeveloperNodeModule(name: string): Promise<Successfu
async function verifyDeveloperNodeModules(): Promise<boolean> {
const modBundles = [
{ name: "firebase-admin", isDev: false, minVersion: "8.9.0" },
{ name: "firebase-functions", isDev: false, minVersion: "3.13.1" },
{ name: "firebase-functions", isDev: false, minVersion: "3.16.0" },
];

for (const modBundle of modBundles) {
Expand Down Expand Up @@ -383,7 +376,6 @@ function initializeNetworkFiltering(): void {
logDebug("Outgoing network have been stubbed.", results);
}

type CallableHandler = (data: any, context: https.CallableContext) => any | Promise<any>;
type HttpsHandler = (req: Request, resp: Response) => void;

/*
Expand Down Expand Up @@ -431,116 +423,12 @@ async function initializeFirebaseFunctionsStubs(): Promise<void> {
httpsProvider.onRequest = (handler: HttpsHandler) => {
return httpsProvider[onRequestInnerMethodName](handler, {});
};

// Mocking https.onCall is very similar to onRequest
const onCallInnerMethodName = "_onCallWithOptions";
const onCallMethodOriginal = httpsProvider[onCallInnerMethodName];

// Newer versions of the firebase-functions package's _onCallWithOptions method expects 3 arguments.
if (onCallMethodOriginal.length === 3) {
httpsProvider[onCallInnerMethodName] = (
opts: any,
handler: any,
deployOpts: DeploymentOptions,
) => {
const wrapped = wrapCallableHandler(handler);
const cf = onCallMethodOriginal(opts, wrapped, deployOpts);
return cf;
};
} else {
httpsProvider[onCallInnerMethodName] = (handler: any, opts: DeploymentOptions) => {
const wrapped = wrapCallableHandler(handler);
const cf = onCallMethodOriginal(wrapped, opts);
return cf;
};
}

// Newer versions of the firebase-functions package's onCall method can accept upto 2 arguments.
httpsProvider.onCall = function (optsOrHandler: any, handler: CallableHandler) {
if (onCallMethodOriginal.length === 3) {
let opts;
if (arguments.length === 1) {
opts = {};
handler = optsOrHandler as CallableHandler;
} else {
opts = optsOrHandler;
}
return httpsProvider[onCallInnerMethodName](opts, handler, {});
} else {
return httpsProvider[onCallInnerMethodName](optsOrHandler, {});
}
};
}

/**
* Wrap a callable functions handler with an outer method that extracts a special authorization
* header used to mock auth in the emulator.
*/
function wrapCallableHandler(handler: CallableHandler): CallableHandler {
const newHandler = (data: any, context: https.CallableContext) => {
if (context.rawRequest) {
const authContext = context.rawRequest.header(HttpConstants.CALLABLE_AUTH_HEADER);
if (authContext) {
logDebug("Callable functions auth override", {
key: HttpConstants.CALLABLE_AUTH_HEADER,
value: authContext,
});
context.auth = JSON.parse(decodeURIComponent(authContext));
delete context.rawRequest.headers[HttpConstants.CALLABLE_AUTH_HEADER];
} else {
logDebug("No callable functions auth found");
}

// Restore the original auth header in case the code relies on parsing it (for
// example, the code could forward it to another function or server).
const originalAuth = context.rawRequest.header(HttpConstants.ORIGINAL_AUTH_HEADER);
if (originalAuth) {
context.rawRequest.headers["authorization"] = originalAuth;
delete context.rawRequest.headers[HttpConstants.ORIGINAL_AUTH_HEADER];
}
}
return handler(data, context);
};

return newHandler;
}

function getDefaultConfig(): any {
return JSON.parse(process.env.FIREBASE_CONFIG || "{}");
}

function initializeRuntimeConfig() {
// Most recent version of Firebase Functions SDK automatically picks up locally
// stored .runtimeconfig.json to populate the config entries.
// However, due to a bug in some older version of the Function SDK, this process may fail.
//
// See the following issues for more detail:
// https://github.com/firebase/firebase-tools/issues/3793
// https://github.com/firebase/firebase-functions/issues/877
//
// As a workaround, the emulator runtime will load the contents of the .runtimeconfig.json
// to the CLOUD_RUNTIME_CONFIG environment variable IF the env var is unused.
// In the future, we will bump up the minimum version of the Firebase Functions SDK
// required to run the functions emulator to v3.15.1 and get rid of this workaround.
if (!process.env.CLOUD_RUNTIME_CONFIG) {
const configPath = `${process.cwd()}/.runtimeconfig.json`;
try {
const configContent = fs.readFileSync(configPath, "utf8");
if (configContent) {
try {
JSON.parse(configContent.toString());
logDebug(`Found local functions config: ${configPath}`);
process.env.CLOUD_RUNTIME_CONFIG = configContent.toString();
} catch (e) {
new EmulatorLog("SYSTEM", "function-runtimeconfig-json-invalid", "").log();
}
}
} catch (e) {
// Ignore, config is optional
}
}
}

/**
* This stub is the most important and one of the only non-optional stubs.This feature redirects
* writes from the admin SDK back into emulated resources.
Expand Down Expand Up @@ -709,48 +597,6 @@ function warnAboutStorageProd(): void {
).log();
}

async function initializeFunctionsConfigHelper(): Promise<void> {
const functionsResolution = await assertResolveDeveloperNodeModule("firebase-functions");
const localFunctionsModule = require(functionsResolution.resolution);

logDebug("Checked functions.config()", {
config: localFunctionsModule.config(),
});

const originalConfig = localFunctionsModule.config();
const proxiedConfig = new Proxied(originalConfig)
.any((parentConfig, parentKey) => {
const isInternal = parentKey.startsWith("Symbol(") || parentKey.startsWith("inspect");
if (!parentConfig[parentKey] && !isInternal) {
new EmulatorLog("SYSTEM", "functions-config-missing-value", "", {
key: parentKey,
}).log();
}

return parentConfig[parentKey];
})
.finalize();

const functionsModuleProxy = new Proxied<typeof localFunctionsModule>(localFunctionsModule);
const proxiedFunctionsModule = functionsModuleProxy
.when("config", () => () => {
return proxiedConfig;
})
.finalize();

// Stub the functions module in the require cache
const v = require.cache[functionsResolution.resolution];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is not precedent.
require.cache[functionsResolution.resolution] = Object.assign(v!, {
exports: proxiedFunctionsModule,
path: path.dirname(functionsResolution.resolution),
});

logDebug("firebase-functions has been stubbed.", {
functionsResolution,
});
}

/*
Retains a reference to the raw body buffer to allow access to the raw body for things like request
signature validation. This is used as the "verify" function in body-parser options.
Expand Down Expand Up @@ -897,9 +743,7 @@ async function initializeRuntime(): Promise<void> {
return;
}

initializeRuntimeConfig();
initializeNetworkFiltering();
await initializeFunctionsConfigHelper();
await initializeFirebaseFunctionsStubs();
await initializeFirebaseAdminStubs();
}
Expand Down
5 changes: 0 additions & 5 deletions src/emulator/functionsEmulatorShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,6 @@ export interface FunctionsRuntimeFeatures {
timeout?: boolean;
}

export class HttpConstants {
static readonly CALLABLE_AUTH_HEADER: string = "x-callable-context-auth";
static readonly ORIGINAL_AUTH_HEADER: string = "x-original-auth";
}

export class EmulatedTrigger {
/*
Here we create a trigger from a single definition (data about what resources does this trigger on, etc) and
Expand Down
Loading