Skip to content

Commit

Permalink
Use credentials as well to distinguish client credentials tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
Colin-b committed Jan 7, 2025
1 parent 01f3646 commit 0fef87e
Show file tree
Hide file tree
Showing 6 changed files with 389 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Bearer tokens with nested JSON string are now properly handled. Thanks to [`Patrick Rodrigues`](https://github.com/pythrick).
- 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.

### Changed
- Requires [`httpx`](https://www.python-httpx.org)==0.28.\*
Expand Down
6 changes: 5 additions & 1 deletion httpx_auth/_oauth2/client_credentials.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
from hashlib import sha512
from typing import Union, Iterable

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

all_parameters_in_url = _add_parameters(self.token_url, self.data)
cache_data = copy.deepcopy(self.data)
cache_data["_httpx_auth_client_id"] = self.client_id
cache_data["_httpx_auth_client_secret"] = self.client_secret
all_parameters_in_url = _add_parameters(self.token_url, cache_data)
state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest()

super().__init__(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def test_okta_client_credentials_flow_token_is_expired_after_30_seconds_by
)
# Add a token that expires in 29 seconds, so should be considered as expired when issuing the request
token_cache._add_token(
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
token="2YotnFZFEjr1zCsicMWpAA",
expiry=to_expiry(expires_in=29),
)
Expand Down Expand Up @@ -127,7 +127,7 @@ async def test_okta_client_credentials_flow_token_custom_expiry(
)
# Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request
token_cache._add_token(
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
token="2YotnFZFEjr1zCsicMWpAA",
expiry=to_expiry(expires_in=29),
)
Expand Down Expand Up @@ -170,3 +170,94 @@ async def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock):

async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth)


@pytest.mark.parametrize(
"client_id1, client_secret1, client_id2, client_secret2",
[
# Use the same client secret but for different client ids (different application)
("user1", "test_pwd", "user2", "test_pwd"),
# Use the same client id but with different client secrets (update of secret)
("test_user", "old_pwd", "test_user", "new_pwd"),
],
)
@pytest.mark.asyncio
async def test_handle_credentials_as_part_of_cache_key(
token_cache,
httpx_mock: HTTPXMock,
client_id1,
client_secret1,
client_id2,
client_secret2,
):
auth1 = httpx_auth.OktaClientCredentials(
"test_okta", client_id=client_id1, client_secret=client_secret1, scope="dummy"
)
auth2 = httpx_auth.OktaClientCredentials(
"test_okta", client_id=client_id2, client_secret=client_secret2, scope="dummy"
)
httpx_mock.add_response(
method="POST",
url="https://test_okta/oauth2/default/v1/token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter": "example_value",
},
match_content=b"grant_type=client_credentials&scope=dummy",
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
},
)

async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth1)

httpx_mock.add_response(
method="POST",
url="https://test_okta/oauth2/default/v1/token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAB",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIB",
"example_parameter": "example_value",
},
match_content=b"grant_type=client_credentials&scope=dummy",
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
},
)

# This should request a new token (different credentials)
async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth2)

httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
},
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
},
)
# Ensure the proper token is fetched
async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth1)
await client.get("https://authorized_only", auth=auth2)
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from pytest_httpx import HTTPXMock
import httpx

Expand Down Expand Up @@ -80,7 +81,7 @@ def test_okta_client_credentials_flow_token_is_expired_after_30_seconds_by_defau
)
# Add a token that expires in 29 seconds, so should be considered as expired when issuing the request
token_cache._add_token(
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
token="2YotnFZFEjr1zCsicMWpAA",
expiry=to_expiry(expires_in=29),
)
Expand Down Expand Up @@ -121,7 +122,7 @@ def test_okta_client_credentials_flow_token_custom_expiry(
)
# Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request
token_cache._add_token(
key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb",
key="73cb07a6e48774ad335f5bae75e036d1df813a3c44ae186895eb6f956b9993ed83590871dddefbc2310b863cda3f414161bc7fcd4c4e5fefa582cba4f7de7ace",
token="2YotnFZFEjr1zCsicMWpAA",
expiry=to_expiry(expires_in=29),
)
Expand Down Expand Up @@ -163,3 +164,93 @@ def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock):

with httpx.Client() as client:
client.get("https://authorized_only", auth=auth)


@pytest.mark.parametrize(
"client_id1, client_secret1, client_id2, client_secret2",
[
# Use the same client secret but for different client ids (different application)
("user1", "test_pwd", "user2", "test_pwd"),
# Use the same client id but with different client secrets (update of secret)
("test_user", "old_pwd", "test_user", "new_pwd"),
],
)
def test_handle_credentials_as_part_of_cache_key(
token_cache,
httpx_mock: HTTPXMock,
client_id1,
client_secret1,
client_id2,
client_secret2,
):
auth1 = httpx_auth.OktaClientCredentials(
"test_okta", client_id=client_id1, client_secret=client_secret1, scope="dummy"
)
auth2 = httpx_auth.OktaClientCredentials(
"test_okta", client_id=client_id2, client_secret=client_secret2, scope="dummy"
)
httpx_mock.add_response(
method="POST",
url="https://test_okta/oauth2/default/v1/token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter": "example_value",
},
match_content=b"grant_type=client_credentials&scope=dummy",
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
},
)

with httpx.Client() as client:
client.get("https://authorized_only", auth=auth1)

httpx_mock.add_response(
method="POST",
url="https://test_okta/oauth2/default/v1/token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAB",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIB",
"example_parameter": "example_value",
},
match_content=b"grant_type=client_credentials&scope=dummy",
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
},
)

# This should request a new token (different credentials)
with httpx.Client() as client:
client.get("https://authorized_only", auth=auth2)

httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
},
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
},
)
# Ensure the proper token is fetched
with httpx.Client() as client:
client.get("https://authorized_only", auth=auth1)
client.get("https://authorized_only", auth=auth2)
103 changes: 99 additions & 4 deletions tests/oauth2/client_credential/test_oauth2_client_credential_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async def test_oauth2_client_credentials_flow_is_able_to_reuse_client(
json={
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "example",
"expires_in": 10,
"expires_in": 2,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter": "example_value",
},
Expand All @@ -82,7 +82,7 @@ async def test_oauth2_client_credentials_flow_is_able_to_reuse_client(
async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth)

time.sleep(10)
time.sleep(2)

httpx_mock.add_response(
method="POST",
Expand Down Expand Up @@ -148,7 +148,7 @@ async def test_oauth2_client_credentials_flow_token_is_expired_after_30_seconds_
)
# Add a token that expires in 29 seconds, so should be considered as expired when issuing the request
token_cache._add_token(
key="76c85306ab93a2db901b2c7add8eaf607fe803c60b24914a1799bdb7cc861b6ef96386025b5a1b97681b557ab761c6fa4040d4731d6f238d3c2b19b0e2ad7344",
key="fcd9be12271843a292d3c87c6051ea3dd54ee66d4938d15ebda9c7492d51fe555064fa9f787d0fb207a76558ae33e57ac11cb7aee668d665db9c6c1d60c5c314",
token="2YotnFZFEjr1zCsicMWpAA",
expiry=to_expiry(expires_in=29),
)
Expand Down Expand Up @@ -189,7 +189,7 @@ async def test_oauth2_client_credentials_flow_token_custom_expiry(
)
# Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request
token_cache._add_token(
key="76c85306ab93a2db901b2c7add8eaf607fe803c60b24914a1799bdb7cc861b6ef96386025b5a1b97681b557ab761c6fa4040d4731d6f238d3c2b19b0e2ad7344",
key="fcd9be12271843a292d3c87c6051ea3dd54ee66d4938d15ebda9c7492d51fe555064fa9f787d0fb207a76558ae33e57ac11cb7aee668d665db9c6c1d60c5c314",
token="2YotnFZFEjr1zCsicMWpAA",
expiry=to_expiry(expires_in=29),
)
Expand Down Expand Up @@ -518,3 +518,98 @@ async def test_with_invalid_grant_request_invalid_scope_error(
== "invalid_scope: The requested scope is invalid, unknown, malformed, or "
"exceeds the scope granted by the resource owner."
)


@pytest.mark.parametrize(
"client_id1, client_secret1, client_id2, client_secret2",
[
# Use the same client secret but for different client ids (different application)
("user1", "test_pwd", "user2", "test_pwd"),
# Use the same client id but with different client secrets (update of secret)
("test_user", "old_pwd", "test_user", "new_pwd"),
],
)
@pytest.mark.asyncio
async def test_oauth2_client_credentials_flow_handle_credentials_as_part_of_cache_key(
token_cache,
httpx_mock: HTTPXMock,
client_id1,
client_secret1,
client_id2,
client_secret2,
):
auth1 = httpx_auth.OAuth2ClientCredentials(
"https://provide_access_token",
client_id=client_id1,
client_secret=client_secret1,
)
auth2 = httpx_auth.OAuth2ClientCredentials(
"https://provide_access_token",
client_id=client_id2,
client_secret=client_secret2,
)
httpx_mock.add_response(
method="POST",
url="https://provide_access_token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter": "example_value",
},
match_content=b"grant_type=client_credentials",
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
},
)

async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth1)

httpx_mock.add_response(
method="POST",
url="https://provide_access_token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAB",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIB",
"example_parameter": "example_value",
},
match_content=b"grant_type=client_credentials",
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
},
)

# This should request a new token (different credentials)
async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth2)

httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA",
},
)
httpx_mock.add_response(
url="https://authorized_only",
method="GET",
match_headers={
"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAB",
},
)
# Ensure the proper token is fetched
async with httpx.AsyncClient() as client:
await client.get("https://authorized_only", auth=auth1)
await client.get("https://authorized_only", auth=auth2)
Loading

0 comments on commit 0fef87e

Please sign in to comment.