diff --git a/spec/common/providers/https.spec.ts b/spec/common/providers/https.spec.ts index 9dc42b504..b11613ce1 100644 --- a/spec/common/providers/https.spec.ts +++ b/spec/common/providers/https.spec.ts @@ -408,6 +408,175 @@ describe("onCallHandler", () => { }); }); + describe("enforceAuth", () => { + it("should handle valid auth token with enforcement enabled", async () => { + const projectId = getApp().options.projectId; + const idToken = generateIdToken(projectId); + await runCallableTest({ + httpRequest: mockRequest(null, "application/json", { + authorization: "Bearer " + idToken, + }), + expectedData: null, + callableOption: { + cors: { origin: true, methods: "POST" }, + enforceAuth: true, + }, + callableFunction: (data, context) => { + checkAuthContext(context, projectId, mocks.user_id); + return null; + }, + callableFunction2: (request) => { + checkAuthContext(request, projectId, mocks.user_id); + return null; + }, + expectedHttpResponse: { + status: 200, + headers: expectedResponseHeaders, + body: { result: null }, + }, + }); + }); + + it("should reject invalid auth token with enforcement enabled (default)", async () => { + const projectId = getApp().options.projectId; + const idToken = generateUnsignedIdToken(projectId); + await runCallableTest({ + httpRequest: mockRequest(null, "application/json", { + authorization: "Bearer " + idToken, + }), + expectedData: null, + callableOption: { + cors: { origin: true, methods: "POST" }, + }, + callableFunction: () => { + return; + }, + callableFunction2: () => { + return; + }, + expectedHttpResponse: { + status: 401, + headers: expectedResponseHeaders, + body: { + error: { + message: "Unauthenticated", + status: "UNAUTHENTICATED", + }, + }, + }, + }); + }); + + it("should reject invalid auth token with enforceAuth explicitly true", async () => { + const projectId = getApp().options.projectId; + const idToken = generateUnsignedIdToken(projectId); + await runCallableTest({ + httpRequest: mockRequest(null, "application/json", { + authorization: "Bearer " + idToken, + }), + expectedData: null, + callableOption: { + cors: { origin: true, methods: "POST" }, + enforceAuth: true, + }, + callableFunction: () => { + return; + }, + callableFunction2: () => { + return; + }, + expectedHttpResponse: { + status: 401, + headers: expectedResponseHeaders, + body: { + error: { + message: "Unauthenticated", + status: "UNAUTHENTICATED", + }, + }, + }, + }); + }); + + it("should allow invalid auth token with enforcement disabled", async () => { + const projectId = getApp().options.projectId; + const idToken = generateUnsignedIdToken(projectId); + await runCallableTest({ + httpRequest: mockRequest(null, "application/json", { + authorization: "Bearer " + idToken, + }), + expectedData: null, + callableOption: { + cors: { origin: true, methods: "POST" }, + enforceAuth: false, + }, + callableFunction: (data, context) => { + expect(context.auth).to.be.undefined; + return null; + }, + callableFunction2: (request) => { + expect(request.auth).to.be.undefined; + return null; + }, + expectedHttpResponse: { + status: 200, + headers: expectedResponseHeaders, + body: { result: null }, + }, + }); + }); + + it("should allow bad authorization header with enforcement disabled", async () => { + await runCallableTest({ + httpRequest: mockRequest(null, "application/json", { + authorization: "Beaver heyyall", + }), + expectedData: null, + callableOption: { + cors: { origin: true, methods: "POST" }, + enforceAuth: false, + }, + callableFunction: (data, context) => { + expect(context.auth).to.be.undefined; + return null; + }, + callableFunction2: (request) => { + expect(request.auth).to.be.undefined; + return null; + }, + expectedHttpResponse: { + status: 200, + headers: expectedResponseHeaders, + body: { result: null }, + }, + }); + }); + + it("should allow missing auth token with enforcement enabled", async () => { + await runCallableTest({ + httpRequest: mockRequest(null, "application/json"), + expectedData: null, + callableOption: { + cors: { origin: true, methods: "POST" }, + enforceAuth: true, + }, + callableFunction: (data, context) => { + expect(context.auth).to.be.undefined; + return null; + }, + callableFunction2: (request) => { + expect(request.auth).to.be.undefined; + return null; + }, + expectedHttpResponse: { + status: 200, + headers: expectedResponseHeaders, + body: { result: null }, + }, + }); + }); + }); + describe("AppCheck", () => { describe("verify token", () => { let mock: nock.Scope; diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index a62dfea5a..257f552f5 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -522,6 +522,21 @@ describe("onCall", () => { } }); + it("should allow boolean params for enforceAuth", async () => { + const enforceAuth = defineBoolean("ENFORCE_AUTH"); + try { + process.env.ENFORCE_AUTH = "false"; + const func = https.onCall({ enforceAuth }, () => 42); + + const req = request({ headers: { authorization: "Beaver invalid" } }); // Invalid auth token + const resp = await runHandler(func, req); + expect(resp.status).to.equal(200); + } finally { + delete process.env.ENFORCE_AUTH; + clearParams(); + } + }); + it("should allow boolean params for consumeAppCheckToken", async () => { const consumeAppCheckToken = defineBoolean("CONSUME_APP_CHECK_TOKEN"); sinon.stub(debug, "isDebugFeatureEnabled").withArgs("skipTokenVerification").returns(true); diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index e6d69cc5b..5b5493467 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -714,6 +714,7 @@ type v2CallableHandler = ( export interface CallableOptions { cors: cors.CorsOptions; enforceAppCheck?: boolean; + enforceAuth?: boolean; consumeAppCheckToken?: boolean; /* @deprecated */ authPolicy?: (token: AuthData | null, data: T) => boolean | Promise; @@ -818,8 +819,15 @@ function wrapOnCallHandler( } const tokenStatus = await checkTokens(req, context, options); + // enforceAuth defaults to true (unlike enforceAppCheck which defaults to false) if (tokenStatus.auth === "INVALID") { - throw new HttpsError("unauthenticated", "Unauthenticated"); + if (options.enforceAuth !== false) { + throw new HttpsError("unauthenticated", "Unauthenticated"); + } else { + logger.warn( + "Allowing request with invalid auth token because enforcement is disabled" + ); + } } if (tokenStatus.app === "INVALID") { if (options.enforceAppCheck) { diff --git a/src/v1/function-configuration.ts b/src/v1/function-configuration.ts index 2852f2dde..cc84004b3 100644 --- a/src/v1/function-configuration.ts +++ b/src/v1/function-configuration.ts @@ -247,6 +247,16 @@ export interface RuntimeOptions { */ enforceAppCheck?: boolean; + /** + * Determines whether Firebase Auth is enforced. Defaults to true. + * + * @remarks + * When true, requests with invalid auth tokens autorespond with a 401 + * (Unauthorized) error. + * When false, requests with invalid tokens set context.auth to undefined. + */ + enforceAuth?: boolean; + /** * Determines whether Firebase App Check token is consumed on request. Defaults to false. * diff --git a/src/v1/providers/https.ts b/src/v1/providers/https.ts index 739c9e001..fec4acac4 100644 --- a/src/v1/providers/https.ts +++ b/src/v1/providers/https.ts @@ -113,6 +113,7 @@ export function _onCallWithOptions( onCallHandler( { enforceAppCheck: options.enforceAppCheck, + enforceAuth: options.enforceAuth, consumeAppCheckToken: options.consumeAppCheckToken, cors: { origin: true, methods: "POST" }, }, diff --git a/src/v2/options.ts b/src/v2/options.ts index c5f8f1ba9..77be44a92 100644 --- a/src/v2/options.ts +++ b/src/v2/options.ts @@ -262,6 +262,16 @@ export interface GlobalOptions { */ enforceAppCheck?: boolean; + /** + * Determines whether Firebase Auth is enforced. Defaults to true. + * + * @remarks + * When true, requests with invalid auth tokens autorespond with a 401 + * (Unauthorized) error. + * When false, requests with invalid tokens set `event.auth` to `undefined`. + */ + enforceAuth?: boolean; + /** * Controls whether function configuration modified outside of function source is preserved. Defaults to false. * @@ -299,7 +309,7 @@ export function getGlobalOptions(): GlobalOptions { /** * Additional fields that can be set on any event-handling function. */ -export interface EventHandlerOptions extends Omit { +export interface EventHandlerOptions extends Omit { /** Type of the event. */ eventType?: string; diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index cfb3cfee3..870c3485b 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -55,7 +55,7 @@ export { HttpsError }; /** * Options that can be set on an onRequest HTTPS function. */ -export interface HttpsOptions extends Omit { +export interface HttpsOptions extends Omit { /** * If true, do not deploy or emulate this function. */ @@ -185,6 +185,14 @@ export interface CallableOptions extends HttpsOptions { */ enforceAppCheck?: boolean | Expression; + /** + * Determines whether Firebase Auth is enforced. Defaults to true. + * When true, requests with invalid tokens autorespond with a 401 + * (Unauthorized) error. + * When false, requests with invalid tokens set event.auth to undefined. + */ + enforceAuth?: boolean | Expression; + /** * Determines whether Firebase App Check token is consumed on request. Defaults to false. * @@ -461,6 +469,11 @@ export function onCall, Stream = unknown>( enforceAppCheck = enforceAppCheck.value(); } + let enforceAuth = opts.enforceAuth ?? options.getGlobalOptions().enforceAuth; + if (enforceAuth instanceof Expression) { + enforceAuth = enforceAuth.value(); + } + let consumeAppCheckToken = opts.consumeAppCheckToken; if (consumeAppCheckToken instanceof Expression) { consumeAppCheckToken = consumeAppCheckToken.value(); @@ -470,6 +483,7 @@ export function onCall, Stream = unknown>( { cors: { origin, methods: "POST" }, enforceAppCheck, + enforceAuth, consumeAppCheckToken, heartbeatSeconds: opts.heartbeatSeconds, authPolicy: opts.authPolicy,