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.
- aauth/__init__.py +137 -0
- aauth/agent/__init__.py +2 -0
- aauth/agent/challenge_handler.py +61 -0
- aauth/agent/signer.py +78 -0
- aauth/errors.py +51 -0
- aauth/headers/__init__.py +2 -0
- aauth/headers/agent_auth.py +146 -0
- aauth/headers/signature.py +53 -0
- aauth/headers/signature_input.py +65 -0
- aauth/headers/signature_key.py +99 -0
- aauth/http/__init__.py +2 -0
- aauth/http/request.py +140 -0
- aauth/http/response.py +106 -0
- aauth/keys/__init__.py +2 -0
- aauth/keys/jwk.py +123 -0
- aauth/keys/jwks.py +191 -0
- aauth/keys/keypair.py +16 -0
- aauth/metadata/__init__.py +2 -0
- aauth/metadata/agent.py +20 -0
- aauth/metadata/auth_server.py +114 -0
- aauth/metadata/resource.py +42 -0
- aauth/resource/__init__.py +2 -0
- aauth/resource/challenge_builder.py +87 -0
- aauth/resource/token_issuer.py +74 -0
- aauth/resource/verifier.py +170 -0
- aauth/signing/__init__.py +2 -0
- aauth/signing/algorithms.py +35 -0
- aauth/signing/signature_base.py +193 -0
- aauth/signing/signer.py +138 -0
- aauth/signing/verifier.py +323 -0
- aauth/tokens/__init__.py +2 -0
- aauth/tokens/agent_token.py +202 -0
- aauth/tokens/auth_token.py +227 -0
- aauth/tokens/resource_token.py +68 -0
- aauth-0.1.0.dist-info/METADATA +584 -0
- aauth-0.1.0.dist-info/RECORD +39 -0
- aauth-0.1.0.dist-info/WHEEL +5 -0
- aauth-0.1.0.dist-info/licenses/LICENSE +21 -0
- aauth-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
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
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
|
+
|