Skip to content

Commit 2b79d49

Browse files
devin-ai-integration[bot]devin-ai-keycardLarry-Osakwe
authored
feat(keycardai-oauth): impersonate() takes a per-call issuer for multi-zone clients (ECO-107) (#337)
Both impersonate() methods accept a keyword-only issuer and pass it to build_http_context the way exchange_token and client_credentials_grant do, so a multi-zone client can pick the zone for an impersonation. Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai> Co-authored-by: Larry Osakwe <larry@keycard.ai>
1 parent 58d47ac commit 2b79d49

2 files changed

Lines changed: 103 additions & 2 deletions

File tree

‎packages/oauth/src/keycardai/oauth/client.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,7 @@ async def impersonate(
10191019
resource: str,
10201020
scope: str | None = None,
10211021
timeout: float | None = None,
1022+
issuer: str | None = None,
10221023
) -> TokenResponse:
10231024
"""Impersonate a user to obtain a resource token.
10241025
@@ -1034,6 +1035,10 @@ async def impersonate(
10341035
resource: Target resource URI (e.g. "https://graph.microsoft.com").
10351036
scope: Optional scope string for the requested token.
10361037
timeout: Optional request timeout override.
1038+
issuer: Issuer URL selecting which credentials a zone-aware auth
1039+
strategy (e.g. MultiZoneBasicAuth) applies for this call.
1040+
Defaults to the client's issuer. Single-credential strategies
1041+
ignore it.
10371042
10381043
Returns:
10391044
TokenResponse with the resource access token.
@@ -1066,7 +1071,7 @@ async def impersonate(
10661071
endpoint=endpoints.token,
10671072
transport=self.transport,
10681073
auth=self.auth_strategy,
1069-
issuer=self.issuer,
1074+
issuer=issuer if issuer is not None else self.issuer,
10701075
user_agent=self.config.user_agent,
10711076
custom_headers=self.config.custom_headers,
10721077
timeout=timeout or self.config.timeout,
@@ -1804,6 +1809,7 @@ def impersonate(
18041809
resource: str,
18051810
scope: str | None = None,
18061811
timeout: float | None = None,
1812+
issuer: str | None = None,
18071813
) -> TokenResponse:
18081814
"""Impersonate a user to obtain a resource token.
18091815
@@ -1819,6 +1825,10 @@ def impersonate(
18191825
resource: Target resource URI (e.g. "https://graph.microsoft.com").
18201826
scope: Optional scope string for the requested token.
18211827
timeout: Optional request timeout override.
1828+
issuer: Issuer URL selecting which credentials a zone-aware auth
1829+
strategy (e.g. MultiZoneBasicAuth) applies for this call.
1830+
Defaults to the client's issuer. Single-credential strategies
1831+
ignore it.
18221832
18231833
Returns:
18241834
TokenResponse with the resource access token.
@@ -1851,7 +1861,7 @@ def impersonate(
18511861
endpoint=endpoints.token,
18521862
transport=self.transport,
18531863
auth=self.auth_strategy,
1854-
issuer=self.issuer,
1864+
issuer=issuer if issuer is not None else self.issuer,
18551865
user_agent=self.config.user_agent,
18561866
custom_headers=self.config.custom_headers,
18571867
timeout=timeout or self.config.timeout,

‎packages/oauth/tests/keycardai/oauth/test_multi_zone_client.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,51 @@ def test_client_credentials_grant_unknown_issuer_fails_closed(self):
178178
)
179179
assert transport.requests == []
180180

181+
def test_impersonate_sends_basic_header_for_selected_issuer(self):
182+
transport = FakeTransport()
183+
client = make_client(transport)
184+
185+
response = client.impersonate(
186+
user_identifier="user@example.com",
187+
resource="https://api.example.com",
188+
issuer=ZONE2,
189+
)
190+
191+
assert response.access_token == "issued_token"
192+
assert len(transport.requests) == 1
193+
sent = transport.requests[0]
194+
assert sent.headers["Authorization"] == _basic_header(
195+
"client_id_2", "client_secret_2"
196+
)
197+
# The issuer selector is not a wire field.
198+
assert b"issuer" not in sent.body
199+
200+
def test_impersonate_defaults_to_client_issuer(self):
201+
transport = FakeTransport()
202+
client = make_client(transport)
203+
204+
client.impersonate(
205+
user_identifier="user@example.com",
206+
resource="https://api.example.com",
207+
)
208+
209+
sent = transport.requests[0]
210+
assert sent.headers["Authorization"] == _basic_header(
211+
"client_id_1", "client_secret_1"
212+
)
213+
214+
def test_impersonate_unknown_issuer_fails_closed(self):
215+
transport = FakeTransport()
216+
client = make_client(transport)
217+
218+
with pytest.raises(KeyError, match="not configured"):
219+
client.impersonate(
220+
user_identifier="user@example.com",
221+
resource="https://api.example.com",
222+
issuer="https://unknown.keycard.cloud",
223+
)
224+
assert transport.requests == []
225+
181226

182227
class TestAsyncClientMultiZone:
183228
@pytest.mark.asyncio
@@ -222,3 +267,49 @@ async def test_exchange_token_unknown_issuer_fails_closed(self):
222267
issuer="https://unknown.keycard.cloud",
223268
)
224269
assert transport.requests == []
270+
271+
@pytest.mark.asyncio
272+
async def test_impersonate_sends_basic_header_for_selected_issuer(self):
273+
transport = FakeAsyncTransport()
274+
client = make_async_client(transport)
275+
276+
response = await client.impersonate(
277+
user_identifier="user@example.com",
278+
resource="https://api.example.com",
279+
issuer=ZONE2,
280+
)
281+
282+
assert response.access_token == "issued_token"
283+
sent = transport.requests[0]
284+
assert sent.headers["Authorization"] == _basic_header(
285+
"client_id_2", "client_secret_2"
286+
)
287+
assert b"issuer" not in sent.body
288+
289+
@pytest.mark.asyncio
290+
async def test_impersonate_defaults_to_client_issuer(self):
291+
transport = FakeAsyncTransport()
292+
client = make_async_client(transport)
293+
294+
await client.impersonate(
295+
user_identifier="user@example.com",
296+
resource="https://api.example.com",
297+
)
298+
299+
sent = transport.requests[0]
300+
assert sent.headers["Authorization"] == _basic_header(
301+
"client_id_1", "client_secret_1"
302+
)
303+
304+
@pytest.mark.asyncio
305+
async def test_impersonate_unknown_issuer_fails_closed(self):
306+
transport = FakeAsyncTransport()
307+
client = make_async_client(transport)
308+
309+
with pytest.raises(KeyError, match="not configured"):
310+
await client.impersonate(
311+
user_identifier="user@example.com",
312+
resource="https://api.example.com",
313+
issuer="https://unknown.keycard.cloud",
314+
)
315+
assert transport.requests == []

0 commit comments

Comments
 (0)