aauth 0.3.2__tar.gz → 0.3.3__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 (65) hide show
  1. {aauth-0.3.2 → aauth-0.3.3}/PKG-INFO +1 -1
  2. {aauth-0.3.2 → aauth-0.3.3}/aauth/__init__.py +3 -0
  3. aauth-0.3.3/aauth/agent/__init__.py +13 -0
  4. aauth-0.3.3/aauth/agent/token_exchange.py +147 -0
  5. {aauth-0.3.2 → aauth-0.3.3}/aauth.egg-info/PKG-INFO +1 -1
  6. {aauth-0.3.2 → aauth-0.3.3}/aauth.egg-info/SOURCES.txt +3 -1
  7. {aauth-0.3.2 → aauth-0.3.3}/pyproject.toml +1 -1
  8. aauth-0.3.3/tests/test_token_exchange.py +200 -0
  9. aauth-0.3.2/aauth/agent/__init__.py +0 -2
  10. {aauth-0.3.2 → aauth-0.3.3}/LICENSE +0 -0
  11. {aauth-0.3.2 → aauth-0.3.3}/README.md +0 -0
  12. {aauth-0.3.2 → aauth-0.3.3}/aauth/agent/challenge_handler.py +0 -0
  13. {aauth-0.3.2 → aauth-0.3.3}/aauth/agent/poller.py +0 -0
  14. {aauth-0.3.2 → aauth-0.3.3}/aauth/agent/signer.py +0 -0
  15. {aauth-0.3.2 → aauth-0.3.3}/aauth/debug.py +0 -0
  16. {aauth-0.3.2 → aauth-0.3.3}/aauth/errors.py +0 -0
  17. {aauth-0.3.2 → aauth-0.3.3}/aauth/headers/__init__.py +0 -0
  18. {aauth-0.3.2 → aauth-0.3.3}/aauth/headers/aauth_header.py +0 -0
  19. {aauth-0.3.2 → aauth-0.3.3}/aauth/headers/agent_auth.py +0 -0
  20. {aauth-0.3.2 → aauth-0.3.3}/aauth/http/__init__.py +0 -0
  21. {aauth-0.3.2 → aauth-0.3.3}/aauth/http/deferred.py +0 -0
  22. {aauth-0.3.2 → aauth-0.3.3}/aauth/http/request.py +0 -0
  23. {aauth-0.3.2 → aauth-0.3.3}/aauth/http/response.py +0 -0
  24. {aauth-0.3.2 → aauth-0.3.3}/aauth/identifiers.py +0 -0
  25. {aauth-0.3.2 → aauth-0.3.3}/aauth/keys/__init__.py +0 -0
  26. {aauth-0.3.2 → aauth-0.3.3}/aauth/keys/jwk.py +0 -0
  27. {aauth-0.3.2 → aauth-0.3.3}/aauth/keys/jwks.py +0 -0
  28. {aauth-0.3.2 → aauth-0.3.3}/aauth/keys/keypair.py +0 -0
  29. {aauth-0.3.2 → aauth-0.3.3}/aauth/metadata/__init__.py +0 -0
  30. {aauth-0.3.2 → aauth-0.3.3}/aauth/metadata/agent.py +0 -0
  31. {aauth-0.3.2 → aauth-0.3.3}/aauth/metadata/auth_server.py +0 -0
  32. {aauth-0.3.2 → aauth-0.3.3}/aauth/metadata/mission_manager.py +0 -0
  33. {aauth-0.3.2 → aauth-0.3.3}/aauth/metadata/resource.py +0 -0
  34. {aauth-0.3.2 → aauth-0.3.3}/aauth/resource/__init__.py +0 -0
  35. {aauth-0.3.2 → aauth-0.3.3}/aauth/resource/challenge_builder.py +0 -0
  36. {aauth-0.3.2 → aauth-0.3.3}/aauth/resource/token_issuer.py +0 -0
  37. {aauth-0.3.2 → aauth-0.3.3}/aauth/resource/verifier.py +0 -0
  38. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/__init__.py +0 -0
  39. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/algorithms.py +0 -0
  40. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/signature.py +0 -0
  41. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/signature_base.py +0 -0
  42. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/signature_input.py +0 -0
  43. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/signature_key.py +0 -0
  44. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/signer.py +0 -0
  45. {aauth-0.3.2 → aauth-0.3.3}/aauth/signing/verifier.py +0 -0
  46. {aauth-0.3.2 → aauth-0.3.3}/aauth/tokens/__init__.py +0 -0
  47. {aauth-0.3.2 → aauth-0.3.3}/aauth/tokens/agent_token.py +0 -0
  48. {aauth-0.3.2 → aauth-0.3.3}/aauth/tokens/auth_token.py +0 -0
  49. {aauth-0.3.2 → aauth-0.3.3}/aauth/tokens/resource_token.py +0 -0
  50. {aauth-0.3.2 → aauth-0.3.3}/aauth.egg-info/dependency_links.txt +0 -0
  51. {aauth-0.3.2 → aauth-0.3.3}/aauth.egg-info/requires.txt +0 -0
  52. {aauth-0.3.2 → aauth-0.3.3}/aauth.egg-info/top_level.txt +0 -0
  53. {aauth-0.3.2 → aauth-0.3.3}/setup.cfg +0 -0
  54. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase1.py +0 -0
  55. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase10.py +0 -0
  56. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase11.py +0 -0
  57. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase12.py +0 -0
  58. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase2.py +0 -0
  59. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase3.py +0 -0
  60. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase4.py +0 -0
  61. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase5.py +0 -0
  62. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase6.py +0 -0
  63. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase7.py +0 -0
  64. {aauth-0.3.2 → aauth-0.3.3}/tests/test_phase8.py +0 -0
  65. {aauth-0.3.2 → aauth-0.3.3}/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.3
4
4
  Summary: AAuth protocol implementation for Python
5
5
  License: MIT License
6
6
 
@@ -147,6 +147,7 @@ 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
150
151
 
151
152
  # Resource role
152
153
  from .resource.verifier import RequestVerifier
@@ -278,6 +279,8 @@ __all__ = [
278
279
  # Agent role
279
280
  "AgentRequestSigner",
280
281
  "ChallengeHandler",
282
+ "exchange_resource_token",
283
+ "extract_resource_token",
281
284
 
282
285
  # Resource role
283
286
  "RequestVerifier",
@@ -0,0 +1,13 @@
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
+
7
+ __all__ = [
8
+ "AgentRequestSigner",
9
+ "ChallengeHandler",
10
+ "exchange_resource_token",
11
+ "extract_resource_token",
12
+ ]
13
+
@@ -0,0 +1,147 @@
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 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
+
12
+
13
+ def extract_resource_token(headers: Mapping[str, str]) -> Optional[str]:
14
+ """Extract resource_token from an AAuth 401 challenge response.
15
+
16
+ Parses the AAuth-Requirement header to find the resource-token parameter
17
+ per SPEC §6. If not found or header is missing, returns None.
18
+
19
+ Args:
20
+ headers: HTTP response headers (dict-like, case-insensitive access).
21
+
22
+ Returns:
23
+ resource_token JWT string if present, else None.
24
+ """
25
+ from ..headers.aauth_header import get_challenge_header_value, parse_aauth_header
26
+
27
+ raw = get_challenge_header_value(headers)
28
+ if not raw:
29
+ return None
30
+ try:
31
+ parsed = parse_aauth_header(raw)
32
+ return parsed.get("resource_token")
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ async def exchange_resource_token(
38
+ resource_token: str,
39
+ private_key,
40
+ agent_jwt: str,
41
+ *,
42
+ ps_discovery_timeout: float = 10.0,
43
+ exchange_timeout: float = 30.0,
44
+ ) -> str:
45
+ """Exchange a resource_token for an auth_token via the PS (SPEC §4.1.3).
46
+
47
+ Three-party token exchange flow:
48
+ 1. Decode resource_token to get PS URL from ``aud`` claim.
49
+ 2. Discover PS token_endpoint via /.well-known/aauth-person.json
50
+ (falls back to {aud}/token if metadata fetch fails).
51
+ 3. POST {resource_token} to PS, signed with aa-agent+jwt.
52
+ 4. Return auth_token (aa-auth+jwt) from PS response.
53
+
54
+ Args:
55
+ resource_token: The resource token JWT from the 401 AAuth challenge.
56
+ private_key: Agent's Ed25519 private key for request signing.
57
+ agent_jwt: Agent token (aa-agent+jwt) for Signature-Key header.
58
+ ps_discovery_timeout: Timeout in seconds for PS metadata fetch.
59
+ exchange_timeout: Timeout in seconds for PS token endpoint POST.
60
+
61
+ Returns:
62
+ auth_token string (aa-auth+jwt) returned by the PS.
63
+
64
+ Raises:
65
+ TokenError: resource_token is malformed, PS returns non-200,
66
+ or response missing auth_token.
67
+ MetadataError: PS metadata fetch encounters a hard error
68
+ (non-timeout, non-404 failures).
69
+ """
70
+ # Step 1: Decode resource_token to get PS URL from aud claim
71
+ try:
72
+ claims = _jwt.decode(resource_token, options={"verify_signature": False})
73
+ except Exception as exc:
74
+ raise TokenError(
75
+ f"Cannot decode resource_token JWT: {exc}",
76
+ token_type="aa-resource+jwt",
77
+ )
78
+
79
+ aud = claims.get("aud")
80
+ if not aud:
81
+ raise TokenError(
82
+ "resource_token missing 'aud' claim — cannot locate PS",
83
+ token_type="aa-resource+jwt",
84
+ )
85
+
86
+ # Step 2: Discover PS token_endpoint from metadata
87
+ ps_base = aud.rstrip("/")
88
+ token_endpoint = f"{ps_base}/token" # fallback
89
+
90
+ try:
91
+ meta = await fetch_ps_metadata_async(ps_base, timeout=ps_discovery_timeout)
92
+ discovered = meta.get("token_endpoint")
93
+ if discovered:
94
+ token_endpoint = discovered
95
+ except Exception:
96
+ # Metadata fetch failed — use default fallback endpoint
97
+ pass
98
+
99
+ # Step 3: Sign and POST resource_token to PS
100
+ body = json.dumps({"resource_token": resource_token}, separators=(",", ":")).encode()
101
+ headers = {"Content-Type": "application/json"}
102
+
103
+ sig_headers = sign_request(
104
+ method="POST",
105
+ target_uri=token_endpoint,
106
+ headers=headers,
107
+ body=None,
108
+ private_key=private_key,
109
+ sig_scheme="jwt",
110
+ jwt=agent_jwt,
111
+ )
112
+ headers.update(sig_headers)
113
+
114
+ try:
115
+ async with httpx.AsyncClient(
116
+ timeout=httpx.Timeout(exchange_timeout)
117
+ ) as http_client:
118
+ resp = await http_client.post(token_endpoint, headers=headers, content=body)
119
+ except Exception as exc:
120
+ raise TokenError(
121
+ f"PS token_endpoint request failed: {exc}",
122
+ token_type="aa-auth+jwt",
123
+ )
124
+
125
+ if resp.status_code != 200:
126
+ raise TokenError(
127
+ f"PS token_endpoint returned HTTP {resp.status_code}: {resp.text[:500]}",
128
+ token_type="aa-auth+jwt",
129
+ )
130
+
131
+ # Step 4: Extract and return auth_token
132
+ try:
133
+ data = resp.json()
134
+ except Exception as exc:
135
+ raise TokenError(
136
+ f"PS response is not valid JSON: {exc}",
137
+ token_type="aa-auth+jwt",
138
+ )
139
+
140
+ auth_token = data.get("auth_token")
141
+ if not auth_token:
142
+ raise TokenError(
143
+ f"PS response missing 'auth_token'; response keys: {list(data.keys())}",
144
+ token_type="aa-auth+jwt",
145
+ )
146
+
147
+ return auth_token
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aauth
3
- Version: 0.3.2
3
+ Version: 0.3.3
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,5 @@ 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_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.3"
8
8
  description = "AAuth protocol implementation for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,200 @@
1
+ """Tests for PS token exchange (three-party mode, SPEC §4.1.3)."""
2
+
3
+ import pytest
4
+ import asyncio
5
+ import json
6
+ import time
7
+ from typing import Dict, Any
8
+ from urllib.parse import urlparse
9
+
10
+ from aauth.agent.token_exchange import extract_resource_token, exchange_resource_token
11
+ from aauth.tokens.resource_token import create_resource_token
12
+ from aauth.tokens.agent_token import create_agent_token
13
+ from aauth.keys.keypair import generate_ed25519_keypair
14
+ from aauth.keys.jwk import public_key_to_jwk, calculate_jwk_thumbprint
15
+ from aauth.errors import TokenError, MetadataError
16
+ import httpx
17
+
18
+
19
+ class TestExtractResourceToken:
20
+ """Tests for extract_resource_token helper."""
21
+
22
+ def test_extract_from_valid_challenge_header(self):
23
+ """Extract resource_token from a valid AAuth challenge header."""
24
+ test_token = "eyJhbGciOiJFZERTQSJ9.eyJhdWQiOiJodHRwczovL2V4YW1wbGUifQ.sig"
25
+ headers = {
26
+ "AAuth-Requirement": f'requirement=auth-token; resource-token="{test_token}"'
27
+ }
28
+ result = extract_resource_token(headers)
29
+ assert result == test_token
30
+
31
+ def test_extract_from_case_insensitive_header(self):
32
+ """extract_resource_token is case-insensitive for header names."""
33
+ test_token = "eyJhbGciOiJFZERTQSJ9.eyJhdWQiOiJodHRwczovL2V4YW1wbGUifQ.sig"
34
+ headers = {
35
+ "aauth-requirement": f'requirement=auth-token; resource-token="{test_token}"'
36
+ }
37
+ result = extract_resource_token(headers)
38
+ assert result == test_token
39
+
40
+ def test_extract_from_missing_header(self):
41
+ """extract_resource_token returns None when no AAuth header present."""
42
+ headers = {"Content-Type": "application/json"}
43
+ result = extract_resource_token(headers)
44
+ assert result is None
45
+
46
+ def test_extract_from_header_without_resource_token_param(self):
47
+ """extract_resource_token returns None when resource-token param missing."""
48
+ headers = {"AAuth-Requirement": "requirement=identity"}
49
+ result = extract_resource_token(headers)
50
+ assert result is None
51
+
52
+ def test_extract_handles_malformed_header_gracefully(self):
53
+ """extract_resource_token returns None on parse errors."""
54
+ headers = {"AAuth-Requirement": "garbage_header_content"}
55
+ result = extract_resource_token(headers)
56
+ assert result is None
57
+
58
+
59
+ class TestExchangeResourceToken:
60
+ """Tests for exchange_resource_token function."""
61
+
62
+ @pytest.fixture
63
+ def agent_keys(self):
64
+ """Generate test agent keys."""
65
+ agent_priv, agent_pub = generate_ed25519_keypair()
66
+ agent_jwk = public_key_to_jwk(agent_pub)
67
+ agent_jkt = calculate_jwk_thumbprint(agent_jwk)
68
+ return agent_priv, agent_pub, agent_jkt
69
+
70
+ def test_exchange_resource_token_missing_aud(self, agent_keys):
71
+ """Raises TokenError if resource_token missing aud claim."""
72
+ import jwt
73
+
74
+ agent_priv, _, _ = agent_keys
75
+
76
+ # Create JWT without aud claim
77
+ bad_token = jwt.encode(
78
+ {"iss": "https://ps.example", "agent": "https://agent.example"},
79
+ agent_priv,
80
+ algorithm="EdDSA",
81
+ )
82
+
83
+ async def run_test():
84
+ with pytest.raises(TokenError, match="missing 'aud' claim"):
85
+ await exchange_resource_token(
86
+ resource_token=bad_token,
87
+ private_key=agent_priv,
88
+ agent_jwt="dummy-jwt",
89
+ )
90
+
91
+ asyncio.run(run_test())
92
+
93
+ def test_exchange_resource_token_malformed(self, agent_keys):
94
+ """Raises TokenError if resource_token is not valid JWT."""
95
+ agent_priv, _, _ = agent_keys
96
+
97
+ async def run_test():
98
+ with pytest.raises(TokenError, match="Cannot decode resource_token"):
99
+ await exchange_resource_token(
100
+ resource_token="not-a-jwt",
101
+ private_key=agent_priv,
102
+ agent_jwt="dummy-jwt",
103
+ )
104
+
105
+ asyncio.run(run_test())
106
+
107
+
108
+ @pytest.mark.integration
109
+ class TestTokenExchangeIntegration:
110
+ """Integration tests using a real PS-like endpoint."""
111
+
112
+ def test_exchange_against_real_ps_server(self):
113
+ """End-to-end test with a real PS-like server."""
114
+ # Import here to avoid hard dependency unless running integration tests
115
+ from starlette.applications import Starlette
116
+ from starlette.responses import JSONResponse
117
+ import threading
118
+ import uvicorn
119
+ import time
120
+
121
+ # Generate test keys
122
+ agent_priv, agent_pub = generate_ed25519_keypair()
123
+ agent_jwk = public_key_to_jwk(agent_pub)
124
+ agent_jkt = calculate_jwk_thumbprint(agent_jwk)
125
+
126
+ ps_priv, ps_pub = generate_ed25519_keypair()
127
+
128
+ # Create a simple Starlette app that mimics PS behavior
129
+ app = Starlette()
130
+
131
+ @app.route("/.well-known/aauth-person.json", methods=["GET"])
132
+ async def ps_metadata(request):
133
+ return JSONResponse(
134
+ {
135
+ "issuer": "http://127.0.0.1:8766",
136
+ "token_endpoint": "http://127.0.0.1:8766/token",
137
+ "jwks_uri": "http://127.0.0.1:8766/.well-known/jwks.json",
138
+ }
139
+ )
140
+
141
+ @app.route("/token", methods=["POST"])
142
+ async def token_endpoint(request):
143
+ body = await request.json()
144
+ # Just echo back a fake auth token
145
+ auth_token = "eyJhbGciOiJFZERTQSIsInR5cCI6ImFhLWF1dGhydHQifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjg3NjYiLCJhdWQiOiJodHRwczovL3Jlc291cmNlLmV4YW1wbGUiLCJhZ2VudCI6Imh0dHBzOi8vYWdlbnQuZXhhbXBsZSJ9.sig"
146
+ return JSONResponse({"auth_token": auth_token})
147
+
148
+ # Run server in background thread
149
+ server_thread = threading.Thread(
150
+ target=lambda: uvicorn.run(
151
+ app,
152
+ host="127.0.0.1",
153
+ port=8766,
154
+ log_level="critical",
155
+ ),
156
+ daemon=True,
157
+ )
158
+ server_thread.start()
159
+ time.sleep(0.5) # Give server time to start
160
+
161
+ try:
162
+ # Create resource token pointing to our test PS
163
+ resource_token = create_resource_token(
164
+ iss="http://127.0.0.1:8766",
165
+ aud="http://127.0.0.1:8766",
166
+ agent="https://agent.example",
167
+ agent_jkt=agent_jkt,
168
+ scope="data.read",
169
+ private_key=ps_priv,
170
+ kid="ps-key-1",
171
+ )
172
+
173
+ # Create agent JWT using correct signature
174
+ agent_jwt = create_agent_token(
175
+ iss="https://agent.example",
176
+ sub="https://agent.example",
177
+ cnf_jwk=agent_jwk,
178
+ private_key=agent_priv,
179
+ kid="agent-key-1",
180
+ )
181
+
182
+ # Perform the exchange
183
+ async def run_exchange():
184
+ return await exchange_resource_token(
185
+ resource_token=resource_token,
186
+ private_key=agent_priv,
187
+ agent_jwt=agent_jwt,
188
+ )
189
+
190
+ auth_token = asyncio.run(run_exchange())
191
+
192
+ # Verify we got back a token
193
+ assert auth_token is not None
194
+ assert isinstance(auth_token, str)
195
+ assert len(auth_token) > 0
196
+ # Should have 3 JWT parts
197
+ assert len(auth_token.split(".")) == 3
198
+ finally:
199
+ # Note: conftest will clean up the server via _server_lifecycle
200
+ pass
@@ -1,2 +0,0 @@
1
- """Agent role implementation for AAuth."""
2
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes