Skip to content

Commit 7bd662f

Browse files
authored
fix: adjust IAM token expiration time (#221)
This commit changes the IAM, Container and VPC Instance authenticators slightly so that an IAM access token will be viewed as "expired" when the current time is within 10 seconds of the official expiration time. IOW, we'll expire the access token 10 secs earlier than the IAM server-computed expiration time. We're doing this to avoid a scenario where an IBM Cloud service receives a request along with an "almost expired" access token and then uses that token to perform downstream requests in a somewhat longer-running transaction and then the access token expires while that transaction is still active. Signed-off-by: Phil Adams <phil_adams@us.ibm.com>
1 parent af890b8 commit 7bd662f

File tree

6 files changed

+196
-31
lines changed

6 files changed

+196
-31
lines changed

src/main/java/com/ibm/cloud/sdk/core/security/IamToken.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* (C) Copyright IBM Corp. 2015, 2021.
2+
* (C) Copyright IBM Corp. 2015, 2024.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
55
* the License. You may obtain a copy of the License at
@@ -22,6 +22,10 @@
2222
*/
2323
public class IamToken extends AbstractToken implements ObjectModel, TokenServerResponse {
2424

25+
// The number of seconds before the IAM server-assigned (official) expiration time
26+
// when we'll treat an otherwise valid access token as "expired".
27+
public static final long IamExpirationWindow = 10L;
28+
2529
// These fields are obtained from the IAM token service "getToken" operation response body.
2630
@SerializedName("access_token")
2731
private String accessToken;
@@ -79,6 +83,10 @@ public Long getExpiration() {
7983
return expiration;
8084
}
8185

86+
public Long getRefreshTime() {
87+
return refreshTime;
88+
}
89+
8290
/**
8391
* Returns true iff currently stored access token should be refreshed.
8492
*
@@ -119,11 +127,18 @@ public synchronized boolean needsRefresh() {
119127
* Check if the currently stored access token is valid. This is different from the needsRefresh method in that it
120128
* uses the actual TTL to calculate the expiration, rather than just a fraction.
121129
*
122-
* @return true iff is the current access token is not expired
130+
* @return true iff the current access token is valid and not expired
123131
*/
124132
@Override
125133
public boolean isTokenValid() {
126134
return this.getException() == null
127-
&& (this.expiration == null || Clock.getCurrentTimeInSeconds() < this.expiration);
135+
&& (this.expiration == null || Clock.getCurrentTimeInSeconds() < (this.expiration - IamExpirationWindow));
136+
}
137+
138+
public String toString() {
139+
String s =
140+
String.format("IamTokenData: accessToken=%s expiration=%d expiresIn=%d",
141+
this.accessToken, this.expiration, this.expiresIn);
142+
return s;
128143
}
129144
}

src/test/java/com/ibm/cloud/sdk/core/security/VpcInstanceAuthenticatorTest.java

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* (C) Copyright IBM Corp. 2022, 2023.
2+
* (C) Copyright IBM Corp. 2022, 2024.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
55
* the License. You may obtain a copy of the License at
@@ -453,10 +453,43 @@ public void testAuthenticateNewAndCachedToken() throws Throwable {
453453

454454
@Test
455455
public void testAuthenticationExpiredToken() throws Throwable {
456-
// Mock current time to ensure that we're past the token expiration time.
457-
long mockTime = vpcIamAccessTokenResponse1.getExpiresAt().getTime() / 1000 + 1;
456+
// Mock current time to ensure that we're way before the first token's expiration time.
457+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
458+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
459+
460+
VpcInstanceAuthenticator authenticator = new VpcInstanceAuthenticator.Builder()
461+
.iamProfileId(mockIamProfileId)
462+
.url(url)
463+
.build();
464+
465+
Request.Builder requestBuilder = new Request.Builder().url("https://test.com");
466+
467+
// Set mock server responses.
468+
server.enqueue(jsonResponse(vpcInstanceIdentityTokenResponse));
469+
server.enqueue(jsonResponse(vpcIamAccessTokenResponse1));
470+
server.enqueue(jsonResponse(vpcInstanceIdentityTokenResponse));
471+
server.enqueue(jsonResponse(vpcIamAccessTokenResponse2));
472+
473+
// Calling "authenticate()" the first time should result in a new, valid token.
474+
authenticator.authenticate(requestBuilder);
475+
verifyAuthHeader(requestBuilder, "Bearer " + vpcIamAccessTokenResponse1.getAccessToken());
476+
477+
// Mock current time so that we detect the first access token has expired.
478+
long mockTime = vpcIamAccessTokenResponse1.getExpiresAt().getTime() / 1000;
458479
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(mockTime);
459480

481+
// Calling "authenticate()" again should result in a new access token
482+
// because we should detect that the first one obtained above has expired.
483+
authenticator.authenticate(requestBuilder);
484+
verifyAuthHeader(requestBuilder, "Bearer " + vpcIamAccessTokenResponse2.getAccessToken());
485+
}
486+
487+
@Test
488+
public void testAuthenticationExpiredToken10SecWindow() throws Throwable {
489+
// Mock current time to ensure that we're way before the first token's expiration time.
490+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
491+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
492+
460493
VpcInstanceAuthenticator authenticator = new VpcInstanceAuthenticator.Builder()
461494
.iamProfileId(mockIamProfileId)
462495
.url(url)
@@ -474,6 +507,11 @@ public void testAuthenticationExpiredToken() throws Throwable {
474507
authenticator.authenticate(requestBuilder);
475508
verifyAuthHeader(requestBuilder, "Bearer " + vpcIamAccessTokenResponse1.getAccessToken());
476509

510+
// Mock current time so that we detect the first access token has expired.
511+
// We subtract 10s from the expiration time to test the boundary condition of the expiration window feature.
512+
long mockTime = vpcIamAccessTokenResponse1.getExpiresAt().getTime() / 1000;
513+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(mockTime - IamToken.IamExpirationWindow);
514+
477515
// Calling "authenticate()" again should result in a new access token
478516
// because we should detect that the first one obtained above has expired.
479517
authenticator.authenticate(requestBuilder);

src/test/java/com/ibm/cloud/sdk/core/test/security/ContainerAuthenticatorTest.java

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* (C) Copyright IBM Corp. 2015, 2023.
2+
* (C) Copyright IBM Corp. 2015, 2024.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
55
* the License. You may obtain a copy of the License at
@@ -251,8 +251,9 @@ public void testConfigCorrectConfig2() {
251251

252252
@Test
253253
public void testAuthenticateNewAndStoredToken() throws Throwable {
254-
// Mock current time to ensure that we're way before the token expiration time.
255-
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100);
254+
// Mock current time to ensure that we're way before the first token's expiration time.
255+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
256+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
256257

257258
ContainerAuthenticator authenticator = new ContainerAuthenticator.Builder()
258259
.crTokenFilename(mockCRTokenFile)
@@ -311,8 +312,56 @@ public void testAuthenticateNewAndStoredToken() throws Throwable {
311312

312313
@Test
313314
public void testAuthenticationExpiredToken() throws Throwable {
314-
// Mock current time to ensure that we've passed the token expiration time.
315-
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 1800000000);
315+
// Mock current time to ensure that we're way before the first token's expiration time.
316+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
317+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
318+
319+
320+
ContainerAuthenticator authenticator = new ContainerAuthenticator.Builder()
321+
.crTokenFilename(mockCRTokenFile)
322+
.iamProfileId(mockIamProfileId)
323+
.url(url)
324+
.build();
325+
326+
Request.Builder requestBuilder = new Request.Builder().url("https://test.com");
327+
328+
// Set mock server to return first access token.
329+
server.enqueue(jsonResponse(tokenData1));
330+
331+
// This will bootstrap the test by forcing the Authenticator to retrieve the first access token,
332+
// which will appear as expired when we call authenticate() the second time below.
333+
authenticator.authenticate(requestBuilder);
334+
335+
// Validate parts of the IAM request that was sent as a result of the authenticate() call above.
336+
RecordedRequest tokenServerRequest = server.takeRequest();
337+
assertNotNull(tokenServerRequest);
338+
339+
// Validate the form params included in the request.
340+
Map<String, String> formBody = getFormBodyAsMap(tokenServerRequest);
341+
assertEquals(formBody.get("cr_token"), mockCRToken);
342+
assertEquals(formBody.get("grant_type"), "urn:ibm:params:oauth:grant-type:cr-token");
343+
assertEquals(formBody.get("profile_id"), mockIamProfileId);
344+
assertFalse(formBody.containsKey("profile_name"));
345+
assertFalse(formBody.containsKey("scope"));
346+
347+
// Now set the mock time to reflect that the first access token ("tokenData1") has expired.
348+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(tokenData1.getExpiration());
349+
350+
// Set mock server to return second access token.
351+
server.enqueue(jsonResponse(tokenData2));
352+
353+
// Authenticator should detect the expiration and request a
354+
// new access token when we call authenticate() again.
355+
authenticator.authenticate(requestBuilder);
356+
verifyAuthHeader(requestBuilder, "Bearer " + tokenData2.getAccessToken());
357+
}
358+
359+
@Test
360+
public void testAuthenticationExpiredToken10SecWindow() throws Throwable {
361+
// Mock current time to ensure that we're way before the first token's expiration time.
362+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
363+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
364+
316365

317366
ContainerAuthenticator authenticator = new ContainerAuthenticator.Builder()
318367
.crTokenFilename(mockCRTokenFile)
@@ -341,6 +390,11 @@ public void testAuthenticationExpiredToken() throws Throwable {
341390
assertFalse(formBody.containsKey("profile_name"));
342391
assertFalse(formBody.containsKey("scope"));
343392

393+
// Now set the mock time to reflect that the first access token ("tokenData") is considered to be "expired".
394+
// We subtract 10s from the expiration time to test the boundary condition of the expiration window feature.
395+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(
396+
tokenData1.getExpiration() - IamToken.IamExpirationWindow);
397+
344398
// Set mock server to return second access token.
345399
server.enqueue(jsonResponse(tokenData2));
346400

@@ -352,8 +406,9 @@ public void testAuthenticationExpiredToken() throws Throwable {
352406

353407
@Test
354408
public void testAuthenticationBackgroundTokenRefresh() throws InterruptedException {
355-
// Mock current time to put us in the "refresh window" where the token is not expired but still needs refreshed.
356-
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 1522788600);
409+
// Set initial mock time to be epoch time.
410+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
411+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
357412

358413
ContainerAuthenticator authenticator = new ContainerAuthenticator.Builder()
359414
.crTokenFilename(mockCRTokenFile)
@@ -383,13 +438,17 @@ public void testAuthenticationBackgroundTokenRefresh() throws InterruptedExcepti
383438
assertEquals(formBody.get("profile_id"), mockIamProfileId);
384439
assertFalse(formBody.containsKey("scope"));
385440

386-
// Set mock server to return second access token.
387-
server.enqueue(jsonResponse(tokenData2).setBodyDelay(2, TimeUnit.SECONDS));
441+
// Now set the mock time to put us in the "refresh window" where the token is not expired,
442+
// but still needs to be refreshed.
443+
long refreshWindow = (long) (tokenData1.getExpiresIn() * 0.2);
444+
long refreshTime = tokenData1.getExpiration() - refreshWindow;
445+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(refreshTime + 2L);
388446

389447
// Authenticator should detect the need to refresh and request a new access token
390448
// IN THE BACKGROUND when we call authenticate() again.
391449
// The immediate response should be the token which was already stored, since it's not yet
392450
// expired.
451+
server.enqueue(jsonResponse(tokenData2).setBodyDelay(2, TimeUnit.SECONDS));
393452
authenticator.authenticate(requestBuilder);
394453
verifyAuthHeader(requestBuilder, "Bearer " + tokenData1.getAccessToken());
395454

src/test/java/com/ibm/cloud/sdk/core/test/security/IamAuthenticatorTest.java

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* (C) Copyright IBM Corp. 2015, 2023.
2+
* (C) Copyright IBM Corp. 2015, 2024.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
55
* the License. You may obtain a copy of the License at
@@ -281,8 +281,9 @@ public void testSetScope() {
281281
public void testAuthenticateNewAndStoredToken() throws Throwable {
282282
server.enqueue(jsonResponse(tokenData));
283283

284-
// Mock current time to ensure that we're way before the token expiration time.
285-
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100);
284+
// Mock current time to ensure that we're way before the first token's expiration time.
285+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
286+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
286287

287288
IamAuthenticator authenticator = new IamAuthenticator.Builder()
288289
.apikey(API_KEY)
@@ -317,6 +318,9 @@ public void testAuthenticateNewAndStoredToken() throws Throwable {
317318
Headers actualHeaders = tokenServerRequest.getHeaders();
318319
assertNull(actualHeaders.get(HttpHeaders.AUTHORIZATION));
319320

321+
// Set mock time to be within tokenData's lifetime, but before the refresh/expiration times.
322+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(tokenData.getExpiration() - 1000L);
323+
320324
// Authenticator should just return the same token this time since we have a valid one stored.
321325
requestBuilder = new Request.Builder().url("https://test.com");
322326
authenticator.authenticate(requestBuilder);
@@ -330,17 +334,24 @@ public void testAuthenticateNewAndStoredToken() throws Throwable {
330334
public void testAuthenticationExpiredToken() {
331335
server.enqueue(jsonResponse(tokenData));
332336

333-
// Mock current time to ensure that we've passed the token expiration time.
334-
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 1800000000);
337+
// Mock current time to ensure that we're way before the first token's expiration time.
338+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
339+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
335340

336-
IamAuthenticator authenticator = new IamAuthenticator(API_KEY);
337-
authenticator.setURL(url);
341+
IamAuthenticator authenticator = new IamAuthenticator.Builder()
342+
.apikey(API_KEY)
343+
.url(url)
344+
.build();
338345

339346
Request.Builder requestBuilder = new Request.Builder().url("https://test.com");
340347

341348
// This will bootstrap the test by forcing the Authenticator to store the expired token
342349
// set above in the mock server.
343350
authenticator.authenticate(requestBuilder);
351+
verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getAccessToken());
352+
353+
// Now set the mock time to reflect that the first access token ("tokenData") has expired.
354+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(tokenData.getExpiration());
344355

345356
// Authenticator should detect the expiration and request a new access token when we call authenticate() again.
346357
server.enqueue(jsonResponse(refreshedTokenData));
@@ -349,21 +360,59 @@ public void testAuthenticationExpiredToken() {
349360
}
350361

351362
@Test
352-
public void testAuthenticationBackgroundTokenRefresh() throws InterruptedException {
363+
public void testAuthenticationExpiredToken10SecWindow() {
353364
server.enqueue(jsonResponse(tokenData));
354365

355-
// Mock current time to put us in the "refresh window" where the token is not expired but still needs refreshed.
356-
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 1522788600);
366+
// Set initial mock time to be epoch time.
367+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
368+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
357369

358-
IamAuthenticator authenticator = new IamAuthenticator.Builder().apikey(API_KEY).build();
359-
authenticator.setURL(url);
370+
IamAuthenticator authenticator = new IamAuthenticator.Builder()
371+
.apikey(API_KEY)
372+
.url(url)
373+
.build();
360374

361375
Request.Builder requestBuilder = new Request.Builder().url("https://test.com");
362376

363-
// This will bootstrap the test by forcing the Authenticator to store the token needing refreshed, which was
377+
// This will bootstrap the test by forcing the Authenticator to store the expired token
364378
// set above in the mock server.
365379
authenticator.authenticate(requestBuilder);
366380

381+
// Now set the mock time to reflect that the first access token ("tokenData") is considered to be "expired".
382+
// We subtract 10s from the expiration time to test the boundary condition of the expiration window feature.
383+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(
384+
tokenData.getExpiration() - IamToken.IamExpirationWindow);
385+
386+
// Authenticator should detect the expiration and request a new access token when we call authenticate() again.
387+
server.enqueue(jsonResponse(refreshedTokenData));
388+
authenticator.authenticate(requestBuilder);
389+
verifyAuthHeader(requestBuilder, "Bearer " + refreshedTokenData.getAccessToken());
390+
}
391+
392+
@Test
393+
public void testAuthenticationBackgroundTokenRefresh() throws InterruptedException {
394+
server.enqueue(jsonResponse(tokenData));
395+
396+
// Set initial mock time to be epoch time.
397+
// This is because initially we have no access token at all so we should fetch one regardless of the current time.
398+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(0L);
399+
400+
IamAuthenticator authenticator = new IamAuthenticator.Builder()
401+
.apikey(API_KEY)
402+
.url(url)
403+
.build();
404+
405+
Request.Builder requestBuilder = new Request.Builder().url("https://test.com");
406+
407+
// This will bootstrap the test by forcing the Authenticator to store the first access token (tokenData).
408+
authenticator.authenticate(requestBuilder);
409+
410+
// Now set the mock time to put us in the "refresh window" where the token is not expired,
411+
// but still needs to be refreshed.
412+
long refreshWindow = (long) (tokenData.getExpiresIn() * 0.2);
413+
long refreshTime = tokenData.getExpiration() - refreshWindow;
414+
clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn(refreshTime + 2L);
415+
367416
// Authenticator should detect the need to refresh and request a new access token IN THE BACKGROUND when we call
368417
// authenticate() again. The immediate response should be the token which was already stored, since it's not yet
369418
// expired.
@@ -391,7 +440,11 @@ public void testUserHeaders() throws Throwable {
391440
headers.put("header1", "value1");
392441
headers.put("header2", "value2");
393442
headers.put("Host", "iam.cloud.ibm.com:81");
394-
IamAuthenticator authenticator = new IamAuthenticator(API_KEY, url, null, null, false, headers);
443+
IamAuthenticator authenticator = new IamAuthenticator.Builder()
444+
.apikey(API_KEY)
445+
.url(url)
446+
.headers(headers)
447+
.build();
395448

396449
Request.Builder requestBuilder = new Request.Builder().url("https://test.com");
397450

src/test/resources/iam_token.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"refresh_token": "00000000",
44
"token_type": "Bearer",
55
"expires_in": 3600,
6-
"expiration": 1522788645
6+
"expiration": 1600000000
77
}

src/test/resources/refreshed_iam_token.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"refresh_token": "00000000",
44
"token_type": "Bearer",
55
"expires_in": 3600,
6-
"expiration": 1999999999
6+
"expiration": 1600003600
77
}

0 commit comments

Comments
 (0)