Skip to content

Commit 1e47a80

Browse files
committed
fix: address code review findings for DPoP implementation
- Fix redundant get_dpop_error_message() call in oauth.py - Fix env var coercion for dpopEnabled/dpopKeyRotationInterval in config_validator - Fix test isolation bug: reset _access_token_dpop_warned in setUp - Fix request_executor timeout returning raw string instead of Exception - Use LOGGER_NAME constant consistently across cache, jwt modules - Narrow except clauses to specific exception types - Improve type hints, docstrings, and variable
1 parent 2c89e2c commit 1e47a80

26 files changed

Lines changed: 3254 additions & 963 deletions

okta/api_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ def __init__(
9595
"authorization_mode": configuration["client"].get("authorizationMode", "SSWS"),
9696
}
9797

98-
# Add DPoP parameters if enabled
98+
# Store DPoP parameters in Configuration for completeness.
99+
# NOTE: DPoP proof generation and header injection are handled
100+
# entirely by RequestExecutor → OAuth → DPoPProofGenerator.
101+
# The Configuration object stores these values but they are NOT
102+
# consumed by the synchronous urllib3/RESTClientObject path.
99103
if configuration["client"].get("dpopEnabled", False):
100104
config_params.update({
101105
"dpop_enabled": True,

okta/cache/no_op_cache.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ class NoOpCache(Cache):
1717
in the cache.
1818
Implementing the okta.cache.cache.Cache abstract class.
1919
20-
.. warning::
21-
**DPoP Performance Impact**: When using DPoP (Demonstrating Proof-of-Possession)
22-
authentication with NoOpCache, OAuth tokens will be regenerated on every request
23-
instead of being cached. This may significantly impact performance and could
24-
trigger rate limits. Consider using OktaCache instead for production DPoP usage.
20+
.. note::
21+
**DPoP with NoOpCache**: When using NoOpCache, the ``OKTA_ACCESS_TOKEN``
22+
cache key is not persisted between requests. However, the OAuth class
23+
maintains its own internal token state, so tokens are still reused
24+
across requests without hitting the authorization server each time.
25+
For production use with DPoP, enabling OktaCache provides an additional
26+
layer of caching that avoids redundant ``get_oauth_token()`` calls.
2527
"""
2628

2729
def __init__(self):
28-
super()
30+
super().__init__()
2931

3032
def get(self, key):
3133
"""

okta/cache/okta_cache.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
import time
1313

1414
from okta.cache.cache import Cache
15+
from okta.constants import LOGGER_NAME
1516

16-
logger = logging.getLogger("okta-sdk-python")
17+
logger = logging.getLogger(LOGGER_NAME)
1718

1819

1920
class OktaCache(Cache):
@@ -46,7 +47,7 @@ def __init__(self, ttl, tti):
4647
ttl {float} -- Time to Live: for cache entries
4748
tti {float} -- Time to Idle: for cache entries
4849
"""
49-
super() # Inherit from parent class
50+
super().__init__() # Inherit from parent class
5051
self._store = {} # key -> {value, TTI, TTL}
5152
self._time_to_live = ttl
5253
self._time_to_idle = tti
@@ -71,8 +72,7 @@ def get(self, key):
7172
entry["tti"] = now + self._time_to_idle
7273
# Return desired value and update cache
7374
self._clean_cache()
74-
logger.info(f'Got value from cache for key "{key}".')
75-
logger.debug(f'Cached value for key {key}: {entry["value"]}')
75+
logger.info('Got value from cache for key "%s".', key)
7676
return entry["value"]
7777

7878
# Return None if key isn't in cache and update cache
@@ -91,13 +91,14 @@ def contains(self, key):
9191
"""
9292
return key in self._store and self._is_valid_entry(self._store[key])
9393

94-
def add(self, key: str, value: tuple):
94+
def add(self, key: str, value):
9595
"""
9696
Adds a key-value pair to the cache.
9797
9898
Arguments:
9999
key {str} -- Key in pair
100-
value {tuple} -- Tuple of response and response body
100+
value -- Value to cache (e.g., tuple of response and body,
101+
or tuple of access_token and token_type)
101102
"""
102103
if isinstance(key, str) and (
103104
not isinstance(value, list) or not isinstance(value[1], list)
@@ -111,8 +112,7 @@ def add(self, key: str, value: tuple):
111112
"tti": now + self._time_to_idle,
112113
"ttl": now + self._time_to_live,
113114
}
114-
logger.info(f'Added to cache value for key "{key}".')
115-
logger.debug(f"Cached value for key {key}: {value}.")
115+
logger.info('Added to cache value for key "%s".', key)
116116
# Update cache
117117
self._clean_cache()
118118

@@ -127,7 +127,7 @@ def delete(self, key):
127127
if key in self._store:
128128
# Delete entry
129129
del self._store[key]
130-
logger.info(f'Removed value from cache for key "{key}".')
130+
logger.info('Removed value from cache for key "%s".', key)
131131

132132
def clear(self):
133133
"""

okta/config/config_setter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class ConfigSetter:
4141
"proxy": {"port": "", "host": "", "username": "", "password": ""},
4242
"rateLimit": {"maxRetries": ""},
4343
"oauthTokenRenewalOffset": "",
44+
"dpopEnabled": "",
45+
"dpopKeyRotationInterval": "",
4446
},
4547
"testing": {"testingDisableHttpsCheck": ""},
4648
}

okta/config/config_validator.py

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111
import logging
1212

13-
from okta.constants import FINDING_OKTA_DOMAIN, REPO_URL, MIN_DPOP_KEY_ROTATION_SECONDS, MAX_DPOP_KEY_ROTATION_SECONDS
13+
from okta.constants import (
14+
FINDING_OKTA_DOMAIN, LOGGER_NAME, REPO_URL,
15+
MIN_DPOP_KEY_ROTATION_SECONDS, MAX_DPOP_KEY_ROTATION_SECONDS,
16+
)
1417
from okta.error_messages import (
1518
ERROR_MESSAGE_ORG_URL_MISSING,
1619
ERROR_MESSAGE_API_TOKEN_DEFAULT,
@@ -28,7 +31,7 @@
2831
ERROR_MESSAGE_PROXY_INVALID_PORT,
2932
)
3033

31-
logger = logging.getLogger("okta-sdk-python")
34+
logger = logging.getLogger(LOGGER_NAME)
3235

3336

3437
class ConfigValidator:
@@ -55,7 +58,6 @@ def validate_config(self):
5558
errors = []
5659
# Defensive: default to empty dict if sections are missing
5760
client = self._config.get("client", {})
58-
_ = self._config.get("testing", {})
5961

6062
# check org url
6163
errors += self._validate_org_url(client.get("orgUrl", ""))
@@ -65,6 +67,14 @@ def validate_config(self):
6567
# check API details based on authorization mode
6668
if client.get("authorizationMode") in ("SSWS", "Bearer"):
6769
errors += self._validate_token(client.get("token", ""))
70+
# Warn if DPoP is enabled with a non-OAuth auth mode (it has no effect)
71+
if client.get("dpopEnabled", False):
72+
logger.warning(
73+
"dpopEnabled is True but authorizationMode is '%s'. "
74+
"DPoP only applies to 'PrivateKey' (OAuth) mode and will "
75+
"be ignored. Set authorizationMode to 'PrivateKey' to use DPoP.",
76+
client.get("authorizationMode"),
77+
)
6878
elif client.get("authorizationMode") == "PrivateKey":
6979
client_fields = [
7080
"clientId",
@@ -237,49 +247,89 @@ def _validate_dpop_config(self, client):
237247
"""
238248
Validate DPoP-specific configuration.
239249
250+
Coerces string values from environment variables to their expected
251+
types (bool for ``dpopEnabled``, int for ``dpopKeyRotationInterval``)
252+
before validation. The coerced values are written back into *client*
253+
so that downstream code receives the correct Python types.
254+
240255
Note: This method is only called when authorizationMode is 'PrivateKey',
241256
so no need to re-check the auth mode here.
242257
243258
Args:
244-
client: Client configuration dict
259+
client (dict): Client configuration dict (mutated in-place for
260+
type coercion of string values from environment variables).
245261
246262
Returns:
247263
list: List of error messages (empty if valid)
248264
"""
249265

250266
errors = []
251267

252-
if not client.get('dpopEnabled'):
268+
dpop_enabled = client.get('dpopEnabled', False)
269+
270+
# Coerce string from env vars to bool (e.g. "true" → True)
271+
if isinstance(dpop_enabled, str):
272+
dpop_enabled = dpop_enabled.strip().lower() == 'true'
273+
client['dpopEnabled'] = dpop_enabled
274+
275+
# Validate dpopEnabled is a boolean
276+
if not isinstance(dpop_enabled, bool):
277+
errors.append(
278+
"dpopEnabled must be a boolean (True/False), "
279+
f"but got {type(dpop_enabled).__name__}: {dpop_enabled!r}"
280+
)
281+
return errors # Cannot validate further if type is wrong
282+
283+
if not dpop_enabled:
253284
return errors # DPoP not enabled, nothing to validate
254285

255286
# Validate key rotation interval
256287
rotation_interval = client.get('dpopKeyRotationInterval', 86400)
257288

289+
# Coerce string from env vars to int (e.g. "86400" → 86400)
290+
if isinstance(rotation_interval, str):
291+
try:
292+
rotation_interval = int(rotation_interval)
293+
client['dpopKeyRotationInterval'] = rotation_interval
294+
except ValueError:
295+
errors.append(
296+
"dpopKeyRotationInterval must be a valid integer "
297+
f"(seconds), but got non-numeric string: "
298+
f"{rotation_interval!r}"
299+
)
300+
return errors
301+
258302
if not isinstance(rotation_interval, int):
259303
errors.append(
260-
f"dpopKeyRotationInterval must be an integer (seconds), "
304+
"dpopKeyRotationInterval must be an integer (seconds), "
261305
f"but got {type(rotation_interval).__name__}"
262306
)
263-
elif rotation_interval < MIN_DPOP_KEY_ROTATION_SECONDS: # Minimum 1 hour
307+
elif rotation_interval < MIN_DPOP_KEY_ROTATION_SECONDS:
264308
errors.append(
265-
f"dpopKeyRotationInterval must be at least {MIN_DPOP_KEY_ROTATION_SECONDS} seconds (1 hour), "
309+
"dpopKeyRotationInterval must be at least "
310+
f"{MIN_DPOP_KEY_ROTATION_SECONDS} seconds (1 hour), "
266311
f"but got {rotation_interval} seconds. "
267312
"Shorter intervals may cause performance issues."
268313
)
269-
elif rotation_interval > MAX_DPOP_KEY_ROTATION_SECONDS: # Maximum 90 days
314+
elif rotation_interval > MAX_DPOP_KEY_ROTATION_SECONDS:
315+
max_days = MAX_DPOP_KEY_ROTATION_SECONDS // 86400
316+
given_days = rotation_interval // 86400
270317
errors.append(
271-
f"dpopKeyRotationInterval must be at most {MAX_DPOP_KEY_ROTATION_SECONDS} seconds "
272-
f"({MAX_DPOP_KEY_ROTATION_SECONDS // 86400} days), "
273-
f"but got {rotation_interval} seconds ({rotation_interval // 86400} days). "
274-
"Excessive rotation intervals defeat the security purpose of DPoP. "
318+
"dpopKeyRotationInterval must be at most "
319+
f"{MAX_DPOP_KEY_ROTATION_SECONDS} seconds "
320+
f"({max_days} days), but got {rotation_interval} "
321+
f"seconds ({given_days} days). "
322+
"Excessive rotation intervals defeat the security "
323+
"purpose of DPoP. "
275324
"Recommended: 24-48 hours for production use."
276325
)
277326
elif rotation_interval > 7 * 24 * 3600: # Warning for > 7 days
278327
# This is a warning, not an error
279328
logger.warning(
280-
f"dpopKeyRotationInterval is very long ({rotation_interval} seconds, "
281-
f"{rotation_interval // 86400} days). "
282-
"Consider shorter intervals (24-48 hours) for better security."
329+
"dpopKeyRotationInterval is very long (%d seconds, "
330+
"%d days). Consider shorter intervals (24-48 hours) "
331+
"for better security.",
332+
rotation_interval, rotation_interval // 86400,
283333
)
284334

285335
return errors

okta/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@
3535
MAX_DPOP_NONCE_RETRIES = 2
3636
MAX_DPOP_BACKOFF_DELAY = 1.0 # Maximum backoff delay in seconds for nonce retries
3737
DPOP_USER_AGENT_EXTENSION = "isDPoP:true"
38+
39+
# SDK-wide logger name — single source of truth for all hand-written modules
40+
LOGGER_NAME = "okta-sdk-python"

0 commit comments

Comments
 (0)