agent-auth-rs 1.0.1__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,100 @@
1
+ """Agent Auth RS 校验 SDK(spec 010 P1b)—— MCP 资源服务器侧 access token 校验。
2
+
3
+ 决策真相源:docs/DESIGN §6/§2 / CONFORMANCE C8.2/C8.3/C8.4/C8.8。RS 侧只消费/校验,不派生 sub。
4
+ """
5
+
6
+ from .types import (
7
+ NAMESPACE,
8
+ AuthResult,
9
+ Jwk,
10
+ RarPolicy,
11
+ RoutePolicy,
12
+ RsSdkConfig,
13
+ ScopeImplications,
14
+ ScopeResolver,
15
+ VerifiedToken,
16
+ VerifyError,
17
+ )
18
+ from .sdk import RsSdk
19
+ from .authorization import (
20
+ create_scope_resolver,
21
+ derive_resource_metadata_url,
22
+ normalize_resource_id,
23
+ validate_resource_metadata_url,
24
+ validate_scope_token,
25
+ )
26
+ from .introspection import (
27
+ IntrospectionClient,
28
+ IntrospectionConfig,
29
+ IntrospectionResult,
30
+ )
31
+ from .rar import (
32
+ AccessRequest,
33
+ RarResult,
34
+ RAR_TYPE_V1,
35
+ PolicyDecision,
36
+ PolicyEvaluator,
37
+ enforce_rar,
38
+ extract_authorization_details,
39
+ )
40
+ from .dpop import (
41
+ DPoPResult,
42
+ verify_dpop_proof,
43
+ compute_jkt,
44
+ compute_ath,
45
+ normalize_htu,
46
+ )
47
+ from .payment import (
48
+ PAYMENT_EXECUTE_SCOPE,
49
+ PAYMENT_RAR_TYPE_V1,
50
+ PAYMENT_TERMS_DIGEST_DOMAIN,
51
+ PaymentAuthorizationError,
52
+ VerifiedPaymentAuthorization,
53
+ payment_terms_canonical_bytes,
54
+ payment_terms_digest,
55
+ validate_payment_terms,
56
+ verify_payment_authorization,
57
+ )
58
+
59
+ __all__ = [
60
+ "NAMESPACE",
61
+ "AuthResult",
62
+ "Jwk",
63
+ "RarPolicy",
64
+ "RoutePolicy",
65
+ "RsSdkConfig",
66
+ "ScopeImplications",
67
+ "ScopeResolver",
68
+ "VerifiedToken",
69
+ "VerifyError",
70
+ "RsSdk",
71
+ "create_scope_resolver",
72
+ "derive_resource_metadata_url",
73
+ "normalize_resource_id",
74
+ "validate_resource_metadata_url",
75
+ "validate_scope_token",
76
+ "IntrospectionClient",
77
+ "IntrospectionConfig",
78
+ "IntrospectionResult",
79
+ "AccessRequest",
80
+ "RarResult",
81
+ "RAR_TYPE_V1",
82
+ "PolicyDecision",
83
+ "PolicyEvaluator",
84
+ "enforce_rar",
85
+ "extract_authorization_details",
86
+ "DPoPResult",
87
+ "verify_dpop_proof",
88
+ "compute_jkt",
89
+ "compute_ath",
90
+ "normalize_htu",
91
+ "PAYMENT_EXECUTE_SCOPE",
92
+ "PAYMENT_RAR_TYPE_V1",
93
+ "PAYMENT_TERMS_DIGEST_DOMAIN",
94
+ "PaymentAuthorizationError",
95
+ "VerifiedPaymentAuthorization",
96
+ "payment_terms_canonical_bytes",
97
+ "payment_terms_digest",
98
+ "validate_payment_terms",
99
+ "verify_payment_authorization",
100
+ ]
@@ -0,0 +1,188 @@
1
+ """RFC 9728 challenge URL and explicit scope implication helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ from collections.abc import Mapping, Sequence
8
+ from urllib.parse import urlsplit
9
+
10
+ from .types import ScopeImplications, ScopeResolver
11
+
12
+ _SCOPE_TOKEN = re.compile(r"^[\x21\x23-\x5b\x5d-\x7e]+$")
13
+ _DOT_SEGMENT = re.compile(r"^(?:\.|%2e){1,2}$", re.IGNORECASE)
14
+ _URI_PATH = re.compile(
15
+ r"^(?:[a-z0-9\-._~!$&'()*+,;=:@/]|%[0-9a-f]{2})*$", re.IGNORECASE
16
+ )
17
+ _URI_QUERY = re.compile(
18
+ r"^(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*$", re.IGNORECASE
19
+ )
20
+ _CANONICAL_IPV4 = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
21
+ _NUMBERISH_HOST = re.compile(
22
+ r"^(?:0x[0-9a-f]+|\d+)(?:\.(?:0x[0-9a-f]+|\d+))*$", re.IGNORECASE
23
+ )
24
+ _PROTECTED_RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource"
25
+
26
+
27
+ def _validate_hostname(hostname: str, label: str) -> None:
28
+ if ":" in hostname:
29
+ try:
30
+ ipaddress.IPv6Address(hostname)
31
+ except ValueError as exc:
32
+ raise ValueError(f"{label} must use a valid IPv6 host") from exc
33
+ return
34
+ without_final_dot = hostname[:-1] if hostname.endswith(".") else hostname
35
+ labels = without_final_dot.split(".")
36
+ if any(
37
+ not part
38
+ or len(part) > 63
39
+ or re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", part, re.IGNORECASE) is None
40
+ for part in labels
41
+ ):
42
+ raise ValueError(f"{label} must use a valid DNS host")
43
+
44
+
45
+ def _explicit_port(netloc: str) -> int | None:
46
+ if netloc.startswith("["):
47
+ suffix = netloc[netloc.index("]") + 1 :]
48
+ return int(suffix[1:]) if suffix.startswith(":") and len(suffix) > 1 else None
49
+ _, separator, port = netloc.rpartition(":")
50
+ return int(port) if separator and port else None
51
+
52
+
53
+ def _parse_https_url(value: str, label: str, allow_query: bool):
54
+ if (
55
+ not isinstance(value, str)
56
+ or not value
57
+ or any(ord(char) < 0x21 or ord(char) > 0x7E for char in value)
58
+ or '"' in value
59
+ or "\\" in value
60
+ ):
61
+ raise ValueError(f"{label} must contain only safe printable URI characters")
62
+ if re.match(r"^https://", value, re.IGNORECASE) is None:
63
+ raise ValueError(f"{label} must be an absolute HTTPS URL")
64
+
65
+ try:
66
+ parsed = urlsplit(value)
67
+ port = parsed.port
68
+ except ValueError as exc:
69
+ raise ValueError(f"{label} must be an absolute HTTPS URL") from exc
70
+ if parsed.scheme.lower() != "https" or not parsed.hostname:
71
+ raise ValueError(f"{label} must be an absolute HTTPS URL")
72
+ if parsed.username is not None or parsed.password is not None:
73
+ raise ValueError(f"{label} must not contain userinfo")
74
+ if "%" in parsed.netloc:
75
+ raise ValueError(f"{label} must not contain an encoded authority")
76
+ if (
77
+ _URI_PATH.fullmatch(parsed.path) is None
78
+ or _URI_QUERY.fullmatch(parsed.query) is None
79
+ ):
80
+ raise ValueError(f"{label} path and query must use valid RFC 3986 characters")
81
+ if "#" in value:
82
+ raise ValueError(f"{label} must not contain a fragment")
83
+ if not allow_query and "?" in value:
84
+ raise ValueError(f"{label} must not contain a query")
85
+ if not allow_query and any(
86
+ _DOT_SEGMENT.fullmatch(segment) for segment in parsed.path.split("/")
87
+ ):
88
+ raise ValueError(f"{label} must not contain dot path segments")
89
+ numeric_hostname = (
90
+ parsed.hostname[:-1] if parsed.hostname.endswith(".") else parsed.hostname
91
+ )
92
+ if _NUMBERISH_HOST.fullmatch(numeric_hostname):
93
+ parts = numeric_hostname.split(".")
94
+ if (
95
+ parsed.hostname.endswith(".")
96
+ or _CANONICAL_IPV4.fullmatch(numeric_hostname) is None
97
+ or any(int(part) > 255 for part in parts)
98
+ or any(len(part) > 1 and part.startswith("0") for part in parts)
99
+ ):
100
+ raise ValueError(f"{label} must use a canonical host")
101
+ _validate_hostname(parsed.hostname, label)
102
+ return parsed, port
103
+
104
+
105
+ def normalize_resource_id(resource_id: str) -> str:
106
+ """Validate and normalize the resource identifier used for exact audience checks."""
107
+ _parse_https_url(resource_id, "resource_id", allow_query=False)
108
+ return resource_id.rstrip("/")
109
+
110
+
111
+ def derive_resource_metadata_url(resource_id: str) -> str:
112
+ """Derive the RFC 9728 endpoint-path protected-resource metadata URL."""
113
+ normalized = normalize_resource_id(resource_id)
114
+ parsed, port = _parse_https_url(normalized, "resource_id", allow_query=False)
115
+ hostname = parsed.hostname.encode("idna").decode("ascii").lower()
116
+ if ":" in hostname:
117
+ hostname = f"[{ipaddress.IPv6Address(hostname).compressed}]"
118
+ configured_port = _explicit_port(parsed.netloc)
119
+ authority = hostname if configured_port is None else f"{hostname}:{configured_port}"
120
+ resource_path = parsed.path.rstrip("/")
121
+ if resource_path == "/":
122
+ resource_path = ""
123
+ return f"https://{authority}{_PROTECTED_RESOURCE_WELL_KNOWN}{resource_path}"
124
+
125
+
126
+ def validate_resource_metadata_url(resource_metadata_url: str) -> str:
127
+ """Validate an explicit challenge URL while preserving its configured spelling."""
128
+ _parse_https_url(resource_metadata_url, "resource_metadata_url", allow_query=True)
129
+ return resource_metadata_url
130
+
131
+
132
+ def validate_scope_token(scope: str) -> str:
133
+ """Validate one RFC 6749 scope-token before using it in a response header."""
134
+ if not isinstance(scope, str) or _SCOPE_TOKEN.fullmatch(scope) is None:
135
+ raise ValueError(
136
+ "scope values must be non-empty ASCII scope-token values without "
137
+ "whitespace, quotes, or backslashes"
138
+ )
139
+ return scope
140
+
141
+
142
+ def normalize_required_scopes(scopes: Sequence[str]) -> list[str]:
143
+ return [validate_scope_token(scope) for scope in scopes]
144
+
145
+
146
+ def create_scope_resolver(
147
+ implications: ScopeImplications | None = None,
148
+ ) -> ScopeResolver:
149
+ """Build a transitive resolver from explicit broader -> narrower declarations."""
150
+ source: Mapping[str, Sequence[str]] = implications or {}
151
+ graph: dict[str, set[str]] = {}
152
+ for broader, narrower_scopes in source.items():
153
+ validate_scope_token(broader)
154
+ graph[broader] = {validate_scope_token(scope) for scope in narrower_scopes}
155
+
156
+ visiting: set[str] = set()
157
+ visited: set[str] = set()
158
+
159
+ def visit(scope: str) -> None:
160
+ if scope in visiting:
161
+ raise ValueError(f"scope implication cycle includes {scope}")
162
+ if scope in visited:
163
+ return
164
+ visiting.add(scope)
165
+ for implied in graph.get(scope, set()):
166
+ visit(implied)
167
+ visiting.remove(scope)
168
+ visited.add(scope)
169
+
170
+ for scope in graph:
171
+ visit(scope)
172
+
173
+ def resolves(granted_scope: str, required_scope: str) -> bool:
174
+ if granted_scope == required_scope:
175
+ return True
176
+ pending = list(graph.get(granted_scope, set()))
177
+ seen: set[str] = set()
178
+ while pending:
179
+ candidate = pending.pop()
180
+ if candidate in seen:
181
+ continue
182
+ if candidate == required_scope:
183
+ return True
184
+ seen.add(candidate)
185
+ pending.extend(graph.get(candidate, set()))
186
+ return False
187
+
188
+ return resolves
agent_auth_rs/dpop.py ADDED
@@ -0,0 +1,175 @@
1
+ """Agent Auth RS SDK — DPoP(RFC 9449)proof 校验(spec 010 §5.2 / C8.9,P3 能力,独立库提前备)。
2
+
3
+ DPoP 的 sender-constraint **强制点在 RS**——AS 只签发 `cnf.jkt`-bound token,真正拦"token 被盗用"
4
+ 要 RS 校验客户端每次请求带的 **DPoP proof**(证明持有对应私钥)。本模块提供**纯逻辑校验器**(零 IO):
5
+ 给定 proof JWT + token 的 `cnf.jkt` + 请求上下文(htu/htm)+ access token → 校验通过/拒。
6
+
7
+ **⚠️ 阶段与启用**:整体 DPoP 标 **P3**(硬化;P0–P2 用 bearer,AS 不签 cnf)。本校验器作为**独立分发库
8
+ 能力提前就位**(与 grant-ref[AS 内闭环]不同——RS SDK 可消费任意符合 RFC 9449 的 AS 的 cnf token,
9
+ 生态价值独立于本 AS 是否签 cnf,codex+Kiro 双评审确认)。**默认不启用**:token 无 `cnf.jkt` 时跳过 DPoP
10
+ (P0–P2 bearer 假设不破坏);token 有 `cnf.jkt` 时 MUST 校验 proof(无 proof / 校验失败 → 拒)。
11
+
12
+ **校验步骤(RFC 9449 §4.3,双评审红线全覆盖)**:
13
+ 1. header:`typ == "dpop+jwt"`;`alg` 非 none/非对称、与嵌入 `jwk` 类型一致(防 alg 混淆);
14
+ 2. 签名:用 proof **内嵌 jwk** 自验签(DPoP proof 自包含公钥);
15
+ 3. `jkt`:RFC 7638 JWK thumbprint(规范化 JSON→SHA-256→base64url)MUST == token 的 `cnf.jkt`;
16
+ 4. `htm`/`htu`:htm==请求方法;htu==请求 URL **去 query/fragment**后匹配(防 query 变化绕过);
17
+ 5. `iat`:在接受窗口内(防陈旧 proof);`jti`:**SDK 提供 jti 供 RS 去重,重放缓存是 RS 责任**;
18
+ 6. `ath`(若 access_token 提供):`ath == base64url(SHA256(access_token))`(防 proof 换绑到别的 token);
19
+ 7. `nonce`(若服务端下发):MUST 精确匹配。
20
+
21
+ RS 侧 jti 重放缓存不在本模块(RS 责任;窗口 = iat 接受范围)。决策真相源:docs §2(cnf.jkt)/§6;C8.9。
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import json
29
+ import time
30
+ from dataclasses import dataclass
31
+ from typing import Any, Callable, Optional
32
+
33
+ import jwt
34
+ from jwt import PyJWK
35
+
36
+ from .types import VerifyError
37
+
38
+
39
+ @dataclass
40
+ class DPoPResult:
41
+ """DPoP 校验通过的结果。"""
42
+
43
+ jkt: str # proof 公钥的 thumbprint(= token.cnf.jkt)
44
+ jti: str # proof 的 jti(供 RS 侧重放缓存去重)
45
+ iat: int
46
+
47
+
48
+ def _b64u_no_pad(b: bytes) -> str:
49
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
50
+
51
+
52
+ def compute_jkt(jwk: dict) -> str:
53
+ """RFC 7638 JWK thumbprint(base64url(SHA-256(规范化 JSON)))。
54
+ 规范化 = 只取该 kty 的 required 成员、按字典序、无空白、UTF-8。"""
55
+ kty = jwk.get("kty")
56
+ if kty == "EC":
57
+ members = {"crv": jwk["crv"], "kty": "EC", "x": jwk["x"], "y": jwk["y"]}
58
+ elif kty == "RSA":
59
+ members = {"e": jwk["e"], "kty": "RSA", "n": jwk["n"]}
60
+ elif kty == "OKP":
61
+ members = {"crv": jwk["crv"], "kty": "OKP", "x": jwk["x"]}
62
+ else:
63
+ raise VerifyError("invalid_token", f"DPoP jwk 不支持的 kty: {kty}")
64
+ canon = json.dumps(members, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
65
+ return _b64u_no_pad(hashlib.sha256(canon.encode("utf-8")).digest())
66
+
67
+
68
+ def compute_ath(access_token: str) -> str:
69
+ """access token hash(base64url(SHA-256(access_token 的 ASCII 字节)))。"""
70
+ return _b64u_no_pad(hashlib.sha256(access_token.encode("ascii")).digest())
71
+
72
+
73
+ def normalize_htu(url: str) -> str:
74
+ """htu 规范化(RFC 9449 §4.3):去 query 与 fragment,只保留 scheme://host[:port]/path。"""
75
+ # 先去 fragment 再去 query(顺序:# 可能在 ? 后)。
76
+ url = url.split("#", 1)[0]
77
+ url = url.split("?", 1)[0]
78
+ return url
79
+
80
+
81
+ def _expected_alg(jwk: dict) -> Optional[str]:
82
+ kty = jwk.get("kty")
83
+ if kty == "EC" and jwk.get("crv") == "P-256":
84
+ return "ES256"
85
+ if kty == "RSA":
86
+ return "RS256"
87
+ return None
88
+
89
+
90
+ def verify_dpop_proof(
91
+ proof_jwt: str,
92
+ token_cnf_jkt: str,
93
+ htm: str,
94
+ htu: str,
95
+ *,
96
+ access_token: Optional[str] = None,
97
+ expected_nonce: Optional[str] = None,
98
+ iat_window_secs: int = 300,
99
+ now: Optional[Callable[[], float]] = None,
100
+ ) -> DPoPResult:
101
+ """校验一个 DPoP proof(RFC 9449 §4.3)。成功返回 DPoPResult;失败 raise VerifyError。
102
+
103
+ - `token_cnf_jkt`:access token 的 `cnf.jkt`(RS 从已验签 token 取);proof 公钥 thumbprint MUST 等它。
104
+ - `htm`/`htu`:本次请求方法/URL(htu 内部规范化去 query/fragment)。
105
+ - `access_token`:若提供,校 proof.ath == SHA256(access_token)(防 proof 换绑)。
106
+ - `expected_nonce`:若服务端下发 nonce,MUST 匹配。
107
+ - `iat_window_secs`:iat 接受窗口(默认 5min)。
108
+ """
109
+ now_fn = now or time.time
110
+ # 1. header:typ + alg + 内嵌 jwk。
111
+ try:
112
+ header = jwt.get_unverified_header(proof_jwt)
113
+ except Exception as e: # noqa: BLE001
114
+ raise VerifyError("invalid_token", f"DPoP proof header 非法: {e}")
115
+ if header.get("typ") != "dpop+jwt":
116
+ raise VerifyError("invalid_token", "DPoP proof typ 必须 dpop+jwt")
117
+ alg = header.get("alg")
118
+ if not alg or alg == "none":
119
+ raise VerifyError("invalid_token", "DPoP proof alg:none 一律拒")
120
+ jwk = header.get("jwk")
121
+ if not isinstance(jwk, dict):
122
+ raise VerifyError("invalid_token", "DPoP proof 缺内嵌 jwk")
123
+ # 私钥字段绝不该出现在 proof 的 jwk(只该是公钥);出现即拒(防误信私钥材料)。
124
+ if any(k in jwk for k in ("d", "p", "q", "dp", "dq", "qi")):
125
+ raise VerifyError("invalid_token", "DPoP jwk 含私钥字段(必须只含公钥)")
126
+ want = _expected_alg(jwk)
127
+ if want is None:
128
+ raise VerifyError("invalid_token", f"DPoP jwk 不支持的类型: {jwk.get('kty')}")
129
+ if alg != want:
130
+ raise VerifyError("invalid_token", f"DPoP alg {alg} 与 jwk 类型(应 {want})不符(防 alg 混淆)")
131
+
132
+ # 2. 签名:用内嵌 jwk 自验。
133
+ try:
134
+ key = PyJWK.from_dict(jwk).key
135
+ claims = jwt.decode(
136
+ proof_jwt,
137
+ key=key,
138
+ algorithms=[want],
139
+ options={"require": ["iat", "jti", "htm", "htu"], "verify_aud": False},
140
+ )
141
+ except Exception as e: # noqa: BLE001
142
+ raise VerifyError("invalid_token", f"DPoP proof 签名/必需 claim 校验失败: {e}")
143
+
144
+ # 3. jkt 匹配 token.cnf.jkt(sender-constraint 核心)。
145
+ jkt = compute_jkt(jwk)
146
+ if jkt != token_cnf_jkt:
147
+ raise VerifyError("invalid_token", "DPoP proof jkt 不匹配 token cnf.jkt(sender-constraint)")
148
+
149
+ # 4. htm/htu。
150
+ if claims.get("htm") != htm:
151
+ raise VerifyError("invalid_token", "DPoP htm 不匹配请求方法")
152
+ if normalize_htu(str(claims.get("htu", ""))) != normalize_htu(htu):
153
+ raise VerifyError("invalid_token", "DPoP htu 不匹配请求 URL")
154
+
155
+ # 5. iat 新鲜度(窗口内)。
156
+ iat = claims.get("iat")
157
+ if not isinstance(iat, (int, float)):
158
+ raise VerifyError("invalid_token", "DPoP proof iat 非法")
159
+ if abs(now_fn() - iat) > iat_window_secs:
160
+ raise VerifyError("invalid_token", "DPoP proof iat 超出接受窗口(陈旧/时钟偏差)")
161
+
162
+ # 6. ath(若提供 access_token)。
163
+ if access_token is not None:
164
+ expected_ath = compute_ath(access_token)
165
+ if claims.get("ath") != expected_ath:
166
+ raise VerifyError("invalid_token", "DPoP ath 不匹配 access token(防 proof 换绑)")
167
+
168
+ # 7. nonce(若服务端下发)。
169
+ if expected_nonce is not None and claims.get("nonce") != expected_nonce:
170
+ raise VerifyError("invalid_token", "DPoP nonce 不匹配")
171
+
172
+ jti = claims.get("jti")
173
+ if not isinstance(jti, str) or not jti:
174
+ raise VerifyError("invalid_token", "DPoP proof 缺 jti")
175
+ return DPoPResult(jkt=jkt, jti=jti, iat=int(iat))