Skip to content

Commit f75f82c

Browse files
Merge pull request #445 from okta/OKTA-641384
Expire and renew the access token when using OAuth 2.0
2 parents 2f0b9c9 + cc761dc commit f75f82c

File tree

8 files changed

+149
-6
lines changed

8 files changed

+149
-6
lines changed

okta/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def __init__(self, user_config: dict = {}):
116116
client_config_setter = ConfigSetter()
117117
client_config_setter._apply_config({'client': user_config})
118118
self._config = client_config_setter.get_config()
119-
# Prune configuration to remove unnecesary fields
119+
# Prune configuration to remove unnecessary fields
120120
self._config = client_config_setter._prune_config(self._config)
121121
# Validate configuration
122122
ConfigValidator(self._config)
@@ -128,6 +128,7 @@ def __init__(self, user_config: dict = {}):
128128
self._client_id = None
129129
self._scopes = None
130130
self._private_key = None
131+
self._oauth_token_renewal_offset = None
131132

132133
# Determine which cache to use
133134
cache = NoOpCache()
@@ -154,6 +155,7 @@ def __init__(self, user_config: dict = {}):
154155
self._client_id = self._config["client"]["clientId"]
155156
self._scopes = self._config["client"]["scopes"]
156157
self._private_key = self._config["client"]["privateKey"]
158+
self._oauth_token_renewal_offset = self._config["client"]["oauthTokenRenewalOffset"]
157159

158160
setup_logging(log_level=self._config["client"]["logging"]["logLevel"])
159161
# Check if logging should be enabled

okta/config/config_setter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ class ConfigSetter():
3737
},
3838
"rateLimit": {
3939
"maxRetries": ''
40-
}
40+
},
41+
"oauthTokenRenewalOffset": ''
4142
},
4243
"testing": {
4344
"testingDisableHttpsCheck": ''
@@ -116,6 +117,7 @@ def _apply_default_values(self):
116117
self._config["client"]["rateLimit"] = {
117118
"maxRetries": 2
118119
}
120+
self._config["client"]["oauthTokenRenewalOffset"] = 5
119121

120122
self._config["testing"]["testingDisableHttpsCheck"] = False
121123

okta/config/config_validator.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,8 @@ def validate_config(self):
4545
self._validate_token(
4646
client.get('token', ""))
4747
elif client.get('authorizationMode') == "PrivateKey":
48-
client_fields = ['clientId', 'scopes', 'privateKey']
49-
client_fields_values = [self._config.get(
50-
'client').get(field, "") for field in client_fields]
48+
client_fields = ['clientId', 'scopes', 'privateKey', 'oauthTokenRenewalOffset']
49+
client_fields_values = [client.get(field, "") for field in client_fields]
5150
errors += self._validate_client_fields(*client_fields_values)
5251
else: # Not a valid authorization mode
5352
errors += [
@@ -61,7 +60,7 @@ def validate_config(self):
6160
f"See {REPO_URL} for usage")
6261

6362
def _validate_client_fields(self, client_id, client_scopes,
64-
client_private_key):
63+
client_private_key, oauth_token_renewal_offset):
6564
client_fields_errors = []
6665

6766
# check client id
@@ -77,6 +76,14 @@ def _validate_client_fields(self, client_id, client_scopes,
7776
if not (client_scopes and client_private_key):
7877
client_fields_errors.append(ERROR_MESSAGE_SCOPES_PK_MISSING)
7978

79+
# Validate oauthTokenRenewalOffset
80+
if not oauth_token_renewal_offset:
81+
client_fields_errors.append("oauthTokenRenewalOffset must be provided")
82+
if not isinstance(oauth_token_renewal_offset, int):
83+
client_fields_errors.append("oauthTokenRenewalOffset must be a valid integer")
84+
if isinstance(oauth_token_renewal_offset, int) and oauth_token_renewal_offset < 0:
85+
client_fields_errors.append("oauthTokenRenewalOffset must be a non-negative integer")
86+
8087
return client_fields_errors
8188

8289
def _validate_token(self, token: str):

okta/oauth.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import time
12
from urllib.parse import urlencode, quote
23
from okta.jwt import JWT
34
from okta.http_client import HTTPClient
@@ -37,6 +38,14 @@ async def get_access_token(self):
3738
str, Exception: Tuple of the access token, error that was raised
3839
(if any)
3940
"""
41+
42+
# Check if access token has expired or will expire soon
43+
current_time = int(time.time())
44+
if self._access_token and hasattr(self, '_access_token_expiry_time'):
45+
renewal_offset = self._config["client"]["oauthTokenRenewalOffset"] * 60 # Convert minutes to seconds
46+
if current_time + renewal_offset >= self._access_token_expiry_time:
47+
self.clear_access_token()
48+
4049
# Return token if already generated
4150
if self._access_token:
4251
return (self._access_token, None)
@@ -82,6 +91,9 @@ async def get_access_token(self):
8291

8392
# Otherwise set token and return it
8493
self._access_token = parsed_response["access_token"]
94+
95+
# Set token expiry time
96+
self._access_token_expiry_time = int(time.time()) + parsed_response["expires_in"]
8597
return (self._access_token, None)
8698

8799
def clear_access_token(self):
@@ -91,3 +103,4 @@ def clear_access_token(self):
91103
self._access_token = None
92104
self._request_executor._cache.delete("OKTA_ACCESS_TOKEN")
93105
self._request_executor._default_headers.pop("Authorization", None)
106+
self._access_token_expiry_time = None

tests/unit/test_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,3 +914,17 @@ async def test_client_session(mocker):
914914
config = {'orgUrl': org_url, 'token': token}
915915
async with OktaClient(config) as client:
916916
assert isinstance(client._request_executor._http_client._session, aiohttp.ClientSession)
917+
918+
919+
def test_client_initialization():
920+
config = {
921+
"orgUrl": "https://dev-1dq2.okta.com/oauth2/default",
922+
"authorizationMode": "PrivateKey",
923+
"clientId": "valid-client-id",
924+
"scopes": ["scope1", "scope2"],
925+
"privateKey": "valid-private-key",
926+
"token": "valid-token",
927+
}
928+
client = OktaClient(config)
929+
assert client._config["client"]["orgUrl"] == "https://dev-1dq2.okta.com/oauth2/default"
930+
assert client._config["client"]["clientId"] == "valid-client-id"

tests/unit/test_config_setter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from okta.config.config_setter import ConfigSetter
2+
3+
"""
4+
Testing Config Setter
5+
"""
6+
7+
def test_env_variable_application(monkeypatch):
8+
config_setter = ConfigSetter()
9+
config_setter._apply_default_values()
10+
11+
assert config_setter._config["client"]["oauthTokenRenewalOffset"] == 5
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import pytest
2+
from okta.config.config_validator import ConfigValidator
3+
4+
"""
5+
Testing Config Validator
6+
"""
7+
8+
def test_validate_config_valid():
9+
config = {
10+
"client": {
11+
"orgUrl": "https://example.okta.com",
12+
"authorizationMode": "PrivateKey",
13+
"clientId": "valid-client-id",
14+
"scopes": ["scope1", "scope2"],
15+
"privateKey": "valid-private-key",
16+
"oauthTokenRenewalOffset": 5
17+
},
18+
"testing": {
19+
"testingDisableHttpsCheck": False
20+
}
21+
}
22+
validator = ConfigValidator(config)
23+
assert validator.validate_config() is None
24+
25+
def test_validate_config_invalid_org_url():
26+
config = {
27+
"client": {
28+
"orgUrl": "http://example.okta.com",
29+
"authorizationMode": "PrivateKey",
30+
"clientId": "valid-client-id",
31+
"scopes": ["scope1", "scope2"],
32+
"privateKey": "valid-private-key",
33+
"oauthTokenRenewalOffset": 5
34+
},
35+
"testing": {
36+
"testingDisableHttpsCheck": False
37+
}
38+
}
39+
with pytest.raises(ValueError) as excinfo:
40+
ConfigValidator(config)
41+
assert "must start with 'https'." in str(excinfo.value)

tests/unit/test_oauth.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import tests.mocks as mocks
33
import os
44
import pytest
5+
from unittest.mock import AsyncMock, MagicMock
6+
from okta.oauth import OAuth
57

68
"""
79
Testing Private Key Inputs
@@ -39,3 +41,54 @@ def test_private_key_PEM_JWK_explicit_string():
3941
def test_invalid_private_key_PEM_JWK(private_key):
4042
with pytest.raises(ValueError):
4143
generated_pem, generated_jwk = JWT.get_PEM_JWK(private_key)
44+
45+
46+
@pytest.mark.asyncio
47+
async def test_get_access_token():
48+
mock_request_executor = MagicMock()
49+
mock_request_executor.create_request = AsyncMock(return_value=({"mock_request": "data"}, None))
50+
mock_response_details = MagicMock()
51+
mock_response_details.content_type = "application/json"
52+
mock_response_details.status = 200
53+
mock_request_executor.fire_request = AsyncMock(
54+
return_value=(None, mock_response_details, '{"access_token": "mock_token", "expires_in": 3600}', None))
55+
56+
config = {
57+
"client": {
58+
"orgUrl": "https://example.okta.com",
59+
"clientId": "valid-client-id",
60+
"privateKey": mocks.SAMPLE_RSA,
61+
"scopes": ["scope1", "scope2"],
62+
"oauthTokenRenewalOffset": 5
63+
}
64+
}
65+
oauth = OAuth(mock_request_executor, config)
66+
token, error = await oauth.get_access_token()
67+
68+
assert token == "mock_token"
69+
assert error is None
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_clear_access_token():
74+
mock_request_executor = MagicMock()
75+
mock_request_executor._cache = MagicMock()
76+
mock_request_executor._default_headers = {}
77+
78+
config = {
79+
"client": {
80+
"orgUrl": "https://example.okta.com",
81+
"clientId": "valid-client-id",
82+
"privateKey": "valid-private-key",
83+
"scopes": ["scope1", "scope2"],
84+
"oauthTokenRenewalOffset": 5
85+
}
86+
}
87+
oauth = OAuth(mock_request_executor, config)
88+
oauth._access_token = "mock_token"
89+
oauth._access_token_expiry_time = 1234567890
90+
91+
oauth.clear_access_token()
92+
93+
assert oauth._access_token is None
94+
assert oauth._access_token_expiry_time is None

0 commit comments

Comments
 (0)