Skip to content

Commit 5344e3d

Browse files
authored
Merge pull request #105 from Colin-b/bugfix/client_cred_cache
Use credentials as well to distinguish client credentials tokens
2 parents 01f3646 + 0fef87e commit 5344e3d

File tree

6 files changed

+389
-13
lines changed

6 files changed

+389
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Fixed
99
- Bearer tokens with nested JSON string are now properly handled. Thanks to [`Patrick Rodrigues`](https://github.com/pythrick).
10+
- Client credentials auth instances will now use credentials (client_id and client_secret) as well to distinguish tokens. This was an issue when the only parameters changing were the credentials.
1011

1112
### Changed
1213
- Requires [`httpx`](https://www.python-httpx.org)==0.28.\*

httpx_auth/_oauth2/client_credentials.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
from hashlib import sha512
23
from typing import Union, Iterable
34

@@ -67,7 +68,10 @@ def __init__(self, token_url: str, client_id: str, client_secret: str, **kwargs)
6768
self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope
6869
self.data.update(kwargs)
6970

70-
all_parameters_in_url = _add_parameters(self.token_url, self.data)
71+
cache_data = copy.deepcopy(self.data)
72+
cache_data["_httpx_auth_client_id"] = self.client_id
73+
cache_data["_httpx_auth_client_secret"] = self.client_secret
74+
all_parameters_in_url = _add_parameters(self.token_url, cache_data)
7175
state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest()
7276

7377
super().__init__(

tests/oauth2/client_credential/okta/test_oauth2_client_credential_okta_async.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async def test_okta_client_credentials_flow_token_is_expired_after_30_seconds_by
8585
)
8686
# Add a token that expires in 29 seconds, so should be considered as expired when issuing the request
8787
token_cache._add_token(
88-
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
88+
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
8989
token="2YotnFZFEjr1zCsicMWpAA",
9090
expiry=to_expiry(expires_in=29),
9191
)
@@ -127,7 +127,7 @@ async def test_okta_client_credentials_flow_token_custom_expiry(
127127
)
128128
# Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request
129129
token_cache._add_token(
130-
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
130+
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
131131
token="2YotnFZFEjr1zCsicMWpAA",
132132
expiry=to_expiry(expires_in=29),
133133
)
@@ -170,3 +170,94 @@ async def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock):
170170

171171
async with httpx.AsyncClient() as client:
172172
await client.get("https://authorized_only", auth=auth)
173+
174+
175+
@pytest.mark.parametrize(
176+
"client_id1, client_secret1, client_id2, client_secret2",
177+
[
178+
# Use the same client secret but for different client ids (different application)
179+
("user1", "test_pwd", "user2", "test_pwd"),
180+
# Use the same client id but with different client secrets (update of secret)
181+
("test_user", "old_pwd", "test_user", "new_pwd"),
182+
],
183+
)
184+
@pytest.mark.asyncio
185+
async def test_handle_credentials_as_part_of_cache_key(
186+
token_cache,
187+
httpx_mock: HTTPXMock,
188+
client_id1,
189+
client_secret1,
190+
client_id2,
191+
client_secret2,
192+
):
193+
auth1 = httpx_auth.OktaClientCredentials(
194+
"test_okta", client_id=client_id1, client_secret=client_secret1, scope="dummy"
195+
)
196+
auth2 = httpx_auth.OktaClientCredentials(
197+
"test_okta", client_id=client_id2, client_secret=client_secret2, scope="dummy"
198+
)
199+
httpx_mock.add_response(
200+
method="POST",
201+
url="https://test_okta/oauth2/default/v1/token",
202+
json={
203+
"access_token": "2YotnFZFEjr1zCsicMWpAA",
204+
"token_type": "example",
205+
"expires_in": 3600,
206+
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
207+
"example_parameter": "example_value",
208+
},
209+
match_content=b"grant_type=client_credentials&scope=dummy",
210+
)
211+
httpx_mock.add_response(
212+
url="https://authorized_only",
213+
method="GET",
214+
match_headers={
215+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
216+
},
217+
)
218+
219+
async with httpx.AsyncClient() as client:
220+
await client.get("https://authorized_only", auth=auth1)
221+
222+
httpx_mock.add_response(
223+
method="POST",
224+
url="https://test_okta/oauth2/default/v1/token",
225+
json={
226+
"access_token": "2YotnFZFEjr1zCsicMWpAB",
227+
"token_type": "example",
228+
"expires_in": 3600,
229+
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIB",
230+
"example_parameter": "example_value",
231+
},
232+
match_content=b"grant_type=client_credentials&scope=dummy",
233+
)
234+
httpx_mock.add_response(
235+
url="https://authorized_only",
236+
method="GET",
237+
match_headers={
238+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
239+
},
240+
)
241+
242+
# This should request a new token (different credentials)
243+
async with httpx.AsyncClient() as client:
244+
await client.get("https://authorized_only", auth=auth2)
245+
246+
httpx_mock.add_response(
247+
url="https://authorized_only",
248+
method="GET",
249+
match_headers={
250+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
251+
},
252+
)
253+
httpx_mock.add_response(
254+
url="https://authorized_only",
255+
method="GET",
256+
match_headers={
257+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
258+
},
259+
)
260+
# Ensure the proper token is fetched
261+
async with httpx.AsyncClient() as client:
262+
await client.get("https://authorized_only", auth=auth1)
263+
await client.get("https://authorized_only", auth=auth2)

tests/oauth2/client_credential/okta/test_oauth2_client_credential_okta_sync.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from pytest_httpx import HTTPXMock
23
import httpx
34

@@ -80,7 +81,7 @@ def test_okta_client_credentials_flow_token_is_expired_after_30_seconds_by_defau
8081
)
8182
# Add a token that expires in 29 seconds, so should be considered as expired when issuing the request
8283
token_cache._add_token(
83-
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
84+
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
8485
token="2YotnFZFEjr1zCsicMWpAA",
8586
expiry=to_expiry(expires_in=29),
8687
)
@@ -121,7 +122,7 @@ def test_okta_client_credentials_flow_token_custom_expiry(
121122
)
122123
# Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request
123124
token_cache._add_token(
124-
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
125+
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
125126
token="2YotnFZFEjr1zCsicMWpAA",
126127
expiry=to_expiry(expires_in=29),
127128
)
@@ -163,3 +164,93 @@ def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock):
163164

164165
with httpx.Client() as client:
165166
client.get("https://authorized_only", auth=auth)
167+
168+
169+
@pytest.mark.parametrize(
170+
"client_id1, client_secret1, client_id2, client_secret2",
171+
[
172+
# Use the same client secret but for different client ids (different application)
173+
("user1", "test_pwd", "user2", "test_pwd"),
174+
# Use the same client id but with different client secrets (update of secret)
175+
("test_user", "old_pwd", "test_user", "new_pwd"),
176+
],
177+
)
178+
def test_handle_credentials_as_part_of_cache_key(
179+
token_cache,
180+
httpx_mock: HTTPXMock,
181+
client_id1,
182+
client_secret1,
183+
client_id2,
184+
client_secret2,
185+
):
186+
auth1 = httpx_auth.OktaClientCredentials(
187+
"test_okta", client_id=client_id1, client_secret=client_secret1, scope="dummy"
188+
)
189+
auth2 = httpx_auth.OktaClientCredentials(
190+
"test_okta", client_id=client_id2, client_secret=client_secret2, scope="dummy"
191+
)
192+
httpx_mock.add_response(
193+
method="POST",
194+
url="https://test_okta/oauth2/default/v1/token",
195+
json={
196+
"access_token": "2YotnFZFEjr1zCsicMWpAA",
197+
"token_type": "example",
198+
"expires_in": 3600,
199+
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
200+
"example_parameter": "example_value",
201+
},
202+
match_content=b"grant_type=client_credentials&scope=dummy",
203+
)
204+
httpx_mock.add_response(
205+
url="https://authorized_only",
206+
method="GET",
207+
match_headers={
208+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
209+
},
210+
)
211+
212+
with httpx.Client() as client:
213+
client.get("https://authorized_only", auth=auth1)
214+
215+
httpx_mock.add_response(
216+
method="POST",
217+
url="https://test_okta/oauth2/default/v1/token",
218+
json={
219+
"access_token": "2YotnFZFEjr1zCsicMWpAB",
220+
"token_type": "example",
221+
"expires_in": 3600,
222+
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIB",
223+
"example_parameter": "example_value",
224+
},
225+
match_content=b"grant_type=client_credentials&scope=dummy",
226+
)
227+
httpx_mock.add_response(
228+
url="https://authorized_only",
229+
method="GET",
230+
match_headers={
231+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
232+
},
233+
)
234+
235+
# This should request a new token (different credentials)
236+
with httpx.Client() as client:
237+
client.get("https://authorized_only", auth=auth2)
238+
239+
httpx_mock.add_response(
240+
url="https://authorized_only",
241+
method="GET",
242+
match_headers={
243+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
244+
},
245+
)
246+
httpx_mock.add_response(
247+
url="https://authorized_only",
248+
method="GET",
249+
match_headers={
250+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
251+
},
252+
)
253+
# Ensure the proper token is fetched
254+
with httpx.Client() as client:
255+
client.get("https://authorized_only", auth=auth1)
256+
client.get("https://authorized_only", auth=auth2)

tests/oauth2/client_credential/test_oauth2_client_credential_async.py

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async def test_oauth2_client_credentials_flow_is_able_to_reuse_client(
6464
json={
6565
"access_token": "2YotnFZFEjr1zCsicMWpAA",
6666
"token_type": "example",
67-
"expires_in": 10,
67+
"expires_in": 2,
6868
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
6969
"example_parameter": "example_value",
7070
},
@@ -82,7 +82,7 @@ async def test_oauth2_client_credentials_flow_is_able_to_reuse_client(
8282
async with httpx.AsyncClient() as client:
8383
await client.get("https://authorized_only", auth=auth)
8484

85-
time.sleep(10)
85+
time.sleep(2)
8686

8787
httpx_mock.add_response(
8888
method="POST",
@@ -148,7 +148,7 @@ async def test_oauth2_client_credentials_flow_token_is_expired_after_30_seconds_
148148
)
149149
# Add a token that expires in 29 seconds, so should be considered as expired when issuing the request
150150
token_cache._add_token(
151-
key="76c85306ab93a2db901b2c7add8eaf607fe803c60b24914a1799bdb7cc861b6ef96386025b5a1b97681b557ab761c6fa4040d4731d6f238d3c2b19b0e2ad7344",
151+
key="fcd9be12271843a292d3c87c6051ea3dd54ee66d4938d15ebda9c7492d51fe555064fa9f787d0fb207a76558ae33e57ac11cb7aee668d665db9c6c1d60c5c314",
152152
token="2YotnFZFEjr1zCsicMWpAA",
153153
expiry=to_expiry(expires_in=29),
154154
)
@@ -189,7 +189,7 @@ async def test_oauth2_client_credentials_flow_token_custom_expiry(
189189
)
190190
# Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request
191191
token_cache._add_token(
192-
key="76c85306ab93a2db901b2c7add8eaf607fe803c60b24914a1799bdb7cc861b6ef96386025b5a1b97681b557ab761c6fa4040d4731d6f238d3c2b19b0e2ad7344",
192+
key="fcd9be12271843a292d3c87c6051ea3dd54ee66d4938d15ebda9c7492d51fe555064fa9f787d0fb207a76558ae33e57ac11cb7aee668d665db9c6c1d60c5c314",
193193
token="2YotnFZFEjr1zCsicMWpAA",
194194
expiry=to_expiry(expires_in=29),
195195
)
@@ -518,3 +518,98 @@ async def test_with_invalid_grant_request_invalid_scope_error(
518518
== "invalid_scope: The requested scope is invalid, unknown, malformed, or "
519519
"exceeds the scope granted by the resource owner."
520520
)
521+
522+
523+
@pytest.mark.parametrize(
524+
"client_id1, client_secret1, client_id2, client_secret2",
525+
[
526+
# Use the same client secret but for different client ids (different application)
527+
("user1", "test_pwd", "user2", "test_pwd"),
528+
# Use the same client id but with different client secrets (update of secret)
529+
("test_user", "old_pwd", "test_user", "new_pwd"),
530+
],
531+
)
532+
@pytest.mark.asyncio
533+
async def test_oauth2_client_credentials_flow_handle_credentials_as_part_of_cache_key(
534+
token_cache,
535+
httpx_mock: HTTPXMock,
536+
client_id1,
537+
client_secret1,
538+
client_id2,
539+
client_secret2,
540+
):
541+
auth1 = httpx_auth.OAuth2ClientCredentials(
542+
"https://provide_access_token",
543+
client_id=client_id1,
544+
client_secret=client_secret1,
545+
)
546+
auth2 = httpx_auth.OAuth2ClientCredentials(
547+
"https://provide_access_token",
548+
client_id=client_id2,
549+
client_secret=client_secret2,
550+
)
551+
httpx_mock.add_response(
552+
method="POST",
553+
url="https://provide_access_token",
554+
json={
555+
"access_token": "2YotnFZFEjr1zCsicMWpAA",
556+
"token_type": "example",
557+
"expires_in": 3600,
558+
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
559+
"example_parameter": "example_value",
560+
},
561+
match_content=b"grant_type=client_credentials",
562+
)
563+
httpx_mock.add_response(
564+
url="https://authorized_only",
565+
method="GET",
566+
match_headers={
567+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
568+
},
569+
)
570+
571+
async with httpx.AsyncClient() as client:
572+
await client.get("https://authorized_only", auth=auth1)
573+
574+
httpx_mock.add_response(
575+
method="POST",
576+
url="https://provide_access_token",
577+
json={
578+
"access_token": "2YotnFZFEjr1zCsicMWpAB",
579+
"token_type": "example",
580+
"expires_in": 3600,
581+
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIB",
582+
"example_parameter": "example_value",
583+
},
584+
match_content=b"grant_type=client_credentials",
585+
)
586+
httpx_mock.add_response(
587+
url="https://authorized_only",
588+
method="GET",
589+
match_headers={
590+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
591+
},
592+
)
593+
594+
# This should request a new token (different credentials)
595+
async with httpx.AsyncClient() as client:
596+
await client.get("https://authorized_only", auth=auth2)
597+
598+
httpx_mock.add_response(
599+
url="https://authorized_only",
600+
method="GET",
601+
match_headers={
602+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
603+
},
604+
)
605+
httpx_mock.add_response(
606+
url="https://authorized_only",
607+
method="GET",
608+
match_headers={
609+
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
610+
},
611+
)
612+
# Ensure the proper token is fetched
613+
async with httpx.AsyncClient() as client:
614+
await client.get("https://authorized_only", auth=auth1)
615+
await client.get("https://authorized_only", auth=auth2)

0 commit comments

Comments
 (0)