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
10 changes: 9 additions & 1 deletion src/auth/googleauth.ts
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 Down Expand Up @@ -282,7 +289,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 +429,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 Down
33 changes: 27 additions & 6 deletions src/auth/jwtaccess.ts
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
9 changes: 5 additions & 4 deletions src/auth/jwtclient.ts
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 Down Expand Up @@ -159,7 +160,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,7 +177,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 +249,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
97 changes: 97 additions & 0 deletions test/test.jwt.ts
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,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: '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
.stub(jwtaccess.JWTAccess, '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
.stub(jwtaccess.JWTAccess, '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
});
});
});
2 changes: 0 additions & 2 deletions test/test.transporters.ts
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