Skip to content

Commit

Permalink
Add support for an authPolicy that returns Permission Denied when fai…
Browse files Browse the repository at this point in the history
…led (#1650)

* Add support for an authPolicy that returns Permission Denied when failed

* Formatter

* Changelog

* remove ignorant comment
  • Loading branch information
inlined authored Dec 13, 2024
1 parent 9ed934d commit 46e6453
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 9 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add an authPolicy callback to CallableOptions for reusable auth middleware as well as helper auth policies (#1650)
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 85 additions & 0 deletions spec/v2/providers/https.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string, string>): 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<string, string>; 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<number>(
{
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);
});
});
});
13 changes: 10 additions & 3 deletions src/common/providers/https.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,10 +703,11 @@ type v2CallableHandler<Req, Res> = (
) => Res;

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

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

/** @internal */
function wrapOnCallHandler<Req = any, Res = any>(
options: CallableOptions,
options: CallableOptions<Req>,
handler: v1CallableHandler | v2CallableHandler<Req, Res>,
version: "gcfv1" | "gcfv2"
): (req: Request, res: express.Response) => Promise<void> {
Expand Down Expand Up @@ -841,6 +842,12 @@ function wrapOnCallHandler<Req = any, Res = any>(
}

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);
Expand Down
43 changes: 38 additions & 5 deletions src/v2/providers/https.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
HttpsError,
onCallHandler,
Request,
AuthData,
} from "../../common/providers/https";
import { initV2Endpoint, ManifestEndpoint } from "../../runtime/manifest";
import { GlobalOptions, SupportedRegion } from "../options";
Expand Down Expand Up @@ -166,7 +167,7 @@ export interface HttpsOptions extends Omit<GlobalOptions, "region" | "enforceApp
/**
* Options that can be set on a callable HTTPS function.
*/
export interface CallableOptions extends HttpsOptions {
export interface CallableOptions<T = any> extends HttpsOptions {
/**
* Determines whether Firebase AppCheck is enforced.
* When true, requests with invalid tokens autorespond with a 401
Expand Down Expand Up @@ -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<boolean>;
}

/**
* 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.
*/
Expand All @@ -233,6 +265,7 @@ export interface CallableFunction<T, Return> extends HttpsFunction {
*/
run(data: CallableRequest<T>): Return;
}

/**
* Handles HTTPS requests.
* @param opts - Options to set on this function
Expand Down Expand Up @@ -355,7 +388,7 @@ export function onRequest(
* @returns A function that you can export and deploy.
*/
export function onCall<T = any, Return = any | Promise<any>>(
opts: CallableOptions,
opts: CallableOptions<T>,
handler: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return
): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;

Expand All @@ -368,7 +401,7 @@ export function onCall<T = any, Return = any | Promise<any>>(
handler: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return
): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>>;
export function onCall<T = any, Return = any | Promise<any>>(
optsOrHandler: CallableOptions | ((request: CallableRequest<T>) => Return),
optsOrHandler: CallableOptions<T> | ((request: CallableRequest<T>) => Return),
handler?: (request: CallableRequest<T>, response?: CallableProxyResponse) => Return
): CallableFunction<T, Return extends Promise<unknown> ? Return : Promise<Return>> {
let opts: CallableOptions;
Expand All @@ -388,14 +421,14 @@ export function onCall<T = any, Return = any | Promise<any>>(
}

// fix the length of handler to make the call to handler consistent
const fixedLen = (req: CallableRequest<T>, resp?: CallableProxyResponse) =>
withInit(handler)(req, resp);
const fixedLen = (req: CallableRequest<T>, 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"
Expand Down

0 comments on commit 46e6453

Please sign in to comment.