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) 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..8fabdf554 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,88 @@ 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..24300cf9d 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,12 @@ function wrapOnCallHandler( } const data: Req = decode(req.body.data); + if (options.authPolicy) { + 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..0a5b3e8c3 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; @@ -388,14 +421,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); + const fixedLen = (req: CallableRequest, resp?: CallableProxyResponse) => 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"