Skip to content

Commit

Permalink
feat: use self-signed JWTs if alwaysUseJWTAccessWithScope is true (#1196
Browse files Browse the repository at this point in the history
)

* feat: use self-signed JWTs if alwaysUseJWTAccessWithScope is true

Co-authored-by: Jeffrey Rennie <rennie@google.com>
Co-authored-by: Benjamin E. Coe <bencoe@google.com>
Co-authored-by: Brent Shaffer <betterbrent@google.com>
  • Loading branch information
4 people committed Aug 30, 2021
1 parent 3d91eae commit ad3f652
Show file tree
Hide file tree
Showing 5 changed files with 445 additions and 30 deletions.
68 changes: 55 additions & 13 deletions src/auth/jwtaccess.ts
Expand Up @@ -63,6 +63,28 @@ export class JWTAccess {
eagerRefreshThresholdMillis ?? 5 * 60 * 1000;
}

/**
* Ensures that we're caching a key appropriately, giving precedence to scopes vs. url
*
* @param url The URI being authorized.
* @param scopes The scope or scopes being authorized
* @returns A string that returns the cached key.
*/
getCachedKey(url?: string, scopes?: string | string[]): string {
let cacheKey = url;
if (scopes && Array.isArray(scopes) && scopes.length) {
cacheKey = url ? `${url}_${scopes.join('_')}` : `${scopes.join('_')}`;
} else if (typeof scopes === 'string') {
cacheKey = url ? `${url}_${scopes}` : scopes;
}

if (!cacheKey) {
throw Error('Scopes or url must be provided');
}

return cacheKey;
}

/**
* Get a non-expired access token, after refreshing if necessary.
*
Expand All @@ -71,30 +93,50 @@ export class JWTAccess {
* include in the payload.
* @returns An object that includes the authorization header.
*/
getRequestHeaders(url: string, additionalClaims?: Claims): Headers {
getRequestHeaders(
url?: string,
additionalClaims?: Claims,
scopes?: string | string[]
): Headers {
// Return cached authorization headers, unless we are within
// eagerRefreshThresholdMillis ms of them expiring:
const cachedToken = this.cache.get(url);
const key = this.getCachedKey(url, scopes);
const cachedToken = this.cache.get(key);
const now = Date.now();
if (
cachedToken &&
cachedToken.expiration - now > this.eagerRefreshThresholdMillis
) {
return cachedToken.headers;
}

const iat = Math.floor(Date.now() / 1000);
const exp = JWTAccess.getExpirationTime(iat);

// The payload used for signed JWT headers has:
// iss == sub == <client email>
// aud == <the authorization uri>
const defaultClaims = {
iss: this.email,
sub: this.email,
aud: url,
exp,
iat,
};
let defaultClaims;
// Turn scopes into space-separated string
if (Array.isArray(scopes)) {
scopes = scopes.join(' ');
}

// If scopes are specified, sign with scopes
if (scopes) {
defaultClaims = {
iss: this.email,
sub: this.email,
scope: scopes,
exp,
iat,
};
} else {
defaultClaims = {
iss: this.email,
sub: this.email,
aud: url,
exp,
iat,
};
}

// if additionalClaims are provided, ensure they do not collide with
// other required claims.
Expand All @@ -116,7 +158,7 @@ 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, {
this.cache.set(key, {
expiration: exp * 1000,
headers,
});
Expand Down
22 changes: 19 additions & 3 deletions src/auth/jwtclient.ts
Expand Up @@ -122,7 +122,11 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
protected async getRequestMetadataAsync(
url?: string | null
): Promise<RequestMetadataResponse> {
if (!this.apiKey && !this.hasUserScopes() && url) {
url = this.defaultServicePath ? `https://${this.defaultServicePath}/` : url;
const useSelfSignedJWT =
(!this.hasUserScopes() && url) ||
(this.useJWTAccessWithScope && this.hasAnyScopes());
if (!this.apiKey && useSelfSignedJWT) {
if (
this.additionalClaims &&
(
Expand All @@ -148,10 +152,22 @@ export class JWT extends OAuth2Client implements IdTokenProvider {
this.eagerRefreshThresholdMillis
);
}

let scopes: string | string[] | undefined;
if (this.hasUserScopes()) {
scopes = this.scopes;
} else if (!url) {
scopes = this.defaultScopes;
}

const headers = await this.access.getRequestHeaders(
url,
this.additionalClaims
url ?? undefined,
this.additionalClaims,
// Scopes take precedent over audience for signing,
// so we only provide them if useJWTAccessWithScope is on
this.useJWTAccessWithScope ? scopes : undefined
);

return {headers: this.addSharedMetadataHeaders(headers)};
}
} else if (this.hasAnyScopes() || this.apiKey) {
Expand Down
14 changes: 14 additions & 0 deletions test/test.googleauth.ts
Expand Up @@ -405,6 +405,20 @@ describe('googleauth', () => {
assert.strictEqual(json.private_key, (result as JWT).key);
});

it('fromJSON should set useJWTAccessWithScope with private key', () => {
auth.useJWTAccessWithScope = true;
const json = createJwtJSON();
const result = auth.fromJSON(json);
assert.ok((result as JWT).useJWTAccessWithScope);
});

it('fromJSON should set default service path with private key', () => {
auth.defaultServicePath = 'a/b/c';
const json = createJwtJSON();
const result = auth.fromJSON(json);
assert.strictEqual((result as JWT).defaultServicePath, 'a/b/c');
});

it('fromJSON should create JWT with null scopes', () => {
const json = createJwtJSON();
const result = auth.fromJSON(json);
Expand Down

0 comments on commit ad3f652

Please sign in to comment.