Skip to content

Commit f2625a3

Browse files
btiernayclaude
andcommitted
refactor: improve code style and add comprehensive tests
- Simplify optional field handling with walrus operator - Update README examples to match existing style patterns - Add 27 parameterized unit tests for get_token_by_exchange_profile - Cover all validation paths, error cases, and edge cases - All tests passing (27/27) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7980f48 commit f2625a3

3 files changed

Lines changed: 325 additions & 18 deletions

File tree

README.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,25 +136,22 @@ async def main():
136136
client_secret="<AUTH0_CLIENT_SECRET>",
137137
))
138138

139-
# The subject_token_type must match a Token Exchange Profile configured in Auth0
140139
custom_token = "..." # Your custom token from legacy system or external source
141140

142141
result = await api_client.get_token_by_exchange_profile(
143142
subject_token=custom_token,
144-
subject_token_type="urn:example:custom-token", # Your custom token type URI
143+
subject_token_type="urn:example:custom-token",
145144
audience="https://api.example.com",
146145
scope="openid profile read:data"
147146
)
148147

149148
# Result contains access_token, expires_at, and optionally id_token, refresh_token
150-
print(f"Access Token: {result['access_token']}")
151-
print(f"Expires At: {result['expires_at']}")
152-
if "id_token" in result:
153-
print(f"ID Token: {result['id_token']}")
154149

155150
asyncio.run(main())
156151
```
157152

153+
The `subject_token_type` must match a Token Exchange Profile configured in Auth0.
154+
158155
#### Custom Parameters
159156

160157
You can pass custom parameters to your Auth0 Action using the `extra` parameter:

src/auth0_api_python/api_client.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -682,23 +682,17 @@ async def get_token_by_exchange_profile(
682682
except (TypeError, ValueError):
683683
raise ApiError("invalid_response", "expires_in is not an integer.", 502)
684684

685-
# Build response (match JS SDK structure)
685+
# Build response with required fields
686686
result = {
687687
"access_token": access_token,
688688
"expires_at": int(time.time()) + expires_in,
689689
}
690690

691-
# Add optional fields if present (conditional spreading like JS)
692-
if "scope" in token_response and token_response["scope"]:
693-
result["scope"] = token_response["scope"]
694-
if "id_token" in token_response and token_response["id_token"]:
695-
result["id_token"] = token_response["id_token"]
696-
if "refresh_token" in token_response and token_response["refresh_token"]:
697-
result["refresh_token"] = token_response["refresh_token"]
698-
if "token_type" in token_response and token_response["token_type"]:
699-
result["token_type"] = token_response["token_type"]
700-
if "issued_token_type" in token_response and token_response["issued_token_type"]:
701-
result["issued_token_type"] = token_response["issued_token_type"]
691+
# Add optional fields if present
692+
optional_fields = ["scope", "id_token", "refresh_token", "token_type", "issued_token_type"]
693+
for field in optional_fields:
694+
if value := token_response.get(field):
695+
result[field] = value
702696

703697
return result
704698

tests/test_api_client.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from auth0_api_python.errors import (
1313
ApiError,
1414
GetAccessTokenForConnectionError,
15+
GetTokenByExchangeProfileError,
1516
InvalidAuthSchemeError,
1617
InvalidDpopProofError,
1718
MissingAuthorizationError,
@@ -1901,3 +1902,318 @@ async def test_get_access_token_for_connection_expires_in_not_integer(httpx_mock
19011902
assert err.value.code == "invalid_response"
19021903
assert "expires_in" in str(err.value).lower()
19031904
assert err.value.status_code == 502
1905+
1906+
1907+
# ===== Custom Token Exchange Tests =====
1908+
1909+
1910+
@pytest.mark.asyncio
1911+
async def test_get_token_by_exchange_profile_success(httpx_mock: HTTPXMock):
1912+
"""Test successful token exchange via profile."""
1913+
httpx_mock.add_response(
1914+
method="GET",
1915+
url="https://auth0.local/.well-known/openid-configuration",
1916+
json={"token_endpoint": "https://auth0.local/oauth/token"}
1917+
)
1918+
httpx_mock.add_response(
1919+
method="POST",
1920+
url="https://auth0.local/oauth/token",
1921+
json={
1922+
"access_token": "exchanged_token",
1923+
"expires_in": 3600,
1924+
"scope": "openid profile",
1925+
"id_token": "id_token_value",
1926+
"refresh_token": "refresh_token_value",
1927+
"token_type": "Bearer",
1928+
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
1929+
}
1930+
)
1931+
1932+
api_client = ApiClient(ApiClientOptions(
1933+
domain="auth0.local",
1934+
audience="my-audience",
1935+
client_id="cid",
1936+
client_secret="csecret"
1937+
))
1938+
1939+
result = await api_client.get_token_by_exchange_profile(
1940+
subject_token="custom-token-123",
1941+
subject_token_type="urn:example:custom-token",
1942+
audience="https://api.example.com",
1943+
scope="openid profile"
1944+
)
1945+
1946+
assert result["access_token"] == "exchanged_token"
1947+
assert result["scope"] == "openid profile"
1948+
assert result["id_token"] == "id_token_value"
1949+
assert result["refresh_token"] == "refresh_token_value"
1950+
assert result["token_type"] == "Bearer"
1951+
assert result["issued_token_type"] == "urn:ietf:params:oauth:token-type:access_token"
1952+
assert isinstance(result["expires_at"], int)
1953+
1954+
# Verify request parameters
1955+
request = httpx_mock.get_requests()[-1]
1956+
form_data = urllib.parse.parse_qs(request.content.decode())
1957+
assert form_data["grant_type"] == ["urn:ietf:params:oauth:grant-type:token-exchange"]
1958+
assert form_data["subject_token"] == ["custom-token-123"]
1959+
assert form_data["subject_token_type"] == ["urn:example:custom-token"]
1960+
assert form_data["audience"] == ["https://api.example.com"]
1961+
1962+
1963+
@pytest.mark.parametrize("missing_field,field_name", [
1964+
("", "subject_token"),
1965+
(None, "subject_token"),
1966+
])
1967+
@pytest.mark.asyncio
1968+
async def test_get_token_by_exchange_profile_missing_required(missing_field, field_name):
1969+
"""Test that missing required parameters raise error."""
1970+
api_client = ApiClient(ApiClientOptions(
1971+
domain="auth0.local",
1972+
audience="my-audience",
1973+
client_id="cid",
1974+
client_secret="csecret"
1975+
))
1976+
1977+
kwargs = {
1978+
"subject_token": "token" if field_name != "subject_token" else missing_field,
1979+
"subject_token_type": "urn:example:type" if field_name != "subject_token_type" else missing_field,
1980+
}
1981+
1982+
with pytest.raises(MissingRequiredArgumentError):
1983+
await api_client.get_token_by_exchange_profile(**kwargs)
1984+
1985+
1986+
@pytest.mark.parametrize("invalid_token,expected_error,expected_msg", [
1987+
(" token", GetTokenByExchangeProfileError, "leading or trailing whitespace"),
1988+
("token ", GetTokenByExchangeProfileError, "leading or trailing whitespace"),
1989+
(" ", GetTokenByExchangeProfileError, "blank or whitespace"),
1990+
("Bearer token123", GetTokenByExchangeProfileError, "'Bearer ' prefix"),
1991+
("bearer token123", GetTokenByExchangeProfileError, "'Bearer ' prefix"),
1992+
("BEARER token123", GetTokenByExchangeProfileError, "'Bearer ' prefix"),
1993+
])
1994+
@pytest.mark.asyncio
1995+
async def test_get_token_by_exchange_profile_invalid_subject_token(invalid_token, expected_error, expected_msg):
1996+
"""Test subject token validation."""
1997+
api_client = ApiClient(ApiClientOptions(
1998+
domain="auth0.local",
1999+
audience="my-audience",
2000+
client_id="cid",
2001+
client_secret="csecret"
2002+
))
2003+
2004+
with pytest.raises(expected_error) as err:
2005+
await api_client.get_token_by_exchange_profile(
2006+
subject_token=invalid_token,
2007+
subject_token_type="urn:example:type"
2008+
)
2009+
assert expected_msg.lower() in str(err.value).lower()
2010+
2011+
2012+
@pytest.mark.asyncio
2013+
async def test_get_token_by_exchange_profile_missing_client_credentials():
2014+
"""Test that missing client credentials raises error."""
2015+
api_client = ApiClient(ApiClientOptions(
2016+
domain="auth0.local",
2017+
audience="my-audience"
2018+
))
2019+
2020+
with pytest.raises(GetTokenByExchangeProfileError) as err:
2021+
await api_client.get_token_by_exchange_profile(
2022+
subject_token="token",
2023+
subject_token_type="urn:example:type"
2024+
)
2025+
assert "client credentials" in str(err.value).lower()
2026+
2027+
2028+
@pytest.mark.asyncio
2029+
async def test_get_token_by_exchange_profile_with_extra_params(httpx_mock: HTTPXMock):
2030+
"""Test token exchange with extra parameters."""
2031+
httpx_mock.add_response(
2032+
method="GET",
2033+
url="https://auth0.local/.well-known/openid-configuration",
2034+
json={"token_endpoint": "https://auth0.local/oauth/token"}
2035+
)
2036+
httpx_mock.add_response(
2037+
method="POST",
2038+
url="https://auth0.local/oauth/token",
2039+
json={"access_token": "token", "expires_in": 3600}
2040+
)
2041+
2042+
api_client = ApiClient(ApiClientOptions(
2043+
domain="auth0.local",
2044+
audience="my-audience",
2045+
client_id="cid",
2046+
client_secret="csecret"
2047+
))
2048+
2049+
await api_client.get_token_by_exchange_profile(
2050+
subject_token="token",
2051+
subject_token_type="urn:example:type",
2052+
extra={
2053+
"device_id": "dev123",
2054+
"roles": ["admin", "user"]
2055+
}
2056+
)
2057+
2058+
request = httpx_mock.get_requests()[-1]
2059+
form_data = urllib.parse.parse_qs(request.content.decode())
2060+
assert form_data["device_id"] == ["dev123"]
2061+
assert form_data["roles"] == ["admin", "user"]
2062+
2063+
2064+
@pytest.mark.parametrize("denied_param", [
2065+
"grant_type", "client_id", "client_secret", "subject_token",
2066+
"subject_token_type", "audience", "scope", "connection"
2067+
])
2068+
@pytest.mark.asyncio
2069+
async def test_get_token_by_exchange_profile_extra_params_denylist(httpx_mock: HTTPXMock, denied_param):
2070+
"""Test that denylisted extra parameters are silently ignored."""
2071+
httpx_mock.add_response(
2072+
method="GET",
2073+
url="https://auth0.local/.well-known/openid-configuration",
2074+
json={"token_endpoint": "https://auth0.local/oauth/token"}
2075+
)
2076+
httpx_mock.add_response(
2077+
method="POST",
2078+
url="https://auth0.local/oauth/token",
2079+
json={"access_token": "token", "expires_in": 3600}
2080+
)
2081+
2082+
api_client = ApiClient(ApiClientOptions(
2083+
domain="auth0.local",
2084+
audience="my-audience",
2085+
client_id="cid",
2086+
client_secret="csecret"
2087+
))
2088+
2089+
await api_client.get_token_by_exchange_profile(
2090+
subject_token="token",
2091+
subject_token_type="urn:example:type",
2092+
extra={denied_param: "should_be_ignored", "allowed_param": "value"}
2093+
)
2094+
2095+
request = httpx_mock.get_requests()[-1]
2096+
form_data = urllib.parse.parse_qs(request.content.decode())
2097+
assert "allowed_param" in form_data
2098+
# Denylisted param should not override the original value
2099+
assert form_data["subject_token"] == ["token"]
2100+
2101+
2102+
@pytest.mark.asyncio
2103+
async def test_get_token_by_exchange_profile_extra_array_size_limit(httpx_mock: HTTPXMock):
2104+
"""Test that extra array parameters enforce size limit."""
2105+
httpx_mock.add_response(
2106+
method="GET",
2107+
url="https://auth0.local/.well-known/openid-configuration",
2108+
json={"token_endpoint": "https://auth0.local/oauth/token"}
2109+
)
2110+
2111+
api_client = ApiClient(ApiClientOptions(
2112+
domain="auth0.local",
2113+
audience="my-audience",
2114+
client_id="cid",
2115+
client_secret="csecret"
2116+
))
2117+
2118+
with pytest.raises(GetTokenByExchangeProfileError) as err:
2119+
await api_client.get_token_by_exchange_profile(
2120+
subject_token="token",
2121+
subject_token_type="urn:example:type",
2122+
extra={"large_array": [f"item{i}" for i in range(21)]}
2123+
)
2124+
assert "exceeds maximum array size" in str(err.value)
2125+
assert "20" in str(err.value)
2126+
2127+
2128+
@pytest.mark.asyncio
2129+
async def test_get_token_by_exchange_profile_api_error(httpx_mock: HTTPXMock):
2130+
"""Test handling of API errors from token endpoint."""
2131+
httpx_mock.add_response(
2132+
method="GET",
2133+
url="https://auth0.local/.well-known/openid-configuration",
2134+
json={"token_endpoint": "https://auth0.local/oauth/token"}
2135+
)
2136+
httpx_mock.add_response(
2137+
method="POST",
2138+
url="https://auth0.local/oauth/token",
2139+
status_code=400,
2140+
json={"error": "invalid_grant", "error_description": "Invalid subject token"}
2141+
)
2142+
2143+
api_client = ApiClient(ApiClientOptions(
2144+
domain="auth0.local",
2145+
audience="my-audience",
2146+
client_id="cid",
2147+
client_secret="csecret"
2148+
))
2149+
2150+
with pytest.raises(ApiError) as err:
2151+
await api_client.get_token_by_exchange_profile(
2152+
subject_token="token",
2153+
subject_token_type="urn:example:type"
2154+
)
2155+
assert err.value.code == "invalid_grant"
2156+
assert err.value.status_code == 400
2157+
2158+
2159+
@pytest.mark.asyncio
2160+
async def test_get_token_by_exchange_profile_timeout(httpx_mock: HTTPXMock):
2161+
"""Test timeout handling."""
2162+
httpx_mock.add_response(
2163+
method="GET",
2164+
url="https://auth0.local/.well-known/openid-configuration",
2165+
json={"token_endpoint": "https://auth0.local/oauth/token"}
2166+
)
2167+
httpx_mock.add_exception(httpx.TimeoutException("timeout"), method="POST")
2168+
2169+
api_client = ApiClient(ApiClientOptions(
2170+
domain="auth0.local",
2171+
audience="my-audience",
2172+
client_id="cid",
2173+
client_secret="csecret"
2174+
))
2175+
2176+
with pytest.raises(ApiError) as err:
2177+
await api_client.get_token_by_exchange_profile(
2178+
subject_token="token",
2179+
subject_token_type="urn:example:type"
2180+
)
2181+
assert err.value.code == "timeout_error"
2182+
assert err.value.status_code == 504
2183+
2184+
2185+
@pytest.mark.parametrize("response_data,error_msg", [
2186+
({}, "missing or invalid access_token"),
2187+
({"access_token": ""}, "missing or invalid access_token"),
2188+
({"access_token": None}, "missing or invalid access_token"),
2189+
({"access_token": 123}, "missing or invalid access_token"),
2190+
({"access_token": "token", "expires_in": "invalid"}, "expires_in is not an integer"),
2191+
])
2192+
@pytest.mark.asyncio
2193+
async def test_get_token_by_exchange_profile_invalid_response(httpx_mock: HTTPXMock, response_data, error_msg):
2194+
"""Test handling of invalid token endpoint responses."""
2195+
httpx_mock.add_response(
2196+
method="GET",
2197+
url="https://auth0.local/.well-known/openid-configuration",
2198+
json={"token_endpoint": "https://auth0.local/oauth/token"}
2199+
)
2200+
httpx_mock.add_response(
2201+
method="POST",
2202+
url="https://auth0.local/oauth/token",
2203+
json=response_data
2204+
)
2205+
2206+
api_client = ApiClient(ApiClientOptions(
2207+
domain="auth0.local",
2208+
audience="my-audience",
2209+
client_id="cid",
2210+
client_secret="csecret"
2211+
))
2212+
2213+
with pytest.raises(ApiError) as err:
2214+
await api_client.get_token_by_exchange_profile(
2215+
subject_token="token",
2216+
subject_token_type="urn:example:type"
2217+
)
2218+
assert error_msg.lower() in str(err.value).lower()
2219+
assert err.value.status_code == 502

0 commit comments

Comments
 (0)