Skip to content

Commit 8e7735b

Browse files
committed
Add support for an authPolicy that returns Permission Denied when failed
1 parent 9ed934d commit 8e7735b

File tree

4 files changed

+133
-8
lines changed

4 files changed

+133
-8
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/v2/providers/https.spec.ts

Lines changed: 82 additions & 0 deletions
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,85 @@ 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(JSON.stringify(auth), "utf-8").toString("base64")}.ignored`;
543+
}
544+
const ret = new MockRequest({ data }, headers);
545+
ret.method = "POST";
546+
return ret;
547+
}
548+
549+
before(() => {
550+
sinon.stub(debug, "isDebugFeatureEnabled").withArgs("skipTokenVerification").returns(true);
551+
});
552+
553+
after(() => {
554+
sinon.restore();
555+
})
556+
557+
it("should check isSignedIn", async () => {
558+
const func = https.onCall(
559+
{
560+
authPolicy: https.isSignedIn(),
561+
},
562+
() => 42
563+
);
564+
565+
const authResp = await runHandler(func, req(null, { sub: "inlined" }));
566+
expect(authResp.status).to.equal(200);
567+
568+
const anonResp = await runHandler(func, req(null, null));
569+
expect(anonResp.status).to.equal(403);
570+
});
571+
572+
it("should check hasClaim", async () => {
573+
const anyValue = https.onCall(
574+
{
575+
authPolicy: https.hasClaim("meaning"),
576+
},
577+
() => "HHGTTG",
578+
);
579+
const specificValue = https.onCall(
580+
{
581+
authPolicy: https.hasClaim("meaning", "42"),
582+
},
583+
() => "HHGTG",
584+
)
585+
586+
const cases: Array<{fn: Handler, auth: null | Record<string, string>, status: number}> = [
587+
{fn: anyValue, auth: { meaning: "42"}, status: 200},
588+
{fn: anyValue, auth: { meaning: "43"}, status: 200},
589+
{fn: anyValue, auth: { order: "66"}, status: 403},
590+
{fn: anyValue, auth: null, status: 403},
591+
{fn: specificValue, auth: { meaning: "42"}, status: 200},
592+
{fn: specificValue, auth: { meaning: "43"}, status: 403},
593+
{fn: specificValue, auth: { order: "66", }, status: 403},
594+
{fn: specificValue, auth: null, status: 403},
595+
];
596+
for (const test of cases) {
597+
const resp = await runHandler(test.fn, req(null, test.auth));
598+
expect(resp.status).to.equal(test.status);
599+
}
600+
});
601+
602+
it("can be any callback", async () => {
603+
const divTwo = https.onCall<number>(
604+
{
605+
authPolicy: (auth, data) => data % 2 === 0,
606+
},
607+
(req) => req.data / 2
608+
);
609+
610+
const authorized = await runHandler(divTwo, req(2));
611+
expect(authorized.status).to.equal(200);
612+
const accessDenied = await runHandler(divTwo, req(1));
613+
expect(accessDenied.status).to.equal(403);
614+
});
615+
});
534616
});

src/common/providers/https.ts

Lines changed: 12 additions & 3 deletions
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,14 @@ function wrapOnCallHandler<Req = any, Res = any>(
841842
}
842843

843844
const data: Req = decode(req.body.data);
845+
if (options.authPolicy) {
846+
// Don't ask me why, but Google decided not to disambiguate between unauthenticated and unauthorized
847+
// in GRPC status codes, despite the pedantry to disambiguate the two in architecture design.
848+
const authorized = await options.authPolicy(context.auth ?? null, data);
849+
if (!authorized) {
850+
throw new HttpsError("permission-denied", "Permission Denied");
851+
}
852+
}
844853
let result: Res;
845854
if (version === "gcfv1") {
846855
result = await (handler as v1CallableHandler)(data, context);

src/v2/providers/https.ts

Lines changed: 38 additions & 4 deletions
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;
@@ -389,13 +422,14 @@ export function onCall<T = any, Return = any | Promise<any>>(
389422

390423
// fix the length of handler to make the call to handler consistent
391424
const fixedLen = (req: CallableRequest<T>, resp?: CallableProxyResponse) =>
392-
withInit(handler)(req, resp);
425+
handler(req, resp);
393426
let func: any = onCallHandler(
394427
{
395428
cors: { origin, methods: "POST" },
396429
enforceAppCheck: opts.enforceAppCheck ?? options.getGlobalOptions().enforceAppCheck,
397430
consumeAppCheckToken: opts.consumeAppCheckToken,
398431
heartbeatSeconds: opts.heartbeatSeconds,
432+
authPolicy: opts.authPolicy,
399433
},
400434
fixedLen,
401435
"gcfv2"

0 commit comments

Comments
 (0)