aauth 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aauth-0.3.2 → aauth-0.3.4}/PKG-INFO +1 -1
- {aauth-0.3.2 → aauth-0.3.4}/aauth/__init__.py +8 -1
- aauth-0.3.4/aauth/agent/__init__.py +17 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/agent/poller.py +223 -3
- aauth-0.3.4/aauth/agent/token_exchange.py +219 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/PKG-INFO +1 -1
- {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/SOURCES.txt +4 -1
- {aauth-0.3.2 → aauth-0.3.4}/pyproject.toml +1 -1
- aauth-0.3.4/tests/test_poller.py +232 -0
- aauth-0.3.4/tests/test_token_exchange.py +420 -0
- aauth-0.3.2/aauth/agent/__init__.py +0 -2
- {aauth-0.3.2 → aauth-0.3.4}/LICENSE +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/README.md +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/agent/challenge_handler.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/agent/signer.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/debug.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/errors.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/headers/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/headers/aauth_header.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/headers/agent_auth.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/http/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/http/deferred.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/http/request.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/http/response.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/identifiers.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/jwk.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/jwks.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/keypair.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/agent.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/auth_server.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/mission_manager.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/resource.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/challenge_builder.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/token_issuer.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/verifier.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/algorithms.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature_base.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature_input.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature_key.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signer.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/verifier.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/__init__.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/agent_token.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/auth_token.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/resource_token.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/dependency_links.txt +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/requires.txt +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/top_level.txt +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/setup.cfg +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase1.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase10.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase11.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase12.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase2.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase3.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase4.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase5.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase6.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase7.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase8.py +0 -0
- {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase9.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AAuth - Agent Authentication Protocol implementation for Python."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.3.
|
|
3
|
+
__version__ = "0.3.4"
|
|
4
4
|
|
|
5
5
|
# Errors and error codes
|
|
6
6
|
from .errors import (
|
|
@@ -147,6 +147,8 @@ from .metadata.mission_manager import (
|
|
|
147
147
|
# Agent role
|
|
148
148
|
from .agent.signer import AgentRequestSigner
|
|
149
149
|
from .agent.challenge_handler import ChallengeHandler
|
|
150
|
+
from .agent.token_exchange import exchange_resource_token, extract_resource_token
|
|
151
|
+
from .agent.poller import poll_pending_url, async_poll_pending_url, PollingResult
|
|
150
152
|
|
|
151
153
|
# Resource role
|
|
152
154
|
from .resource.verifier import RequestVerifier
|
|
@@ -278,6 +280,11 @@ __all__ = [
|
|
|
278
280
|
# Agent role
|
|
279
281
|
"AgentRequestSigner",
|
|
280
282
|
"ChallengeHandler",
|
|
283
|
+
"exchange_resource_token",
|
|
284
|
+
"extract_resource_token",
|
|
285
|
+
"poll_pending_url",
|
|
286
|
+
"async_poll_pending_url",
|
|
287
|
+
"PollingResult",
|
|
281
288
|
|
|
282
289
|
# Resource role
|
|
283
290
|
"RequestVerifier",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Agent role implementation for AAuth."""
|
|
2
|
+
|
|
3
|
+
from .signer import AgentRequestSigner
|
|
4
|
+
from .challenge_handler import ChallengeHandler
|
|
5
|
+
from .token_exchange import exchange_resource_token, extract_resource_token
|
|
6
|
+
from .poller import poll_pending_url, async_poll_pending_url, PollingResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentRequestSigner",
|
|
10
|
+
"ChallengeHandler",
|
|
11
|
+
"exchange_resource_token",
|
|
12
|
+
"extract_resource_token",
|
|
13
|
+
"poll_pending_url",
|
|
14
|
+
"async_poll_pending_url",
|
|
15
|
+
"PollingResult",
|
|
16
|
+
]
|
|
17
|
+
|
|
@@ -3,16 +3,40 @@
|
|
|
3
3
|
Per spec Section 10.6, the agent polls the pending URL with GET
|
|
4
4
|
until a terminal response is received.
|
|
5
5
|
|
|
6
|
-
This module provides a synchronous polling implementation for demos
|
|
6
|
+
This module provides both a synchronous polling implementation (for demos
|
|
7
|
+
and scripts) and an async implementation suitable for use inside async
|
|
8
|
+
frameworks and the exchange_resource_token() function.
|
|
7
9
|
"""
|
|
8
10
|
|
|
11
|
+
import asyncio
|
|
9
12
|
import time
|
|
10
13
|
import logging
|
|
11
|
-
from typing import Dict, Any, Optional, Callable
|
|
14
|
+
from typing import Awaitable, Dict, Any, Optional, Callable
|
|
12
15
|
|
|
13
16
|
logger = logging.getLogger("aauth.agent.poller")
|
|
14
17
|
|
|
15
18
|
|
|
19
|
+
def _extract_interaction_url(aauth_req_header: str, code: str, pending_url: str) -> str:
|
|
20
|
+
"""Extract the user-facing interaction URL from AAuth-Requirement header.
|
|
21
|
+
|
|
22
|
+
The header carries ``url="<interaction_endpoint>"`` per spec §6.2.
|
|
23
|
+
The code is appended as a query parameter so the user arrives pre-filled.
|
|
24
|
+
Falls back to ``pending_url`` when the header carries no url field.
|
|
25
|
+
"""
|
|
26
|
+
if not aauth_req_header:
|
|
27
|
+
return pending_url
|
|
28
|
+
try:
|
|
29
|
+
from ..headers.aauth_header import parse_aauth_header
|
|
30
|
+
parsed = parse_aauth_header(aauth_req_header)
|
|
31
|
+
endpoint = parsed.get("url")
|
|
32
|
+
if endpoint:
|
|
33
|
+
sep = "&" if "?" in endpoint else "?"
|
|
34
|
+
return f"{endpoint}{sep}code={code}"
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return pending_url
|
|
38
|
+
|
|
39
|
+
|
|
16
40
|
class PollingResult:
|
|
17
41
|
"""Result of polling a pending URL."""
|
|
18
42
|
|
|
@@ -169,7 +193,8 @@ def poll_pending_url(
|
|
|
169
193
|
|
|
170
194
|
# Handle interaction requirement (first time only)
|
|
171
195
|
if require == "interaction" and code and on_interaction and attempt == 0:
|
|
172
|
-
|
|
196
|
+
interaction_url = _extract_interaction_url(aauth_req_header, code, pending_url)
|
|
197
|
+
on_interaction(interaction_url, code)
|
|
173
198
|
|
|
174
199
|
# When status=interacting, user has arrived — stop prompting
|
|
175
200
|
if poll_status == "interacting":
|
|
@@ -234,6 +259,201 @@ def poll_pending_url(
|
|
|
234
259
|
)
|
|
235
260
|
|
|
236
261
|
|
|
262
|
+
async def async_poll_pending_url(
|
|
263
|
+
pending_url: str,
|
|
264
|
+
sign_and_send_get: Callable[[str], Awaitable[Any]],
|
|
265
|
+
max_polls: int = 60,
|
|
266
|
+
default_wait: int = 2,
|
|
267
|
+
on_interaction: Optional[Callable[[str, str], Awaitable[None]]] = None,
|
|
268
|
+
on_clarification: Optional[Callable[[str, str], Awaitable[Optional[str]]]] = None,
|
|
269
|
+
sign_and_send_post: Optional[Callable[[str, Dict], Awaitable[Any]]] = None,
|
|
270
|
+
) -> PollingResult:
|
|
271
|
+
"""Async version of poll_pending_url — same state machine, awaitable throughout.
|
|
272
|
+
|
|
273
|
+
Implements the agent state machine from spec Section 10.6.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
pending_url: The Location URL from the 202 response.
|
|
277
|
+
sign_and_send_get: Async callable that sends a signed GET to a URL and
|
|
278
|
+
returns a response with .status_code, .json(), and .headers.
|
|
279
|
+
max_polls: Maximum number of poll attempts.
|
|
280
|
+
default_wait: Default seconds between polls.
|
|
281
|
+
on_interaction: Async callback when require=interaction is received.
|
|
282
|
+
Called with (pending_url, code). Agent should direct user there.
|
|
283
|
+
on_clarification: Async callback when clarification question is received.
|
|
284
|
+
Called with (pending_url, question). Should return answer string or None.
|
|
285
|
+
sign_and_send_post: Async callable for POST requests (needed for
|
|
286
|
+
clarification responses). Called with (url, json_body).
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
PollingResult with the outcome.
|
|
290
|
+
"""
|
|
291
|
+
for attempt in range(max_polls):
|
|
292
|
+
logger.debug(f"Poll attempt {attempt + 1}/{max_polls}: GET {pending_url}")
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
response = await sign_and_send_get(pending_url)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f"Poll request failed: {e}")
|
|
298
|
+
return PollingResult(
|
|
299
|
+
success=False,
|
|
300
|
+
error="network_error",
|
|
301
|
+
error_description=str(e),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
status = response.status_code
|
|
305
|
+
|
|
306
|
+
# Terminal: 200 OK — success
|
|
307
|
+
if status == 200:
|
|
308
|
+
body = response.json()
|
|
309
|
+
return PollingResult(
|
|
310
|
+
success=True,
|
|
311
|
+
auth_token=body.get("auth_token"),
|
|
312
|
+
response_body=body,
|
|
313
|
+
status_code=200,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Terminal: 403 Denied/Abandoned
|
|
317
|
+
if status == 403:
|
|
318
|
+
body = response.json()
|
|
319
|
+
return PollingResult(
|
|
320
|
+
success=False,
|
|
321
|
+
status_code=403,
|
|
322
|
+
response_body=body,
|
|
323
|
+
error=body.get("error", "denied"),
|
|
324
|
+
error_description=body.get("error_description"),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Terminal: 408 Expired
|
|
328
|
+
if status == 408:
|
|
329
|
+
body = response.json()
|
|
330
|
+
return PollingResult(
|
|
331
|
+
success=False,
|
|
332
|
+
status_code=408,
|
|
333
|
+
response_body=body,
|
|
334
|
+
error=body.get("error", "expired"),
|
|
335
|
+
error_description=body.get("error_description"),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Terminal: 410 Gone
|
|
339
|
+
if status == 410:
|
|
340
|
+
body = response.json()
|
|
341
|
+
return PollingResult(
|
|
342
|
+
success=False,
|
|
343
|
+
status_code=410,
|
|
344
|
+
response_body=body,
|
|
345
|
+
error=body.get("error", "invalid_code"),
|
|
346
|
+
error_description=body.get("error_description"),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Terminal: 500 Server Error
|
|
350
|
+
if status == 500:
|
|
351
|
+
body = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
|
|
352
|
+
return PollingResult(
|
|
353
|
+
success=False,
|
|
354
|
+
status_code=500,
|
|
355
|
+
response_body=body,
|
|
356
|
+
error=body.get("error", "server_error"),
|
|
357
|
+
error_description=body.get("error_description"),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Transient: 429 Too Many Requests (slow_down)
|
|
361
|
+
if status == 429:
|
|
362
|
+
default_wait += 5 # Per spec: increase interval by 5 seconds
|
|
363
|
+
retry_after = default_wait
|
|
364
|
+
retry_header = getattr(response, 'headers', {}).get('retry-after') or getattr(response, 'headers', {}).get('Retry-After')
|
|
365
|
+
if retry_header:
|
|
366
|
+
try:
|
|
367
|
+
retry_after = max(int(retry_header), default_wait)
|
|
368
|
+
except (ValueError, TypeError):
|
|
369
|
+
pass
|
|
370
|
+
logger.debug(f"Received 429 slow_down, increasing poll interval to {retry_after}s")
|
|
371
|
+
await asyncio.sleep(retry_after)
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# Transient: 202 Pending or Interacting — continue polling
|
|
375
|
+
if status == 202:
|
|
376
|
+
body = response.json()
|
|
377
|
+
require = body.get("requirement") or body.get("require")
|
|
378
|
+
code = body.get("code")
|
|
379
|
+
aauth_req_header = (
|
|
380
|
+
getattr(response, "headers", {}).get("aauth-requirement")
|
|
381
|
+
or getattr(response, "headers", {}).get("AAuth-Requirement")
|
|
382
|
+
or ""
|
|
383
|
+
)
|
|
384
|
+
if not require and "requirement=clarification" in aauth_req_header:
|
|
385
|
+
require = "clarification"
|
|
386
|
+
clarification = body.get("clarification")
|
|
387
|
+
poll_status = body.get("status", "pending")
|
|
388
|
+
|
|
389
|
+
# Handle interaction requirement (first time only)
|
|
390
|
+
if require == "interaction" and code and on_interaction and attempt == 0:
|
|
391
|
+
interaction_url = _extract_interaction_url(aauth_req_header, code, pending_url)
|
|
392
|
+
await on_interaction(interaction_url, code)
|
|
393
|
+
|
|
394
|
+
# When status=interacting, user has arrived — stop prompting
|
|
395
|
+
if poll_status == "interacting":
|
|
396
|
+
logger.debug("User has arrived at interaction endpoint (status=interacting)")
|
|
397
|
+
|
|
398
|
+
# Handle clarification question
|
|
399
|
+
if clarification and on_clarification and sign_and_send_post:
|
|
400
|
+
answer = await on_clarification(pending_url, clarification)
|
|
401
|
+
if answer:
|
|
402
|
+
try:
|
|
403
|
+
await sign_and_send_post(pending_url, {
|
|
404
|
+
"clarification_response": answer
|
|
405
|
+
})
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.warning(f"Failed to send clarification response: {e}")
|
|
408
|
+
|
|
409
|
+
# Respect Retry-After
|
|
410
|
+
retry_after = default_wait
|
|
411
|
+
retry_header = getattr(response, 'headers', {}).get('retry-after') or getattr(response, 'headers', {}).get('Retry-After')
|
|
412
|
+
if retry_header:
|
|
413
|
+
try:
|
|
414
|
+
retry_after = max(int(retry_header), 0)
|
|
415
|
+
except (ValueError, TypeError):
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
if retry_after > 0:
|
|
419
|
+
await asyncio.sleep(retry_after)
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
# Transient: 503 Temporarily unavailable
|
|
423
|
+
if status == 503:
|
|
424
|
+
retry_after = default_wait * 2
|
|
425
|
+
retry_header = getattr(response, 'headers', {}).get('retry-after') or getattr(response, 'headers', {}).get('Retry-After')
|
|
426
|
+
if retry_header:
|
|
427
|
+
try:
|
|
428
|
+
retry_after = max(int(retry_header), 1)
|
|
429
|
+
except (ValueError, TypeError):
|
|
430
|
+
pass
|
|
431
|
+
await asyncio.sleep(retry_after)
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# Unknown status — treat as fatal
|
|
435
|
+
logger.warning(f"Unexpected status code {status} during polling")
|
|
436
|
+
body = {}
|
|
437
|
+
try:
|
|
438
|
+
body = response.json()
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
return PollingResult(
|
|
442
|
+
success=False,
|
|
443
|
+
status_code=status,
|
|
444
|
+
response_body=body,
|
|
445
|
+
error="unexpected_status",
|
|
446
|
+
error_description=f"Unexpected HTTP status {status}",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Exhausted polls
|
|
450
|
+
return PollingResult(
|
|
451
|
+
success=False,
|
|
452
|
+
error="max_polls_exceeded",
|
|
453
|
+
error_description=f"Exceeded maximum {max_polls} poll attempts",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
237
457
|
def cancel_pending_request(
|
|
238
458
|
sign_and_send_delete: Callable[[str], Any],
|
|
239
459
|
pending_url: str,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""AAuth PS token exchange for three-party mode (SPEC §4.1.3)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import httpx
|
|
5
|
+
import jwt as _jwt
|
|
6
|
+
from typing import Awaitable, Callable, Dict, Optional, Mapping
|
|
7
|
+
|
|
8
|
+
from ..errors import TokenError, MetadataError
|
|
9
|
+
from ..signing.signer import sign_request
|
|
10
|
+
from ..metadata.mission_manager import fetch_ps_metadata_async
|
|
11
|
+
from .poller import async_poll_pending_url
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_resource_token(headers: Mapping[str, str]) -> Optional[str]:
|
|
15
|
+
"""Extract resource_token from an AAuth 401 challenge response.
|
|
16
|
+
|
|
17
|
+
Parses the AAuth-Requirement header to find the resource-token parameter
|
|
18
|
+
per SPEC §6. If not found or header is missing, returns None.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
headers: HTTP response headers (dict-like, case-insensitive access).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
resource_token JWT string if present, else None.
|
|
25
|
+
"""
|
|
26
|
+
from ..headers.aauth_header import get_challenge_header_value, parse_aauth_header
|
|
27
|
+
|
|
28
|
+
raw = get_challenge_header_value(headers)
|
|
29
|
+
if not raw:
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
parsed = parse_aauth_header(raw)
|
|
33
|
+
return parsed.get("resource_token")
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def exchange_resource_token(
|
|
39
|
+
resource_token: str,
|
|
40
|
+
private_key,
|
|
41
|
+
agent_jwt: str,
|
|
42
|
+
*,
|
|
43
|
+
ps_discovery_timeout: float = 10.0,
|
|
44
|
+
exchange_timeout: float = 30.0,
|
|
45
|
+
on_interaction: Optional[Callable[[str, str], Awaitable[None]]] = None,
|
|
46
|
+
on_clarification: Optional[Callable[[str, str], Awaitable[Optional[str]]]] = None,
|
|
47
|
+
max_polls: int = 60,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Exchange a resource_token for an auth_token via the PS (SPEC §4.1.3).
|
|
50
|
+
|
|
51
|
+
Three-party token exchange flow:
|
|
52
|
+
1. Decode resource_token to get PS URL from ``aud`` claim.
|
|
53
|
+
2. Discover PS token_endpoint via /.well-known/aauth-person.json
|
|
54
|
+
(falls back to {aud}/token if metadata fetch fails).
|
|
55
|
+
3. POST {resource_token} to PS, signed with aa-agent+jwt.
|
|
56
|
+
4a. 200 → return auth_token directly.
|
|
57
|
+
4b. 202 → poll the Location URL until a terminal response, honouring
|
|
58
|
+
any interaction or clarification callbacks, then return auth_token.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
resource_token: The resource token JWT from the 401 AAuth challenge.
|
|
62
|
+
private_key: Agent's Ed25519 private key for request signing.
|
|
63
|
+
agent_jwt: Agent token (aa-agent+jwt) for Signature-Key header.
|
|
64
|
+
ps_discovery_timeout: Timeout in seconds for PS metadata fetch.
|
|
65
|
+
exchange_timeout: Timeout in seconds for individual HTTP requests.
|
|
66
|
+
on_interaction: Async callback invoked when the PS requires human
|
|
67
|
+
interaction. Called with ``(pending_url, code)`` on the first
|
|
68
|
+
poll that returns ``requirement=interaction``. The app should
|
|
69
|
+
surface the URL and code to the user (e.g. via SSE).
|
|
70
|
+
on_clarification: Async callback invoked when the PS asks a
|
|
71
|
+
clarification question. Called with ``(pending_url, question)``
|
|
72
|
+
and should return the user's answer string, or None to skip.
|
|
73
|
+
max_polls: Maximum polling attempts before giving up (default 60).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
auth_token string (aa-auth+jwt) returned by the PS.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
TokenError: resource_token is malformed, PS returns a non-recoverable
|
|
80
|
+
error, or polling is exhausted/denied.
|
|
81
|
+
MetadataError: PS metadata fetch encounters a hard error
|
|
82
|
+
(non-timeout, non-404 failures).
|
|
83
|
+
"""
|
|
84
|
+
# Step 1: Decode resource_token to get PS URL from aud claim
|
|
85
|
+
try:
|
|
86
|
+
claims = _jwt.decode(resource_token, options={"verify_signature": False})
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
raise TokenError(
|
|
89
|
+
f"Cannot decode resource_token JWT: {exc}",
|
|
90
|
+
token_type="aa-resource+jwt",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
aud = claims.get("aud")
|
|
94
|
+
if not aud:
|
|
95
|
+
raise TokenError(
|
|
96
|
+
"resource_token missing 'aud' claim — cannot locate PS",
|
|
97
|
+
token_type="aa-resource+jwt",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Step 2: Discover PS token_endpoint from metadata
|
|
101
|
+
ps_base = aud.rstrip("/")
|
|
102
|
+
token_endpoint = f"{ps_base}/token" # fallback
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
meta = await fetch_ps_metadata_async(ps_base, timeout=ps_discovery_timeout)
|
|
106
|
+
discovered = meta.get("token_endpoint")
|
|
107
|
+
if discovered:
|
|
108
|
+
token_endpoint = discovered
|
|
109
|
+
except Exception:
|
|
110
|
+
# Metadata fetch failed — use default fallback endpoint
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
# Step 3: Sign and POST resource_token to PS
|
|
114
|
+
body = json.dumps({"resource_token": resource_token}, separators=(",", ":")).encode()
|
|
115
|
+
headers = {"Content-Type": "application/json"}
|
|
116
|
+
|
|
117
|
+
sig_headers = sign_request(
|
|
118
|
+
method="POST",
|
|
119
|
+
target_uri=token_endpoint,
|
|
120
|
+
headers=headers,
|
|
121
|
+
body=None,
|
|
122
|
+
private_key=private_key,
|
|
123
|
+
sig_scheme="jwt",
|
|
124
|
+
jwt=agent_jwt,
|
|
125
|
+
)
|
|
126
|
+
headers.update(sig_headers)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
async with httpx.AsyncClient(
|
|
130
|
+
timeout=httpx.Timeout(exchange_timeout)
|
|
131
|
+
) as http_client:
|
|
132
|
+
resp = await http_client.post(token_endpoint, headers=headers, content=body)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise TokenError(
|
|
135
|
+
f"PS token_endpoint request failed: {exc}",
|
|
136
|
+
token_type="aa-auth+jwt",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Step 4a: Immediate success
|
|
140
|
+
if resp.status_code == 200:
|
|
141
|
+
try:
|
|
142
|
+
data = resp.json()
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
raise TokenError(
|
|
145
|
+
f"PS response is not valid JSON: {exc}",
|
|
146
|
+
token_type="aa-auth+jwt",
|
|
147
|
+
)
|
|
148
|
+
auth_token = data.get("auth_token")
|
|
149
|
+
if not auth_token:
|
|
150
|
+
raise TokenError(
|
|
151
|
+
f"PS response missing 'auth_token'; response keys: {list(data.keys())}",
|
|
152
|
+
token_type="aa-auth+jwt",
|
|
153
|
+
)
|
|
154
|
+
return auth_token
|
|
155
|
+
|
|
156
|
+
# Step 4b: Deferred — PS needs human interaction or approval before issuing token
|
|
157
|
+
if resp.status_code == 202:
|
|
158
|
+
location = resp.headers.get("location") or resp.headers.get("Location")
|
|
159
|
+
if not location:
|
|
160
|
+
raise TokenError(
|
|
161
|
+
"PS returned 202 but no Location header — cannot poll",
|
|
162
|
+
token_type="aa-auth+jwt",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Build async signing closures so the poller can make authenticated requests
|
|
166
|
+
async def _signed_get(url: str):
|
|
167
|
+
sig_hdrs = sign_request(
|
|
168
|
+
method="GET",
|
|
169
|
+
target_uri=url,
|
|
170
|
+
headers={},
|
|
171
|
+
body=None,
|
|
172
|
+
private_key=private_key,
|
|
173
|
+
sig_scheme="jwt",
|
|
174
|
+
jwt=agent_jwt,
|
|
175
|
+
)
|
|
176
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(exchange_timeout)) as c:
|
|
177
|
+
return await c.get(url, headers=sig_hdrs)
|
|
178
|
+
|
|
179
|
+
async def _signed_post(url: str, body_dict: Dict):
|
|
180
|
+
post_body = json.dumps(body_dict, separators=(",", ":")).encode()
|
|
181
|
+
post_headers = {"Content-Type": "application/json"}
|
|
182
|
+
sig_hdrs = sign_request(
|
|
183
|
+
method="POST",
|
|
184
|
+
target_uri=url,
|
|
185
|
+
headers=post_headers,
|
|
186
|
+
body=None,
|
|
187
|
+
private_key=private_key,
|
|
188
|
+
sig_scheme="jwt",
|
|
189
|
+
jwt=agent_jwt,
|
|
190
|
+
)
|
|
191
|
+
post_headers.update(sig_hdrs)
|
|
192
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(exchange_timeout)) as c:
|
|
193
|
+
return await c.post(url, headers=post_headers, content=post_body)
|
|
194
|
+
|
|
195
|
+
result = await async_poll_pending_url(
|
|
196
|
+
pending_url=location,
|
|
197
|
+
sign_and_send_get=_signed_get,
|
|
198
|
+
max_polls=max_polls,
|
|
199
|
+
on_interaction=on_interaction,
|
|
200
|
+
on_clarification=on_clarification,
|
|
201
|
+
sign_and_send_post=_signed_post if on_clarification else None,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if not result.success:
|
|
205
|
+
raise TokenError(
|
|
206
|
+
f"PS deferred exchange failed: {result.error} — {result.error_description}",
|
|
207
|
+
token_type="aa-auth+jwt",
|
|
208
|
+
)
|
|
209
|
+
if not result.auth_token:
|
|
210
|
+
raise TokenError(
|
|
211
|
+
"PS polling succeeded but response missing 'auth_token'",
|
|
212
|
+
token_type="aa-auth+jwt",
|
|
213
|
+
)
|
|
214
|
+
return result.auth_token
|
|
215
|
+
|
|
216
|
+
raise TokenError(
|
|
217
|
+
f"PS token_endpoint returned HTTP {resp.status_code}: {resp.text[:500]}",
|
|
218
|
+
token_type="aa-auth+jwt",
|
|
219
|
+
)
|
|
@@ -14,6 +14,7 @@ aauth/agent/__init__.py
|
|
|
14
14
|
aauth/agent/challenge_handler.py
|
|
15
15
|
aauth/agent/poller.py
|
|
16
16
|
aauth/agent/signer.py
|
|
17
|
+
aauth/agent/token_exchange.py
|
|
17
18
|
aauth/headers/__init__.py
|
|
18
19
|
aauth/headers/aauth_header.py
|
|
19
20
|
aauth/headers/agent_auth.py
|
|
@@ -57,4 +58,6 @@ tests/test_phase5.py
|
|
|
57
58
|
tests/test_phase6.py
|
|
58
59
|
tests/test_phase7.py
|
|
59
60
|
tests/test_phase8.py
|
|
60
|
-
tests/test_phase9.py
|
|
61
|
+
tests/test_phase9.py
|
|
62
|
+
tests/test_poller.py
|
|
63
|
+
tests/test_token_exchange.py
|