Skip to content
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

Use ID token from metadata server when sending tasks for extensions #1812

Merged
merged 4 commits into from Jul 18, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 || '';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we explicitly handle the cases where resp.text is undefined, or is it safe to just return an empty token here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call - we probably should throw in this case (since if were requesting an id token & get an empty string, we'll almost certainly get an error later).

}).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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use validator.isNonEmptyString(extensionId) here?

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