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

feat: default self-signed JWTs #1054

Merged
merged 12 commits into from
Sep 22, 2020
14 changes: 12 additions & 2 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {Compute, ComputeOptions} from './computeclient';
import {CredentialBody, JWTInput} from './credentials';
import {IdTokenClient} from './idtokenclient';
import {GCPEnv, getEnv} from './envDetect';
import {JWT, JWTOptions} from './jwtclient';
import {JWT, JWTOptions, DefaultScopesKey} from './jwtclient';
import {
Headers,
OAuth2Client,
Expand Down Expand Up @@ -81,6 +81,12 @@ export interface GoogleAuthOptions {
*/
clientOptions?: JWTOptions | OAuth2ClientOptions | UserRefreshClientOptions;

/**
* Scopes populated by the client library by default. We differentiate between
* these and user defined scopes when deciding whether to use a self-signed JWT.
*/
[DefaultScopesKey]?: string | string[];

/**
* Required scopes for the desired API request
*/
Expand Down Expand Up @@ -121,6 +127,7 @@ export class GoogleAuth {

private keyFilename?: string;
private scopes?: string | string[];
private [DefaultScopesKey]?: string | string[];
private clientOptions?: RefreshOptions;

/**
Expand All @@ -133,6 +140,7 @@ export class GoogleAuth {
this._cachedProjectId = opts.projectId || null;
this.keyFilename = opts.keyFilename || opts.keyFile;
this.scopes = opts.scopes;
this[DefaultScopesKey] = opts[DefaultScopesKey];
this.jsonContent = opts.credentials || null;
this.clientOptions = opts.clientOptions;
}
Expand Down Expand Up @@ -244,6 +252,7 @@ export class GoogleAuth {
);
if (credential) {
if (credential instanceof JWT) {
credential[DefaultScopesKey] = this[DefaultScopesKey];
credential.scopes = this.scopes;
}
this.cachedCredential = credential;
Expand Down Expand Up @@ -282,7 +291,7 @@ export class GoogleAuth {

// For GCE, just return a default ComputeClient. It will take care of
// the rest.
(options as ComputeOptions).scopes = this.scopes;
(options as ComputeOptions).scopes = this.scopes || this[DefaultScopesKey];
this.cachedCredential = new Compute(options);
projectId = await this.getProjectId();
return {projectId, credential: this.cachedCredential};
Expand Down Expand Up @@ -420,6 +429,7 @@ export class GoogleAuth {
if (json.type === 'authorized_user') {
client = new UserRefreshClient(options);
} else {
(options as JWTOptions)[DefaultScopesKey] = this[DefaultScopesKey];
(options as JWTOptions).scopes = this.scopes;
client = new JWT(options);
}
Expand Down
33 changes: 27 additions & 6 deletions src/auth/jwtaccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ const DEFAULT_HEADER: jws.Header = {
export interface Claims {
[index: string]: string;
}
const EXPIRATION_DELTA = 15000; // If token is expiring with 15s, create a new token.

export class JWTAccess {
email?: string | null;
key?: string | null;
keyId?: string | null;
projectId?: string;

private cache = new LRU<string, Headers>({max: 500, maxAge: 60 * 60 * 1000});
private cache = new LRU<string, {expiration: number; headers: Headers}>({
max: 500,
maxAge: 60 * 60 * 1000,
});

/**
* JWTAccess service account credentials.
Expand Down Expand Up @@ -65,12 +69,15 @@ export class JWTAccess {
* @returns An object that includes the authorization header.
*/
getRequestHeaders(url: string, additionalClaims?: Claims): Headers {
// Return cached authorization headers, unless we are within
// EXPIRY_DELTA ms of them expiring:
const cachedToken = this.cache.get(url);
if (cachedToken) {
return cachedToken;
const now = Date.now();
if (cachedToken && cachedToken.expiration - now > EXPIRATION_DELTA) {
bcoe marked this conversation as resolved.
Show resolved Hide resolved
return cachedToken.headers;
}
const iat = Math.floor(new Date().getTime() / 1000);
const exp = iat + 3600; // 3600 seconds = 1 hour
const iat = Math.floor(Date.now() / 1000);
const exp = JWTAccess.getExpirationTime(iat);

// The payload used for signed JWT headers has:
// iss == sub == <client email>
Expand Down Expand Up @@ -103,10 +110,24 @@ export class JWTAccess {
// Sign the jwt and add it to the cache
const signedJWT = jws.sign({header, payload, secret: this.key});
const headers = {Authorization: `Bearer ${signedJWT}`};
this.cache.set(url, headers);
this.cache.set(url, {
expiration: exp * 1000,
headers,
});
return headers;
}

/**
* Returns an expiration time for the JWT token.
*
* @param iat The issued at time for the JWT.
* @returns An expiration time for the JWT.
*/
static getExpirationTime(iat: number): number {
bcoe marked this conversation as resolved.
Show resolved Hide resolved
const exp = iat + 3600; // 3600 seconds = 1 hour
return exp;
}

/**
* Create a JWTAccess credentials instance using the given input options.
* @param json The input object.
Expand Down
16 changes: 12 additions & 4 deletions src/auth/jwtclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ import {
RequestMetadataResponse,
} from './oauth2client';

// Default scopes are provided by GAPIC libraries to indicate that it
// is safe to use a self-signed JWT. We hide this option behind a symbol,
// to discourage users from setting this field:
export const DefaultScopesKey = Symbol('default-scopes-symbol');

export interface JWTOptions extends RefreshOptions {
email?: string;
keyFile?: string;
key?: string;
keyId?: string;
scopes?: string | string[];
[DefaultScopesKey]?: string | string[];
subject?: string;
additionalClaims?: {};
}
Expand All @@ -40,6 +46,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
keyFile?: string;
key?: string;
keyId?: string;
[DefaultScopesKey]?: string | string[];
scopes?: string | string[];
scope?: string;
subject?: string;
Expand Down Expand Up @@ -90,6 +97,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
this.key = opts.key;
this.keyId = opts.keyId;
this.scopes = opts.scopes;
this[DefaultScopesKey] = opts[DefaultScopesKey];
this.subject = opts.subject;
this.additionalClaims = opts.additionalClaims;
this.credentials = {refresh_token: 'jwt-placeholder', expiry_date: 1};
Expand Down Expand Up @@ -120,7 +128,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
protected async getRequestMetadataAsync(
url?: string | null
): Promise<RequestMetadataResponse> {
if (!this.apiKey && !this.hasScopes() && url) {
if (!this.apiKey && !this.hasUserScopes() && url) {
if (
this.additionalClaims &&
(this.additionalClaims as {
Expand Down Expand Up @@ -159,7 +167,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
const gtoken = new GoogleToken({
iss: this.email,
sub: this.subject,
scope: this.scopes,
scope: this.scopes || this[DefaultScopesKey],
keyFile: this.keyFile,
key: this.key,
additionalClaims: {target_audience: targetAudience},
Expand All @@ -176,7 +184,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
/**
* Determine if there are currently scopes available.
*/
private hasScopes() {
private hasUserScopes() {
if (!this.scopes) {
return false;
}
Expand Down Expand Up @@ -248,7 +256,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
this.gtoken = new GoogleToken({
iss: this.email,
sub: this.subject,
scope: this.scopes,
scope: this.scopes || this[DefaultScopesKey],
keyFile: this.keyFile,
key: this.key,
additionalClaims: this.additionalClaims,
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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 {JWT, JWTOptions, DefaultScopesKey} from './auth/jwtclient';
export {
Certificates,
CodeChallengeMethod,
Expand Down
99 changes: 98 additions & 1 deletion test/test.jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import * as jws from 'jws';
import * as nock from 'nock';
import * as sinon from 'sinon';

import {GoogleAuth, JWT} from '../src';
import {GoogleAuth, JWT, DefaultScopesKey} from '../src';
import {CredentialRequest, JWTInput} from '../src/auth/credentials';
import * as jwtaccess from '../src/auth/jwtaccess';

describe('jwt', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand Down Expand Up @@ -783,4 +784,100 @@ describe('jwt', () => {
}
assert.fail('failed to throw');
});

describe('self-signed JWT', () => {
afterEach(() => {
sandbox.restore();
});

it('uses self signed JWT when no scopes are provided', async () => {
const stubJWTAccess = sandbox.stub(jwtaccess, 'JWTAccess').returns({
getRequestHeaders: sinon.stub().returns({}),
});
const jwt = new JWT({
email: '[email protected]',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: '[email protected]',
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};
await jwt.getRequestHeaders('https//beepboop.googleapis.com');
sandbox.assert.calledOnce(stubJWTAccess);
});

it('uses self signed JWT when default scopes are provided', async () => {
const JWTAccess = sandbox.stub(jwtaccess, 'JWTAccess').returns({
getRequestHeaders: sinon.stub().returns({}),
});
const jwt = new JWT({
email: '[email protected]',
key: fs.readFileSync(PEM_PATH, 'utf8'),
[DefaultScopesKey]: ['http://bar', 'http://foo'],
subject: '[email protected]',
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};
await jwt.getRequestHeaders('https//beepboop.googleapis.com');
sandbox.assert.calledOnce(JWTAccess);
});

it('does not use self signed JWT if target_audience provided', async () => {
const JWTAccess = sandbox.stub(jwtaccess, 'JWTAccess').returns({
getRequestHeaders: sinon.stub().returns({}),
});
const keys = keypair(512 /* bitsize of private key */);
const jwt = new JWT({
email: '[email protected]',
key: keys.private,
subject: '[email protected]',
[DefaultScopesKey]: ['foo', 'bar'],
additionalClaims: {target_audience: 'beepboop'},
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};
const testUri = 'http:/example.com/my_test_service';
const scope = createGTokenMock({id_token: 'abc123'});
await jwt.getRequestHeaders(testUri);
scope.done();
sandbox.assert.notCalled(JWTAccess);
});

it('returns headers from cache, prior to their expiry time', async () => {
const sign = sandbox.stub(jws, 'sign').returns('abc123');
const getExpirationTime = sandbox
.stub(jwtaccess.JWTAccess, 'getExpirationTime')
.returns(Date.now() / 1000 + 3600); // expire in an hour.
const jwt = new JWT({
email: '[email protected]',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: '[email protected]',
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};
await jwt.getRequestHeaders('https//beepboop.googleapis.com');
// The second time we fetch headers should not cause getExpirationTime
// to be invoked a second time:
await jwt.getRequestHeaders('https//beepboop.googleapis.com');
sandbox.assert.calledOnce(getExpirationTime);
sandbox.assert.calledOnce(sign);
});

it('creates a new self-signed JWT, if headers are close to expiring', async () => {
const sign = sandbox.stub(jws, 'sign').returns('abc123');
const getExpirationTime = sandbox
.stub(jwtaccess.JWTAccess, 'getExpirationTime')
.returns(Date.now() / 1000 + 5); // expire in 5 seconds.
const jwt = new JWT({
email: '[email protected]',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: '[email protected]',
});
jwt.credentials = {refresh_token: 'jwt-placeholder'};
await jwt.getRequestHeaders('https//beepboop.googleapis.com');
// The second time we fetch headers should not cause getExpirationTime
// to be invoked a second time:
await jwt.getRequestHeaders('https//beepboop.googleapis.com');
sandbox.assert.calledTwice(getExpirationTime);
sandbox.assert.calledTwice(sign);
bcoe marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
2 changes: 0 additions & 2 deletions test/test.transporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ describe('transporters', () => {
url: '',
};
let configuredOpts = transporter.configure(opts);
console.info(configuredOpts);
configuredOpts = transporter.configure(opts);
console.info(configuredOpts);
assert(
/^gdcl\/[.-\w$]+ auth\/[.-\w$]+$/.test(
configuredOpts.headers!['x-goog-api-client']
Expand Down