Skip to content

Commit

Permalink
fix: adjust IAM token expiration time (#189)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
padamstx authored Feb 28, 2024
1 parent 41dfaec commit f4f0b5a
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 28 deletions.
44 changes: 34 additions & 10 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": "package-lock.json|^.secrets.baseline$",
"lines": null
},
"generated_at": "2024-01-24T12:09:17Z",
"generated_at": "2024-02-26T20:31:05Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -242,31 +242,31 @@
"hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6",
"is_secret": false,
"is_verified": false,
"line_number": 14,
"line_number": 30,
"type": "JSON Web Token",
"verified_result": null
},
{
"hashed_secret": "f06e1073ca9afdd800a2cf27f944d06530b5b755",
"is_secret": false,
"is_verified": false,
"line_number": 15,
"line_number": 31,
"type": "JSON Web Token",
"verified_result": null
},
{
"hashed_secret": "360c23c1ac7d9d6dad1d0710606b0df9de6e1a18",
"is_secret": false,
"is_verified": false,
"line_number": 19,
"line_number": 35,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
"is_secret": false,
"is_verified": false,
"line_number": 122,
"line_number": 139,
"type": "Secret Keyword",
"verified_result": null
}
Expand Down Expand Up @@ -326,27 +326,43 @@
}
],
"test/test_iam_token_manager.py": [
{
"hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6",
"is_secret": false,
"is_verified": false,
"line_number": 28,
"type": "JSON Web Token",
"verified_result": null
},
{
"hashed_secret": "f06e1073ca9afdd800a2cf27f944d06530b5b755",
"is_secret": false,
"is_verified": false,
"line_number": 29,
"type": "JSON Web Token",
"verified_result": null
},
{
"hashed_secret": "da2f27d2c57a0e1ed2dc3a34b4ef02faf2f7a4c2",
"is_secret": false,
"is_verified": false,
"line_number": 26,
"line_number": 52,
"type": "Hex High Entropy String",
"verified_result": null
},
{
"hashed_secret": "b3f00e146afe19aab0069029b7fb3926ad756d26",
"is_secret": false,
"is_verified": false,
"line_number": 103,
"line_number": 129,
"type": "Hex High Entropy String",
"verified_result": null
},
{
"hashed_secret": "62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
"is_secret": false,
"is_verified": false,
"line_number": 165,
"line_number": 191,
"type": "Secret Keyword",
"verified_result": null
}
Expand Down Expand Up @@ -444,13 +460,21 @@
"hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6",
"is_secret": false,
"is_verified": false,
"line_number": 28,
"line_number": 29,
"type": "JSON Web Token",
"verified_result": null
},
{
"hashed_secret": "f06e1073ca9afdd800a2cf27f944d06530b5b755",
"is_secret": false,
"is_verified": false,
"line_number": 30,
"type": "JSON Web Token",
"verified_result": null
}
]
},
"version": "0.13.1+ibm.61.dss",
"version": "0.13.1+ibm.62.dss",
"word_list": {
"file": null,
"hash": null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# coding: utf-8

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

DEFAULT_IAM_URL = 'https://iam.cloud.ibm.com'
OPERATION_PATH = "/identity/token"
IAM_EXPIRATION_WINDOW = 10

def __init__(
self,
Expand Down Expand Up @@ -167,3 +168,19 @@ def set_scope(self, value: str) -> None:
value: A space seperated string that makes up the scope parameter.
"""
self.scope = value

def _is_token_expired(self) -> bool:
"""
Returns true iff the current cached token is expired.
We'll consider an access token as expired when we reach its IAM server-reported expiration time
minus our expiration window (10 secs).
We do this to avoid using an access token that might expire in the middle of a long-running transaction
within an IBM Cloud service.
Returns
-------
bool
True if token is expired; False otherwise
"""
current_time = self._get_current_time()
return current_time >= (self.expire_time - self.IAM_EXPIRATION_WINDOW)
19 changes: 18 additions & 1 deletion ibm_cloud_sdk_core/token_managers/vpc_instance_token_manager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# coding: utf-8

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

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

return response['access_token']

def _is_token_expired(self) -> bool:
"""
Returns true iff the current cached token is expired.
We'll consider an access token as expired when we reach its IAM server-reported expiration time
minus our expiration window (10 secs).
We do this to avoid using an access token that might expire in the middle of a long-running transaction
within an IBM Cloud service.
Returns
-------
bool
True if token is expired; False otherwise
"""
current_time = self._get_current_time()
return current_time >= (self.expire_time - self.IAM_EXPIRATION_WINDOW)
49 changes: 37 additions & 12 deletions test/test_container_token_manager.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# coding: utf-8

# Copyright 2021, 2024 IBM All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# pylint: disable=missing-docstring
import json
import os
Expand All @@ -17,6 +33,7 @@
MOCK_IAM_PROFILE_NAME = 'iam-user-123'
MOCK_CLIENT_ID = 'client-id-1'
MOCK_CLIENT_SECRET = 'client-secret-1'
EXPIRATION_WINDOW = 10

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

Expand Down Expand Up @@ -169,18 +186,22 @@ def test_get_token_success():
assert access_token == TEST_ACCESS_TOKEN_1
assert token_manager.access_token == TEST_ACCESS_TOKEN_1

# Verify the token manager return the cached value.
# Before we call the `get_token` again, set the expiration and time.
# This is necessary because we are using a fix JWT response.
token_manager.expire_time = _get_current_time() + 3600
token_manager.refresh_time = _get_current_time() + 3600
# Verify that the token manager returns the cached value.
# Before we call `get_token` again, set the expiration and refresh time
# so that we do not fetch a new access token.
# This is necessary because we are using a fixed JWT response.
token_manager.expire_time = _get_current_time() + 1000
token_manager.refresh_time = _get_current_time() + 1000
token_manager.set_scope('send-second-token')
access_token = token_manager.get_token()
assert access_token == TEST_ACCESS_TOKEN_1
assert token_manager.access_token == TEST_ACCESS_TOKEN_1

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

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

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

Expand Down
82 changes: 82 additions & 0 deletions test/test_iam_token_manager.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# coding: utf-8

# Copyright 2021, 2024 IBM All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# pylint: disable=missing-docstring
import os
import time
Expand All @@ -8,6 +24,16 @@

from ibm_cloud_sdk_core import IAMTokenManager, ApiException, get_authenticator_from_environment

# pylint: disable=line-too-long
TEST_ACCESS_TOKEN_1 = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImhlbGxvIiwicm9sZSI6InVzZXIiLCJwZXJtaXNzaW9ucyI6WyJhZG1pbmlzdHJhdG9yIiwiZGVwbG95bWVudF9hZG1pbiJdLCJzdWIiOiJoZWxsbyIsImlzcyI6IkpvaG4iLCJhdWQiOiJEU1giLCJ1aWQiOiI5OTkiLCJpYXQiOjE1NjAyNzcwNTEsImV4cCI6MTU2MDI4MTgxOSwianRpIjoiMDRkMjBiMjUtZWUyZC00MDBmLTg2MjMtOGNkODA3MGI1NDY4In0.cIodB4I6CCcX8vfIImz7Cytux3GpWyObt9Gkur5g1QI'
TEST_ACCESS_TOKEN_2 = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6IjIzMDQ5ODE1MWMyMTRiNzg4ZGQ5N2YyMmI4NTQxMGE1In0.eyJ1c2VybmFtZSI6ImR1bW15Iiwicm9sZSI6IkFkbWluIiwicGVybWlzc2lvbnMiOlsiYWRtaW5pc3RyYXRvciIsIm1hbmFnZV9jYXRhbG9nIl0sInN1YiI6ImFkbWluIiwiaXNzIjoic3NzIiwiYXVkIjoic3NzIiwidWlkIjoic3NzIiwiaWF0IjozNjAwLCJleHAiOjE2MjgwMDcwODF9.zvUDpgqWIWs7S1CuKv40ERw1IZ5FqSFqQXsrwZJyfRM'
TEST_REFRESH_TOKEN = 'Xj7Gle500MachEOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImhlbGxvIiwicm9sZSI6InVzZXIiLCJwZXJtaXNzaW9ucyI6WyJhZG1pbmlzdHJhdG9yIiwiZGVwbG95bWVudF9hZG1pbiJdLCJzdWIiOiJoZWxsbyIsImlzcyI6IkpvaG4iLCJhdWQiOiJEU1giLCJ1aWQiOiI5OTkiLCJpYXQiOjE1NjAyNzcwNTEsImV4cCI6MTU2MDI4MTgxOSwianRpIjoiMDRkMjBiMjUtZWUyZC00MDBmLTg2MjMtOGNkODA3MGI1NDY4In0.cIodB4I6CCcX8vfIImz7Cytux3GpWyObt9Gkur5g1QI'
EXPIRATION_WINDOW = 10


def _get_current_time() -> int:
return int(time.time())


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


@responses.activate
def test_get_token_success():
iam_url = "https://iam.cloud.ibm.com/identity/token"

# Create two mock responses with different access tokens.
response1 = """{
"access_token": "%s",
"token_type": "Bearer",
"expires_in": 3600,
"expiration": 1600003600,
"refresh_token": "jy4gl91BQ"
}""" % (
TEST_ACCESS_TOKEN_1
)
response2 = """{
"access_token": "%s",
"token_type": "Bearer",
"expires_in": 3600,
"expiration": 1600007200,
"refresh_token": "jy4gl91BQ"
}""" % (
TEST_ACCESS_TOKEN_2
)

token_manager = IAMTokenManager("iam_apikey")

access_token = token_manager.access_token
assert access_token is None

responses.add(responses.POST, url=iam_url, body=response1, status=200)
access_token = token_manager.get_token()
assert access_token == TEST_ACCESS_TOKEN_1
assert token_manager.access_token == TEST_ACCESS_TOKEN_1

# Verify that the token manager returns the cached value.
# Before we call `get_token` again, set the expiration and refresh time
# so that we do not fetch a new access token.
# This is necessary because we are using a fixed JWT response.
token_manager.expire_time = _get_current_time() + 1000
token_manager.refresh_time = _get_current_time() + 1000
access_token = token_manager.get_token()
assert access_token == TEST_ACCESS_TOKEN_1
assert token_manager.access_token == TEST_ACCESS_TOKEN_1

# Force expiration to get the second token.
# We'll set the expiration time to be current-time + EXPIRATION_WINDOW (10 secs)
# because we want the access token to be considered as "expired"
# when we reach the IAM-server reported expiration time minus 10 secs.
responses.add(responses.POST, url=iam_url, body=response2, status=200)
token_manager.expire_time = _get_current_time() + EXPIRATION_WINDOW
token_manager.refresh_time = _get_current_time() + 1000
access_token = token_manager.get_token()
assert access_token == TEST_ACCESS_TOKEN_2
assert token_manager.access_token == TEST_ACCESS_TOKEN_2


@responses.activate
def test_get_refresh_token():
iam_url = "https://iam.cloud.ibm.com/identity/token"
Expand Down
Loading

0 comments on commit f4f0b5a

Please sign in to comment.