Skip to content

Commit

Permalink
feat: Group Concurrent Access Token Requests for Base External Clients (
Browse files Browse the repository at this point in the history
  • Loading branch information
danielbankhead committed Jul 29, 2024
1 parent 76666f8 commit 0e08fc5
Show file tree
Hide file tree
Showing 2 changed files with 82 additions and 0 deletions.
18 changes: 18 additions & 0 deletions src/auth/baseexternalclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ export abstract class BaseExternalAccountClient extends AuthClient {
*/
protected cloudResourceManagerURL: URL | string;
protected supplierContext: ExternalAccountSupplierContext;
/**
* A pending access token request. Used for concurrent calls.
*/
#pendingAccessToken: Promise<CredentialsWithResponse> | null = null;

/**
* Instantiate a BaseExternalAccountClient instance using the provided JSON
* object loaded from an external account credentials file.
Expand Down Expand Up @@ -545,6 +550,19 @@ export abstract class BaseExternalAccountClient extends AuthClient {
* @return A promise that resolves with the fresh GCP access tokens.
*/
protected async refreshAccessTokenAsync(): Promise<CredentialsWithResponse> {
// Use an existing access token request, or cache a new one
this.#pendingAccessToken =
this.#pendingAccessToken || this.#internalRefreshAccessTokenAsync();

try {
return await this.#pendingAccessToken;
} finally {
// clear pending access token for future requests
this.#pendingAccessToken = null;
}
}

async #internalRefreshAccessTokenAsync(): Promise<CredentialsWithResponse> {
// Retrieve the external credential.
const subjectToken = await this.retrieveSubjectToken();
// Construct the STS credentials options.
Expand Down
64 changes: 64 additions & 0 deletions test/test.baseexternalclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,70 @@ describe('BaseExternalAccountClient', () => {

scope.done();
});

it('should not duplicate access token requests for concurrent requests', async () => {
const client = new TestExternalAccountClient(externalAccountOptionsNoUrl);
const RESPONSE_A = {
access_token: 'ACCESS_TOKEN',
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
token_type: 'Bearer',
expires_in: ONE_HOUR_IN_SECS,
scope: 'scope1 scope2',
};

const RESPONSE_B = {
...RESPONSE_A,
access_token: 'ACCESS_TOKEN_2',
};

const scope = mockStsTokenExchange([
{
statusCode: 200,
response: RESPONSE_A,
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience,
scope: 'https://www.googleapis.com/auth/cloud-platform',
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: 'subject_token_0',
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
},
},
{
statusCode: 200,
response: RESPONSE_B,
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience,
scope: 'https://www.googleapis.com/auth/cloud-platform',
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: 'subject_token_1',
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
},
},
]);

// simulate 5 concurrent requests
const calls = [
client.getAccessToken(),
client.getAccessToken(),
client.getAccessToken(),
client.getAccessToken(),
client.getAccessToken(),
];

for (const {token} of await Promise.all(calls)) {
assert.strictEqual(token, RESPONSE_A.access_token);
}

// this should be handled in a second request as the above were all awaited and we're forcing an expiration
client.eagerRefreshThresholdMillis = RESPONSE_A.expires_in * 1000;
assert((await client.getAccessToken()).token, RESPONSE_B.access_token);

scope.done();
});
});

describe('projectNumber', () => {
Expand Down

0 comments on commit 0e08fc5

Please sign in to comment.