aauth 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,99 @@
1
+ """Signature-Key header parsing and building for AAuth."""
2
+
3
+ import re
4
+ from typing import Dict, Any
5
+ from ..keys.jwk import public_key_to_jwk
6
+
7
+
8
+ def build_signature_key_header(
9
+ sig_scheme: str,
10
+ private_key,
11
+ label: str = "sig1",
12
+ **kwargs
13
+ ) -> str:
14
+ """Build Signature-Key header as RFC 8941 Structured Fields Dictionary.
15
+
16
+ Format: {label}=(scheme=hwk kty="OKP" crv="Ed25519" x="...")
17
+ The label must match the label used in Signature-Input and Signature headers.
18
+
19
+ Args:
20
+ sig_scheme: Signature scheme ("hwk", "jwks", "jwt")
21
+ private_key: Private key (for hwk scheme)
22
+ label: Signature label (default: "sig1")
23
+ **kwargs: Additional parameters:
24
+ - For "hwk": None (key extracted from private_key)
25
+ - For "jwks": id (required), kid (required), well-known (optional)
26
+ - For "jwt": jwt (required)
27
+
28
+ Returns:
29
+ Signature-Key header value
30
+
31
+ Raises:
32
+ ValueError: If required parameters are missing
33
+ """
34
+ if sig_scheme == "hwk":
35
+ public_key = private_key.public_key()
36
+ jwk = public_key_to_jwk(public_key)
37
+ return f'{label}=(scheme=hwk kty="{jwk["kty"]}" crv="{jwk["crv"]}" x="{jwk["x"]}")'
38
+ elif sig_scheme == "jwks":
39
+ agent_id = kwargs.get("id")
40
+ kid = kwargs.get("kid", "key-1")
41
+ well_known = kwargs.get("well-known")
42
+ if not agent_id:
43
+ raise ValueError("sig=jwks requires 'id' parameter")
44
+ header_parts = [f'scheme=jwks', f'id="{agent_id}"', f'kid="{kid}"']
45
+ if well_known:
46
+ header_parts.append(f'well-known="{well_known}"')
47
+ return f'{label}=({" ".join(header_parts)})'
48
+ elif sig_scheme == "jwt":
49
+ jwt_token = kwargs.get("jwt")
50
+ if not jwt_token:
51
+ raise ValueError("sig=jwt requires 'jwt' parameter")
52
+ return f'{label}=(scheme=jwt jwt="{jwt_token}")'
53
+ else:
54
+ raise ValueError(f"Unknown signature scheme: {sig_scheme}")
55
+
56
+
57
+ def parse_signature_key(header_value: str) -> Dict[str, Any]:
58
+ """Parse Signature-Key header value (RFC 8941 Structured Fields Dictionary).
59
+
60
+ Supports any label (sig, sig1, etc.) - the label is extracted but not validated here.
61
+
62
+ Args:
63
+ header_value: Signature-Key header value
64
+
65
+ Returns:
66
+ Dictionary with keys: scheme, params, label
67
+
68
+ Raises:
69
+ ValueError: If header format is invalid
70
+ """
71
+ # Match any label: sig=, sig1=, etc.
72
+ match = re.match(r'(\w+)=\((.*)\)', header_value)
73
+ if not match:
74
+ raise ValueError(f"Invalid Signature-Key format: {header_value}")
75
+
76
+ label = match.group(1)
77
+ inner_content = match.group(2)
78
+ params = {}
79
+ scheme = None
80
+
81
+ # Allow hyphens in parameter names (e.g., "well-known")
82
+ param_pattern = r'([\w-]+)=(?:"([^"]*)"|([^\s)]+))'
83
+ for match in re.finditer(param_pattern, inner_content):
84
+ key = match.group(1)
85
+ value = match.group(2) or match.group(3)
86
+ if key == "scheme":
87
+ scheme = value
88
+ else:
89
+ params[key] = value
90
+
91
+ if not scheme:
92
+ raise ValueError(f"Missing scheme in Signature-Key: {header_value}")
93
+
94
+ return {
95
+ "scheme": scheme,
96
+ "params": params,
97
+ "label": label # Include label for consistency checking
98
+ }
99
+
aauth/http/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """HTTP abstraction layer for framework-agnostic AAuth operations."""
2
+
aauth/http/request.py ADDED
@@ -0,0 +1,140 @@
1
+ """Framework-agnostic HTTP request representation for AAuth."""
2
+
3
+ from typing import Dict, Optional
4
+ from urllib.parse import urlparse
5
+
6
+
7
+ class AAuthRequest:
8
+ """Framework-agnostic HTTP request representation.
9
+
10
+ This class provides a common interface for HTTP requests that AAuth
11
+ operations can work with, independent of the underlying framework
12
+ (FastAPI, Flask, Django, etc.).
13
+
14
+ Attributes:
15
+ method: HTTP method (GET, POST, etc.)
16
+ authority: Canonical authority (host:port) per SPEC 10.3.1
17
+ path: Request path (e.g., "/api/data")
18
+ query: Query string (without leading "?")
19
+ headers: Request headers dictionary (case-insensitive keys)
20
+ body: Request body bytes (None if no body)
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ method: str,
26
+ authority: str,
27
+ path: str,
28
+ query: Optional[str] = None,
29
+ headers: Optional[Dict[str, str]] = None,
30
+ body: Optional[bytes] = None
31
+ ):
32
+ """Initialize AAuthRequest.
33
+
34
+ Args:
35
+ method: HTTP method
36
+ authority: Canonical authority (host:port)
37
+ path: Request path
38
+ query: Query string (without leading "?")
39
+ headers: Request headers
40
+ body: Request body bytes
41
+ """
42
+ self.method = method.upper()
43
+ self.authority = authority
44
+ self.path = path or "/"
45
+ self.query = query
46
+ self.headers = headers or {}
47
+ self.body = body
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: Dict) -> "AAuthRequest":
51
+ """Create AAuthRequest from dictionary.
52
+
53
+ Args:
54
+ data: Dictionary with keys: method, authority, path, query (optional),
55
+ headers (optional), body (optional)
56
+
57
+ Returns:
58
+ AAuthRequest instance
59
+ """
60
+ return cls(
61
+ method=data["method"],
62
+ authority=data["authority"],
63
+ path=data.get("path", "/"),
64
+ query=data.get("query"),
65
+ headers=data.get("headers"),
66
+ body=data.get("body")
67
+ )
68
+
69
+ @classmethod
70
+ def from_fastapi_request(cls, request) -> "AAuthRequest":
71
+ """Create AAuthRequest from FastAPI Request object.
72
+
73
+ Args:
74
+ request: FastAPI Request object
75
+
76
+ Returns:
77
+ AAuthRequest instance
78
+
79
+ Note:
80
+ This is a convenience method. The library remains framework-agnostic.
81
+ """
82
+ try:
83
+ from fastapi import Request as FastAPIRequest
84
+
85
+ if not isinstance(request, FastAPIRequest):
86
+ raise ValueError("Expected FastAPI Request object")
87
+
88
+ # Extract authority from URL
89
+ parsed_url = urlparse(str(request.url))
90
+ authority = parsed_url.netloc
91
+
92
+ # Extract query string (without leading "?")
93
+ query = parsed_url.query if parsed_url.query else None
94
+
95
+ # Extract headers (FastAPI uses case-insensitive headers)
96
+ headers = dict(request.headers)
97
+
98
+ # Get body if available
99
+ body = None
100
+ if hasattr(request, "_body"):
101
+ body = request._body
102
+ elif hasattr(request, "body"):
103
+ # For async requests, body() is a coroutine
104
+ # This is a limitation - users should read body separately
105
+ pass
106
+
107
+ return cls(
108
+ method=request.method,
109
+ authority=authority,
110
+ path=parsed_url.path,
111
+ query=query,
112
+ headers=headers,
113
+ body=body
114
+ )
115
+ except ImportError:
116
+ raise ValueError("FastAPI not installed. Use from_dict() or construct directly.")
117
+
118
+ def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
119
+ """Get header value (case-insensitive).
120
+
121
+ Args:
122
+ name: Header name (case-insensitive)
123
+ default: Default value if header not found
124
+
125
+ Returns:
126
+ Header value or default
127
+ """
128
+ name_lower = name.lower()
129
+ for key, value in self.headers.items():
130
+ if key.lower() == name_lower:
131
+ return value
132
+ return default
133
+
134
+ def __repr__(self) -> str:
135
+ """String representation."""
136
+ return (
137
+ f"AAuthRequest(method={self.method!r}, authority={self.authority!r}, "
138
+ f"path={self.path!r}, query={self.query!r})"
139
+ )
140
+
aauth/http/response.py ADDED
@@ -0,0 +1,106 @@
1
+ """Framework-agnostic HTTP response representation for AAuth."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+
6
+ class AAuthResponse:
7
+ """Framework-agnostic HTTP response representation.
8
+
9
+ This class provides a common interface for HTTP responses that AAuth
10
+ operations can work with, independent of the underlying framework.
11
+
12
+ Attributes:
13
+ status_code: HTTP status code (200, 401, etc.)
14
+ headers: Response headers dictionary
15
+ body: Response body bytes (None if no body)
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ status_code: int,
21
+ headers: Optional[Dict[str, str]] = None,
22
+ body: Optional[bytes] = None
23
+ ):
24
+ """Initialize AAuthResponse.
25
+
26
+ Args:
27
+ status_code: HTTP status code
28
+ headers: Response headers
29
+ body: Response body bytes
30
+ """
31
+ self.status_code = status_code
32
+ self.headers = headers or {}
33
+ self.body = body
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: Dict) -> "AAuthResponse":
37
+ """Create AAuthResponse from dictionary.
38
+
39
+ Args:
40
+ data: Dictionary with keys: status_code, headers (optional),
41
+ body (optional)
42
+
43
+ Returns:
44
+ AAuthResponse instance
45
+ """
46
+ return cls(
47
+ status_code=data["status_code"],
48
+ headers=data.get("headers"),
49
+ body=data.get("body")
50
+ )
51
+
52
+ @classmethod
53
+ def from_fastapi_response(cls, response) -> "AAuthResponse":
54
+ """Create AAuthResponse from FastAPI Response object.
55
+
56
+ Args:
57
+ response: FastAPI Response object
58
+
59
+ Returns:
60
+ AAuthResponse instance
61
+
62
+ Note:
63
+ This is a convenience method. The library remains framework-agnostic.
64
+ """
65
+ try:
66
+ from fastapi.responses import Response as FastAPIResponse
67
+
68
+ if not isinstance(response, FastAPIResponse):
69
+ raise ValueError("Expected FastAPI Response object")
70
+
71
+ # Extract body if available
72
+ body = None
73
+ if hasattr(response, "body"):
74
+ body = response.body
75
+
76
+ return cls(
77
+ status_code=response.status_code,
78
+ headers=dict(response.headers),
79
+ body=body
80
+ )
81
+ except ImportError:
82
+ raise ValueError("FastAPI not installed. Use from_dict() or construct directly.")
83
+
84
+ def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
85
+ """Get header value (case-insensitive).
86
+
87
+ Args:
88
+ name: Header name (case-insensitive)
89
+ default: Default value if header not found
90
+
91
+ Returns:
92
+ Header value or default
93
+ """
94
+ name_lower = name.lower()
95
+ for key, value in self.headers.items():
96
+ if key.lower() == name_lower:
97
+ return value
98
+ return default
99
+
100
+ def __repr__(self) -> str:
101
+ """String representation."""
102
+ return (
103
+ f"AAuthResponse(status_code={self.status_code}, "
104
+ f"headers={len(self.headers)} headers)"
105
+ )
106
+
aauth/keys/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Key management and JWK operations for AAuth."""
2
+
aauth/keys/jwk.py ADDED
@@ -0,0 +1,123 @@
1
+ """JWK (JSON Web Key) operations for AAuth."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ from typing import Dict, Any, Optional
7
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
8
+ from cryptography.hazmat.primitives import serialization
9
+
10
+
11
+ def private_key_to_jwk(private_key: Ed25519PrivateKey, kid: Optional[str] = None) -> Dict[str, Any]:
12
+ """Convert Ed25519 private key to JWK format.
13
+
14
+ Args:
15
+ private_key: Ed25519 private key
16
+ kid: Optional key ID
17
+
18
+ Returns:
19
+ JWK dictionary
20
+ """
21
+ public_key = private_key.public_key()
22
+ return public_key_to_jwk(public_key, kid)
23
+
24
+
25
+ def public_key_to_jwk(public_key: Ed25519PublicKey, kid: Optional[str] = None) -> Dict[str, Any]:
26
+ """Convert Ed25519 public key to JWK format.
27
+
28
+ Args:
29
+ public_key: Ed25519 public key
30
+ kid: Optional key ID
31
+
32
+ Returns:
33
+ JWK dictionary
34
+ """
35
+ public_bytes = public_key.public_bytes(
36
+ encoding=serialization.Encoding.Raw,
37
+ format=serialization.PublicFormat.Raw
38
+ )
39
+
40
+ # Ed25519 public key is 32 bytes, encode as base64url
41
+ x = base64.urlsafe_b64encode(public_bytes).decode('utf-8').rstrip('=')
42
+
43
+ jwk = {
44
+ "kty": "OKP",
45
+ "crv": "Ed25519",
46
+ "x": x
47
+ }
48
+
49
+ if kid:
50
+ jwk["kid"] = kid
51
+
52
+ return jwk
53
+
54
+
55
+ def jwk_to_public_key(jwk: Dict[str, Any]) -> Ed25519PublicKey:
56
+ """Convert JWK to Ed25519PublicKey object.
57
+
58
+ Args:
59
+ jwk: JWK dictionary with kty="OKP", crv="Ed25519"
60
+
61
+ Returns:
62
+ Ed25519PublicKey object
63
+
64
+ Raises:
65
+ ValueError: If JWK is not Ed25519 format
66
+ """
67
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
68
+ raise ValueError("JWK must be Ed25519 (OKP, Ed25519)")
69
+
70
+ x = jwk["x"]
71
+ # Add padding if needed
72
+ x += '=' * (4 - len(x) % 4)
73
+ public_bytes = base64.urlsafe_b64decode(x)
74
+
75
+ return Ed25519PublicKey.from_public_bytes(public_bytes)
76
+
77
+
78
+ def calculate_jwk_thumbprint(jwk: Dict[str, Any]) -> str:
79
+ """Calculate JWK Thumbprint per RFC 7638.
80
+
81
+ Args:
82
+ jwk: JWK dictionary (must be canonical - only include required fields)
83
+
84
+ Returns:
85
+ Base64url-encoded SHA-256 hash of canonical JWK JSON
86
+ """
87
+ # Create canonical JWK (only include required fields, sorted)
88
+ # For Ed25519: kty, crv, x (kid excluded from thumbprint)
89
+ canonical_jwk = {}
90
+
91
+ # Required fields in order
92
+ if "kty" in jwk:
93
+ canonical_jwk["kty"] = jwk["kty"]
94
+ if "crv" in jwk:
95
+ canonical_jwk["crv"] = jwk["crv"]
96
+ if "x" in jwk:
97
+ canonical_jwk["x"] = jwk["x"]
98
+
99
+ # Convert to canonical JSON (no spaces, sorted keys)
100
+ canonical_json = json.dumps(canonical_jwk, separators=(',', ':'), sort_keys=True)
101
+
102
+ # SHA-256 hash
103
+ hash_bytes = hashlib.sha256(canonical_json.encode('utf-8')).digest()
104
+
105
+ # Base64url encode (no padding)
106
+ thumbprint = base64.urlsafe_b64encode(hash_bytes).decode('utf-8').rstrip('=')
107
+
108
+ return thumbprint
109
+
110
+
111
+ def generate_jwks(keys: list[Dict[str, Any]]) -> Dict[str, Any]:
112
+ """Generate a JWKS document from a list of JWKs.
113
+
114
+ Args:
115
+ keys: List of JWK dictionaries
116
+
117
+ Returns:
118
+ JWKS document dictionary
119
+ """
120
+ return {
121
+ "keys": keys
122
+ }
123
+
aauth/keys/jwks.py ADDED
@@ -0,0 +1,191 @@
1
+ """JWKS fetching and caching for AAuth."""
2
+
3
+ import time
4
+ from typing import Dict, Any, Optional, Callable, Protocol
5
+ from ..errors import JWKSError
6
+
7
+
8
+ class HTTPClient(Protocol):
9
+ """Protocol for HTTP client implementations."""
10
+
11
+ async def fetch_json(self, url: str) -> Dict[str, Any]:
12
+ """Fetch JSON from URL.
13
+
14
+ Args:
15
+ url: URL to fetch
16
+
17
+ Returns:
18
+ Parsed JSON dictionary
19
+
20
+ Raises:
21
+ Exception: If fetch fails
22
+ """
23
+ ...
24
+
25
+
26
+ class DefaultHTTPClient:
27
+ """Default httpx-based HTTP client."""
28
+
29
+ async def fetch_json(self, url: str) -> Dict[str, Any]:
30
+ """Fetch JSON using httpx."""
31
+ try:
32
+ import httpx
33
+ async with httpx.AsyncClient() as client:
34
+ response = await client.get(url, timeout=10.0)
35
+ response.raise_for_status()
36
+ return response.json()
37
+ except ImportError:
38
+ raise JWKSError("httpx not installed. Install it or provide custom HTTP client.")
39
+ except Exception as e:
40
+ raise JWKSError(f"Failed to fetch JWKS from {url}: {e}", jwks_uri=url)
41
+
42
+
43
+ class JWKSCache:
44
+ """Simple in-memory cache for JWKS documents."""
45
+
46
+ def __init__(self, ttl: int = 3600):
47
+ """Initialize cache.
48
+
49
+ Args:
50
+ ttl: Time-to-live in seconds (default: 1 hour)
51
+ """
52
+ self._cache: Dict[str, tuple[Dict[str, Any], float]] = {}
53
+ self._ttl = ttl
54
+
55
+ def get(self, url: str) -> Optional[Dict[str, Any]]:
56
+ """Get cached JWKS if still valid.
57
+
58
+ Args:
59
+ url: JWKS URL
60
+
61
+ Returns:
62
+ Cached JWKS or None if expired/not found
63
+ """
64
+ if url not in self._cache:
65
+ return None
66
+
67
+ jwks, cached_at = self._cache[url]
68
+ if time.time() - cached_at > self._ttl:
69
+ del self._cache[url]
70
+ return None
71
+
72
+ return jwks
73
+
74
+ def set(self, url: str, jwks: Dict[str, Any]) -> None:
75
+ """Cache JWKS.
76
+
77
+ Args:
78
+ url: JWKS URL
79
+ jwks: JWKS document
80
+ """
81
+ self._cache[url] = (jwks, time.time())
82
+
83
+ def clear(self) -> None:
84
+ """Clear all cached entries."""
85
+ self._cache.clear()
86
+
87
+
88
+ class JWKSFetcher:
89
+ """JWKS fetcher with caching support."""
90
+
91
+ def __init__(
92
+ self,
93
+ http_client: Optional[HTTPClient] = None,
94
+ cache: Optional[JWKSCache] = None,
95
+ cache_ttl: int = 3600
96
+ ):
97
+ """Initialize JWKS fetcher.
98
+
99
+ Args:
100
+ http_client: HTTP client implementation (default: DefaultHTTPClient)
101
+ cache: Cache implementation (default: JWKSCache with specified TTL)
102
+ cache_ttl: Cache TTL in seconds (used if cache not provided)
103
+ """
104
+ self._http_client = http_client or DefaultHTTPClient()
105
+ self._cache = cache or JWKSCache(ttl=cache_ttl)
106
+
107
+ async def fetch(
108
+ self,
109
+ identifier: str,
110
+ kid: Optional[str] = None,
111
+ well_known: Optional[str] = None
112
+ ) -> Dict[str, Any]:
113
+ """Fetch JWKS for an identifier.
114
+
115
+ Args:
116
+ identifier: Agent/resource/auth server identifier (HTTPS URL)
117
+ kid: Optional key ID (for cache key)
118
+ well_known: Optional well-known metadata document name
119
+
120
+ Returns:
121
+ JWKS document dictionary
122
+
123
+ Raises:
124
+ JWKSError: If fetch fails
125
+ """
126
+ # Determine JWKS URL
127
+ if well_known:
128
+ # Fetch metadata first, then extract jwks_uri
129
+ metadata_url = f"{identifier}/.well-known/{well_known}"
130
+ try:
131
+ metadata = await self._http_client.fetch_json(metadata_url)
132
+ jwks_uri = metadata.get("jwks_uri")
133
+ if not jwks_uri:
134
+ raise JWKSError(
135
+ f"No jwks_uri in metadata from {metadata_url}",
136
+ jwks_uri=metadata_url
137
+ )
138
+ except Exception as e:
139
+ raise JWKSError(
140
+ f"Failed to fetch metadata from {metadata_url}: {e}",
141
+ jwks_uri=metadata_url
142
+ )
143
+ else:
144
+ # Assume identifier is JWKS URL or can be fetched directly
145
+ jwks_uri = identifier
146
+
147
+ # Check cache
148
+ cache_key = f"{jwks_uri}:{kid}" if kid else jwks_uri
149
+ cached = self._cache.get(cache_key)
150
+ if cached:
151
+ return cached
152
+
153
+ # Fetch JWKS
154
+ try:
155
+ jwks = await self._http_client.fetch_json(jwks_uri)
156
+
157
+ # Validate JWKS structure
158
+ if not isinstance(jwks, dict) or "keys" not in jwks:
159
+ raise JWKSError(
160
+ f"Invalid JWKS structure from {jwks_uri}",
161
+ jwks_uri=jwks_uri
162
+ )
163
+
164
+ # Cache it
165
+ self._cache.set(cache_key, jwks)
166
+
167
+ return jwks
168
+ except JWKSError:
169
+ raise
170
+ except Exception as e:
171
+ raise JWKSError(
172
+ f"Failed to fetch JWKS from {jwks_uri}: {e}",
173
+ jwks_uri=jwks_uri
174
+ )
175
+
176
+ def get_key_by_kid(self, jwks: Dict[str, Any], kid: str) -> Optional[Dict[str, Any]]:
177
+ """Get key from JWKS by kid.
178
+
179
+ Args:
180
+ jwks: JWKS document
181
+ kid: Key ID
182
+
183
+ Returns:
184
+ JWK dictionary or None if not found
185
+ """
186
+ keys = jwks.get("keys", [])
187
+ for key in keys:
188
+ if key.get("kid") == kid:
189
+ return key
190
+ return None
191
+
aauth/keys/keypair.py ADDED
@@ -0,0 +1,16 @@
1
+ """Key pair generation for AAuth."""
2
+
3
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
4
+ from typing import Tuple
5
+
6
+
7
+ def generate_ed25519_keypair() -> Tuple[Ed25519PrivateKey, Ed25519PublicKey]:
8
+ """Generate a new Ed25519 key pair.
9
+
10
+ Returns:
11
+ Tuple of (private_key, public_key)
12
+ """
13
+ private_key = Ed25519PrivateKey.generate()
14
+ public_key = private_key.public_key()
15
+ return private_key, public_key
16
+
@@ -0,0 +1,2 @@
1
+ """Metadata discovery for AAuth."""
2
+