diff --git a/README.md b/README.md index 7a5324f8..6de836b3 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,7 @@ main().catch(console.error); ``` ## Working with ID Tokens +### Fetching 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. @@ -358,6 +359,8 @@ async function main() { main().catch(console.error); ``` +A complete example can be found in [`samples/idtokens-cloudrun.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-cloudrun.js). + 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. @@ -377,7 +380,34 @@ async function main() main().catch(console.error); ``` -See how to [secure your IAP app with signed headers](https://cloud.google.com/iap/docs/signed-headers-howto). +A complete example can be found in [`samples/idtokens-iap.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-iap.js). + +### Verifying ID Tokens + +If you've [secured your IAP app with signed headers](https://cloud.google.com/iap/docs/signed-headers-howto), +you can use this library to verify the IAP header: + +```js +const {OAuth2Client} = require('google-auth-library'); +// Expected audience for App Engine. +const expectedAudience = `/projects/your-project-number/apps/your-project-id`; +// IAP issuer +const issuers = ['https://cloud.google.com/iap']; +// Verify the token. OAuth2Client throws an Error if verification fails +const oAuth2Client = new OAuth2Client(); +const response = await oAuth2Client.getIapCerts(); +const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + idToken, + response.pubkeys, + expectedAudience, + issuers +); + +// Print out the info contained in the IAP ID token +console.log(ticket) +``` + +A complete example can be found in `samples/verifyIdToken-iap.js`. ## Questions/problems? diff --git a/package.json b/package.json index 6dd5fe5e..7699d8b9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "arrify": "^2.0.0", "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", "fast-text-encoding": "^1.0.0", "gaxios": "^2.1.0", "gcp-metadata": "^3.3.0", diff --git a/samples/verifyIdToken-iap.js b/samples/verifyIdToken-iap.js new file mode 100644 index 00000000..20327534 --- /dev/null +++ b/samples/verifyIdToken-iap.js @@ -0,0 +1,62 @@ +// 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. + +'use strict'; + +// [START iap_validate_jwt] +const {OAuth2Client} = require('google-auth-library'); + +/** + * Verify the ID token from IAP + * @see https://cloud.google.com/iap/docs/signed-headers-howto + */ +async function main( + iapJwt, + projectNumber = '', + projectId = '', + backendServiceId = '' +) { + // set Audience + let expectedAudience = null; + if (projectNumber && projectId) { + // Expected Audience for App Engine. + expectedAudience = `/projects/${projectNumber}/apps/${projectId}`; + } else if (projectNumber && backendServiceId) { + // Expected Audience for Compute Engine + expectedAudience = `/projects/${projectNumber}/global/backendServices/${backendServiceId}`; + } + + const oAuth2Client = new OAuth2Client(); + + // Verify the id_token, and access the claims. + const response = await oAuth2Client.getIapPublicKeys(); + const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + iapJwt, + response.pubkeys, + expectedAudience, + ['https://cloud.google.com/iap'] + ); + + // Print out the info contained in the IAP ID token + console.log(ticket); + + if (!expectedAudience) { + console.log( + 'Audience not verified! Supply a projectNumber and projectID to verify' + ); + } +} +// [END iap_validate_jwt] + +const args = process.argv.slice(2); +main(...args).catch(console.error); diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 5dae743f..44637972 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -20,6 +20,7 @@ import { } from 'gaxios'; import * as querystring from 'querystring'; import * as stream from 'stream'; +import * as formatEcdsa from 'ecdsa-sig-formatter'; import {createCrypto, JwkCertificate, hasBrowserCrypto} from '../crypto/crypto'; import * as messages from '../messages'; @@ -28,7 +29,6 @@ import {BodyResponseCallback} from '../transporters'; import {AuthClient} from './authclient'; import {CredentialRequest, Credentials, JWTInput} from './credentials'; import {LoginTicket, TokenPayload} from './loginticket'; - /** * The results from the `generateCodeVerifierAsync` method. To learn more, * See the sample: @@ -51,6 +51,10 @@ export interface Certificates { [index: string]: string | JwkCertificate; } +export interface PublicKeys { + [index: string]: string; +} + export interface Headers { [index: string]: string; } @@ -349,6 +353,19 @@ export interface FederatedSignonCertsResponse { res?: GaxiosResponse | null; } +export interface GetIapPublicKeysCallback { + ( + err: GaxiosError | null, + pubkeys?: PublicKeys, + response?: GaxiosResponse | null + ): void; +} + +export interface IapPublicKeysResponse { + pubkeys: PublicKeys; + res?: GaxiosResponse | null; +} + export interface RevokeCredentialsResult { success: boolean; } @@ -462,6 +479,12 @@ export class OAuth2Client extends AuthClient { private static readonly GOOGLE_OAUTH2_FEDERATED_SIGNON_JWK_CERTS_URL_ = 'https://www.googleapis.com/oauth2/v3/certs'; + /** + * Google Sign on certificates in JWK format. + */ + private static readonly GOOGLE_OAUTH2_IAP_PUBLIC_KEY_URL_ = + 'https://www.gstatic.com/iap/verify/public_key'; + /** * Clock skew - five minutes in seconds */ @@ -1108,6 +1131,43 @@ export class OAuth2Client extends AuthClient { return {certs: certificates, format, res}; } + /** + * Gets federated sign-on certificates to use for verifying identity tokens. + * Returns certs as array structure, where keys are key ids, and values + * are certificates in either PEM or JWK format. + * @param callback Callback supplying the certificates + */ + getIapPublicKeys(): Promise; + getIapPublicKeys(callback: GetIapPublicKeysCallback): void; + getIapPublicKeys( + callback?: GetIapPublicKeysCallback + ): Promise | void { + if (callback) { + this.getIapPublicKeysAsync().then( + r => callback(null, r.pubkeys, r.res), + callback + ); + } else { + return this.getIapPublicKeysAsync(); + } + } + + async getIapPublicKeysAsync(): Promise { + const nowTime = new Date().getTime(); + + let res: GaxiosResponse; + const url: string = OAuth2Client.GOOGLE_OAUTH2_IAP_PUBLIC_KEY_URL_; + + try { + res = await this.transporter.request({url}); + } catch (e) { + e.message = `Failed to retrieve verification certificates: ${e.message}`; + throw e; + } + + return {pubkeys: res.data, res}; + } + verifySignedJwtWithCerts() { // To make the code compatible with browser SubtleCrypto we need to make // this method async. @@ -1128,7 +1188,7 @@ export class OAuth2Client extends AuthClient { */ async verifySignedJwtWithCertsAsync( jwt: string, - certs: Certificates, + certs: Certificates | PublicKeys, requiredAudience: string | string[], issuers?: string[], maxExpiry?: number @@ -1144,7 +1204,7 @@ export class OAuth2Client extends AuthClient { throw new Error('Wrong number of segments in token: ' + jwt); } const signed = segments[0] + '.' + segments[1]; - const signature = segments[2]; + let signature = segments[2]; let envelope; let payload: TokenPayload; @@ -1177,6 +1237,11 @@ export class OAuth2Client extends AuthClient { } const cert = certs[envelope.kid]; + + if (envelope.alg === 'ES256') { + signature = formatEcdsa.joseToDer(signature, 'ES256').toString('base64'); + } + const verified = await crypto.verify(cert, signed, signature); if (!verified) { diff --git a/test/fixtures/ecdsapublickeys.json b/test/fixtures/ecdsapublickeys.json new file mode 100644 index 00000000..9bce6a7c --- /dev/null +++ b/test/fixtures/ecdsapublickeys.json @@ -0,0 +1,4 @@ +{ +"2nMJtw": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9e1x7YRZg53A5zIJ0p2ZQ9yTrgPL\nGIf4ntOk+4O2R2+ryIObueyenPXE92tYG1NlKjDNyJLc7tsxi0UUnyxpig==\n-----END PUBLIC KEY-----\n", +"f9R3yg": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESqCmEwytkqG6tL6a2GTQGmSNI4jH\nYo5MeDUs7DpETVhCXXLIFrLg2sZvNqw8SGnnonLoeqgOSqRdjJBGt4I6jQ==\n-----END PUBLIC KEY-----\n" +} \ No newline at end of file diff --git a/test/fixtures/fake-ecdsa-private.pem b/test/fixtures/fake-ecdsa-private.pem new file mode 100644 index 00000000..c0cdbb7e --- /dev/null +++ b/test/fixtures/fake-ecdsa-private.pem @@ -0,0 +1,18 @@ +-----BEGIN EC PARAMETERS----- +MIH3AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP////////// +/////zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6 +k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+ +kARBBGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tK +fA+eFivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTz +ucrC/GMlUQIBAQ== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MIIBaAIBAQQgI59iRg8vYTW9VySmCM3Tvn+WPLKQr132U5HGec3HTgWggfowgfcC +AQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAAAAAAAAAAAAAA//////////////// +MFsEIP////8AAAABAAAAAAAAAAAAAAAA///////////////8BCBaxjXYqjqT57Pr +vVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSdNgiG5wSTamZ44ROdJreBn36QBEEE +axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li/hp/m47n60p8D54W +K84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8 +YyVRAgEBoUQDQgAEUF5XFvBU1J2PP2Ggh/DiLNv9l4MTM/edN145vGOZIvWe4QBp +FaqzLN7WjTP7BiJCXI044iqRbuDGc2goPf8LMw== +-----END EC PRIVATE KEY----- diff --git a/test/fixtures/fake-ecdsa-public.pem b/test/fixtures/fake-ecdsa-public.pem new file mode 100644 index 00000000..2b1c483b --- /dev/null +++ b/test/fixtures/fake-ecdsa-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBSzCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAA +AAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA//// +///////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSd +NgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5 +RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA +//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABFBeVxbwVNSdjz9hoIfw4izb +/ZeDEzP3nTdeObxjmSL1nuEAaRWqsyze1o0z+wYiQlyNOOIqkW7gxnNoKD3/CzM= +-----END PUBLIC KEY----- diff --git a/test/test.jwt.ts b/test/test.jwt.ts index 339e1406..3cfb3f5f 100644 --- a/test/test.jwt.ts +++ b/test/test.jwt.ts @@ -195,7 +195,7 @@ describe('jwt', () => { }); it('gets a jwt header access token', async () => { - const keys = keypair(1024 /* bitsize of private key */); + const keys = keypair(512 /* bitsize of private key */); const email = 'foo@serviceaccount.com'; const jwt = new JWT({ email: 'foo@serviceaccount.com', @@ -216,7 +216,7 @@ describe('jwt', () => { }); it('gets a jwt header access token with key id', async () => { - const keys = keypair(1024 /* bitsize of private key */); + const keys = keypair(512 /* bitsize of private key */); const jwt = new JWT({ email: 'foo@serviceaccount.com', key: keys.private, @@ -236,7 +236,7 @@ describe('jwt', () => { }); it('should accept additionalClaims', async () => { - const keys = keypair(1024 /* bitsize of private key */); + const keys = keypair(512 /* bitsize of private key */); const someClaim = 'cat-on-my-desk'; const jwt = new JWT({ email: 'foo@serviceaccount.com', @@ -256,7 +256,7 @@ describe('jwt', () => { }); it('should accept additionalClaims that include a target_audience', async () => { - const keys = keypair(1024 /* bitsize of private key */); + const keys = keypair(512 /* bitsize of private key */); const jwt = new JWT({ email: 'foo@serviceaccount.com', key: keys.private, @@ -821,7 +821,7 @@ describe('jwt', () => { credentials: Object.assign( require('../../test/fixtures/service-account-with-quota.json'), { - private_key: keypair(1024 /* bitsize of private key */).private, + private_key: keypair(512 /* bitsize of private key */).private, } ), }); diff --git a/test/test.jwtaccess.ts b/test/test.jwtaccess.ts index c074eeed..b76066cc 100644 --- a/test/test.jwtaccess.ts +++ b/test/test.jwtaccess.ts @@ -33,7 +33,7 @@ describe('jwtaccess', () => { type: 'service_account', }; - const keys = keypair(1024 /* bitsize of private key */); + const keys = keypair(512 /* bitsize of private key */); const testUri = 'http:/example.com/my_test_service'; const email = 'foo@serviceaccount.com'; diff --git a/test/test.oauth2.ts b/test/test.oauth2.ts index 850594e8..2ddc95c5 100644 --- a/test/test.oauth2.ts +++ b/test/test.oauth2.ts @@ -16,6 +16,7 @@ import * as assert from 'assert'; import {describe, it, beforeEach, afterEach} from 'mocha'; const assertRejects = require('assert-rejects'); import * as crypto from 'crypto'; +import * as formatEcdsa from 'ecdsa-sig-formatter'; import * as fs from 'fs'; import {GaxiosError} from 'gaxios'; import * as nock from 'nock'; @@ -45,6 +46,18 @@ describe('oauth2', () => { __dirname, '../../test/fixtures/oauthcertspem.json' ); + const publicKeyEcdsa = fs.readFileSync( + './test/fixtures/fake-ecdsa-public.pem', + 'utf-8' + ); + const privateKeyEcdsa = fs.readFileSync( + './test/fixtures/fake-ecdsa-private.pem', + 'utf-8' + ); + const pubkeysResPath = path.join( + __dirname, + '../../test/fixtures/ecdsapublickeys.json' + ); describe(__filename, () => { let client: OAuth2Client; @@ -773,6 +786,45 @@ describe('oauth2', () => { ); }); + it('should pass for ECDSA-encrypted JWTs', async () => { + const maxLifetimeSecs = 86400; + const now = new Date().getTime() / 1000; + const expiry = now + maxLifetimeSecs / 2; + const idToken = + '{' + + '"iss":"testissuer",' + + '"aud":"testaudience",' + + '"azp":"testauthorisedparty",' + + '"email_verified":"true",' + + '"id":"123456789",' + + '"sub":"123456789",' + + '"email":"test@test.com",' + + '"iat":' + + now + + ',' + + '"exp":' + + expiry + + '}'; + const envelope = '{' + '"kid":"keyid",' + '"alg":"ES256"' + '}'; + let data = + Buffer.from(envelope).toString('base64') + + '.' + + Buffer.from(idToken).toString('base64'); + const signer = crypto.createSign('RSA-SHA256'); + signer.update(data); + const signature = formatEcdsa.derToJose( + signer.sign(privateKeyEcdsa, 'base64'), + 'ES256' + ); + data += '.' + signature; + await client.verifySignedJwtWithCertsAsync( + data, + {keyid: publicKeyEcdsa}, + 'testaudience', + ['testissuer'] + ); + }); + it('should be able to retrieve a list of Google certificates', done => { const scope = nock('https://www.googleapis.com') .get(certsPath) @@ -815,6 +867,20 @@ describe('oauth2', () => { }); }); + it('should be able to retrieve a list of IAP certificates', done => { + const scope = nock('https://www.gstatic.com') + .get('/iap/verify/public_key') + .replyWithFile(200, pubkeysResPath); + client.getIapPublicKeys((err, pubkeys) => { + assert.strictEqual(err, null); + assert.strictEqual(Object.keys(pubkeys!).length, 2); + assert.notStrictEqual(pubkeys!.f9R3yg, null); + assert.notStrictEqual(pubkeys!['2nMJtw'], null); + scope.done(); + done(); + }); + }); + it('should set redirect_uri if not provided in options', () => { const generated = client.generateAuthUrl({}); const parsed = url.parse(generated);