Skip to content

Commit

Permalink
Use ID token from metadata server when sending tasks for extensions (#…
Browse files Browse the repository at this point in the history
…1812)

* Use ID token from metadata server when sending tasks for extensions

* revert changes to package-lock.json

* self review
  • Loading branch information
joehan committed Jul 18, 2022
1 parent 0482f0b commit 1dc314c
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 22 deletions.
22 changes: 22 additions & 0 deletions src/app/credential-internal.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> {
const request = this.buildRequest(`${GOOGLE_METADATA_SERVICE_IDENTITY_PATH}?audience=${audience}`);
return requestIDToken(this.httpClient, request);
}

public getProjectId(): Promise<string> {
if (this.projectId) {
return Promise.resolve(this.projectId);
Expand Down Expand Up @@ -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<string> {
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.
*/
Expand Down
43 changes: 24 additions & 19 deletions src/functions/functions-api-client-internal.ts
Expand Up @@ -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}';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -224,22 +225,22 @@ export class FunctionsApiClient {
return task;
}

private updateTaskPayload(task: Task, resources: utils.ParsedResource): Promise<Task> {
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<Task> {
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 {
Expand Down Expand Up @@ -274,15 +275,19 @@ 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;
// A duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s".
dispatchDeadline?: string;
httpRequest: {
url: string;
oidcToken: {
oidcToken?: {
serviceAccountEmail: string;
};
// A base64-encoded string.
Expand Down
14 changes: 14 additions & 0 deletions test/resources/mocks.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +91,19 @@ export class MockCredential implements Credential {
}
}

export class MockComputeEngineCredential extends ComputeEngineCredential {
public getAccessToken(): Promise<GoogleOAuthAccessToken> {
return Promise.resolve({
access_token: 'mock-token',
expires_in: 3600,
});
}

public getIDToken(): Promise<string> {
return Promise.resolve('mockIdToken');
}
}

export function app(): FirebaseApp {
return new FirebaseApp(appOptions, appName);
}
Expand Down
17 changes: 17 additions & 0 deletions test/unit/app/credential-internal.spec.ts
Expand Up @@ -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);
Expand Down
17 changes: 14 additions & 3 deletions test/unit/functions/functions-api-client-internal.spec.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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));
Expand Down

0 comments on commit 1dc314c

Please sign in to comment.