diff --git a/spec/common/providers/https.spec.ts b/spec/common/providers/https.spec.ts index 66c8e661c..d751fca38 100644 --- a/spec/common/providers/https.spec.ts +++ b/spec/common/providers/https.spec.ts @@ -38,6 +38,8 @@ interface CallTest { // The function to execute with the request. callableFunction: (data: any, context: https.CallableContext) => any; + callableFunction2: (request: https.CallableRequest) => any; + // The expected shape of the http response returned to the callable SDK. expectedHttpResponse: RunHandlerResult; } @@ -92,16 +94,27 @@ function runHandler( // Runs a CallTest test. async function runTest(test: CallTest): Promise { const opts = { origin: true, methods: 'POST' }; - const callableFunction = https.onCallHandler(opts, (data, context) => { + const callableFunctionV1 = https.onCallHandler(opts, (data, context) => { expect(data).to.deep.equal(test.expectedData); return test.callableFunction(data, context); }); - const response = await runHandler(callableFunction, test.httpRequest); + const responseV1 = await runHandler(callableFunctionV1, test.httpRequest); + + expect(responseV1.body).to.deep.equal(test.expectedHttpResponse.body); + expect(responseV1.headers).to.deep.equal(test.expectedHttpResponse.headers); + expect(responseV1.status).to.equal(test.expectedHttpResponse.status); + + const callableFunctionV2 = https.onCallHandler(opts, (request) => { + expect(request.data).to.deep.equal(test.expectedData); + return test.callableFunction2(request); + }); + + const responseV2 = await runHandler(callableFunctionV2, test.httpRequest); - expect(response.body).to.deep.equal(test.expectedHttpResponse.body); - expect(response.headers).to.deep.equal(test.expectedHttpResponse.headers); - expect(response.status).to.equal(test.expectedHttpResponse.status); + expect(responseV2.body).to.deep.equal(test.expectedHttpResponse.body); + expect(responseV2.headers).to.deep.equal(test.expectedHttpResponse.headers); + expect(responseV2.status).to.equal(test.expectedHttpResponse.status); } describe('onCallHandler', () => { @@ -138,6 +151,7 @@ describe('onCallHandler', () => { httpRequest: mockRequest({ foo: 'bar' }), expectedData: { foo: 'bar' }, callableFunction: (data, context) => ({ baz: 'qux' }), + callableFunction2: (request) => ({ baz: 'qux' }), expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -151,6 +165,7 @@ describe('onCallHandler', () => { httpRequest: mockRequest(null), expectedData: null, callableFunction: (data, context) => null, + callableFunction2: (request) => null, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -166,6 +181,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -183,6 +201,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 400, headers: expectedResponseHeaders, @@ -200,6 +221,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -215,6 +239,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 400, headers: expectedResponseHeaders, @@ -234,6 +261,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 400, headers: expectedResponseHeaders, @@ -251,6 +281,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { throw new Error(`ceci n'est pas une error`); }, + callableFunction2: (request) => { + throw new Error(`cece n'est pas une error`); + }, expectedHttpResponse: { status: 500, headers: expectedResponseHeaders, @@ -266,6 +299,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { throw new https.HttpsError('THIS_IS_NOT_VALID' as any, 'nope'); }, + callableFunction2: (request) => { + throw new https.HttpsError('THIS_IS_NOT_VALID' as any, 'nope'); + }, expectedHttpResponse: { status: 500, headers: expectedResponseHeaders, @@ -281,6 +317,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { throw new https.HttpsError('not-found', 'i am error'); }, + callableFunction2: (request) => { + throw new https.HttpsError('not-found', 'i am error'); + }, expectedHttpResponse: { status: 404, headers: expectedResponseHeaders, @@ -308,6 +347,16 @@ describe('onCallHandler', () => { expect(context.instanceIdToken).to.be.undefined; return null; }, + callableFunction2: (request) => { + expect(request.auth).to.not.be.undefined; + expect(request.auth).to.not.be.null; + expect(request.auth.uid).to.equal(mocks.user_id); + expect(request.auth.token.uid).to.equal(mocks.user_id); + expect(request.auth.token.sub).to.equal(mocks.user_id); + expect(request.auth.token.aud).to.equal(projectId); + expect(request.instanceIdToken).to.be.undefined; + return null; + }, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -326,6 +375,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 401, headers: expectedResponseHeaders, @@ -360,6 +412,19 @@ describe('onCallHandler', () => { expect(context.instanceIdToken).to.be.undefined; return null; }, + callableFunction2: (request) => { + expect(request.app).to.not.be.undefined; + expect(request.app).to.not.be.null; + expect(request.app.appId).to.equal(appId); + expect(request.app.token.app_id).to.be.equal(appId); + expect(request.app.token.sub).to.be.equal(appId); + expect(request.app.token.aud).to.be.deep.equal([ + `projects/${projectId}`, + ]); + expect(request.auth).to.be.undefined; + expect(request.instanceIdToken).to.be.undefined; + return null; + }, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -378,6 +443,9 @@ describe('onCallHandler', () => { callableFunction: (data, context) => { return; }, + callableFunction2: (request) => { + return; + }, expectedHttpResponse: { status: 401, headers: expectedResponseHeaders, @@ -402,6 +470,11 @@ describe('onCallHandler', () => { expect(context.instanceIdToken).to.equal('iid-token'); return null; }, + callableFunction2: (request) => { + expect(request.auth).to.be.undefined; + expect(request.instanceIdToken).to.equal('iid-token'); + return null; + }, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -420,6 +493,11 @@ describe('onCallHandler', () => { expect(context.rawRequest).to.equal(mockReq); return null; }, + callableFunction2: (request) => { + expect(request.rawRequest).to.not.be.undefined; + expect(request.rawRequest).to.equal(mockReq); + return null; + }, expectedHttpResponse: { status: 200, headers: expectedResponseHeaders, @@ -477,7 +555,6 @@ describe('encoding/decoding', () => { it('encodes double', () => { expect(https.encode(1.2)).to.equal(1.2); }); - it('decodes double', () => { expect(https.decode(1.2)).to.equal(1.2); }); diff --git a/spec/v2/providers/https.spec.ts b/spec/v2/providers/https.spec.ts index 7103372a0..6bc4030e8 100644 --- a/spec/v2/providers/https.spec.ts +++ b/spec/v2/providers/https.spec.ts @@ -194,7 +194,7 @@ describe('onCall', () => { }); it('should return a minimal trigger with appropriate values', () => { - const result = https.onCall((data, context) => 42); + const result = https.onCall((request) => 42); expect(result.__trigger).to.deep.equal({ apiVersion: 2, platform: 'gcfv2', @@ -208,7 +208,7 @@ describe('onCall', () => { }); it('should create a complex trigger with appropriate values', () => { - const result = https.onCall(FULL_OPTIONS, (data, context) => 42); + const result = https.onCall(FULL_OPTIONS, (request) => 42); expect(result.__trigger).to.deep.equal({ ...FULL_TRIGGER, httpsTrigger: { @@ -233,7 +233,7 @@ describe('onCall', () => { region: ['us-west1', 'us-central1'], minInstances: 3, }, - (data, context) => 42 + (request) => 42 ); expect(result.__trigger).to.deep.equal({ @@ -252,23 +252,23 @@ describe('onCall', () => { }); it('has a .run method', () => { - const cf = https.onCall((d, c) => { - return { data: d, context: c }; + const cf = https.onCall((request) => { + return request; }); - const data = 'data'; - const context: any = { + const request: any = { + data: 'data', instanceIdToken: 'token', auth: { uid: 'abc', token: 'token', }, }; - expect(cf.run(data, context)).to.deep.equal({ data, context }); + expect(cf.run(request)).to.deep.equal(request); }); it('should be an express handler', async () => { - const func = https.onCall((data, context) => 42); + const func = https.onCall((request) => 42); const req = new MockRequest( { @@ -286,7 +286,7 @@ describe('onCall', () => { }); it('should enforce CORS options', async () => { - const func = https.onCall({ cors: 'example.com' }, (req, res) => { + const func = https.onCall({ cors: 'example.com' }, (request) => { throw new Error('Should not reach here for OPTIONS preflight'); }); @@ -314,7 +314,7 @@ describe('onCall', () => { }); it('adds CORS headers', async () => { - const func = https.onCall((data, context) => 42); + const func = https.onCall((request) => 42); const req = new MockRequest( { data: {}, diff --git a/src/common/providers/https.ts b/src/common/providers/https.ts index 1b3d93a8b..e2f73f7a5 100644 --- a/src/common/providers/https.ts +++ b/src/common/providers/https.ts @@ -35,6 +35,78 @@ export interface Request extends express.Request { rawBody: Buffer; } +/** + * The interface for AppCheck tokens verified in Callable functions + */ +export interface AppCheckData { + appId: string; + + // This is actually a firebase.appCheck.DecodedAppCheckToken, but + // that type may not be available in some supported SDK versions. + // Declare as an inline type, which DecodedAppCheckToken will be + // able to merge with. + // TODO: Replace with the real type once we bump the min-version of + // the admin SDK + token: { + /** + * The issuer identifier for the issuer of the response. + * + * This value is a URL with the format + * `https://firebaseappcheck.googleapis.com/`, where `` is the + * same project number specified in the [`aud`](#aud) property. + */ + iss: string; + + /** + * The Firebase App ID corresponding to the app the token belonged to. + * + * As a convenience, this value is copied over to the [`app_id`](#app_id) property. + */ + sub: string; + + /** + * The audience for which this token is intended. + * + * This value is a JSON array of two strings, the first is the project number of your + * Firebase project, and the second is the project ID of the same project. + */ + aud: string[]; + + /** + * The App Check token's expiration time, in seconds since the Unix epoch. That is, the + * time at which this App Check token expires and should no longer be considered valid. + */ + exp: number; + + /** + * The App Check token's issued-at time, in seconds since the Unix epoch. That is, the + * time at which this App Check token was issued and should start to be considered + * valid. + */ + iat: number; + + /** + * The App ID corresponding to the App the App Check token belonged to. + * + * This value is not actually one of the JWT token claims. It is added as a + * convenience, and is set as the value of the [`sub`](#sub) property. + */ + app_id: string; + [key: string]: any; + }; +} + +/** + * The interface for Auth tokens verified in Callable functions + */ +export interface AuthData { + uid: string; + token: firebase.auth.DecodedIdToken; +} + +// This type is the direct v1 callable interface and is also an interface +// that the v2 API can conform to. This allows us to pass the v2 CallableRequest +// directly to the same helper methods. /** * The interface for metadata for the API as passed to the handler. */ @@ -42,71 +114,45 @@ export interface CallableContext { /** * The result of decoding and verifying a Firebase AppCheck token. */ - app?: { - appId: string; - - // This is actually a firebase.appCheck.DecodedAppCheckToken, but - // that type may not be available in some supported SDK versions. - // Declare as an inline type, which DecodedAppCheckToken will be - // able to merge with. - // TODO: Replace with the real type once we bump the min-version of - // the admin SDK - token: { - /** - * The issuer identifier for the issuer of the response. - * - * This value is a URL with the format - * `https://firebaseappcheck.googleapis.com/`, where `` is the - * same project number specified in the [`aud`](#aud) property. - */ - iss: string; - - /** - * The Firebase App ID corresponding to the app the token belonged to. - * - * As a convenience, this value is copied over to the [`app_id`](#app_id) property. - */ - sub: string; - - /** - * The audience for which this token is intended. - * - * This value is a JSON array of two strings, the first is the project number of your - * Firebase project, and the second is the project ID of the same project. - */ - aud: string[]; - - /** - * The App Check token's expiration time, in seconds since the Unix epoch. That is, the - * time at which this App Check token expires and should no longer be considered valid. - */ - exp: number; - - /** - * The App Check token's issued-at time, in seconds since the Unix epoch. That is, the - * time at which this App Check token was issued and should start to be considered - * valid. - */ - iat: number; - - /** - * The App ID corresponding to the App the App Check token belonged to. - * - * This value is not actually one of the JWT token claims. It is added as a - * convenience, and is set as the value of the [`sub`](#sub) property. - */ - app_id: string; - [key: string]: any; - }; - }; + app?: AppCheckData; /** * The result of decoding and verifying a Firebase Auth ID token. */ - auth?: { - uid: string; - token: firebase.auth.DecodedIdToken; - }; + auth?: AuthData; + + /** + * An unverified token for a Firebase Instance ID. + */ + instanceIdToken?: string; + + /** + * The raw request handled by the callable. + */ + rawRequest: Request; +} + +// This could be a simple extension of CallableContext, but we're +// avoiding that to avoid muddying the docs and making a v2 type depend +// on a v1 type. +/** + * The request used to call a callable function. + */ +export interface CallableRequest { + /** + * The parameters used by a client when calling this function. + */ + data: T; + + /** + * The result of decoding and verifying a Firebase AppCheck token. + */ + app?: AppCheckData; + + /** + * The result of decoding and verifying a Firebase Auth ID token. + */ + auth?: AuthData; /** * An unverified token for a Firebase Instance ID. @@ -543,10 +589,13 @@ async function checkTokens( return verifications; } +type v1Handler = (data: any, context: CallableContext) => any | Promise; +type v2Handler = (request: CallableRequest) => Res; + /** @hidden */ -export function onCallHandler( +export function onCallHandler( options: cors.CorsOptions, - handler: (data: any, context: CallableContext) => any | Promise + handler: v1Handler | v2Handler ): (req: Request, res: express.Response) => Promise { const wrapped = wrapOnCallHandler(handler); return async (req: Request, res: express.Response) => { @@ -557,49 +606,62 @@ export function onCallHandler( } /** @internal */ -const wrapOnCallHandler = ( - handler: (data: any, context: CallableContext) => any | Promise -) => async (req: Request, res: express.Response) => { - try { - if (!isValidRequest(req)) { - logger.error('Invalid request, unable to process.'); - throw new HttpsError('invalid-argument', 'Bad Request'); - } +function wrapOnCallHandler( + handler: v1Handler | v2Handler +): (req: Request, res: express.Response) => Promise { + return async (req: Request, res: express.Response): Promise => { + try { + if (!isValidRequest(req)) { + logger.error('Invalid request, unable to process.'); + throw new HttpsError('invalid-argument', 'Bad Request'); + } - const context: CallableContext = { rawRequest: req }; - const tokenStatus = await checkTokens(req, context); - if (tokenStatus.app === 'INVALID' || tokenStatus.auth === 'INVALID') { - throw new HttpsError('unauthenticated', 'Unauthenticated'); - } + const context: CallableContext = { rawRequest: req }; + const tokenStatus = await checkTokens(req, context); + if (tokenStatus.app === 'INVALID' || tokenStatus.auth === 'INVALID') { + throw new HttpsError('unauthenticated', 'Unauthenticated'); + } - const instanceId = req.header('Firebase-Instance-ID-Token'); - if (instanceId) { - // Validating the token requires an http request, so we don't do it. - // If the user wants to use it for something, it will be validated then. - // Currently, the only real use case for this token is for sending - // pushes with FCM. In that case, the FCM APIs will validate the token. - context.instanceIdToken = req.header('Firebase-Instance-ID-Token'); - } + const instanceId = req.header('Firebase-Instance-ID-Token'); + if (instanceId) { + // Validating the token requires an http request, so we don't do it. + // If the user wants to use it for something, it will be validated then. + // Currently, the only real use case for this token is for sending + // pushes with FCM. In that case, the FCM APIs will validate the token. + context.instanceIdToken = req.header('Firebase-Instance-ID-Token'); + } - const data = decode(req.body.data); - let result: any = await handler(data, context); + const data: Req = decode(req.body.data); + let result: Res; + if (handler.length === 2) { + result = await handler(data, context); + } else { + const arg: CallableRequest = { + ...context, + data, + }; + // For some reason the type system isn't picking up that the handler + // is a one argument function. + result = await (handler as any)(arg); + } - // Encode the result as JSON to preserve types like Dates. - result = encode(result); + // Encode the result as JSON to preserve types like Dates. + result = encode(result); - // If there was some result, encode it in the body. - const responseBody: HttpResponseBody = { result }; - res.status(200).send(responseBody); - } catch (err) { - if (!(err instanceof HttpsError)) { - // This doesn't count as an 'explicit' error. - logger.error('Unhandled error', err); - err = new HttpsError('internal', 'INTERNAL'); - } + // If there was some result, encode it in the body. + const responseBody: HttpResponseBody = { result }; + res.status(200).send(responseBody); + } catch (err) { + if (!(err instanceof HttpsError)) { + // This doesn't count as an 'explicit' error. + logger.error('Unhandled error', err); + err = new HttpsError('internal', 'INTERNAL'); + } - const { status } = err.httpErrorCode; - const body = { error: err.toJSON() }; + const { status } = err.httpErrorCode; + const body = { error: err.toJSON() }; - res.status(status).send(body); - } -}; + res.status(status).send(body); + } + }; +} diff --git a/src/v2/providers/https.ts b/src/v2/providers/https.ts index edfc4674a..b31a1207c 100644 --- a/src/v2/providers/https.ts +++ b/src/v2/providers/https.ts @@ -28,7 +28,7 @@ import * as options from '../options'; export type Request = common.Request; -export type CallableContext = common.CallableContext; +export type CallableRequest = common.CallableRequest; export type FunctionsErrorCode = common.FunctionsErrorCode; export type HttpsError = common.HttpsError; @@ -44,15 +44,14 @@ export type HttpsHandler = ( request: Request, response: express.Response ) => void | Promise; -export type CallableHandler = ( - data: T, - context: CallableContext -) => Ret; +export type CallableHandler = ( + request: CallableRequest +) => Return; export type HttpsFunction = HttpsHandler & { __trigger: unknown }; -export interface CallableFunction extends HttpsHandler { +export interface CallableFunction extends HttpsHandler { __trigger: unknown; - run(data: T, context: CallableContext): Ret; + run(data: CallableRequest): Return; } export function onRequest( @@ -110,21 +109,21 @@ export function onRequest( return handler as HttpsFunction; } -export function onCall>( +export function onCall>( opts: HttpsOptions, - handler: CallableHandler -): CallableFunction; -export function onCall>( - handler: CallableHandler -): CallableFunction; -export function onCall>( - optsOrHandler: HttpsOptions | CallableHandler, - handler?: CallableHandler -): CallableFunction { + handler: CallableHandler +): CallableFunction; +export function onCall>( + handler: CallableHandler +): CallableFunction; +export function onCall>( + optsOrHandler: HttpsOptions | CallableHandler, + handler?: CallableHandler +): CallableFunction { let opts: HttpsOptions; if (arguments.length == 1) { opts = {}; - handler = optsOrHandler as CallableHandler; + handler = optsOrHandler as CallableHandler; } else { opts = optsOrHandler as HttpsOptions; }