diff --git a/samples/test/auth.test.js b/samples/test/auth.test.js index 7473fc4d..436ec671 100644 --- a/samples/test/auth.test.js +++ b/samples/test/auth.test.js @@ -69,7 +69,7 @@ describe('auth samples', () => { const idToken = await client.fetchIdToken(TARGET_AUDIENCE); const output = execSync( - `node verifyGoogleIdToken ${idToken} ${TARGET_AUDIENCE} https://www.googleapis.com/oauth2/v3/certs` + `node verifyGoogleIdToken ${idToken} ${TARGET_AUDIENCE}` ); assert.match(output, /ID token verified./); diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index c9f87d56..5898d515 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -22,7 +22,7 @@ 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 {createCrypto, JwkCertificate} from '../crypto/crypto'; import {BodyResponseCallback} from '../transporters'; import {AuthClient, AuthClientOptions} from './authclient'; @@ -64,6 +64,9 @@ export enum CodeChallengeMethod { } export enum CertificateFormat { + /** + * @deprecated - Use JWK. + */ PEM = 'PEM', JWK = 'JWK', } @@ -402,6 +405,7 @@ export interface VerifyIdTokenOptions { idToken: string; audience?: string | string[]; maxExpiry?: number; + certificateFormat?: CertificateFormat; } export interface OAuth2ClientEndpoints { @@ -433,12 +437,12 @@ export interface OAuth2ClientEndpoints { * The base endpoint to revoke tokens. * * @example - * 'https://oauth2.googleapis.com/revoke' + * 'https://www.accounts.google.com/o/oauth2/revoke' */ oauth2RevokeUrl: string | URL; /** - * Sign on certificates in PEM format. + * Sign on certificates in the legacy PEM format. * * @example * 'https://www.googleapis.com/oauth2/v1/certs' @@ -461,6 +465,8 @@ export interface OAuth2ClientEndpoints { * 'https://www.gstatic.com/iap/verify/public_key' */ oauth2IapPublicKeyUrl: string | URL; + + [endpoint: string]: string | URL; } export interface OAuth2ClientOptions extends AuthClientOptions { @@ -487,7 +493,7 @@ export class OAuth2Client extends AuthClient { private redirectUri?: string; private certificateCache: Certificates = {}; private certificateExpiry: Date | null = null; - private certificateCacheFormat: CertificateFormat = CertificateFormat.PEM; + private certificateCacheFormat: CertificateFormat = CertificateFormat.JWK; protected refreshTokenPromises = new Map>(); readonly endpoints: Readonly; readonly issuers: string[]; @@ -534,7 +540,7 @@ export class OAuth2Client extends AuthClient { tokenInfoUrl: 'https://oauth2.googleapis.com/tokeninfo', oauth2AuthBaseUrl: 'https://accounts.google.com/o/oauth2/v2/auth', oauth2TokenUrl: 'https://oauth2.googleapis.com/token', - oauth2RevokeUrl: 'https://oauth2.googleapis.com/revoke', + oauth2RevokeUrl: 'https://www.accounts.google.com/o/oauth2/revoke', oauth2FederatedSignonPemCertsUrl: 'https://www.googleapis.com/oauth2/v1/certs', oauth2FederatedSignonJwkCertsUrl: @@ -659,7 +665,6 @@ export class OAuth2Client extends AuthClient { private async getTokenAsync( options: GetTokenOptions ): Promise { - const url = this.endpoints.oauth2TokenUrl.toString(); const values = { code: options.code, client_id: options.client_id || this._clientId, @@ -670,7 +675,7 @@ export class OAuth2Client extends AuthClient { }; const res = await this.transporter.request({ method: 'POST', - url, + url: this.endpoints.oauth2TokenUrl, data: querystring.stringify(values), headers: {'Content-Type': 'application/x-www-form-urlencoded'}, }); @@ -720,7 +725,7 @@ export class OAuth2Client extends AuthClient { if (!refreshToken) { throw new Error('No refresh token is set.'); } - const url = this.endpoints.oauth2TokenUrl.toString(); + const data = { refresh_token: refreshToken, client_id: this._clientId, @@ -734,7 +739,7 @@ export class OAuth2Client extends AuthClient { // request for new token res = await this.transporter.request({ method: 'POST', - url, + url: this.endpoints.oauth2TokenUrl, data: querystring.stringify(data), headers: {'Content-Type': 'application/x-www-form-urlencoded'}, }); @@ -1136,10 +1141,12 @@ export class OAuth2Client extends AuthClient { if (!options.idToken) { throw new Error('The verifyIdToken method requires an ID Token'); } - const response = await this.getFederatedSignonCertsAsync(); + const {certs} = await this.getFederatedSignonCertsAsync( + options.certificateFormat + ); const login = await this.verifySignedJwtWithCertsAsync( options.idToken, - response.certs, + certs, options.audience, this.issuers, options.maxExpiry @@ -1182,26 +1189,31 @@ export class OAuth2Client extends AuthClient { * are certificates in either PEM or JWK format. * @param callback Callback supplying the certificates */ - getFederatedSignonCerts(): Promise; + getFederatedSignonCerts( + format: CertificateFormat + ): Promise; getFederatedSignonCerts(callback: GetFederatedSignonCertsCallback): void; getFederatedSignonCerts( - callback?: GetFederatedSignonCertsCallback + callbackOrFormat?: CertificateFormat | GetFederatedSignonCertsCallback ): Promise | void { - if (callback) { + if (typeof callbackOrFormat === 'function') { + const callback = callbackOrFormat; + this.getFederatedSignonCertsAsync().then( r => callback(null, r.certs, r.res), callback ); } else { - return this.getFederatedSignonCertsAsync(); + const format = callbackOrFormat; + return this.getFederatedSignonCertsAsync(format); } } - async getFederatedSignonCertsAsync(): Promise { + async getFederatedSignonCertsAsync( + format: CertificateFormat = CertificateFormat.JWK + ): Promise { const nowTime = new Date().getTime(); - const format = hasBrowserCrypto() - ? CertificateFormat.JWK - : CertificateFormat.PEM; + if ( this.certificateExpiry && nowTime < this.certificateExpiry.getTime() && @@ -1209,27 +1221,20 @@ export class OAuth2Client extends AuthClient { ) { return {certs: this.certificateCache, format}; } - let res: GaxiosResponse; - let url: string; + + let url: string | URL; switch (format) { case CertificateFormat.PEM: - url = this.endpoints.oauth2FederatedSignonPemCertsUrl.toString(); + url = this.endpoints.oauth2FederatedSignonPemCertsUrl; break; case CertificateFormat.JWK: - url = this.endpoints.oauth2FederatedSignonJwkCertsUrl.toString(); + url = this.endpoints.oauth2FederatedSignonJwkCertsUrl; break; default: throw new Error(`Unsupported certificate format ${format}`); } - try { - res = await this.transporter.request({url}); - } catch (e) { - if (e instanceof Error) { - e.message = `Failed to retrieve verification certificates: ${e.message}`; - } - throw e; - } + const res: GaxiosResponse = await this.transporter.request({url}); const cacheControl = res ? res.headers['cache-control'] : undefined; let cacheAge = -1; @@ -1287,10 +1292,11 @@ export class OAuth2Client extends AuthClient { async getIapPublicKeysAsync(): Promise { let res: GaxiosResponse; - const url = this.endpoints.oauth2IapPublicKeyUrl.toString(); try { - res = await this.transporter.request({url}); + res = await this.transporter.request({ + url: this.endpoints.oauth2IapPublicKeyUrl, + }); } catch (e) { if (e instanceof Error) { e.message = `Failed to retrieve verification certificates: ${e.message}`; diff --git a/src/crypto/node/crypto.ts b/src/crypto/node/crypto.ts index 7d045f2b..a4d5ee65 100644 --- a/src/crypto/node/crypto.ts +++ b/src/crypto/node/crypto.ts @@ -13,7 +13,7 @@ // limitations under the License. import * as crypto from 'crypto'; -import {Crypto} from '../crypto'; +import {Crypto, JwkCertificate} from '../crypto'; export class NodeCrypto implements Crypto { async sha256DigestBase64(str: string): Promise { @@ -25,21 +25,51 @@ export class NodeCrypto implements Crypto { } async verify( - pubkey: string, + pubkey: string | JwkCertificate | crypto.JsonWebKey, data: string | Buffer, signature: string ): Promise { const verifier = crypto.createVerify('RSA-SHA256'); verifier.update(data); verifier.end(); - return verifier.verify(pubkey, signature, 'base64'); + + if (typeof pubkey === 'string') { + // must be PEM + return verifier.verify(pubkey, signature, 'base64'); + } else { + // must be JWK + return verifier.verify( + { + key: pubkey as crypto.JsonWebKey, + format: 'jwk', + }, + signature, + 'base64' + ); + } } - async sign(privateKey: string, data: string | Buffer): Promise { + async sign( + privateKey: string | JwkCertificate | crypto.JsonWebKey, + data: string | Buffer + ): Promise { const signer = crypto.createSign('RSA-SHA256'); signer.update(data); signer.end(); - return signer.sign(privateKey, 'base64'); + + if (typeof privateKey === 'string') { + // must be PEM + return signer.sign(privateKey, 'base64'); + } else { + // must be JWK + return signer.sign( + { + key: privateKey as unknown as crypto.KeyObject, + format: 'jwk', + }, + 'base64' + ); + } } decodeBase64StringUtf8(base64: string): string { diff --git a/test/fixtures/oauthcerts.json b/test/fixtures/oauthcerts.json new file mode 100644 index 00000000..f1d7548c --- /dev/null +++ b/test/fixtures/oauthcerts.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "n": "q0CrF3x3aYsjr0YOLMOAhEGMvyFp6o4RqyEdUrnTDYkhZbcud-fJEQafCTnjS9QHN1IjpuK6gpx5i3-Z63vRjs5EQX7lP1jG8Qg-CnBdTTLw4uJi7RmmlKPsYaO1DbNkFO2uEN62sOOzmJCh1od3CZXI1UYH5cvZ_sLJaN2A4TwvUTU3aXlXbUNJz_Hy3l0q1Jjta75NrJtJ7Pfj9tVXs8qXp15tZXrnbaM-AI0puswt35VsQbmLwUovFFGeToo5q2c_c1xYnV5uQYMadANekGPRFPM9JZpSSIvH0Lv_f15V2zRqmIgX7a3RcmTnr3-w3QNQTogdy-MogxPUdRbxow", + "kty": "RSA", + "use": "sig", + "kid": "55c188a83546fc188e51576ba72836e0600e8b73", + "alg": "RS256", + "e": "AQAB" + }, + { + "n": "pOpd5-7RpMvcfBcSjqlTNYjGg3YRwYRV9T9k7eDOEWgMBQEs6ii3cjcuoa1oD6N48QJmcNvAme_ud985DV2mQpOaCUy22MVRKI8DHxAKGWzZO5yzn6otsN9Vy0vOEO_I-vnmrO1-1ONFuH2zieziaXCUVh9087dRkM9qaQYt6QJhMmiNpyrbods6AsU8N1jeAQl31ovHWGGk8axXNmwbx3dDZQhx-t9ZD31oF-usPhFZtM92mxgehDqi2kpvFmM0nzSVgPrOXlbDb9ztg8lclxKwnT1EtcwHUq4FeuOPQMtZ2WehrY10OvsqS5ml3mxXUQEXrtYfa5V1v4o3rWx9Ow", + "alg": "RS256", + "kty": "RSA", + "e": "AQAB", + "kid": "6f9777a685907798ef794062c00b65d66c240b1b", + "use": "sig" + } + ] +} diff --git a/test/test.crypto.ts b/test/test.crypto.ts index 3488c732..0b9c5f64 100644 --- a/test/test.crypto.ts +++ b/test/test.crypto.ts @@ -15,11 +15,18 @@ import * as fs from 'fs'; import {assert} from 'chai'; import {describe, it} from 'mocha'; -import {createCrypto, fromArrayBufferToHex} from '../src/crypto/crypto'; +import { + JwkCertificate, + createCrypto, + fromArrayBufferToHex, +} from '../src/crypto/crypto'; import {NodeCrypto} from '../src/crypto/node/crypto'; +import {createPrivateKey, createPublicKey} from 'crypto'; const publicKey = fs.readFileSync('./test/fixtures/public.pem', 'utf-8'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); +const jwtPublicKeyJWT = createPublicKey(publicKey).export({format: 'jwk'}); +const jwtPrivateKeyJWT = createPrivateKey(privateKey).export({format: 'jwk'}); /** * Converts a Node.js Buffer to an ArrayBuffer. @@ -63,7 +70,25 @@ describe('crypto', () => { assert.notStrictEqual(generated1Base64, generated2Base64); }); - it('should verify a signature', async () => { + it('should verify a signature (JWK)', async () => { + const message = 'This message is signed'; + const signatureBase64 = [ + 'ufyKBV+Ar7Yq8CSmSIN9m38ch4xnWBz8CP4qHh6V+', + 'm4cCbeXdR1MEmWVhNJjZQFv3KL3tDAnl0Q4bTcSR/', + 'mmhXaRjdxyJ6xAUp0KcbVq6xsDIbnnYHSgYr3zVoS', + 'dRRefWSWTknN1S69fNmKEfUeBIJA93xitr3pbqtLC', + 'bP28XNU', + ].join(''); // note: no padding + + const verified = await crypto.verify( + jwtPublicKeyJWT as unknown as JwkCertificate, + message, + signatureBase64 + ); + assert(verified); + }); + + it('should verify a signature (PEM)', async () => { const message = 'This message is signed'; const signatureBase64 = [ 'ufyKBV+Ar7Yq8CSmSIN9m38ch4xnWBz8CP4qHh6V+', @@ -76,7 +101,24 @@ describe('crypto', () => { assert(verified); }); - it('should sign a message', async () => { + it('should sign a message (JWK)', async () => { + const message = 'This message is signed'; + const expectedSignatureBase64 = [ + 'ufyKBV+Ar7Yq8CSmSIN9m38ch4xnWBz8CP4qHh6V+', + 'm4cCbeXdR1MEmWVhNJjZQFv3KL3tDAnl0Q4bTcSR/', + 'mmhXaRjdxyJ6xAUp0KcbVq6xsDIbnnYHSgYr3zVoS', + 'dRRefWSWTknN1S69fNmKEfUeBIJA93xitr3pbqtLC', + 'bP28XNU=', + ].join(''); + + const signatureBase64 = await crypto.sign( + jwtPrivateKeyJWT as unknown as JwkCertificate, + message + ); + assert.strictEqual(signatureBase64, expectedSignatureBase64); + }); + + it('should sign a message (PEM)', async () => { const message = 'This message is signed'; const expectedSignatureBase64 = [ 'ufyKBV+Ar7Yq8CSmSIN9m38ch4xnWBz8CP4qHh6V+', diff --git a/test/test.oauth2.ts b/test/test.oauth2.ts index 6156b8aa..18edf246 100644 --- a/test/test.oauth2.ts +++ b/test/test.oauth2.ts @@ -25,6 +25,7 @@ import * as sinon from 'sinon'; import {CodeChallengeMethod, Credentials, OAuth2Client} from '../src'; import {LoginTicket} from '../src/auth/loginticket'; +import {CertificateFormat} from '../src/auth/oauth2client'; nock.disableNetConnect(); @@ -38,8 +39,13 @@ describe('oauth2', () => { const publicKey = fs.readFileSync('./test/fixtures/public.pem', 'utf-8'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); const baseUrl = 'https://oauth2.googleapis.com'; - const certsPath = '/oauth2/v1/certs'; + const certsPath = '/oauth2/v3/certs'; + const legacyCertsPath = '/oauth2/v1/certs'; const certsResPath = path.join( + __dirname, + '../../test/fixtures/oauthcerts.json' + ); + const legacyCertsResPath = path.join( __dirname, '../../test/fixtures/oauthcertspem.json' ); @@ -132,7 +138,53 @@ describe('oauth2', () => { ); }); - it('should verifyIdToken properly', async () => { + it('should verifyIdToken properly (JWK certs)', async () => { + const fakeCertsResponse = { + keys: [ + {kid: 'a', n: 'a-n'}, + {kid: 'b', n: 'b-n'}, + ], + }; + const fakeCerts = { + a: {kid: 'a', n: 'a-n'}, + b: {kid: 'b', n: 'b-n'}, + }; + const idToken = 'idToken'; + const audience = 'fakeAudience'; + const maxExpiry = 5; + const payload = { + aud: 'aud', + sub: 'sub', + iss: 'iss', + iat: 1514162443, + exp: 1514166043, + }; + const scope = nock('https://www.googleapis.com') + .get(certsPath) + .reply(200, fakeCertsResponse); + client.verifySignedJwtWithCertsAsync = async ( + jwt: string, + certs: {}, + requiredAudience: string | string[], + issuers?: string[], + theMaxExpiry?: number + ) => { + assert.strictEqual(jwt, idToken); + assert.deepStrictEqual(certs, fakeCerts); + assert.strictEqual(requiredAudience, audience); + assert.strictEqual(theMaxExpiry, maxExpiry); + return new LoginTicket('c', payload); + }; + const result = await client.verifyIdToken({idToken, audience, maxExpiry}); + scope.done(); + assert.notStrictEqual(result, null); + if (result) { + assert.strictEqual(result.getEnvelope(), 'c'); + assert.strictEqual(result.getPayload(), payload); + } + }); + + it('should verifyIdToken properly (PEM certs)', async () => { const fakeCerts = {a: 'a', b: 'b'}; const idToken = 'idToken'; const audience = 'fakeAudience'; @@ -145,8 +197,9 @@ describe('oauth2', () => { exp: 1514166043, }; const scope = nock('https://www.googleapis.com') - .get('/oauth2/v1/certs') + .get(legacyCertsPath) .reply(200, fakeCerts); + client.verifySignedJwtWithCertsAsync = async ( jwt: string, certs: {}, @@ -160,7 +213,12 @@ describe('oauth2', () => { assert.strictEqual(theMaxExpiry, maxExpiry); return new LoginTicket('c', payload); }; - const result = await client.verifyIdToken({idToken, audience, maxExpiry}); + const result = await client.verifyIdToken({ + idToken, + audience, + maxExpiry, + certificateFormat: CertificateFormat.PEM, + }); scope.done(); assert.notStrictEqual(result, null); if (result) { @@ -813,23 +871,45 @@ describe('oauth2', () => { ); }); - it('should be able to retrieve a list of Google certificates', done => { + it('should be able to retrieve a list of Google certificates (JWK)', async () => { const scope = nock('https://www.googleapis.com') .get(certsPath) - .replyWithFile(200, certsResPath); - client.getFederatedSignonCerts((err, certs) => { - assert.strictEqual(err, null); - assert.notStrictEqual( - certs!['a15eea964ab9cce480e5ef4f47cb17b9fa7d0b21'], - null - ); - assert.notStrictEqual( - certs!['39596dc3a3f12aa74b481579e4ec944f86d24b95'], - null - ); - scope.done(); - done(); - }); + .replyWithFile(200, certsResPath, { + 'content-type': 'application/json', + }); + + const expectedCertsData = fs.readFileSync(certsResPath, 'utf-8'); + const expectedCerts: {[kid: string]: {}} = {}; + for (const cert of JSON.parse(expectedCertsData).keys) { + expectedCerts[cert.kid] = cert; + } + + const {certs} = await client.getFederatedSignonCerts( + CertificateFormat.JWK + ); + + assert.deepStrictEqual(certs, expectedCerts); + + scope.done(); + }); + + it('should be able to retrieve a list of Google certificates (PEM)', async () => { + const scope = nock('https://www.googleapis.com') + .get(legacyCertsPath) + .replyWithFile(200, legacyCertsResPath, { + 'content-type': 'application/json', + }); + + const expectedCertsData = fs.readFileSync(legacyCertsResPath, 'utf-8'); + const expectedCerts = JSON.parse(expectedCertsData); + + const {certs} = await client.getFederatedSignonCerts( + CertificateFormat.PEM + ); + + assert.deepStrictEqual(certs, expectedCerts); + + scope.done(); }); it('should be able to retrieve a list of Google certificates from cache again', done => { @@ -1298,18 +1378,16 @@ describe('oauth2', () => { ); }); - it('should revoke credentials if access token present', done => { - const scope = nock('https://oauth2.googleapis.com') - .post('/revoke?token=abc') + it('should revoke credentials if access token present', async () => { + const scope = nock('https://www.accounts.google.com') + .post('/o/oauth2/revoke?token=abc') .reply(200, {success: true}); client.credentials = {access_token: 'abc', refresh_token: 'abc'}; - client.revokeCredentials((err, result) => { - assert.strictEqual(err, null); - assert.strictEqual(result!.data!.success, true); - assert.deepStrictEqual(client.credentials, {}); - scope.done(); - done(); - }); + const result = await client.revokeCredentials(); + + assert(result.data.success); + assert.deepStrictEqual(client.credentials, {}); + scope.done(); }); it('should clear credentials and return error if no access token to revoke', done => {