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.
Files changed (66) hide show
  1. {aauth-0.3.2 → aauth-0.3.4}/PKG-INFO +1 -1
  2. {aauth-0.3.2 → aauth-0.3.4}/aauth/__init__.py +8 -1
  3. aauth-0.3.4/aauth/agent/__init__.py +17 -0
  4. {aauth-0.3.2 → aauth-0.3.4}/aauth/agent/poller.py +223 -3
  5. aauth-0.3.4/aauth/agent/token_exchange.py +219 -0
  6. {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/PKG-INFO +1 -1
  7. {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/SOURCES.txt +4 -1
  8. {aauth-0.3.2 → aauth-0.3.4}/pyproject.toml +1 -1
  9. aauth-0.3.4/tests/test_poller.py +232 -0
  10. aauth-0.3.4/tests/test_token_exchange.py +420 -0
  11. aauth-0.3.2/aauth/agent/__init__.py +0 -2
  12. {aauth-0.3.2 → aauth-0.3.4}/LICENSE +0 -0
  13. {aauth-0.3.2 → aauth-0.3.4}/README.md +0 -0
  14. {aauth-0.3.2 → aauth-0.3.4}/aauth/agent/challenge_handler.py +0 -0
  15. {aauth-0.3.2 → aauth-0.3.4}/aauth/agent/signer.py +0 -0
  16. {aauth-0.3.2 → aauth-0.3.4}/aauth/debug.py +0 -0
  17. {aauth-0.3.2 → aauth-0.3.4}/aauth/errors.py +0 -0
  18. {aauth-0.3.2 → aauth-0.3.4}/aauth/headers/__init__.py +0 -0
  19. {aauth-0.3.2 → aauth-0.3.4}/aauth/headers/aauth_header.py +0 -0
  20. {aauth-0.3.2 → aauth-0.3.4}/aauth/headers/agent_auth.py +0 -0
  21. {aauth-0.3.2 → aauth-0.3.4}/aauth/http/__init__.py +0 -0
  22. {aauth-0.3.2 → aauth-0.3.4}/aauth/http/deferred.py +0 -0
  23. {aauth-0.3.2 → aauth-0.3.4}/aauth/http/request.py +0 -0
  24. {aauth-0.3.2 → aauth-0.3.4}/aauth/http/response.py +0 -0
  25. {aauth-0.3.2 → aauth-0.3.4}/aauth/identifiers.py +0 -0
  26. {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/__init__.py +0 -0
  27. {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/jwk.py +0 -0
  28. {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/jwks.py +0 -0
  29. {aauth-0.3.2 → aauth-0.3.4}/aauth/keys/keypair.py +0 -0
  30. {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/__init__.py +0 -0
  31. {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/agent.py +0 -0
  32. {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/auth_server.py +0 -0
  33. {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/mission_manager.py +0 -0
  34. {aauth-0.3.2 → aauth-0.3.4}/aauth/metadata/resource.py +0 -0
  35. {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/__init__.py +0 -0
  36. {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/challenge_builder.py +0 -0
  37. {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/token_issuer.py +0 -0
  38. {aauth-0.3.2 → aauth-0.3.4}/aauth/resource/verifier.py +0 -0
  39. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/__init__.py +0 -0
  40. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/algorithms.py +0 -0
  41. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature.py +0 -0
  42. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature_base.py +0 -0
  43. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature_input.py +0 -0
  44. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signature_key.py +0 -0
  45. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/signer.py +0 -0
  46. {aauth-0.3.2 → aauth-0.3.4}/aauth/signing/verifier.py +0 -0
  47. {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/__init__.py +0 -0
  48. {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/agent_token.py +0 -0
  49. {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/auth_token.py +0 -0
  50. {aauth-0.3.2 → aauth-0.3.4}/aauth/tokens/resource_token.py +0 -0
  51. {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/dependency_links.txt +0 -0
  52. {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/requires.txt +0 -0
  53. {aauth-0.3.2 → aauth-0.3.4}/aauth.egg-info/top_level.txt +0 -0
  54. {aauth-0.3.2 → aauth-0.3.4}/setup.cfg +0 -0
  55. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase1.py +0 -0
  56. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase10.py +0 -0
  57. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase11.py +0 -0
  58. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase12.py +0 -0
  59. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase2.py +0 -0
  60. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase3.py +0 -0
  61. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase4.py +0 -0
  62. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase5.py +0 -0
  63. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase6.py +0 -0
  64. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase7.py +0 -0
  65. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase8.py +0 -0
  66. {aauth-0.3.2 → aauth-0.3.4}/tests/test_phase9.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aauth
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: AAuth protocol implementation for Python
5
5
  License: MIT License
6
6
 
@@ -1,6 +1,6 @@
1
1
  """AAuth - Agent Authentication Protocol implementation for Python."""
2
2
 
3
- __version__ = "0.3.0"
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
- on_interaction(pending_url, code)
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
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aauth
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: AAuth protocol implementation for Python
5
5
  License: MIT License
6
6
 
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aauth"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "AAuth protocol implementation for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"