From 8036f1a51d1a103b08daf62c7ce372c9f68cd9d4 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 14 Jan 2020 13:34:06 -0800 Subject: [PATCH] feat: add methods for fetching and using id tokens (#867) --- README.md | 45 ++++++++++++++++++ package.json | 2 +- samples/README.md | 25 ++++++++-- samples/iap.js | 41 ----------------- samples/idtokens-cloudrun.js | 55 ++++++++++++++++++++++ samples/idtokens-iap.js | 50 ++++++++++++++++++++ samples/test/jwt.test.js | 21 +++++++++ src/auth/computeclient.ts | 23 ++++++++++ src/auth/googleauth.ts | 16 +++++++ src/auth/idtokenclient.ts | 82 +++++++++++++++++++++++++++++++++ src/auth/jwtclient.ts | 26 ++++++++++- src/index.ts | 1 + synth.metadata | 8 +++- test/test.compute.ts | 36 +++++++++++++++ test/test.googleauth.ts | 53 ++++++++++++++++++++- test/test.idtokenclient.ts | 89 ++++++++++++++++++++++++++++++++++++ test/test.jwt.ts | 30 ++++++++++++ 17 files changed, 554 insertions(+), 49 deletions(-) delete mode 100644 samples/iap.js create mode 100644 samples/idtokens-cloudrun.js create mode 100644 samples/idtokens-iap.js create mode 100644 src/auth/idtokenclient.ts create mode 100644 test/test.idtokenclient.ts diff --git a/README.md b/README.md index b1780507..7a5324f8 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,51 @@ async function main() { main().catch(console.error); ``` +## Working with ID Tokens +If your application is running behind Cloud Run, or using Cloud Identity-Aware +Proxy (IAP), you will need to fetch an ID token to access your application. For +this, use the method `getIdTokenClient` on the `GoogleAuth` client. + +For invoking Cloud Run services, your service account will need the +[`Cloud Run Invoker`](https://cloud.google.com/run/docs/authenticating/service-to-service) +IAM permission. + +``` js +// Make a request to a protected Cloud Run +const {GoogleAuth} = require('google-auth-library'); + +async function main() { + const url = 'https://cloud-run-url.com'; + const auth = new GoogleAuth(); + const client = auth.getIdTokenClient(url); + const res = await client.request({url}); + console.log(res.data); +} + +main().catch(console.error); +``` + +For invoking Cloud Identity-Aware Proxy, you will need to pass the Client ID +used when you set up your protected resource as the target audience. + +``` js +// Make a request to a protected Cloud Identity-Aware Proxy (IAP) resource +const {GoogleAuth} = require('google-auth-library'); + +async function main() + const targetAudience = 'iap-client-id'; + const url = 'https://iap-url.com'; + const auth = new GoogleAuth(); + const client = auth.getIdTokenClient(targetAudience); + const res = await client.request({url}); + console.log(res.data); +} + +main().catch(console.error); +``` + +See how to [secure your IAP app with signed headers](https://cloud.google.com/iap/docs/signed-headers-howto). + ## Questions/problems? * Ask your development related questions on [Stack Overflow][stackoverflow]. diff --git a/package.json b/package.json index 7d78fdc9..3b90f7bd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/sinon": "^7.0.0", "@types/tmp": "^0.1.0", "assert-rejects": "^1.0.0", + "c8": "^7.0.0", "chai": "^4.2.0", "codecov": "^3.0.2", "eslint": "^6.0.0", @@ -64,7 +65,6 @@ "ncp": "^2.0.0", "nock": "^11.3.2", "null-loader": "^3.0.0", - "c8": "^7.0.0", "prettier": "^1.13.4", "puppeteer": "^2.0.0", "sinon": "^8.0.0", diff --git a/samples/README.md b/samples/README.md index b9d60ca2..7d6e5e84 100644 --- a/samples/README.md +++ b/samples/README.md @@ -107,16 +107,33 @@ __Usage:__ -### Iap +### ID Tokens with IAP -View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/iap.js). +View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-iap.js). -[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/iap.js,samples/README.md) +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-iap.js,samples/README.md) __Usage:__ -`node samples/iap.js` +`node samples/idtokens-iap.js` + + +----- + + + + +### ID Tokens with Cloud Run + +View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-cloudrun.js). + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-cloudrun.js,samples/README.md) + +__Usage:__ + + +`node samples/idtokens-cloudrun.js` ----- diff --git a/samples/iap.js b/samples/iap.js deleted file mode 100644 index 26529b28..00000000 --- a/samples/iap.js +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2017, Google, Inc. -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -'use strict'; - -const {JWT} = require('google-auth-library'); - -/** - * The JWT authorization is ideal for performing server-to-server - * communication without asking for user consent. - * - * Suggested reading for Admin SDK users using service accounts: - * https://developers.google.com/admin-sdk/directory/v1/guides/delegation - **/ - -const keys = require('./jwt.keys.json'); -const oauth2Keys = require('./iap.keys.json'); - -async function main() { - const clientId = oauth2Keys.web.client_id; - const client = new JWT({ - email: keys.client_email, - key: keys.private_key, - additionalClaims: {target_audience: clientId}, - }); - const url = `https://iap-demo-dot-el-gato.appspot.com`; - const res = await client.request({url}); - console.log(res.data); -} - -main().catch(console.error); diff --git a/samples/idtokens-cloudrun.js b/samples/idtokens-cloudrun.js new file mode 100644 index 00000000..213f3567 --- /dev/null +++ b/samples/idtokens-cloudrun.js @@ -0,0 +1,55 @@ +// Copyright 2020 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// sample-metadata: +// title: ID Tokens for Cloud Run +// description: Requests a Cloud Run URL with an ID Token. +// usage: node idtokens-cloudrun.js [] + +'use strict'; + +function main( + url = 'https://service-1234-uc.a.run.app', + targetAudience = null +) { + // [START google_auth_idtoken_cloudrun] + /** + * TODO(developer): Uncomment these variables before running the sample. + */ + // const url = 'https://YOUR_CLOUD_RUN_URL.run.app'; + const {GoogleAuth} = require('google-auth-library'); + const auth = new GoogleAuth(); + + async function request() { + if (!targetAudience) { + // Use the request URL hostname as the target audience for Cloud Run requests + const {URL} = require('url'); + targetAudience = new URL(url).origin; + } + console.info( + `request Cloud Run ${url} with target audience ${targetAudience}` + ); + const client = await auth.getIdTokenClient(targetAudience); + const res = await client.request({url}); + console.info(res.data); + } + + request().catch(err => { + console.error(err.message); + process.exitCode = 1; + }); + // [END google_auth_idtoken_cloudrun] +} + +const args = process.argv.slice(2); +main(...args); diff --git a/samples/idtokens-iap.js b/samples/idtokens-iap.js new file mode 100644 index 00000000..c5c6135a --- /dev/null +++ b/samples/idtokens-iap.js @@ -0,0 +1,50 @@ +// Copyright 2020 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// sample-metadata: +// title: ID Tokens for Identity-Aware Proxy (IAP) +// description: Requests an IAP-protected resource with an ID Token. +// usage: node idtokens-iap.js + +'use strict'; + +function main( + url = 'https://some.iap.url', + targetAudience = 'IAP_CLIENT_ID.apps.googleusercontent.com' +) { + // [START google_auth_idtoken_iap] + /** + * TODO(developer): Uncomment these variables before running the sample. + */ + // const url = 'https://some.iap.url'; + // const targetAudience = 'IAP_CLIENT_ID.apps.googleusercontent.com'; + + const {GoogleAuth} = require('google-auth-library'); + const auth = new GoogleAuth(); + + async function request() { + console.info(`request IAP ${url} with target audience ${targetAudience}`); + const client = await auth.getIdTokenClient(targetAudience); + const res = await client.request({url}); + console.info(res.data); + } + + request().catch(err => { + console.error(err.message); + process.exitCode = 1; + }); + // [END google_auth_idtoken_iap] +} + +const args = process.argv.slice(2); +main(...args); diff --git a/samples/test/jwt.test.js b/samples/test/jwt.test.js index 4da5c069..3664ec1e 100644 --- a/samples/test/jwt.test.js +++ b/samples/test/jwt.test.js @@ -63,4 +63,25 @@ describe('samples', () => { assert.match(output, /Headers:/); assert.match(output, /DNS Info:/); }); + + it('should fetch ID token for Cloud Run', async () => { + // process.env.CLOUD_RUN_URL should be a cloud run container, protected with + // IAP, running gcr.io/cloudrun/hello: + const url = + process.env.CLOUD_RUN_URL || 'https://hello-rftcw63abq-uc.a.run.app'; + const output = execSync(`node idtokens-cloudrun ${url}`); + assert.match(output, /What's next?/); + }); + + it('should fetch ID token for IAP', async () => { + // process.env.CLOUD_RUN_URL should be a cloud run container, protected with + // IAP, running gcr.io/cloudrun/hello: + const url = + process.env.IAP_URL || 'https://nodejs-docs-samples-iap.appspot.com'; + const targetAudience = + process.env.IAP_CLIENT_ID || + '170454875485-fbn7jalc9214bb67lslv1pbvmnijrb20.apps.googleusercontent.com'; + const output = execSync(`node idtokens-iap ${url} ${targetAudience}`); + assert.match(output, /Hello, world/); + }); }); diff --git a/src/auth/computeclient.ts b/src/auth/computeclient.ts index 1b97bef6..479c8b51 100644 --- a/src/auth/computeclient.ts +++ b/src/auth/computeclient.ts @@ -19,6 +19,7 @@ import * as gcpMetadata from 'gcp-metadata'; import * as messages from '../messages'; import {CredentialRequest, Credentials} from './credentials'; +import {IdTokenProvider} from './idtokenclient'; import {GetTokenResponse, OAuth2Client, RefreshOptions} from './oauth2client'; export interface ComputeOptions extends RefreshOptions { @@ -101,6 +102,28 @@ export class Compute extends OAuth2Client { return {tokens, res: null}; } + /** + * Fetches an ID token. + * @param targetAudience the audience for the fetched ID token. + */ + async fetchIdToken(targetAudience: string): Promise { + const idTokenPath = + `service-accounts/${this.serviceAccountEmail}/identity` + + `?audience=${targetAudience}`; + let idToken: string; + try { + const instanceOptions: gcpMetadata.Options = { + property: idTokenPath, + }; + idToken = await gcpMetadata.instance(instanceOptions); + } catch (e) { + e.message = `Could not fetch ID token: ${e.message}`; + throw e; + } + + return idToken; + } + protected wrapError(e: GaxiosError) { const res = e.response; if (res && res.status) { diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 64a9ed9c..c6d28e49 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -26,6 +26,7 @@ import {DefaultTransporter, Transporter} from '../transporters'; import {Compute, ComputeOptions} from './computeclient'; import {CredentialBody, JWTInput} from './credentials'; +import {IdTokenClient, IdTokenProvider} from './idtokenclient'; import {GCPEnv, getEnv} from './envDetect'; import {JWT, JWTOptions} from './jwtclient'; import { @@ -728,6 +729,21 @@ export class GoogleAuth { return this.cachedCredential!; } + /** + * Creates a client which will fetch an ID token for authorization. + * @param targetAudience the audience for the fetched ID token. + * @returns IdTokenClient for making HTTP calls authenticated with ID tokens. + */ + async getIdTokenClient(targetAudience: string): Promise { + const client = await this.getClient(); + if (!('fetchIdToken' in client)) { + throw new Error( + 'Cannot fetch ID token in this environment, use GCE or set the GOOGLE_APPLICATION_CREDENTIALS environment variable to a service account credentials JSON file.' + ); + } + return new IdTokenClient({targetAudience, idTokenProvider: client}); + } + /** * Automatically obtain application default credentials, and return * an access token for making requests. diff --git a/src/auth/idtokenclient.ts b/src/auth/idtokenclient.ts new file mode 100644 index 00000000..9d0d7af5 --- /dev/null +++ b/src/auth/idtokenclient.ts @@ -0,0 +1,82 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {BodyResponseCallback} from '../transporters'; + +import {Credentials} from './credentials'; +import {Headers, OAuth2Client, RequestMetadataResponse} from './oauth2client'; + +export interface IdTokenOptions { + /** + * The client to make the request to fetch an ID token. + */ + idTokenProvider: IdTokenProvider; + /** + * The audience to use when requesting an ID token. + */ + targetAudience: string; +} + +export interface IdTokenProvider { + fetchIdToken: (targetAudience: string) => Promise; +} + +export class IdTokenClient extends OAuth2Client { + targetAudience: string; + idTokenProvider: IdTokenProvider; + + /** + * Google ID Token client + * + * Retrieve access token from the metadata server. + * See: https://developers.google.com/compute/docs/authentication + */ + constructor(options: IdTokenOptions) { + super(); + this.targetAudience = options.targetAudience; + this.idTokenProvider = options.idTokenProvider; + } + + protected async getRequestMetadataAsync( + url?: string | null + ): Promise { + if ( + !this.credentials.id_token || + (this.credentials.expiry_date || 0) < Date.now() + ) { + const idToken = await this.idTokenProvider.fetchIdToken( + this.targetAudience + ); + this.credentials = { + id_token: idToken, + expiry_date: this.getIdTokenExpiryDate(idToken), + } as Credentials; + } + + const headers: Headers = { + Authorization: 'Bearer ' + this.credentials.id_token, + }; + return {headers}; + } + + private getIdTokenExpiryDate(idToken: string): number | void { + const payloadB64 = idToken.split('.')[1]; + if (payloadB64) { + const payload = JSON.parse( + Buffer.from(payloadB64, 'base64').toString('ascii') + ); + return payload.exp * 1000; + } + } +} diff --git a/src/auth/jwtclient.ts b/src/auth/jwtclient.ts index e5ffa58d..e4f173dc 100644 --- a/src/auth/jwtclient.ts +++ b/src/auth/jwtclient.ts @@ -17,6 +17,7 @@ import * as stream from 'stream'; import * as messages from '../messages'; import {CredentialBody, Credentials, JWTInput} from './credentials'; +import {IdTokenProvider} from './idtokenclient'; import {JWTAccess} from './jwtaccess'; import { GetTokenResponse, @@ -35,7 +36,7 @@ export interface JWTOptions extends RefreshOptions { additionalClaims?: {}; } -export class JWT extends OAuth2Client { +export class JWT extends OAuth2Client implements IdTokenProvider { email?: string; keyFile?: string; key?: string; @@ -150,6 +151,29 @@ export class JWT extends OAuth2Client { } } + /** + * Fetches an ID token. + * @param targetAudience the audience for the fetched ID token. + */ + async fetchIdToken(targetAudience: string): Promise { + // Create a new gToken for fetching an ID token + const gtoken = new GoogleToken({ + iss: this.email, + sub: this.subject, + scope: this.scopes, + keyFile: this.keyFile, + key: this.key, + additionalClaims: {target_audience: targetAudience}, + }); + await gtoken.getToken({ + forceRefresh: true, + }); + if (!gtoken.idToken) { + throw new Error('Unknown error: Failed to fetch ID token'); + } + return gtoken.idToken; + } + /** * Indicates whether the credential requires scopes to be created by calling * createScoped before use. diff --git a/src/index.ts b/src/index.ts index 39d98b3b..1299a721 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export { export {GCPEnv} from './auth/envDetect'; export {GoogleAuthOptions, ProjectIdCallback} from './auth/googleauth'; export {IAMAuth, RequestMetadata} from './auth/iam'; +export {IdTokenClient, IdTokenProvider} from './auth/idtokenclient'; export {Claims, JWTAccess} from './auth/jwtaccess'; export {JWT, JWTOptions} from './auth/jwtclient'; export { diff --git a/synth.metadata b/synth.metadata index b191ce9e..825a6721 100644 --- a/synth.metadata +++ b/synth.metadata @@ -427,6 +427,9 @@ { "path": "src/auth/iam.ts" }, + { + "path": "src/auth/idtokenclient.ts" + }, { "path": "src/auth/oauth2client.ts" }, @@ -473,7 +476,10 @@ "path": "samples/headers.js" }, { - "path": "samples/iap.js" + "path": "samples/idtokens-cloudrun.js" + }, + { + "path": "samples/idtokens-iap.js" }, { "path": "samples/jwt.js" diff --git a/test/test.compute.ts b/test/test.compute.ts index eee4dd01..74f9925c 100644 --- a/test/test.compute.ts +++ b/test/test.compute.ts @@ -24,6 +24,7 @@ nock.disableNetConnect(); const url = 'http://example.com'; const tokenPath = `${BASE_PATH}/instance/service-accounts/default/token`; +const identityPath = `${BASE_PATH}/instance/service-accounts/default/identity`; function mockToken(statusCode = 200, scopes?: string[]) { let path = tokenPath; if (scopes && scopes.length > 0) { @@ -231,3 +232,38 @@ it('should accept a custom service account', async () => { scopes.forEach(s => s.done()); assert.strictEqual(compute.credentials.access_token, 'abc123'); }); + +it('should request the identity endpoint for fetchIdToken', async () => { + const targetAudience = 'a-target-audience'; + const path = `${identityPath}?audience=${targetAudience}`; + + const tokenFetchNock = nock(HOST_ADDRESS) + .get(path, undefined, {reqheaders: HEADERS}) + .reply(200, 'abc123', HEADERS); + + const compute = new Compute(); + const idToken = await compute.fetchIdToken(targetAudience); + + tokenFetchNock.done(); + + assert.strictEqual(idToken, 'abc123'); +}); + +it('should throw an error if metadata server is unavailable', async () => { + const targetAudience = 'a-target-audience'; + const path = `${identityPath}?audience=${targetAudience}`; + + const tokenFetchNock = nock(HOST_ADDRESS) + .get(path, undefined, {reqheaders: HEADERS}) + .reply(500, 'a server error!', HEADERS); + + const compute = new Compute(); + try { + await compute.fetchIdToken(targetAudience); + } catch { + tokenFetchNock.done(); + return; + } + + assert.fail('failed to throw'); +}); diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index af01ed30..5b5c2bb6 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -30,7 +30,7 @@ import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import {GoogleAuth, JWT, UserRefreshClient} from '../src'; +import {GoogleAuth, JWT, UserRefreshClient, IdTokenClient} from '../src'; import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; import {Compute} from '../src/auth/computeclient'; @@ -1524,6 +1524,57 @@ describe('googleauth', () => { apiReq.done(); }); + it('should return a Compute client for getIdTokenClient', async () => { + const nockScopes = [nockIsGCE(), createGetProjectIdNock()]; + const auth = new GoogleAuth(); + const client = await auth.getIdTokenClient('a-target-audience'); + assert(client instanceof IdTokenClient); + assert(client.idTokenProvider instanceof Compute); + }); + + it('should return a JWT client for getIdTokenClient', async () => { + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/private.json' + ); + + const auth = new GoogleAuth(); + const client = await auth.getIdTokenClient('a-target-audience'); + assert(client instanceof IdTokenClient); + assert(client.idTokenProvider instanceof JWT); + }); + + it('should call getClient for getIdTokenClient', async () => { + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/private.json' + ); + + const spy = sinon.spy(auth, 'getClient'); + const client = await auth.getIdTokenClient('a-target-audience'); + assert(client instanceof IdTokenClient); + assert(spy.calledOnce); + }); + + it('should fail when using UserRefreshClient', async () => { + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/refresh.json' + ); + mockEnvVar('GOOGLE_CLOUD_PROJECT', 'some-project-id'); + + try { + const client = await auth.getIdTokenClient('a-target-audience'); + } catch (e) { + assert(e.message.startsWith('Cannot fetch ID token in this environment')); + return; + } + assert.fail('failed to throw'); + }); + function mockApplicationDefaultCredentials(path: string) { // Fake a home directory in our fixtures path. mockEnvVar('GCLOUD_PROJECT', 'my-fake-project'); diff --git a/test/test.idtokenclient.ts b/test/test.idtokenclient.ts new file mode 100644 index 00000000..bfe88a8f --- /dev/null +++ b/test/test.idtokenclient.ts @@ -0,0 +1,89 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import * as assert from 'assert'; +import {it} from 'mocha'; +import * as fs from 'fs'; +import * as nock from 'nock'; + +import {IdTokenClient, JWT} from '../src'; +import {CredentialRequest} from '../src/auth/credentials'; + +const PEM_PATH = './test/fixtures/private.pem'; +const PEM_CONTENTS = fs.readFileSync(PEM_PATH, 'utf8'); +nock.disableNetConnect(); + +function createGTokenMock(body: CredentialRequest) { + return nock('https://www.googleapis.com') + .post('/oauth2/v4/token') + .reply(200, body); +} + +afterEach(() => { + nock.cleanAll(); +}); + +it('should determine expiry_date from JWT', async () => { + const idToken = 'header.eyJleHAiOiAxNTc4NzAyOTU2fQo.signature'; + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: PEM_CONTENTS, + subject: 'ignored@subjectaccount.com', + }); + + const scope = createGTokenMock({id_token: idToken}); + const targetAudience = 'a-target-audience'; + const client = new IdTokenClient({idTokenProvider: jwt, targetAudience}); + await client.getRequestHeaders(); + scope.done(); + assert.strictEqual(client.credentials.expiry_date, 1578702956000); +}); + +it('should refresh ID token if expired', async () => { + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: PEM_CONTENTS, + subject: 'ignored@subjectaccount.com', + }); + + const scope = createGTokenMock({id_token: 'abc123'}); + const targetAudience = 'a-target-audience'; + const client = new IdTokenClient({idTokenProvider: jwt, targetAudience}); + client.credentials = { + id_token: 'an-identity-token', + expiry_date: new Date().getTime() - 1000, + }; + const headers = await client.getRequestHeaders(); + scope.done(); + assert.strictEqual(client.credentials.id_token, 'abc123'); + assert.deepStrictEqual(headers, {Authorization: 'Bearer abc123'}); +}); + +it('should refresh ID token if expiry_date not set', async () => { + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: PEM_CONTENTS, + subject: 'ignored@subjectaccount.com', + }); + + const scope = createGTokenMock({id_token: 'abc123'}); + const targetAudience = 'a-target-audience'; + const client = new IdTokenClient({idTokenProvider: jwt, targetAudience}); + client.credentials = { + id_token: 'an-identity-token', + }; + const headers = await client.getRequestHeaders(); + scope.done(); + assert.strictEqual(client.credentials.id_token, 'abc123'); + assert.deepStrictEqual(headers, {Authorization: 'Bearer abc123'}); +}); diff --git a/test/test.jwt.ts b/test/test.jwt.ts index 1f83ff53..12d00916 100644 --- a/test/test.jwt.ts +++ b/test/test.jwt.ts @@ -830,3 +830,33 @@ it('getRequestHeaders populates x-goog-user-project for JWT client', async () => ); assert.strictEqual(headers['x-goog-user-project'], 'fake-quota-project'); }); + +it('should return an ID token for fetchIdToken', async () => { + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: PEM_CONTENTS, + subject: 'ignored@subjectaccount.com', + }); + + const scope = createGTokenMock({id_token: 'abc123'}); + const idtoken = await jwt.fetchIdToken('a-target-audience'); + scope.done(); + assert.strictEqual(idtoken, 'abc123'); +}); + +it('should throw an error if ID token is not set', async () => { + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: PEM_CONTENTS, + subject: 'ignored@subjectaccount.com', + }); + + const scope = createGTokenMock({access_token: 'a-token'}); + try { + await jwt.fetchIdToken('a-target-audience'); + } catch { + scope.done(); + return; + } + assert.fail('failed to throw'); +});