Skip to content

Commit 80a7aeb

Browse files
authored
BREAKING: clean up monkey patching in functions emulator runtime (#9402)
1. Removed `functions.config()` Polyfill Removed initializeRuntimeConfig() from `src/emulator/functionsEmulatorRuntime.ts`. This function manually read `.runtimeconfig.json` and set `CLOUD_RUNTIME_CONFIG` env var to polyfill the logic for old clients of the Functions SDK. Functions SDK has included this feature for more than 5+ years now. 2. Removed Callable Auth Monkey Patches Removed wrapCallableHandler from src/emulator/functionsEmulatorRuntime.ts. that handled manual auth header swapping logic in src/emulator/functionsEmulator.ts. We now rely on the SDK's built-in skipTokenVerification feature shipped since 3.16.0. 3. Bumped Minimum SDK Version Increased minimum required firebase-functions version from 3.15.1 to 3.16.0 (launched 4+ years ago) in `src/emulator/functionsEmulatorRuntime.ts`. This ensures users have an SDK that supports skipTokenVerification and likely handles .runtimeconfig.json loading correctly in emulation.
1 parent 9903737 commit 80a7aeb

File tree

4 files changed

+3
-252
lines changed

4 files changed

+3
-252
lines changed

scripts/emulator-tests/functionsEmulatorRuntime.spec.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -398,36 +398,6 @@ describe("FunctionsEmulator-Runtime", function () {
398398
});
399399
});
400400
});
401-
describe("_InitializeFunctionsConfigHelper()", () => {
402-
const cfgPath = path.join(FUNCTIONS_DIR, ".runtimeconfig.json");
403-
404-
before(async () => {
405-
await fs.writeFile(cfgPath, '{"real":{"exist":"already exists" }}');
406-
});
407-
408-
after(async () => {
409-
await fs.unlink(cfgPath);
410-
});
411-
412-
it("should tell the user if they've accessed a non-existent function field", async () => {
413-
runtime = await startRuntime("functionId", "event", () => {
414-
require("firebase-admin").initializeApp();
415-
return {
416-
functionId: require("firebase-functions")
417-
.firestore.document("test/test")
418-
.onCreate(() => {
419-
// Exists
420-
console.log(require("firebase-functions").config().real);
421-
// Does not exist
422-
console.log(require("firebase-functions").config().foo);
423-
console.log(require("firebase-functions").config().bar);
424-
}),
425-
};
426-
});
427-
await sendEvent(runtime, FunctionRuntimeBundles.onCreate.proto);
428-
expect(runtime.sysMsg["functions-config-missing-value"]?.length).to.eq(2);
429-
});
430-
});
431401
describe("Runtime", () => {
432402
describe("HTTPS", () => {
433403
it("should handle a GET request", async () => {

src/emulator/functionsEmulator.ts

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as path from "path";
33
import * as express from "express";
44
import * as clc from "colorette";
55
import * as http from "http";
6-
import * as jwt from "jsonwebtoken";
76
import * as cors from "cors";
87
import * as semver from "semver";
98
import { URL } from "url";
@@ -28,7 +27,6 @@ import {
2827
FunctionsRuntimeFeatures,
2928
getFunctionService,
3029
getSignatureType,
31-
HttpConstants,
3230
ParsedTriggerDefinition,
3331
emulatedFunctionsFromEndpoints,
3432
emulatedFunctionsByRegion,
@@ -1732,41 +1730,6 @@ export class FunctionsEmulator implements EmulatorInstance {
17321730
return EmulatorRegistry.getInfo(emulator);
17331731
}
17341732

1735-
private tokenFromAuthHeader(authHeader: string) {
1736-
const match = /^Bearer (.*)$/.exec(authHeader);
1737-
if (!match) {
1738-
return;
1739-
}
1740-
1741-
let idToken = match[1];
1742-
logger.debug(`ID Token: ${idToken}`);
1743-
1744-
// The @firebase/testing library sometimes produces JWTs with invalid padding, so we
1745-
// remove that via regex. This is the spec that says trailing = should be removed:
1746-
// https://tools.ietf.org/html/rfc7515#section-2
1747-
if (idToken && idToken.includes("=")) {
1748-
idToken = idToken.replace(/[=]+?\./g, ".");
1749-
logger.debug(`ID Token contained invalid padding, new value: ${idToken}`);
1750-
}
1751-
1752-
try {
1753-
const decoded = jwt.decode(idToken, { complete: true }) as any;
1754-
if (!decoded || typeof decoded !== "object") {
1755-
logger.debug(`Failed to decode ID Token: ${decoded}`);
1756-
return;
1757-
}
1758-
1759-
// In firebase-functions we manually copy 'sub' to 'uid'
1760-
// https://github.com/firebase/firebase-admin-node/blob/0b2082f1576f651e75069e38ce87e639c25289af/src/auth/token-verifier.ts#L249
1761-
const claims = decoded.payload as jwt.JwtPayload;
1762-
claims.uid = claims.sub;
1763-
1764-
return claims;
1765-
} catch (e: any) {
1766-
return;
1767-
}
1768-
}
1769-
17701733
private async handleHttpsTrigger(req: express.Request, res: express.Response) {
17711734
const method = req.method;
17721735
let triggerId: string = req.params.trigger_name;
@@ -1804,27 +1767,6 @@ export class FunctionsEmulator implements EmulatorInstance {
18041767
}
18051768
}
18061769

1807-
// For callable functions we want to accept tokens without actually calling verifyIdToken
1808-
const isCallable = trigger.labels && trigger.labels["deployment-callable"] === "true";
1809-
const authHeader = req.header("Authorization");
1810-
if (authHeader && isCallable && trigger.platform !== "gcfv2") {
1811-
const token = this.tokenFromAuthHeader(authHeader);
1812-
if (token) {
1813-
const contextAuth = {
1814-
uid: token.uid,
1815-
token: token,
1816-
};
1817-
1818-
// Stash the "Authorization" header in a temporary place, we will replace it
1819-
// when invoking the callable handler
1820-
req.headers[HttpConstants.ORIGINAL_AUTH_HEADER] = req.headers["authorization"];
1821-
delete req.headers["authorization"];
1822-
1823-
req.headers[HttpConstants.CALLABLE_AUTH_HEADER] = encodeURIComponent(
1824-
JSON.stringify(contextAuth),
1825-
);
1826-
}
1827-
}
18281770
// For analytics, track the invoked service
18291771
void trackEmulator(EVENT_INVOKE_GA4, {
18301772
function_service: getFunctionService(trigger),

src/emulator/functionsEmulatorRuntime.ts

Lines changed: 3 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import * as fs from "fs";
2-
3-
import { CloudFunction, DeploymentOptions, https } from "firebase-functions";
1+
import { CloudFunction, DeploymentOptions } from "firebase-functions";
42
import * as express from "express";
53
import * as path from "path";
64
import * as admin from "firebase-admin";
@@ -10,12 +8,7 @@ import * as _ from "lodash";
108

119
import { EmulatorLog } from "./types";
1210
import { Constants } from "./constants";
13-
import {
14-
findModuleRoot,
15-
FunctionsRuntimeBundle,
16-
HttpConstants,
17-
SignatureType,
18-
} from "./functionsEmulatorShared";
11+
import { findModuleRoot, FunctionsRuntimeBundle, SignatureType } from "./functionsEmulatorShared";
1912
import { compareVersionStrings, isLocalHost } from "./functionsEmulatorUtils";
2013
import { EventUtils } from "./events/types";
2114

@@ -252,7 +245,7 @@ async function assertResolveDeveloperNodeModule(name: string): Promise<Successfu
252245
async function verifyDeveloperNodeModules(): Promise<boolean> {
253246
const modBundles = [
254247
{ name: "firebase-admin", isDev: false, minVersion: "8.9.0" },
255-
{ name: "firebase-functions", isDev: false, minVersion: "3.13.1" },
248+
{ name: "firebase-functions", isDev: false, minVersion: "3.16.0" },
256249
];
257250

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

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

389381
/*
@@ -431,116 +423,12 @@ async function initializeFirebaseFunctionsStubs(): Promise<void> {
431423
httpsProvider.onRequest = (handler: HttpsHandler) => {
432424
return httpsProvider[onRequestInnerMethodName](handler, {});
433425
};
434-
435-
// Mocking https.onCall is very similar to onRequest
436-
const onCallInnerMethodName = "_onCallWithOptions";
437-
const onCallMethodOriginal = httpsProvider[onCallInnerMethodName];
438-
439-
// Newer versions of the firebase-functions package's _onCallWithOptions method expects 3 arguments.
440-
if (onCallMethodOriginal.length === 3) {
441-
httpsProvider[onCallInnerMethodName] = (
442-
opts: any,
443-
handler: any,
444-
deployOpts: DeploymentOptions,
445-
) => {
446-
const wrapped = wrapCallableHandler(handler);
447-
const cf = onCallMethodOriginal(opts, wrapped, deployOpts);
448-
return cf;
449-
};
450-
} else {
451-
httpsProvider[onCallInnerMethodName] = (handler: any, opts: DeploymentOptions) => {
452-
const wrapped = wrapCallableHandler(handler);
453-
const cf = onCallMethodOriginal(wrapped, opts);
454-
return cf;
455-
};
456-
}
457-
458-
// Newer versions of the firebase-functions package's onCall method can accept upto 2 arguments.
459-
httpsProvider.onCall = function (optsOrHandler: any, handler: CallableHandler) {
460-
if (onCallMethodOriginal.length === 3) {
461-
let opts;
462-
if (arguments.length === 1) {
463-
opts = {};
464-
handler = optsOrHandler as CallableHandler;
465-
} else {
466-
opts = optsOrHandler;
467-
}
468-
return httpsProvider[onCallInnerMethodName](opts, handler, {});
469-
} else {
470-
return httpsProvider[onCallInnerMethodName](optsOrHandler, {});
471-
}
472-
};
473-
}
474-
475-
/**
476-
* Wrap a callable functions handler with an outer method that extracts a special authorization
477-
* header used to mock auth in the emulator.
478-
*/
479-
function wrapCallableHandler(handler: CallableHandler): CallableHandler {
480-
const newHandler = (data: any, context: https.CallableContext) => {
481-
if (context.rawRequest) {
482-
const authContext = context.rawRequest.header(HttpConstants.CALLABLE_AUTH_HEADER);
483-
if (authContext) {
484-
logDebug("Callable functions auth override", {
485-
key: HttpConstants.CALLABLE_AUTH_HEADER,
486-
value: authContext,
487-
});
488-
context.auth = JSON.parse(decodeURIComponent(authContext));
489-
delete context.rawRequest.headers[HttpConstants.CALLABLE_AUTH_HEADER];
490-
} else {
491-
logDebug("No callable functions auth found");
492-
}
493-
494-
// Restore the original auth header in case the code relies on parsing it (for
495-
// example, the code could forward it to another function or server).
496-
const originalAuth = context.rawRequest.header(HttpConstants.ORIGINAL_AUTH_HEADER);
497-
if (originalAuth) {
498-
context.rawRequest.headers["authorization"] = originalAuth;
499-
delete context.rawRequest.headers[HttpConstants.ORIGINAL_AUTH_HEADER];
500-
}
501-
}
502-
return handler(data, context);
503-
};
504-
505-
return newHandler;
506426
}
507427

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

512-
function initializeRuntimeConfig() {
513-
// Most recent version of Firebase Functions SDK automatically picks up locally
514-
// stored .runtimeconfig.json to populate the config entries.
515-
// However, due to a bug in some older version of the Function SDK, this process may fail.
516-
//
517-
// See the following issues for more detail:
518-
// https://github.com/firebase/firebase-tools/issues/3793
519-
// https://github.com/firebase/firebase-functions/issues/877
520-
//
521-
// As a workaround, the emulator runtime will load the contents of the .runtimeconfig.json
522-
// to the CLOUD_RUNTIME_CONFIG environment variable IF the env var is unused.
523-
// In the future, we will bump up the minimum version of the Firebase Functions SDK
524-
// required to run the functions emulator to v3.15.1 and get rid of this workaround.
525-
if (!process.env.CLOUD_RUNTIME_CONFIG) {
526-
const configPath = `${process.cwd()}/.runtimeconfig.json`;
527-
try {
528-
const configContent = fs.readFileSync(configPath, "utf8");
529-
if (configContent) {
530-
try {
531-
JSON.parse(configContent.toString());
532-
logDebug(`Found local functions config: ${configPath}`);
533-
process.env.CLOUD_RUNTIME_CONFIG = configContent.toString();
534-
} catch (e) {
535-
new EmulatorLog("SYSTEM", "function-runtimeconfig-json-invalid", "").log();
536-
}
537-
}
538-
} catch (e) {
539-
// Ignore, config is optional
540-
}
541-
}
542-
}
543-
544432
/**
545433
* This stub is the most important and one of the only non-optional stubs.This feature redirects
546434
* writes from the admin SDK back into emulated resources.
@@ -709,48 +597,6 @@ function warnAboutStorageProd(): void {
709597
).log();
710598
}
711599

712-
async function initializeFunctionsConfigHelper(): Promise<void> {
713-
const functionsResolution = await assertResolveDeveloperNodeModule("firebase-functions");
714-
const localFunctionsModule = require(functionsResolution.resolution);
715-
716-
logDebug("Checked functions.config()", {
717-
config: localFunctionsModule.config(),
718-
});
719-
720-
const originalConfig = localFunctionsModule.config();
721-
const proxiedConfig = new Proxied(originalConfig)
722-
.any((parentConfig, parentKey) => {
723-
const isInternal = parentKey.startsWith("Symbol(") || parentKey.startsWith("inspect");
724-
if (!parentConfig[parentKey] && !isInternal) {
725-
new EmulatorLog("SYSTEM", "functions-config-missing-value", "", {
726-
key: parentKey,
727-
}).log();
728-
}
729-
730-
return parentConfig[parentKey];
731-
})
732-
.finalize();
733-
734-
const functionsModuleProxy = new Proxied<typeof localFunctionsModule>(localFunctionsModule);
735-
const proxiedFunctionsModule = functionsModuleProxy
736-
.when("config", () => () => {
737-
return proxiedConfig;
738-
})
739-
.finalize();
740-
741-
// Stub the functions module in the require cache
742-
const v = require.cache[functionsResolution.resolution];
743-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- this is not precedent.
744-
require.cache[functionsResolution.resolution] = Object.assign(v!, {
745-
exports: proxiedFunctionsModule,
746-
path: path.dirname(functionsResolution.resolution),
747-
});
748-
749-
logDebug("firebase-functions has been stubbed.", {
750-
functionsResolution,
751-
});
752-
}
753-
754600
/*
755601
Retains a reference to the raw body buffer to allow access to the raw body for things like request
756602
signature validation. This is used as the "verify" function in body-parser options.
@@ -897,9 +743,7 @@ async function initializeRuntime(): Promise<void> {
897743
return;
898744
}
899745

900-
initializeRuntimeConfig();
901746
initializeNetworkFiltering();
902-
await initializeFunctionsConfigHelper();
903747
await initializeFirebaseFunctionsStubs();
904748
await initializeFirebaseAdminStubs();
905749
}

src/emulator/functionsEmulatorShared.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,6 @@ export interface FunctionsRuntimeFeatures {
106106
timeout?: boolean;
107107
}
108108

109-
export class HttpConstants {
110-
static readonly CALLABLE_AUTH_HEADER: string = "x-callable-context-auth";
111-
static readonly ORIGINAL_AUTH_HEADER: string = "x-original-auth";
112-
}
113-
114109
export class EmulatedTrigger {
115110
/*
116111
Here we create a trigger from a single definition (data about what resources does this trigger on, etc) and

0 commit comments

Comments
 (0)