Skip to content

Commit f4f0b5a

Browse files
authored
fix: adjust IAM token expiration time (#189)
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 <[email protected]>
1 parent 41dfaec commit f4f0b5a

File tree

6 files changed

+248
-28
lines changed

6 files changed

+248
-28
lines changed

.secrets.baseline

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "package-lock.json|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2024-01-24T12:09:17Z",
6+
"generated_at": "2024-02-26T20:31:05Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -242,31 +242,31 @@
242242
"hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6",
243243
"is_secret": false,
244244
"is_verified": false,
245-
"line_number": 14,
245+
"line_number": 30,
246246
"type": "JSON Web Token",
247247
"verified_result": null
248248
},
249249
{
250250
"hashed_secret": "f06e1073ca9afdd800a2cf27f944d06530b5b755",
251251
"is_secret": false,
252252
"is_verified": false,
253-
"line_number": 15,
253+
"line_number": 31,
254254
"type": "JSON Web Token",
255255
"verified_result": null
256256
},
257257
{
258258
"hashed_secret": "360c23c1ac7d9d6dad1d0710606b0df9de6e1a18",
259259
"is_secret": false,
260260
"is_verified": false,
261-
"line_number": 19,
261+
"line_number": 35,
262262
"type": "Secret Keyword",
263263
"verified_result": null
264264
},
265265
{
266266
"hashed_secret": "62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
267267
"is_secret": false,
268268
"is_verified": false,
269-
"line_number": 122,
269+
"line_number": 139,
270270
"type": "Secret Keyword",
271271
"verified_result": null
272272
}
@@ -326,27 +326,43 @@
326326
}
327327
],
328328
"test/test_iam_token_manager.py": [
329+
{
330+
"hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6",
331+
"is_secret": false,
332+
"is_verified": false,
333+
"line_number": 28,
334+
"type": "JSON Web Token",
335+
"verified_result": null
336+
},
337+
{
338+
"hashed_secret": "f06e1073ca9afdd800a2cf27f944d06530b5b755",
339+
"is_secret": false,
340+
"is_verified": false,
341+
"line_number": 29,
342+
"type": "JSON Web Token",
343+
"verified_result": null
344+
},
329345
{
330346
"hashed_secret": "da2f27d2c57a0e1ed2dc3a34b4ef02faf2f7a4c2",
331347
"is_secret": false,
332348
"is_verified": false,
333-
"line_number": 26,
349+
"line_number": 52,
334350
"type": "Hex High Entropy String",
335351
"verified_result": null
336352
},
337353
{
338354
"hashed_secret": "b3f00e146afe19aab0069029b7fb3926ad756d26",
339355
"is_secret": false,
340356
"is_verified": false,
341-
"line_number": 103,
357+
"line_number": 129,
342358
"type": "Hex High Entropy String",
343359
"verified_result": null
344360
},
345361
{
346362
"hashed_secret": "62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
347363
"is_secret": false,
348364
"is_verified": false,
349-
"line_number": 165,
365+
"line_number": 191,
350366
"type": "Secret Keyword",
351367
"verified_result": null
352368
}
@@ -444,13 +460,21 @@
444460
"hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6",
445461
"is_secret": false,
446462
"is_verified": false,
447-
"line_number": 28,
463+
"line_number": 29,
464+
"type": "JSON Web Token",
465+
"verified_result": null
466+
},
467+
{
468+
"hashed_secret": "f06e1073ca9afdd800a2cf27f944d06530b5b755",
469+
"is_secret": false,
470+
"is_verified": false,
471+
"line_number": 30,
448472
"type": "JSON Web Token",
449473
"verified_result": null
450474
}
451475
]
452476
},
453-
"version": "0.13.1+ibm.61.dss",
477+
"version": "0.13.1+ibm.62.dss",
454478
"word_list": {
455479
"file": null,
456480
"hash": null

ibm_cloud_sdk_core/token_managers/iam_request_based_token_manager.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# coding: utf-8
22

3-
# Copyright 2019 IBM All Rights Reserved.
3+
# Copyright 2019, 2024 IBM All Rights Reserved.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
66
# you may not use this file except in compliance with the License.
@@ -63,6 +63,7 @@ class IAMRequestBasedTokenManager(JWTTokenManager):
6363

6464
DEFAULT_IAM_URL = 'https://iam.cloud.ibm.com'
6565
OPERATION_PATH = "/identity/token"
66+
IAM_EXPIRATION_WINDOW = 10
6667

6768
def __init__(
6869
self,
@@ -167,3 +168,19 @@ def set_scope(self, value: str) -> None:
167168
value: A space seperated string that makes up the scope parameter.
168169
"""
169170
self.scope = value
171+
172+
def _is_token_expired(self) -> bool:
173+
"""
174+
Returns true iff the current cached token is expired.
175+
We'll consider an access token as expired when we reach its IAM server-reported expiration time
176+
minus our expiration window (10 secs).
177+
We do this to avoid using an access token that might expire in the middle of a long-running transaction
178+
within an IBM Cloud service.
179+
180+
Returns
181+
-------
182+
bool
183+
True if token is expired; False otherwise
184+
"""
185+
current_time = self._get_current_time()
186+
return current_time >= (self.expire_time - self.IAM_EXPIRATION_WINDOW)

ibm_cloud_sdk_core/token_managers/vpc_instance_token_manager.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# coding: utf-8
22

3-
# Copyright 2021, 2023 IBM All Rights Reserved.
3+
# Copyright 2021, 2024 IBM All Rights Reserved.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
66
# you may not use this file except in compliance with the License.
@@ -55,6 +55,7 @@ class VPCInstanceTokenManager(JWTTokenManager):
5555
METADATA_SERVICE_VERSION = '2022-03-01'
5656
DEFAULT_IMS_ENDPOINT = 'http://169.254.169.254'
5757
TOKEN_NAME = 'access_token'
58+
IAM_EXPIRATION_WINDOW = 10
5859

5960
def __init__(
6061
self, iam_profile_crn: Optional[str] = None, iam_profile_id: Optional[str] = None, url: Optional[str] = None
@@ -152,3 +153,19 @@ def retrieve_instance_identity_token(self) -> str:
152153
logger.debug('Returned from VPC \'create_access_token\' operation."')
153154

154155
return response['access_token']
156+
157+
def _is_token_expired(self) -> bool:
158+
"""
159+
Returns true iff the current cached token is expired.
160+
We'll consider an access token as expired when we reach its IAM server-reported expiration time
161+
minus our expiration window (10 secs).
162+
We do this to avoid using an access token that might expire in the middle of a long-running transaction
163+
within an IBM Cloud service.
164+
165+
Returns
166+
-------
167+
bool
168+
True if token is expired; False otherwise
169+
"""
170+
current_time = self._get_current_time()
171+
return current_time >= (self.expire_time - self.IAM_EXPIRATION_WINDOW)

test/test_container_token_manager.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
# coding: utf-8
2+
3+
# Copyright 2021, 2024 IBM All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
117
# pylint: disable=missing-docstring
218
import json
319
import os
@@ -17,6 +33,7 @@
1733
MOCK_IAM_PROFILE_NAME = 'iam-user-123'
1834
MOCK_CLIENT_ID = 'client-id-1'
1935
MOCK_CLIENT_SECRET = 'client-secret-1'
36+
EXPIRATION_WINDOW = 10
2037

2138
cr_token_file = os.path.join(os.path.dirname(__file__), '../resources/cr-token.txt')
2239

@@ -169,18 +186,22 @@ def test_get_token_success():
169186
assert access_token == TEST_ACCESS_TOKEN_1
170187
assert token_manager.access_token == TEST_ACCESS_TOKEN_1
171188

172-
# Verify the token manager return the cached value.
173-
# Before we call the `get_token` again, set the expiration and time.
174-
# This is necessary because we are using a fix JWT response.
175-
token_manager.expire_time = _get_current_time() + 3600
176-
token_manager.refresh_time = _get_current_time() + 3600
189+
# Verify that the token manager returns the cached value.
190+
# Before we call `get_token` again, set the expiration and refresh time
191+
# so that we do not fetch a new access token.
192+
# This is necessary because we are using a fixed JWT response.
193+
token_manager.expire_time = _get_current_time() + 1000
194+
token_manager.refresh_time = _get_current_time() + 1000
177195
token_manager.set_scope('send-second-token')
178196
access_token = token_manager.get_token()
179197
assert access_token == TEST_ACCESS_TOKEN_1
180198
assert token_manager.access_token == TEST_ACCESS_TOKEN_1
181199

182200
# Force expiration to get the second token.
183-
token_manager.expire_time = _get_current_time() - 1
201+
# We'll set the expiration time to be current-time + EXPIRATION_WINDOW (10 secs)
202+
# because we want the access token to be considered as "expired"
203+
# when we reach the IAM-server reported expiration time minus 10 secs.
204+
token_manager.expire_time = _get_current_time() + EXPIRATION_WINDOW
184205
access_token = token_manager.get_token()
185206
assert access_token == TEST_ACCESS_TOKEN_2
186207
assert token_manager.access_token == TEST_ACCESS_TOKEN_2
@@ -206,17 +227,21 @@ def test_authenticate_success():
206227
authenticator.authenticate(request)
207228
assert request['headers']['Authorization'] == 'Bearer ' + TEST_ACCESS_TOKEN_1
208229

209-
# Verify the token manager return the cached value.
210-
# Before we call the `get_token` again, set the expiration and time.
211-
# This is necessary because we are using a fix JWT response.
212-
authenticator.token_manager.expire_time = _get_current_time() + 3600
213-
authenticator.token_manager.refresh_time = _get_current_time() + 3600
230+
# Verify that the token manager returns the cached value.
231+
# Before we call `get_token` again, set the expiration and refresh time
232+
# so that we do not fetch a new access token.
233+
# This is necessary because we are using a fixed JWT response.
234+
authenticator.token_manager.expire_time = _get_current_time() + 1000
235+
authenticator.token_manager.refresh_time = _get_current_time() + 1000
214236
authenticator.token_manager.set_scope('send-second-token')
215237
authenticator.authenticate(request)
216238
assert request['headers']['Authorization'] == 'Bearer ' + TEST_ACCESS_TOKEN_1
217239

218240
# Force expiration to get the second token.
219-
authenticator.token_manager.expire_time = _get_current_time() - 1
241+
# We'll set the expiration time to be current-time + EXPIRATION_WINDOW (10 secs)
242+
# because we want the access token to be considered as "expired"
243+
# when we reach the IAM-server reported expiration time minus 10 secs.
244+
authenticator.token_manager.expire_time = _get_current_time() + EXPIRATION_WINDOW
220245
authenticator.authenticate(request)
221246
assert request['headers']['Authorization'] == 'Bearer ' + TEST_ACCESS_TOKEN_2
222247

test/test_iam_token_manager.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
# coding: utf-8
2+
3+
# Copyright 2021, 2024 IBM All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
117
# pylint: disable=missing-docstring
218
import os
319
import time
@@ -8,6 +24,16 @@
824

925
from ibm_cloud_sdk_core import IAMTokenManager, ApiException, get_authenticator_from_environment
1026

27+
# pylint: disable=line-too-long
28+
TEST_ACCESS_TOKEN_1 = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImhlbGxvIiwicm9sZSI6InVzZXIiLCJwZXJtaXNzaW9ucyI6WyJhZG1pbmlzdHJhdG9yIiwiZGVwbG95bWVudF9hZG1pbiJdLCJzdWIiOiJoZWxsbyIsImlzcyI6IkpvaG4iLCJhdWQiOiJEU1giLCJ1aWQiOiI5OTkiLCJpYXQiOjE1NjAyNzcwNTEsImV4cCI6MTU2MDI4MTgxOSwianRpIjoiMDRkMjBiMjUtZWUyZC00MDBmLTg2MjMtOGNkODA3MGI1NDY4In0.cIodB4I6CCcX8vfIImz7Cytux3GpWyObt9Gkur5g1QI'
29+
TEST_ACCESS_TOKEN_2 = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6IjIzMDQ5ODE1MWMyMTRiNzg4ZGQ5N2YyMmI4NTQxMGE1In0.eyJ1c2VybmFtZSI6ImR1bW15Iiwicm9sZSI6IkFkbWluIiwicGVybWlzc2lvbnMiOlsiYWRtaW5pc3RyYXRvciIsIm1hbmFnZV9jYXRhbG9nIl0sInN1YiI6ImFkbWluIiwiaXNzIjoic3NzIiwiYXVkIjoic3NzIiwidWlkIjoic3NzIiwiaWF0IjozNjAwLCJleHAiOjE2MjgwMDcwODF9.zvUDpgqWIWs7S1CuKv40ERw1IZ5FqSFqQXsrwZJyfRM'
30+
TEST_REFRESH_TOKEN = 'Xj7Gle500MachEOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImhlbGxvIiwicm9sZSI6InVzZXIiLCJwZXJtaXNzaW9ucyI6WyJhZG1pbmlzdHJhdG9yIiwiZGVwbG95bWVudF9hZG1pbiJdLCJzdWIiOiJoZWxsbyIsImlzcyI6IkpvaG4iLCJhdWQiOiJEU1giLCJ1aWQiOiI5OTkiLCJpYXQiOjE1NjAyNzcwNTEsImV4cCI6MTU2MDI4MTgxOSwianRpIjoiMDRkMjBiMjUtZWUyZC00MDBmLTg2MjMtOGNkODA3MGI1NDY4In0.cIodB4I6CCcX8vfIImz7Cytux3GpWyObt9Gkur5g1QI'
31+
EXPIRATION_WINDOW = 10
32+
33+
34+
def _get_current_time() -> int:
35+
return int(time.time())
36+
1137

1238
def get_access_token() -> str:
1339
access_token_layout = {
@@ -268,6 +294,62 @@ def test_request_token_auth_in_setter_scope():
268294
assert 'scope=john+snow' in responses.calls[0].response.request.body
269295

270296

297+
@responses.activate
298+
def test_get_token_success():
299+
iam_url = "https://iam.cloud.ibm.com/identity/token"
300+
301+
# Create two mock responses with different access tokens.
302+
response1 = """{
303+
"access_token": "%s",
304+
"token_type": "Bearer",
305+
"expires_in": 3600,
306+
"expiration": 1600003600,
307+
"refresh_token": "jy4gl91BQ"
308+
}""" % (
309+
TEST_ACCESS_TOKEN_1
310+
)
311+
response2 = """{
312+
"access_token": "%s",
313+
"token_type": "Bearer",
314+
"expires_in": 3600,
315+
"expiration": 1600007200,
316+
"refresh_token": "jy4gl91BQ"
317+
}""" % (
318+
TEST_ACCESS_TOKEN_2
319+
)
320+
321+
token_manager = IAMTokenManager("iam_apikey")
322+
323+
access_token = token_manager.access_token
324+
assert access_token is None
325+
326+
responses.add(responses.POST, url=iam_url, body=response1, status=200)
327+
access_token = token_manager.get_token()
328+
assert access_token == TEST_ACCESS_TOKEN_1
329+
assert token_manager.access_token == TEST_ACCESS_TOKEN_1
330+
331+
# Verify that the token manager returns the cached value.
332+
# Before we call `get_token` again, set the expiration and refresh time
333+
# so that we do not fetch a new access token.
334+
# This is necessary because we are using a fixed JWT response.
335+
token_manager.expire_time = _get_current_time() + 1000
336+
token_manager.refresh_time = _get_current_time() + 1000
337+
access_token = token_manager.get_token()
338+
assert access_token == TEST_ACCESS_TOKEN_1
339+
assert token_manager.access_token == TEST_ACCESS_TOKEN_1
340+
341+
# Force expiration to get the second token.
342+
# We'll set the expiration time to be current-time + EXPIRATION_WINDOW (10 secs)
343+
# because we want the access token to be considered as "expired"
344+
# when we reach the IAM-server reported expiration time minus 10 secs.
345+
responses.add(responses.POST, url=iam_url, body=response2, status=200)
346+
token_manager.expire_time = _get_current_time() + EXPIRATION_WINDOW
347+
token_manager.refresh_time = _get_current_time() + 1000
348+
access_token = token_manager.get_token()
349+
assert access_token == TEST_ACCESS_TOKEN_2
350+
assert token_manager.access_token == TEST_ACCESS_TOKEN_2
351+
352+
271353
@responses.activate
272354
def test_get_refresh_token():
273355
iam_url = "https://iam.cloud.ibm.com/identity/token"

0 commit comments

Comments
 (0)