diff --git a/src/app/credential-internal.ts b/src/app/credential-internal.ts index 28442ba8f7..569bc05899 100644 --- a/src/app/credential-internal.ts +++ b/src/app/credential-internal.ts @@ -32,6 +32,7 @@ const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; // NOTE: the Google Metadata Service uses HTTP over a vlan const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; +const GOOGLE_METADATA_SERVICE_IDENTITY_PATH = '/computeMetadata/v1/instance/service-accounts/default/identity'; const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; const GOOGLE_METADATA_SERVICE_ACCOUNT_ID_PATH = '/computeMetadata/v1/instance/service-accounts/default/email'; @@ -209,6 +210,16 @@ export class ComputeEngineCredential implements Credential { return requestAccessToken(this.httpClient, request); } + /** + * getIDToken returns a OIDC token from the compute metadata service + * that can be used to make authenticated calls to audience + * @param audience the URL the returned ID token will be used to call. + */ + public getIDToken(audience: string): Promise { + const request = this.buildRequest(`${GOOGLE_METADATA_SERVICE_IDENTITY_PATH}?audience=${audience}`); + return requestIDToken(this.httpClient, request); + } + public getProjectId(): Promise { if (this.projectId) { return Promise.resolve(this.projectId); @@ -421,6 +432,17 @@ function requestAccessToken(client: HttpClient, request: HttpRequestConfig): Pro }); } +/** + * Obtain a new OIDC token by making a remote service call. + */ +function requestIDToken(client: HttpClient, request: HttpRequestConfig): Promise { + return client.send(request).then((resp) => { + return resp.text || ''; + }).catch((err) => { + throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, getErrorMessage(err)); + }); +} + /** * Constructs a human-readable error message from the given Error. */ diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 36b7bf99c2..1ca6c9d28e 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -24,6 +24,7 @@ import { PrefixedFirebaseError } from '../utils/error'; import * as utils from '../utils/index'; import * as validator from '../utils/validator'; import { TaskOptions } from './functions-api'; +import { ComputeEngineCredential } from '../app/credential-internal'; const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks'; const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}'; @@ -84,7 +85,7 @@ export class FunctionsApiClient { return this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT) .then((serviceUrl) => { - return this.updateTaskPayload(task, resources) + return this.updateTaskPayload(task, resources, extensionId) .then((task) => { const request: HttpRequestConfig = { method: 'POST', @@ -224,22 +225,22 @@ export class FunctionsApiClient { return task; } - private updateTaskPayload(task: Task, resources: utils.ParsedResource): Promise { - return Promise.resolve() - .then(() => { - if (validator.isNonEmptyString(task.httpRequest.url)) { - return task.httpRequest.url; - } - return this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT); - }) - .then((functionUrl) => { - return this.getServiceAccount() - .then((account) => { - task.httpRequest.oidcToken.serviceAccountEmail = account; - task.httpRequest.url = functionUrl; - return task; - }) - }); + private async updateTaskPayload(task: Task, resources: utils.ParsedResource, extensionId?: string): Promise { + const functionUrl = validator.isNonEmptyString(task.httpRequest.url) + ? task.httpRequest.url + : await this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT); + task.httpRequest.url = functionUrl; + // When run from a deployed extension, we should be using ComputeEngineCredentials + if (extensionId && this.app.options.credential instanceof ComputeEngineCredential) { + const idToken = await this.app.options.credential.getIDToken(functionUrl); + task.httpRequest.headers = { ...task.httpRequest.headers, 'Authorization': `Bearer ${idToken}` }; + // Don't send httpRequest.oidcToken if we set Authorization header, or Cloud Tasks will overwrite it. + delete task.httpRequest.oidcToken; + } else { + const account = await this.getServiceAccount(); + task.httpRequest.oidcToken = { serviceAccountEmail: account }; + } + return task; } private toFirebaseError(err: HttpError): PrefixedFirebaseError { @@ -274,7 +275,11 @@ interface Error { status?: string; } -interface Task { +/** + * Task is a limited subset of https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#resource:-task + * containing the relevant fields for enqueueing tasks that tirgger Cloud Functions. + */ +export interface Task { // A timestamp in RFC3339 UTC "Zulu" format, with nanosecond resolution and up to nine fractional // digits. Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045123456Z". scheduleTime?: string; @@ -282,7 +287,7 @@ interface Task { dispatchDeadline?: string; httpRequest: { url: string; - oidcToken: { + oidcToken?: { serviceAccountEmail: string; }; // A base64-encoded string. diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index f55fd9052b..f4ae055bbc 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -28,6 +28,7 @@ import * as jwt from 'jsonwebtoken'; import { AppOptions } from '../../src/firebase-namespace-api'; import { FirebaseApp } from '../../src/app/firebase-app'; import { Credential, GoogleOAuthAccessToken, cert } from '../../src/app/index'; +import { ComputeEngineCredential } from '../../src/app/credential-internal'; const ALGORITHM = 'RS256' as const; const ONE_HOUR_IN_SECONDS = 60 * 60; @@ -90,6 +91,19 @@ export class MockCredential implements Credential { } } +export class MockComputeEngineCredential extends ComputeEngineCredential { + public getAccessToken(): Promise { + return Promise.resolve({ + access_token: 'mock-token', + expires_in: 3600, + }); + } + + public getIDToken(): Promise { + return Promise.resolve('mockIdToken'); + } +} + export function app(): FirebaseApp { return new FirebaseApp(appOptions, appName); } diff --git a/test/unit/app/credential-internal.spec.ts b/test/unit/app/credential-internal.spec.ts index 189c7ac99a..1b1525d974 100644 --- a/test/unit/app/credential-internal.spec.ts +++ b/test/unit/app/credential-internal.spec.ts @@ -333,6 +333,23 @@ describe('Credential', () => { }); }); + it('should create id tokens', () => { + const expected = 'an-id-token-encoded'; + const response = utils.responseFrom(expected); + httpStub.resolves(response); + + const c = new ComputeEngineCredential(); + return c.getIDToken('my-audience.cloudfunctions.net').then((token) => { + expect(token).to.equal(expected); + expect(httpStub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url: 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=my-audience.cloudfunctions.net', + headers: { 'Metadata-Flavor': 'Google' }, + httpAgent: undefined, + }); + }); + }); + it('should discover project id', () => { const expectedProjectId = 'test-project-id'; const response = utils.responseFrom(expectedProjectId); diff --git a/test/unit/functions/functions-api-client-internal.spec.ts b/test/unit/functions/functions-api-client-internal.spec.ts index 8f5cc1b6f7..02e76765b2 100644 --- a/test/unit/functions/functions-api-client-internal.spec.ts +++ b/test/unit/functions/functions-api-client-internal.spec.ts @@ -25,7 +25,7 @@ import * as mocks from '../../resources/mocks'; import { getSdkVersion } from '../../../src/utils'; import { FirebaseApp } from '../../../src/app/firebase-app'; -import { FirebaseFunctionsError, FunctionsApiClient } from '../../../src/functions/functions-api-client-internal'; +import { FirebaseFunctionsError, FunctionsApiClient, Task } from '../../../src/functions/functions-api-client-internal'; import { HttpClient } from '../../../src/utils/api-request'; import { FirebaseAppError } from '../../../src/utils/error'; import { deepCopy } from '../../../src/utils/deep-copy'; @@ -65,7 +65,13 @@ describe('FunctionsApiClient', () => { serviceAccountId: 'service-acct@email.com' }; - const TEST_TASK_PAYLOAD = { + const mockExtensionOptions = { + credential: new mocks.MockComputeEngineCredential(), + projectId: 'test-project', + serviceAccountId: 'service-acct@email.com' + }; + + const TEST_TASK_PAYLOAD: Task = { httpRequest: { url: `https://${DEFAULT_REGION}-${mockOptions.projectId}.cloudfunctions.net/${FUNCTION_NAME}`, oidcToken: { @@ -291,10 +297,15 @@ describe('FunctionsApiClient', () => { }); }); - it('should update the function name when the extension-id is provided', () => { + it('should update the function name and set headers when the extension-id is provided', () => { + app = mocks.appWithOptions(mockExtensionOptions); + apiClient = new FunctionsApiClient(app); + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); expectedPayload.httpRequest.url = `https://${DEFAULT_REGION}-${mockOptions.projectId}.cloudfunctions.net/ext-${EXTENSION_ID}-${FUNCTION_NAME}`; + expectedPayload.httpRequest.headers['Authorization'] = 'Bearer mockIdToken'; + delete expectedPayload.httpRequest.oidcToken; const stub = sinon .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({}, 200));