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.
- agent_auth_rs/__init__.py +100 -0
- agent_auth_rs/authorization.py +188 -0
- agent_auth_rs/dpop.py +175 -0
- agent_auth_rs/introspection.py +247 -0
- agent_auth_rs/jwks_cache.py +106 -0
- agent_auth_rs/payment.py +436 -0
- agent_auth_rs/py.typed +1 -0
- agent_auth_rs/rar.py +379 -0
- agent_auth_rs/sdk.py +230 -0
- agent_auth_rs/types.py +85 -0
- agent_auth_rs/verifier.py +135 -0
- agent_auth_rs-1.0.1.dist-info/METADATA +72 -0
- agent_auth_rs-1.0.1.dist-info/RECORD +17 -0
- agent_auth_rs-1.0.1.dist-info/WHEEL +5 -0
- agent_auth_rs-1.0.1.dist-info/licenses/LICENSE +201 -0
- agent_auth_rs-1.0.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +14 -0
- agent_auth_rs-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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))
|