Skip to content

Commit 1dc314c

Browse files
authored
Use ID token from metadata server when sending tasks for extensions (#1812)
* Use ID token from metadata server when sending tasks for extensions * revert changes to package-lock.json * self review
1 parent 0482f0b commit 1dc314c

File tree

5 files changed

+91
-22
lines changed

5 files changed

+91
-22
lines changed

src/app/credential-internal.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token';
3232
// NOTE: the Google Metadata Service uses HTTP over a vlan
3333
const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal';
3434
const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token';
35+
const GOOGLE_METADATA_SERVICE_IDENTITY_PATH = '/computeMetadata/v1/instance/service-accounts/default/identity';
3536
const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id';
3637
const GOOGLE_METADATA_SERVICE_ACCOUNT_ID_PATH = '/computeMetadata/v1/instance/service-accounts/default/email';
3738

@@ -209,6 +210,16 @@ export class ComputeEngineCredential implements Credential {
209210
return requestAccessToken(this.httpClient, request);
210211
}
211212

213+
/**
214+
* getIDToken returns a OIDC token from the compute metadata service
215+
* that can be used to make authenticated calls to audience
216+
* @param audience the URL the returned ID token will be used to call.
217+
*/
218+
public getIDToken(audience: string): Promise<string> {
219+
const request = this.buildRequest(`${GOOGLE_METADATA_SERVICE_IDENTITY_PATH}?audience=${audience}`);
220+
return requestIDToken(this.httpClient, request);
221+
}
222+
212223
public getProjectId(): Promise<string> {
213224
if (this.projectId) {
214225
return Promise.resolve(this.projectId);
@@ -421,6 +432,17 @@ function requestAccessToken(client: HttpClient, request: HttpRequestConfig): Pro
421432
});
422433
}
423434

435+
/**
436+
* Obtain a new OIDC token by making a remote service call.
437+
*/
438+
function requestIDToken(client: HttpClient, request: HttpRequestConfig): Promise<string> {
439+
return client.send(request).then((resp) => {
440+
return resp.text || '';
441+
}).catch((err) => {
442+
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, getErrorMessage(err));
443+
});
444+
}
445+
424446
/**
425447
* Constructs a human-readable error message from the given Error.
426448
*/

src/functions/functions-api-client-internal.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { PrefixedFirebaseError } from '../utils/error';
2424
import * as utils from '../utils/index';
2525
import * as validator from '../utils/validator';
2626
import { TaskOptions } from './functions-api';
27+
import { ComputeEngineCredential } from '../app/credential-internal';
2728

2829
const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks';
2930
const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}';
@@ -84,7 +85,7 @@ export class FunctionsApiClient {
8485

8586
return this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT)
8687
.then((serviceUrl) => {
87-
return this.updateTaskPayload(task, resources)
88+
return this.updateTaskPayload(task, resources, extensionId)
8889
.then((task) => {
8990
const request: HttpRequestConfig = {
9091
method: 'POST',
@@ -224,22 +225,22 @@ export class FunctionsApiClient {
224225
return task;
225226
}
226227

227-
private updateTaskPayload(task: Task, resources: utils.ParsedResource): Promise<Task> {
228-
return Promise.resolve()
229-
.then(() => {
230-
if (validator.isNonEmptyString(task.httpRequest.url)) {
231-
return task.httpRequest.url;
232-
}
233-
return this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT);
234-
})
235-
.then((functionUrl) => {
236-
return this.getServiceAccount()
237-
.then((account) => {
238-
task.httpRequest.oidcToken.serviceAccountEmail = account;
239-
task.httpRequest.url = functionUrl;
240-
return task;
241-
})
242-
});
228+
private async updateTaskPayload(task: Task, resources: utils.ParsedResource, extensionId?: string): Promise<Task> {
229+
const functionUrl = validator.isNonEmptyString(task.httpRequest.url)
230+
? task.httpRequest.url
231+
: await this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT);
232+
task.httpRequest.url = functionUrl;
233+
// When run from a deployed extension, we should be using ComputeEngineCredentials
234+
if (extensionId && this.app.options.credential instanceof ComputeEngineCredential) {
235+
const idToken = await this.app.options.credential.getIDToken(functionUrl);
236+
task.httpRequest.headers = { ...task.httpRequest.headers, 'Authorization': `Bearer ${idToken}` };
237+
// Don't send httpRequest.oidcToken if we set Authorization header, or Cloud Tasks will overwrite it.
238+
delete task.httpRequest.oidcToken;
239+
} else {
240+
const account = await this.getServiceAccount();
241+
task.httpRequest.oidcToken = { serviceAccountEmail: account };
242+
}
243+
return task;
243244
}
244245

245246
private toFirebaseError(err: HttpError): PrefixedFirebaseError {
@@ -274,15 +275,19 @@ interface Error {
274275
status?: string;
275276
}
276277

277-
interface Task {
278+
/**
279+
* Task is a limited subset of https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#resource:-task
280+
* containing the relevant fields for enqueueing tasks that tirgger Cloud Functions.
281+
*/
282+
export interface Task {
278283
// A timestamp in RFC3339 UTC "Zulu" format, with nanosecond resolution and up to nine fractional
279284
// digits. Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045123456Z".
280285
scheduleTime?: string;
281286
// A duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s".
282287
dispatchDeadline?: string;
283288
httpRequest: {
284289
url: string;
285-
oidcToken: {
290+
oidcToken?: {
286291
serviceAccountEmail: string;
287292
};
288293
// A base64-encoded string.

test/resources/mocks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import * as jwt from 'jsonwebtoken';
2828
import { AppOptions } from '../../src/firebase-namespace-api';
2929
import { FirebaseApp } from '../../src/app/firebase-app';
3030
import { Credential, GoogleOAuthAccessToken, cert } from '../../src/app/index';
31+
import { ComputeEngineCredential } from '../../src/app/credential-internal';
3132

3233
const ALGORITHM = 'RS256' as const;
3334
const ONE_HOUR_IN_SECONDS = 60 * 60;
@@ -90,6 +91,19 @@ export class MockCredential implements Credential {
9091
}
9192
}
9293

94+
export class MockComputeEngineCredential extends ComputeEngineCredential {
95+
public getAccessToken(): Promise<GoogleOAuthAccessToken> {
96+
return Promise.resolve({
97+
access_token: 'mock-token',
98+
expires_in: 3600,
99+
});
100+
}
101+
102+
public getIDToken(): Promise<string> {
103+
return Promise.resolve('mockIdToken');
104+
}
105+
}
106+
93107
export function app(): FirebaseApp {
94108
return new FirebaseApp(appOptions, appName);
95109
}

test/unit/app/credential-internal.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,23 @@ describe('Credential', () => {
333333
});
334334
});
335335

336+
it('should create id tokens', () => {
337+
const expected = 'an-id-token-encoded';
338+
const response = utils.responseFrom(expected);
339+
httpStub.resolves(response);
340+
341+
const c = new ComputeEngineCredential();
342+
return c.getIDToken('my-audience.cloudfunctions.net').then((token) => {
343+
expect(token).to.equal(expected);
344+
expect(httpStub).to.have.been.calledOnce.and.calledWith({
345+
method: 'GET',
346+
url: 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=my-audience.cloudfunctions.net',
347+
headers: { 'Metadata-Flavor': 'Google' },
348+
httpAgent: undefined,
349+
});
350+
});
351+
});
352+
336353
it('should discover project id', () => {
337354
const expectedProjectId = 'test-project-id';
338355
const response = utils.responseFrom(expectedProjectId);

test/unit/functions/functions-api-client-internal.spec.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import * as mocks from '../../resources/mocks';
2525
import { getSdkVersion } from '../../../src/utils';
2626

2727
import { FirebaseApp } from '../../../src/app/firebase-app';
28-
import { FirebaseFunctionsError, FunctionsApiClient } from '../../../src/functions/functions-api-client-internal';
28+
import { FirebaseFunctionsError, FunctionsApiClient, Task } from '../../../src/functions/functions-api-client-internal';
2929
import { HttpClient } from '../../../src/utils/api-request';
3030
import { FirebaseAppError } from '../../../src/utils/error';
3131
import { deepCopy } from '../../../src/utils/deep-copy';
@@ -65,7 +65,13 @@ describe('FunctionsApiClient', () => {
6565
serviceAccountId: '[email protected]'
6666
};
6767

68-
const TEST_TASK_PAYLOAD = {
68+
const mockExtensionOptions = {
69+
credential: new mocks.MockComputeEngineCredential(),
70+
projectId: 'test-project',
71+
serviceAccountId: '[email protected]'
72+
};
73+
74+
const TEST_TASK_PAYLOAD: Task = {
6975
httpRequest: {
7076
url: `https://${DEFAULT_REGION}-${mockOptions.projectId}.cloudfunctions.net/${FUNCTION_NAME}`,
7177
oidcToken: {
@@ -291,10 +297,15 @@ describe('FunctionsApiClient', () => {
291297
});
292298
});
293299

294-
it('should update the function name when the extension-id is provided', () => {
300+
it('should update the function name and set headers when the extension-id is provided', () => {
301+
app = mocks.appWithOptions(mockExtensionOptions);
302+
apiClient = new FunctionsApiClient(app);
303+
295304
const expectedPayload = deepCopy(TEST_TASK_PAYLOAD);
296305
expectedPayload.httpRequest.url =
297306
`https://${DEFAULT_REGION}-${mockOptions.projectId}.cloudfunctions.net/ext-${EXTENSION_ID}-${FUNCTION_NAME}`;
307+
expectedPayload.httpRequest.headers['Authorization'] = 'Bearer mockIdToken';
308+
delete expectedPayload.httpRequest.oidcToken;
298309
const stub = sinon
299310
.stub(HttpClient.prototype, 'send')
300311
.resolves(utils.responseFrom({}, 200));

0 commit comments

Comments
 (0)