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
12 changes: 11 additions & 1 deletion src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ export class GoogleAuth {

cachedCredential: JWT | UserRefreshClient | Compute | null = null;

/**
* 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.
*/
defaultScopes?: string | string[];

private keyFilename?: string;
private scopes?: string | string[];
private clientOptions?: RefreshOptions;
Expand Down Expand Up @@ -244,6 +250,7 @@ export class GoogleAuth {
);
if (credential) {
if (credential instanceof JWT) {
credential.defaultScopes = this.defaultScopes;
credential.scopes = this.scopes;
}
this.cachedCredential = credential;
Expand All @@ -257,6 +264,7 @@ export class GoogleAuth {
);
if (credential) {
if (credential instanceof JWT) {
credential.defaultScopes = this.defaultScopes;
credential.scopes = this.scopes;
}
this.cachedCredential = credential;
Expand All @@ -282,7 +290,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.defaultScopes;
this.cachedCredential = new Compute(options);
projectId = await this.getProjectId();
return {projectId, credential: this.cachedCredential};
Expand Down Expand Up @@ -422,6 +430,7 @@ export class GoogleAuth {
} else {
(options as JWTOptions).scopes = this.scopes;
client = new JWT(options);
client.defaultScopes = this.defaultScopes;
}
client.fromJSON(json);
return client;
Expand All @@ -446,6 +455,7 @@ export class GoogleAuth {
} else {
(options as JWTOptions).scopes = this.scopes;
client = new JWT(options);
client.defaultScopes = this.defaultScopes;
}
client.fromJSON(json);
// cache both raw data used to instantiate client and client itself.
Expand Down
41 changes: 34 additions & 7 deletions src/auth/jwtaccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ export class JWTAccess {
key?: string | null;
keyId?: string | null;
projectId?: string;
eagerRefreshThresholdMillis: number;

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 All @@ -49,11 +53,14 @@ export class JWTAccess {
constructor(
email?: string | null,
key?: string | null,
keyId?: string | null
keyId?: string | null,
eagerRefreshThresholdMillis?: number
) {
this.email = email;
this.key = key;
this.keyId = keyId;
this.eagerRefreshThresholdMillis =
eagerRefreshThresholdMillis ?? 5 * 60 * 1000;
}

/**
Expand All @@ -65,12 +72,18 @@ 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
// eagerRefreshThresholdMillis ms of them expiring:
const cachedToken = this.cache.get(url);
if (cachedToken) {
return cachedToken;
const now = Date.now();
if (
cachedToken &&
cachedToken.expiration - now > this.eagerRefreshThresholdMillis
) {
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 +116,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.
*/
private static getExpirationTime(iat: number): number {
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
38 changes: 26 additions & 12 deletions src/auth/jwtclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
keyFile?: string;
key?: string;
keyId?: string;
defaultScopes?: string | string[];
scopes?: string | string[];
scope?: string;
subject?: string;
Expand Down Expand Up @@ -120,7 +121,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 All @@ -137,16 +138,25 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
// no scopes have been set, but a uri has been provided. Use JWTAccess
// credentials.
if (!this.access) {
this.access = new JWTAccess(this.email, this.key, this.keyId);
this.access = new JWTAccess(
this.email,
this.key,
this.keyId,
this.eagerRefreshThresholdMillis
);
}
const headers = await this.access.getRequestHeaders(
url,
this.additionalClaims
);
return {headers: this.addSharedMetadataHeaders(headers)};
}
} else {
} else if (this.hasAnyScopes() || this.apiKey) {
return super.getRequestMetadataAsync(url);
} else {
// If no audience, apiKey, or scopes are provided, we should not attempt
// to populate any headers:
return {headers: {}};
}
}

Expand All @@ -159,7 +169,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.defaultScopes,
keyFile: this.keyFile,
key: this.key,
additionalClaims: {target_audience: targetAudience},
Expand All @@ -176,16 +186,20 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
/**
* Determine if there are currently scopes available.
*/
private hasScopes() {
private hasUserScopes() {
if (!this.scopes) {
return false;
}
// For arrays, check the array length.
if (this.scopes instanceof Array) {
return this.scopes.length > 0;
}
// For others, convert to a string and check the length.
return String(this.scopes).length > 0;
return this.scopes.length > 0;
}

/**
* Are there any default or user scopes defined.
*/
private hasAnyScopes() {
if (this.scopes && this.scopes.length > 0) return true;
if (this.defaultScopes && this.defaultScopes.length > 0) return true;
return false;
}

/**
Expand Down Expand Up @@ -248,7 +262,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.defaultScopes,
keyFile: this.keyFile,
key: this.key,
additionalClaims: this.additionalClaims,
Expand Down
110 changes: 110 additions & 0 deletions test/test.jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as sinon from 'sinon';

import {GoogleAuth, JWT} 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,113 @@ 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: 'foo@serviceaccount.com',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: 'bar@subjectaccount.com',
});
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: 'foo@serviceaccount.com',
key: fs.readFileSync(PEM_PATH, 'utf8'),
subject: 'bar@subjectaccount.com',
});
jwt.defaultScopes = ['http://bar', 'http://foo'];
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: 'foo@serviceaccount.com',
key: keys.private,
subject: 'ignored@subjectaccount.com',
additionalClaims: {target_audience: 'beepboop'},
});
jwt.defaultScopes = ['foo', 'bar'];
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.stub(jwtaccess.JWTAccess as any, 'getExpirationTime')
.returns(Date.now() / 1000 + 3600); // expire in an hour.
const jwt = new JWT({
email: 'foo@serviceaccount.com',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: 'bar@subjectaccount.com',
});
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.stub(jwtaccess.JWTAccess as any, 'getExpirationTime')
.returns(Date.now() / 1000 + 5); // expire in 5 seconds.
const jwt = new JWT({
email: 'foo@serviceaccount.com',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: 'bar@subjectaccount.com',
});
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
});

it('returns no headers when no scopes or audiences are provided', async () => {
const jwt = new JWT({
email: 'foo@serviceaccount.com',
key: fs.readFileSync(PEM_PATH, 'utf8'),
scopes: [],
subject: 'bar@subjectaccount.com',
});
const headers = await jwt.getRequestHeaders();
assert.deepStrictEqual(headers, {});
});
});
});
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