From 8e7735ba6565e381a515e528705c85d372b35fe0 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Wed, 11 Dec 2024 17:02:22 -0800 Subject: [PATCH 1/4] Add support for an authPolicy that returns Permission Denied when failed --- package-lock.json | 2 +- spec/v2/providers/https.spec.ts | 82 +++++++++++++++++++++++++++++++++ src/common/providers/https.ts | 15 ++++-- src/v2/providers/https.ts | 42 +++++++++++++++-- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1cb495cce..115a93eca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "node": ">=14.10.0" }, "peerDependencies": { - "firebase-admin": ">=11.0.0" + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" } }, "node_modules/@babel/parser": { diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index 77d69bfcc..159ad09e5 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -30,6 +30,7 @@ import { expectedResponseHeaders, MockRequest } from "../../fixtures/mockrequest import { runHandler } from "../../helper"; import { FULL_ENDPOINT, MINIMAL_V2_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from "./fixtures"; import { onInit } from "../../../src/v2/core"; +import { Handler } from "express"; describe("onRequest", () => { beforeEach(() => { @@ -531,4 +532,85 @@ describe("onCall", () => { await runHandler(func, req as any); expect(hello).to.equal("world"); }); + + describe("authPolicy", () => { + function req(data: any, auth?: Record): any { + const headers = { + "content-type": "application/json" + }; + if (auth) { + headers["authorization"] = `bearer ignored.${Buffer.from(JSON.stringify(auth), "utf-8").toString("base64")}.ignored`; + } + const ret = new MockRequest({ data }, headers); + ret.method = "POST"; + return ret; + } + + before(() => { + sinon.stub(debug, "isDebugFeatureEnabled").withArgs("skipTokenVerification").returns(true); + }); + + after(() => { + sinon.restore(); + }) + + it("should check isSignedIn", async () => { + const func = https.onCall( + { + authPolicy: https.isSignedIn(), + }, + () => 42 + ); + + const authResp = await runHandler(func, req(null, { sub: "inlined" })); + expect(authResp.status).to.equal(200); + + const anonResp = await runHandler(func, req(null, null)); + expect(anonResp.status).to.equal(403); + }); + + it("should check hasClaim", async () => { + const anyValue = https.onCall( + { + authPolicy: https.hasClaim("meaning"), + }, + () => "HHGTTG", + ); + const specificValue = https.onCall( + { + authPolicy: https.hasClaim("meaning", "42"), + }, + () => "HHGTG", + ) + + const cases: Array<{fn: Handler, auth: null | Record, status: number}> = [ + {fn: anyValue, auth: { meaning: "42"}, status: 200}, + {fn: anyValue, auth: { meaning: "43"}, status: 200}, + {fn: anyValue, auth: { order: "66"}, status: 403}, + {fn: anyValue, auth: null, status: 403}, + {fn: specificValue, auth: { meaning: "42"}, status: 200}, + {fn: specificValue, auth: { meaning: "43"}, status: 403}, + {fn: specificValue, auth: { order: "66", }, status: 403}, + {fn: specificValue, auth: null, status: 403}, + ]; + for (const test of cases) { + const resp = await runHandler(test.fn, req(null, test.auth)); + expect(resp.status).to.equal(test.status); + } + }); + + it("can be any callback", async () => { + const divTwo = https.onCall( + { + authPolicy: (auth, data) => data % 2 === 0, + }, + (req) => req.data / 2 + ); + + const authorized = await runHandler(divTwo, req(2)); + expect(authorized.status).to.equal(200); + const accessDenied = await runHandler(divTwo, req(1)); + expect(accessDenied.status).to.equal(403); + }); + }); }); diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index 83eaba433..81b0c5d22 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -703,10 +703,11 @@ type v2CallableHandler = ( ) => Res; /** @internal **/ -export interface CallableOptions { +export interface CallableOptions { cors: cors.CorsOptions; enforceAppCheck?: boolean; consumeAppCheckToken?: boolean; + authPolicy?: (token: AuthData | null, data: T) => boolean | Promise; /** * Time in seconds between sending heartbeat messages to keep the connection * alive. Set to `null` to disable heartbeats. @@ -718,7 +719,7 @@ export interface CallableOptions { /** @internal */ export function onCallHandler( - options: CallableOptions, + options: CallableOptions, handler: v1CallableHandler | v2CallableHandler, version: "gcfv1" | "gcfv2" ): (req: Request, res: express.Response) => Promise { @@ -739,7 +740,7 @@ function encodeSSE(data: unknown): string { /** @internal */ function wrapOnCallHandler( - options: CallableOptions, + options: CallableOptions, handler: v1CallableHandler | v2CallableHandler, version: "gcfv1" | "gcfv2" ): (req: Request, res: express.Response) => Promise { @@ -841,6 +842,14 @@ function wrapOnCallHandler( } const data: Req = decode(req.body.data); + if (options.authPolicy) { + // Don't ask me why, but Google decided not to disambiguate between unauthenticated and unauthorized + // in GRPC status codes, despite the pedantry to disambiguate the two in architecture design. + const authorized = await options.authPolicy(context.auth ?? null, data); + if (!authorized) { + throw new HttpsError("permission-denied", "Permission Denied"); + } + } let result: Res; if (version === "gcfv1") { result = await (handler as v1CallableHandler)(data, context); diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index 321c31765..0740503b3 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -38,6 +38,7 @@ import { HttpsError, onCallHandler, Request, + AuthData, } from "../../common/providers/https"; import { initV2Endpoint, ManifestEndpoint } from "../../runtime/manifest"; import { GlobalOptions, SupportedRegion } from "../options"; @@ -166,7 +167,7 @@ export interface HttpsOptions extends Omit extends HttpsOptions { /** * Determines whether Firebase AppCheck is enforced. * When true, requests with invalid tokens autorespond with a 401 @@ -206,8 +207,39 @@ export interface CallableOptions extends HttpsOptions { * Defaults to 30 seconds. */ heartbeatSeconds?: number | null; + + /** + * Callback for whether a request is authorized. + * + * Designed to allow reusable auth policies to be passed as an options object. Two built-in reusable policies exist: + * isSignedIn and hasClaim. + */ + authPolicy?: (auth: AuthData | null, data: T) => boolean | Promise; } +/** + * An auth policy that requires a user to be signed in. + */ +export const isSignedIn = + () => + (auth: AuthData | null): boolean => + !!auth; + +/** + * An auth policy that requires a user to be both signed in and have a specific claim (optionally with a specific value) + */ +export const hasClaim = + (claim: string, value?: string) => + (auth: AuthData | null): boolean => { + if (!auth) { + return false; + } + if (!(claim in auth.token)) { + return false; + } + return !value || auth.token[claim] === value; + }; + /** * Handles HTTPS requests. */ @@ -233,6 +265,7 @@ export interface CallableFunction extends HttpsFunction { */ run(data: CallableRequest): Return; } + /** * Handles HTTPS requests. * @param opts - Options to set on this function @@ -355,7 +388,7 @@ export function onRequest( * @returns A function that you can export and deploy. */ export function onCall>( - opts: CallableOptions, + opts: CallableOptions, handler: (request: CallableRequest, response?: CallableProxyResponse) => Return ): CallableFunction ? Return : Promise>; @@ -368,7 +401,7 @@ export function onCall>( handler: (request: CallableRequest, response?: CallableProxyResponse) => Return ): CallableFunction ? Return : Promise>; export function onCall>( - optsOrHandler: CallableOptions | ((request: CallableRequest) => Return), + optsOrHandler: CallableOptions | ((request: CallableRequest) => Return), handler?: (request: CallableRequest, response?: CallableProxyResponse) => Return ): CallableFunction ? Return : Promise> { let opts: CallableOptions; @@ -389,13 +422,14 @@ export function onCall>( // fix the length of handler to make the call to handler consistent const fixedLen = (req: CallableRequest, resp?: CallableProxyResponse) => - withInit(handler)(req, resp); + handler(req, resp); let func: any = onCallHandler( { cors: { origin, methods: "POST" }, enforceAppCheck: opts.enforceAppCheck ?? options.getGlobalOptions().enforceAppCheck, consumeAppCheckToken: opts.consumeAppCheckToken, heartbeatSeconds: opts.heartbeatSeconds, + authPolicy: opts.authPolicy, }, fixedLen, "gcfv2" From 9a66d71f278237f49c557c0369dbdf85c7c698e6 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Wed, 11 Dec 2024 17:03:26 -0800 Subject: [PATCH 2/4] Formatter --- spec/v2/providers/https.spec.ts | 37 ++++++++++++++++++--------------- src/v2/providers/https.ts | 3 +-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index 159ad09e5..8fabdf554 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -536,10 +536,13 @@ describe("onCall", () => { describe("authPolicy", () => { function req(data: any, auth?: Record): any { const headers = { - "content-type": "application/json" + "content-type": "application/json", }; if (auth) { - headers["authorization"] = `bearer ignored.${Buffer.from(JSON.stringify(auth), "utf-8").toString("base64")}.ignored`; + headers["authorization"] = `bearer ignored.${Buffer.from( + JSON.stringify(auth), + "utf-8" + ).toString("base64")}.ignored`; } const ret = new MockRequest({ data }, headers); ret.method = "POST"; @@ -552,7 +555,7 @@ describe("onCall", () => { after(() => { sinon.restore(); - }) + }); it("should check isSignedIn", async () => { const func = https.onCall( @@ -561,7 +564,7 @@ describe("onCall", () => { }, () => 42 ); - + const authResp = await runHandler(func, req(null, { sub: "inlined" })); expect(authResp.status).to.equal(200); @@ -574,24 +577,24 @@ describe("onCall", () => { { authPolicy: https.hasClaim("meaning"), }, - () => "HHGTTG", + () => "HHGTTG" ); const specificValue = https.onCall( { authPolicy: https.hasClaim("meaning", "42"), }, - () => "HHGTG", - ) - - const cases: Array<{fn: Handler, auth: null | Record, status: number}> = [ - {fn: anyValue, auth: { meaning: "42"}, status: 200}, - {fn: anyValue, auth: { meaning: "43"}, status: 200}, - {fn: anyValue, auth: { order: "66"}, status: 403}, - {fn: anyValue, auth: null, status: 403}, - {fn: specificValue, auth: { meaning: "42"}, status: 200}, - {fn: specificValue, auth: { meaning: "43"}, status: 403}, - {fn: specificValue, auth: { order: "66", }, status: 403}, - {fn: specificValue, auth: null, status: 403}, + () => "HHGTG" + ); + + const cases: Array<{ fn: Handler; auth: null | Record; status: number }> = [ + { fn: anyValue, auth: { meaning: "42" }, status: 200 }, + { fn: anyValue, auth: { meaning: "43" }, status: 200 }, + { fn: anyValue, auth: { order: "66" }, status: 403 }, + { fn: anyValue, auth: null, status: 403 }, + { fn: specificValue, auth: { meaning: "42" }, status: 200 }, + { fn: specificValue, auth: { meaning: "43" }, status: 403 }, + { fn: specificValue, auth: { order: "66" }, status: 403 }, + { fn: specificValue, auth: null, status: 403 }, ]; for (const test of cases) { const resp = await runHandler(test.fn, req(null, test.auth)); diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index 0740503b3..0a5b3e8c3 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -421,8 +421,7 @@ export function onCall>( } // fix the length of handler to make the call to handler consistent - const fixedLen = (req: CallableRequest, resp?: CallableProxyResponse) => - handler(req, resp); + const fixedLen = (req: CallableRequest, resp?: CallableProxyResponse) => handler(req, resp); let func: any = onCallHandler( { cors: { origin, methods: "POST" }, From 3f9f457c6ba16f9a3f70c98bfef075770c9140d4 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Wed, 11 Dec 2024 17:04:58 -0800 Subject: [PATCH 3/4] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb..605aabbf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Add an authPolicy callback to CallableOptions for reusable auth middleware as well as helper auth policies (#1650) From 7a7a251b77a634485e206078d0d96ba7d5abc789 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Fri, 13 Dec 2024 14:22:15 -0800 Subject: [PATCH 4/4] remove ignorant comment --- src/common/providers/https.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index 81b0c5d22..24300cf9d 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -843,8 +843,6 @@ function wrapOnCallHandler( const data: Req = decode(req.body.data); if (options.authPolicy) { - // Don't ask me why, but Google decided not to disambiguate between unauthenticated and unauthorized - // in GRPC status codes, despite the pedantry to disambiguate the two in architecture design. const authorized = await options.authPolicy(context.auth ?? null, data); if (!authorized) { throw new HttpsError("permission-denied", "Permission Denied");