Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix!: Update OAuth2Client Endpoints to JWT Versions #1762

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion samples/test/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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./);
Expand Down
72 changes: 39 additions & 33 deletions src/auth/oauth2client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,6 +64,9 @@ export enum CodeChallengeMethod {
}

export enum CertificateFormat {
/**
* @deprecated - Use JWK.
*/
PEM = 'PEM',
JWK = 'JWK',
}
Expand Down Expand Up @@ -402,6 +405,7 @@ export interface VerifyIdTokenOptions {
idToken: string;
audience?: string | string[];
maxExpiry?: number;
certificateFormat?: CertificateFormat;
}

export interface OAuth2ClientEndpoints {
Expand Down Expand Up @@ -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'
Expand All @@ -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 {
Expand All @@ -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<string, Promise<GetTokenResponse>>();
readonly endpoints: Readonly<OAuth2ClientEndpoints>;
readonly issuers: string[];
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -659,7 +665,6 @@ export class OAuth2Client extends AuthClient {
private async getTokenAsync(
options: GetTokenOptions
): Promise<GetTokenResponse> {
const url = this.endpoints.oauth2TokenUrl.toString();
const values = {
code: options.code,
client_id: options.client_id || this._clientId,
Expand All @@ -670,7 +675,7 @@ export class OAuth2Client extends AuthClient {
};
const res = await this.transporter.request<CredentialRequest>({
method: 'POST',
url,
url: this.endpoints.oauth2TokenUrl,
data: querystring.stringify(values),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
Expand Down Expand Up @@ -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,
Expand All @@ -734,7 +739,7 @@ export class OAuth2Client extends AuthClient {
// request for new token
res = await this.transporter.request<CredentialRequest>({
method: 'POST',
url,
url: this.endpoints.oauth2TokenUrl,
data: querystring.stringify(data),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1182,54 +1189,52 @@ export class OAuth2Client extends AuthClient {
* are certificates in either PEM or JWK format.
* @param callback Callback supplying the certificates
*/
getFederatedSignonCerts(): Promise<FederatedSignonCertsResponse>;
getFederatedSignonCerts(
format: CertificateFormat
): Promise<FederatedSignonCertsResponse>;
getFederatedSignonCerts(callback: GetFederatedSignonCertsCallback): void;
getFederatedSignonCerts(
callback?: GetFederatedSignonCertsCallback
callbackOrFormat?: CertificateFormat | GetFederatedSignonCertsCallback
): Promise<FederatedSignonCertsResponse> | 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<FederatedSignonCertsResponse> {
async getFederatedSignonCertsAsync(
format: CertificateFormat = CertificateFormat.JWK
): Promise<FederatedSignonCertsResponse> {
const nowTime = new Date().getTime();
const format = hasBrowserCrypto()
? CertificateFormat.JWK
: CertificateFormat.PEM;

if (
this.certificateExpiry &&
nowTime < this.certificateExpiry.getTime() &&
this.certificateCacheFormat === format
) {
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;
Expand Down Expand Up @@ -1287,10 +1292,11 @@ export class OAuth2Client extends AuthClient {

async getIapPublicKeysAsync(): Promise<IapPublicKeysResponse> {
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}`;
Expand Down
40 changes: 35 additions & 5 deletions src/crypto/node/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand All @@ -25,21 +25,51 @@ export class NodeCrypto implements Crypto {
}

async verify(
pubkey: string,
pubkey: string | JwkCertificate | crypto.JsonWebKey,
data: string | Buffer,
signature: string
): Promise<boolean> {
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<string> {
async sign(
privateKey: string | JwkCertificate | crypto.JsonWebKey,
data: string | Buffer
): Promise<string> {
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 {
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/oauthcerts.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
48 changes: 45 additions & 3 deletions test/test.crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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+',
Expand All @@ -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+',
Expand Down
Loading
Loading