Skip to content

Commit

Permalink
feat: support for verifying ES256 and retrieving IAP public keys
Browse files Browse the repository at this point in the history
  • Loading branch information
bshaffer committed Feb 10, 2020
1 parent 163e43d commit 7bf13f4
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 3 deletions.
48 changes: 48 additions & 0 deletions samples/verifyIdToken-iap.js
@@ -0,0 +1,48 @@
// Copyright 2020, 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 {OAuth2Client} = require('google-auth-library');

/**
* Verify the ID token from IAP
* @see https://cloud.google.com/iap/docs/signed-headers-howto
*/
async function main(idToken, projectNumber = '', projectId = '') {
const oAuth2Client = new OAuth2Client();

// set Audience
let expectedAudience = null;
if (projectId && projectNumber) {
expectedAudience = `/projects/${projectNumber}/apps/${projectId}`;
}

// Verify the id_token, and access the claims.
const response = await oAuth2Client.getIapCerts();
const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync(
idToken,
response.certs,
expectedAudience,
['https://cloud.google.com/iap']
);
console.log(ticket);
if (!expectedAudience) {
console.log(
'Audience not verified! Supply a projectNumber and projectID to verify'
);
}
}

const args = process.argv.slice(2);
main(...args).catch(console.error);
71 changes: 68 additions & 3 deletions src/auth/oauth2client.ts
Expand Up @@ -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';
Expand All @@ -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:
Expand All @@ -51,6 +51,10 @@ export interface Certificates {
[index: string]: string | JwkCertificate;
}

export interface PublicKeys {
[index: string]: string;
}

export interface Headers {
[index: string]: string;
}
Expand Down Expand Up @@ -349,6 +353,19 @@ export interface FederatedSignonCertsResponse {
res?: GaxiosResponse<void> | null;
}

export interface GetIapPublicKeysCallback {
(
err: GaxiosError | null,
pubkeys?: PublicKeys,
response?: GaxiosResponse<void> | null
): void;
}

export interface IapPublicKeysResponse {
pubkeys: PublicKeys;
res?: GaxiosResponse<void> | null;
}

export interface RevokeCredentialsResult {
success: boolean;
}
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<IapPublicKeysResponse>;
getIapPublicKeys(callback: GetIapPublicKeysCallback): void;
getIapPublicKeys(
callback?: GetIapPublicKeysCallback
): Promise<IapPublicKeysResponse> | void {
if (callback) {
this.getIapPublicKeysAsync().then(
r => callback(null, r.pubkeys, r.res),
callback
);
} else {
return this.getIapPublicKeysAsync();
}
}

async getIapPublicKeysAsync(): Promise<IapPublicKeysResponse> {
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.
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions test/fixtures/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-----
MIIBaAIBAQQgeg2m9tJJsnURyjTUihohiJahj9ETy3csUIt4EYrV+J2ggfowgfcC
AQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAAAAAAAAAAAAAA////////////////
MFsEIP////8AAAABAAAAAAAAAAAAAAAA///////////////8BCBaxjXYqjqT57Pr
vVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSdNgiG5wSTamZ44ROdJreBn36QBEEE
axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li/hp/m47n60p8D54W
K84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8
YyVRAgEBoUQDQgAEEWluurrkZECnq27UpNauq16f9+5DDMFJZ3HV43Ujc3tcXQ++
N1T/0CAA8ve286f32s7rkqX/pPokI/HBpP5p3g==
-----END EC PRIVATE KEY-----
9 changes: 9 additions & 0 deletions test/fixtures/ecdsa-public.pem
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBSzCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAA
AAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA////
///////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSd
NgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5
RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA
//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABBFpbrq65GRAp6tu1KTWrqte
n/fuQwzBSWdx1eN1I3N7XF0PvjdU/9AgAPL3tvOn99rO65Kl/6T6JCPxwaT+ad4=
-----END PUBLIC KEY-----
4 changes: 4 additions & 0 deletions 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"
}
66 changes: 66 additions & 0 deletions test/test.oauth2.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +46,18 @@ describe('oauth2', () => {
__dirname,
'../../test/fixtures/oauthcertspem.json'
);
const publicKeyEcdsa = fs.readFileSync(
'./test/fixtures/ecdsa-public.pem',
'utf-8'
);
const privateKeyEcdsa = fs.readFileSync(
'./test/fixtures/ecdsa-private.pem',
'utf-8'
);
const pubkeysResPath = path.join(
__dirname,
'../../test/fixtures/ecdsapublickeys.json'
);

describe(__filename, () => {
let client: OAuth2Client;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 7bf13f4

Please sign in to comment.