Skip to content

Commit 46e6453

Browse files
authored
Add support for an authPolicy that returns Permission Denied when failed (#1650)
* Add support for an authPolicy that returns Permission Denied when failed * Formatter * Changelog * remove ignorant comment
1 parent 9ed934d commit 46e6453

File tree

5 files changed

+135
-9
lines changed

5 files changed

+135
-9
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add an authPolicy callback to CallableOptions for reusable auth middleware as well as helper auth policies (#1650)

package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/v2/providers/https.spec.ts

+85
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { expectedResponseHeaders, MockRequest } from "../../fixtures/mockrequest
3030
import { runHandler } from "../../helper";
3131
import { FULL_ENDPOINT, MINIMAL_V2_ENDPOINT, FULL_OPTIONS, FULL_TRIGGER } from "./fixtures";
3232
import { onInit } from "../../../src/v2/core";
33+
import { Handler } from "express";
3334

3435
describe("onRequest", () => {
3536
beforeEach(() => {
@@ -531,4 +532,88 @@ describe("onCall", () => {
531532
await runHandler(func, req as any);
532533
expect(hello).to.equal("world");
533534
});
535+
536+
describe("authPolicy", () => {
537+
function req(data: any, auth?: Record<string, string>): any {
538+
const headers = {
539+
"content-type": "application/json",
540+
};
541+
if (auth) {
542+
headers["authorization"] = `bearer ignored.${Buffer.from(
543+
JSON.stringify(auth),
544+
"utf-8"
545+
).toString("base64")}.ignored`;
546+
}
547+
const ret = new MockRequest({ data }, headers);
548+
ret.method = "POST";
549+
return ret;
550+
}
551+
552+
before(() => {
553+
sinon.stub(debug, "isDebugFeatureEnabled").withArgs("skipTokenVerification").returns(true);
554+
});
555+
556+
after(() => {
557+
sinon.restore();
558+
});
559+
560+
it("should check isSignedIn", async () => {
561+
const func = https.onCall(
562+
{
563+
authPolicy: https.isSignedIn(),
564+
},
565+
() => 42
566+
);
567+
568+
const authResp = await runHandler(func, req(null, { sub: "inlined" }));
569+
expect(authResp.status).to.equal(200);
570+
571+
const anonResp = await runHandler(func, req(null, null));
572+
expect(anonResp.status).to.equal(403);
573+
});
574+
575+
it("should check hasClaim", async () => {
576+
const anyValue = https.onCall(
577+
{
578+
authPolicy: https.hasClaim("meaning"),
579+
},
580+
() => "HHGTTG"
581+
);
582+
const specificValue = https.onCall(
583+
{
584+
authPolicy: https.hasClaim("meaning", "42"),
585+
},
586+
() => "HHGTG"
587+
);
588+
589+
const cases: Array<{ fn: Handler; auth: null | Record<string, string>; status: number }> = [
590+
{ fn: anyValue, auth: { meaning: "42" }, status: 200 },
591+
{ fn: anyValue, auth: { meaning: "43" }, status: 200 },
592+
{ fn: anyValue, auth: { order: "66" }, status: 403 },
593+
{ fn: anyValue, auth: null, status: 403 },
594+
{ fn: specificValue, auth: { meaning: "42" }, status: 200 },
595+
{ fn: specificValue, auth: { meaning: "43" }, status: 403 },
596+
{ fn: specificValue, auth: { order: "66" }, status: 403 },
597+
{ fn: specificValue, auth: null, status: 403 },
598+
];
599+
for (const test of cases) {
600+
const resp = await runHandler(test.fn, req(null, test.auth));
601+
expect(resp.status).to.equal(test.status);
602+
}
603+
});
604+
605+
it("can be any callback", async () => {
606+
const divTwo = https.onCall<number>(
607+
{
608+
authPolicy: (auth, data) => data % 2 === 0,
609+
},
610+
(req) => req.data / 2
611+
);
612+
613+
const authorized = await runHandler(divTwo, req(2));
614+
expect(authorized.status).to.equal(200);
615+
const accessDenied = await runHandler(divTwo, req(1));
616+
expect(accessDenied.status).to.equal(403);
617+
});
618+
});
534619
});

src/common/providers/https.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -703,10 +703,11 @@ type v2CallableHandler<Req, Res> = (
703703
) => Res;
704704

705705
/** @internal **/
706-
export interface CallableOptions {
706+
export interface CallableOptions<T = any> {
707707
cors: cors.CorsOptions;
708708
enforceAppCheck?: boolean;
709709
consumeAppCheckToken?: boolean;
710+
authPolicy?: (token: AuthData | null, data: T) => boolean | Promise<boolean>;
710711
/**
711712
* Time in seconds between sending heartbeat messages to keep the connection
712713
* alive. Set to `null` to disable heartbeats.
@@ -718,7 +719,7 @@ export interface CallableOptions {
718719

719720
/** @internal */
720721
export function onCallHandler<Req = any, Res = any>(
721-
options: CallableOptions,
722+
options: CallableOptions<Req>,
722723
handler: v1CallableHandler | v2CallableHandler<Req, Res>,
723724
version: "gcfv1" | "gcfv2"
724725
): (req: Request, res: express.Response) => Promise<void> {
@@ -739,7 +740,7 @@ function encodeSSE(data: unknown): string {
739740

740741
/** @internal */
741742
function wrapOnCallHandler<Req = any, Res = any>(
742-
options: CallableOptions,
743+
options: CallableOptions<Req>,
743744
handler: v1CallableHandler | v2CallableHandler<Req, Res>,
744745
version: "gcfv1" | "gcfv2"
745746
): (req: Request, res: express.Response) => Promise<void> {
@@ -841,6 +842,12 @@ function wrapOnCallHandler<Req = any, Res = any>(
841842
}
842843

843844
const data: Req = decode(req.body.data);
845+
if (options.authPolicy) {
846+
const authorized = await options.authPolicy(context.auth ?? null, data);
847+
if (!authorized) {
848+
throw new HttpsError("permission-denied", "Permission Denied");
849+
}
850+
}
844851
let result: Res;
845852
if (version === "gcfv1") {
846853
result = await (handler as v1CallableHandler)(data, context);

src/v2/providers/https.ts

+38-5
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
HttpsError,
3939
onCallHandler,
4040
Request,
41+
AuthData,
4142
} from "../../common/providers/https";
4243
import { initV2Endpoint, ManifestEndpoint } from "../../runtime/manifest";
4344
import { GlobalOptions, SupportedRegion } from "../options";
@@ -166,7 +167,7 @@ export interface HttpsOptions extends Omit<GlobalOptions, "region" | "enforceApp
166167
/**
167168
* Options that can be set on a callable HTTPS function.
168169
*/
169-
export interface CallableOptions extends HttpsOptions {
170+
export interface CallableOptions<T = any> extends HttpsOptions {
170171
/**
171172
* Determines whether Firebase AppCheck is enforced.
172173
* When true, requests with invalid tokens autorespond with a 401
@@ -206,8 +207,39 @@ export interface CallableOptions extends HttpsOptions {
206207
* Defaults to 30 seconds.
207208
*/
208209
heartbeatSeconds?: number | null;
210+
211+
/**
212+
* Callback for whether a request is authorized.
213+
*
214+
* Designed to allow reusable auth policies to be passed as an options object. Two built-in reusable policies exist:
215+
* isSignedIn and hasClaim.
216+
*/
217+
authPolicy?: (auth: AuthData | null, data: T) => boolean | Promise<boolean>;
209218
}
210219

220+
/**
221+
* An auth policy that requires a user to be signed in.
222+
*/
223+
export const isSignedIn =
224+
() =>
225+
(auth: AuthData | null): boolean =>
226+
!!auth;
227+
228+
/**
229+
* An auth policy that requires a user to be both signed in and have a specific claim (optionally with a specific value)
230+
*/
231+
export const hasClaim =
232+
(claim: string, value?: string) =>
233+
(auth: AuthData | null): boolean => {
234+
if (!auth) {
235+
return false;
236+
}
237+
if (!(claim in auth.token)) {
238+
return false;
239+
}
240+
return !value || auth.token[claim] === value;
241+
};
242+
211243
/**
212244
* Handles HTTPS requests.
213245
*/
@@ -233,6 +265,7 @@ export interface CallableFunction<T, Return> extends HttpsFunction {
233265
*/
234266
run(data: CallableRequest<T>): Return;
235267
}
268+
236269
/**
237270
* Handles HTTPS requests.
238271
* @param opts - Options to set on this function
@@ -355,7 +388,7 @@ export function onRequest(
355388
* @returns A function that you can export and deploy.
356389
*/
357390
export function onCall<T = any, Return = any | Promise<any>>(
358-
opts: CallableOptions,
391+
opts: CallableOptions<T>,
359392
handler: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return
360393
): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
361394

@@ -368,7 +401,7 @@ export function onCall<T = any, Return = any | Promise<any>>(
368401
handler: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return
369402
): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
370403
export function onCall<T = any, Return = any | Promise<any>>(
371-
optsOrHandler: CallableOptions | ((request: CallableRequest<T>) => Return),
404+
optsOrHandler: CallableOptions<T> | ((request: CallableRequest<T>) => Return),
372405
handler?: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return
373406
): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>> {
374407
let opts: CallableOptions;
@@ -388,14 +421,14 @@ export function onCall<T = any, Return = any | Promise<any>>(
388421
}
389422

390423
// fix the length of handler to make the call to handler consistent
391-
const fixedLen = (req: CallableRequest<T>, resp?: CallableProxyResponse) =>
392-
withInit(handler)(req, resp);
424+
const fixedLen = (req: CallableRequest<T>, resp?: CallableProxyResponse) => handler(req, resp);
393425
let func: any = onCallHandler(
394426
{
395427
cors: { origin, methods: "POST" },
396428
enforceAppCheck: opts.enforceAppCheck ?? options.getGlobalOptions().enforceAppCheck,
397429
consumeAppCheckToken: opts.consumeAppCheckToken,
398430
heartbeatSeconds: opts.heartbeatSeconds,
431+
authPolicy: opts.authPolicy,
399432
},
400433
fixedLen,
401434
"gcfv2"

0 commit comments

Comments
 (0)