Skip to content

Add support for an authPolicy that returns Permission Denied when failed #1650

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading