cimdkit 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.
- cimdkit/__init__.py +53 -0
- cimdkit/__main__.py +5 -0
- cimdkit/cli.py +95 -0
- cimdkit/document.py +327 -0
- cimdkit/errors.py +68 -0
- cimdkit/fetch.py +213 -0
- cimdkit/resolver.py +143 -0
- cimdkit/urls.py +146 -0
- cimdkit-0.1.0.dist-info/METADATA +188 -0
- cimdkit-0.1.0.dist-info/RECORD +13 -0
- cimdkit-0.1.0.dist-info/WHEEL +4 -0
- cimdkit-0.1.0.dist-info/entry_points.txt +2 -0
- cimdkit-0.1.0.dist-info/licenses/LICENSE +21 -0
cimdkit/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""cimdkit: OAuth Client ID Metadata Documents (CIMD) for authorization servers.
|
|
2
|
+
|
|
3
|
+
MCP clients identify themselves with a URL ``client_id`` instead of
|
|
4
|
+
registering. cimdkit fetches that URL safely, validates the document against
|
|
5
|
+
draft-ietf-oauth-client-id-metadata-document, caches it by its HTTP headers and
|
|
6
|
+
matches redirect URIs, including the loopback any-port rule.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .document import ClientMetadata, Policy, make_document, validate_document
|
|
10
|
+
from .errors import (
|
|
11
|
+
BlockedAddress,
|
|
12
|
+
CIMDError,
|
|
13
|
+
ClientIdMismatch,
|
|
14
|
+
FetchError,
|
|
15
|
+
InvalidClientId,
|
|
16
|
+
InvalidDocument,
|
|
17
|
+
InvalidRedirectUri,
|
|
18
|
+
TransientFetchError,
|
|
19
|
+
)
|
|
20
|
+
from .fetch import DEFAULT_MAX_BYTES, FetchResult, Response, fetch_document, is_public_address
|
|
21
|
+
from .resolver import ClientResolver, cache_ttl
|
|
22
|
+
from .urls import check_client_id_url, is_client_id_url, redirect_uri_matches
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
#: Add these to your RFC 8414 authorization server metadata.
|
|
27
|
+
SERVER_METADATA = {"client_id_metadata_document_supported": True}
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BlockedAddress",
|
|
31
|
+
"CIMDError",
|
|
32
|
+
"ClientIdMismatch",
|
|
33
|
+
"ClientMetadata",
|
|
34
|
+
"ClientResolver",
|
|
35
|
+
"DEFAULT_MAX_BYTES",
|
|
36
|
+
"FetchError",
|
|
37
|
+
"FetchResult",
|
|
38
|
+
"InvalidClientId",
|
|
39
|
+
"InvalidDocument",
|
|
40
|
+
"InvalidRedirectUri",
|
|
41
|
+
"Policy",
|
|
42
|
+
"Response",
|
|
43
|
+
"SERVER_METADATA",
|
|
44
|
+
"TransientFetchError",
|
|
45
|
+
"cache_ttl",
|
|
46
|
+
"check_client_id_url",
|
|
47
|
+
"fetch_document",
|
|
48
|
+
"is_client_id_url",
|
|
49
|
+
"is_public_address",
|
|
50
|
+
"make_document",
|
|
51
|
+
"redirect_uri_matches",
|
|
52
|
+
"validate_document",
|
|
53
|
+
]
|
cimdkit/__main__.py
ADDED
cimdkit/cli.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``cimdkit check``: test a Client ID Metadata Document the way a server will."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .document import Policy, validate_document
|
|
13
|
+
from .errors import CIMDError
|
|
14
|
+
from .fetch import DEFAULT_MAX_BYTES, fetch_document
|
|
15
|
+
from .resolver import cache_ttl
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parser() -> argparse.ArgumentParser:
|
|
19
|
+
p = argparse.ArgumentParser(
|
|
20
|
+
prog="cimdkit",
|
|
21
|
+
description="Check an OAuth Client ID Metadata Document (CIMD) as an authorization "
|
|
22
|
+
"server would: fetch rules, spec rules and redirect URIs.")
|
|
23
|
+
p.add_argument("--version", action="version", version=f"cimdkit {__version__}")
|
|
24
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
25
|
+
c = sub.add_parser("check", help="fetch (or read) and validate a metadata document")
|
|
26
|
+
c.add_argument("client_id", help="the Client Identifier URL")
|
|
27
|
+
c.add_argument("--file", help="validate this local JSON file instead of fetching the URL")
|
|
28
|
+
c.add_argument("--redirect-uri", action="append", default=[],
|
|
29
|
+
help="also check that this redirect_uri would be accepted (repeatable)")
|
|
30
|
+
c.add_argument("--auth-method", action="append",
|
|
31
|
+
help="auth methods the server supports (default: none, private_key_jwt)")
|
|
32
|
+
c.add_argument("--allow-loopback", action="store_true",
|
|
33
|
+
help="allow fetching from 127.0.0.1 / ::1 (development only)")
|
|
34
|
+
c.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
|
|
35
|
+
c.add_argument("--timeout", type=float, default=5.0)
|
|
36
|
+
c.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check(args: argparse.Namespace) -> int:
|
|
41
|
+
policy = Policy(auth_methods=tuple(args.auth_method)) if args.auth_method else Policy()
|
|
42
|
+
headers = {}
|
|
43
|
+
try:
|
|
44
|
+
if args.file:
|
|
45
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
46
|
+
doc = json.load(fh)
|
|
47
|
+
else:
|
|
48
|
+
result = fetch_document(args.client_id, timeout=args.timeout,
|
|
49
|
+
max_bytes=args.max_bytes,
|
|
50
|
+
allow_loopback=args.allow_loopback)
|
|
51
|
+
doc, headers = result.document, dict(result.headers)
|
|
52
|
+
meta = validate_document(doc, args.client_id, policy)
|
|
53
|
+
bad = [u for u in args.redirect_uri if meta.match_redirect_uri(u) is None]
|
|
54
|
+
except CIMDError as exc:
|
|
55
|
+
kind = "transient" if exc.transient else "invalid_client"
|
|
56
|
+
if args.json:
|
|
57
|
+
print(json.dumps({"ok": False, "kind": kind, "error": str(exc)}))
|
|
58
|
+
else:
|
|
59
|
+
print(f"FAIL ({kind}): {exc}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
except (OSError, ValueError) as exc:
|
|
62
|
+
print(f"FAIL: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
ttl = cache_ttl(headers, now=time.time()) if headers else None
|
|
66
|
+
if args.json:
|
|
67
|
+
print(json.dumps({
|
|
68
|
+
"ok": not bad, "client_id": meta.client_id, "client_name": meta.client_name,
|
|
69
|
+
"host": meta.host, "redirect_uris": list(meta.redirect_uris),
|
|
70
|
+
"grant_types": list(meta.grant_types), "response_types": list(meta.response_types),
|
|
71
|
+
"token_endpoint_auth_method": meta.token_endpoint_auth_method,
|
|
72
|
+
"cache_seconds": ttl, "warnings": list(meta.warnings),
|
|
73
|
+
"rejected_redirect_uris": bad,
|
|
74
|
+
}, indent=2))
|
|
75
|
+
else:
|
|
76
|
+
print(f"{'OK' if not bad else 'FAIL'} {meta.client_id}")
|
|
77
|
+
print(f" name {meta.display_name} (host {meta.host})")
|
|
78
|
+
print(f" auth method {meta.token_endpoint_auth_method}")
|
|
79
|
+
print(f" grant types {', '.join(meta.grant_types)}")
|
|
80
|
+
for uri in meta.redirect_uris:
|
|
81
|
+
print(f" redirect_uri {uri}")
|
|
82
|
+
if ttl is not None:
|
|
83
|
+
print(f" cache {int(ttl)} s")
|
|
84
|
+
for w in meta.warnings:
|
|
85
|
+
print(f" warning {w}")
|
|
86
|
+
for uri in bad:
|
|
87
|
+
print(f" rejected {uri} is not a registered redirect_uri")
|
|
88
|
+
return 1 if bad else 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
92
|
+
args = _parser().parse_args(argv)
|
|
93
|
+
if args.command == "check":
|
|
94
|
+
return _check(args)
|
|
95
|
+
return 2 # pragma: no cover
|
cimdkit/document.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Validate a fetched Client ID Metadata Document against the spec and a server policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
from .errors import ClientIdMismatch, InvalidClientId, InvalidDocument, InvalidRedirectUri
|
|
10
|
+
from .urls import (
|
|
11
|
+
_split,
|
|
12
|
+
check_client_id_url,
|
|
13
|
+
client_id_host,
|
|
14
|
+
find_redirect_uri,
|
|
15
|
+
is_loopback_redirect,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
#: Shared-secret methods are forbidden: the document is public.
|
|
19
|
+
SYMMETRIC_AUTH_METHODS = frozenset({"client_secret_post", "client_secret_basic", "client_secret_jwt"})
|
|
20
|
+
|
|
21
|
+
#: JWK members that only appear in private or symmetric keys.
|
|
22
|
+
PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
|
|
23
|
+
|
|
24
|
+
#: Schemes never accepted in redirect_uris.
|
|
25
|
+
FORBIDDEN_REDIRECT_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "about", "blob"})
|
|
26
|
+
|
|
27
|
+
INFORMATIVE_URIS = ("client_uri", "logo_uri", "policy_uri", "tos_uri")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Policy:
|
|
32
|
+
"""What this authorization server supports and allows.
|
|
33
|
+
|
|
34
|
+
``auth_methods`` is in the server's terms; the client's own order of
|
|
35
|
+
preference (``token_endpoint_auth_methods_supported``) picks among them.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
grant_types: Tuple[str, ...] = ("authorization_code", "refresh_token")
|
|
39
|
+
response_types: Tuple[str, ...] = ("code",)
|
|
40
|
+
auth_methods: Tuple[str, ...] = ("none", "private_key_jwt")
|
|
41
|
+
#: Ids the client_id must never equal: your issuer, trusted resource servers.
|
|
42
|
+
forbidden_client_ids: Tuple[str, ...] = ()
|
|
43
|
+
#: Require https redirect_uris to share the client_id's origin.
|
|
44
|
+
same_origin_redirects: bool = False
|
|
45
|
+
#: Accept http://localhost as a loopback redirect (127.0.0.1 and [::1] always are).
|
|
46
|
+
allow_localhost: bool = True
|
|
47
|
+
#: Accept a query in the client_id URL (the spec says SHOULD NOT, not MUST NOT).
|
|
48
|
+
allow_query: bool = True
|
|
49
|
+
#: Only accept client_ids on these hosts: "app.example.com" or "*.example.com".
|
|
50
|
+
#: Empty means any public host.
|
|
51
|
+
allowed_hosts: Tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
def host_allowed(self, host: str) -> bool:
|
|
54
|
+
if not self.allowed_hosts:
|
|
55
|
+
return True
|
|
56
|
+
host = host.lower().rstrip(".")
|
|
57
|
+
for pattern in self.allowed_hosts:
|
|
58
|
+
pattern = pattern.lower().rstrip(".")
|
|
59
|
+
if pattern.startswith("*."):
|
|
60
|
+
if host.endswith(pattern[1:]) and host != pattern[2:]:
|
|
61
|
+
return True
|
|
62
|
+
elif host == pattern:
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ClientMetadata:
|
|
69
|
+
"""A validated client. ``raw`` keeps every property, including unknown ones."""
|
|
70
|
+
|
|
71
|
+
client_id: str
|
|
72
|
+
redirect_uris: Tuple[str, ...]
|
|
73
|
+
grant_types: Tuple[str, ...]
|
|
74
|
+
response_types: Tuple[str, ...]
|
|
75
|
+
token_endpoint_auth_method: str
|
|
76
|
+
client_name: Optional[str] = None
|
|
77
|
+
client_uri: Optional[str] = None
|
|
78
|
+
logo_uri: Optional[str] = None
|
|
79
|
+
policy_uri: Optional[str] = None
|
|
80
|
+
tos_uri: Optional[str] = None
|
|
81
|
+
scope: Optional[str] = None
|
|
82
|
+
jwks: Optional[Mapping[str, Any]] = None
|
|
83
|
+
jwks_uri: Optional[str] = None
|
|
84
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
85
|
+
#: Things that were dropped or look wrong but did not fail validation.
|
|
86
|
+
warnings: Tuple[str, ...] = ()
|
|
87
|
+
allow_localhost: bool = field(default=True, repr=False)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def host(self) -> str:
|
|
91
|
+
"""Show this next to the client name on the consent screen."""
|
|
92
|
+
return client_id_host(self.client_id)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def display_name(self) -> str:
|
|
96
|
+
return self.client_name or self.host
|
|
97
|
+
|
|
98
|
+
def match_redirect_uri(self, redirect_uri: str) -> Optional[str]:
|
|
99
|
+
"""The registered redirect URI that ``redirect_uri`` matches, or None."""
|
|
100
|
+
return find_redirect_uri(redirect_uri, self.redirect_uris,
|
|
101
|
+
allow_localhost=self.allow_localhost)
|
|
102
|
+
|
|
103
|
+
def check_redirect_uri(self, redirect_uri: Optional[str]) -> str:
|
|
104
|
+
"""Return the redirect_uri to use, or raise :class:`InvalidRedirectUri`.
|
|
105
|
+
|
|
106
|
+
With no redirect_uri in the request, the only registered one is used
|
|
107
|
+
(RFC 6749 section 3.1.2.3); with several it is an error.
|
|
108
|
+
"""
|
|
109
|
+
if redirect_uri is None:
|
|
110
|
+
if len(self.redirect_uris) == 1:
|
|
111
|
+
return self.redirect_uris[0]
|
|
112
|
+
raise InvalidRedirectUri("redirect_uri is required: the client registers several")
|
|
113
|
+
if self.match_redirect_uri(redirect_uri) is None:
|
|
114
|
+
raise InvalidRedirectUri(
|
|
115
|
+
f"redirect_uri {redirect_uri!r} is not listed in the client's metadata document")
|
|
116
|
+
return redirect_uri
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _str_list(doc: Mapping[str, Any], key: str) -> Optional[List[str]]:
|
|
120
|
+
if key not in doc:
|
|
121
|
+
return None
|
|
122
|
+
value = doc[key]
|
|
123
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
124
|
+
raise InvalidDocument(f"{key} must be an array of strings")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _opt_str(doc: Mapping[str, Any], key: str) -> Optional[str]:
|
|
129
|
+
value = doc.get(key)
|
|
130
|
+
if value is not None and not isinstance(value, str):
|
|
131
|
+
raise InvalidDocument(f"{key} must be a string")
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _origin(url: str) -> str:
|
|
136
|
+
scheme, netloc, _ = _split(url)
|
|
137
|
+
return f"{scheme}://{netloc.lower()}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_redirect(uri: str, client_id: str, policy: Policy) -> None:
|
|
141
|
+
scheme, colon, _ = uri.partition(":")
|
|
142
|
+
if not colon or not scheme or not scheme[0].isalpha():
|
|
143
|
+
raise InvalidDocument(f"redirect_uri {uri!r} is not an absolute URI")
|
|
144
|
+
if "#" in uri:
|
|
145
|
+
raise InvalidDocument(f"redirect_uri {uri!r} must not contain a fragment")
|
|
146
|
+
lower = scheme.lower()
|
|
147
|
+
if lower in FORBIDDEN_REDIRECT_SCHEMES:
|
|
148
|
+
raise InvalidDocument(f"redirect_uri {uri!r} uses a forbidden scheme")
|
|
149
|
+
if lower == "http":
|
|
150
|
+
if not is_loopback_redirect(uri, allow_localhost=policy.allow_localhost):
|
|
151
|
+
raise InvalidDocument(f"redirect_uri {uri!r}: http is only allowed on loopback")
|
|
152
|
+
elif lower == "https":
|
|
153
|
+
try:
|
|
154
|
+
_split(uri)
|
|
155
|
+
except ValueError:
|
|
156
|
+
raise InvalidDocument(f"redirect_uri {uri!r} is not an absolute URI") from None
|
|
157
|
+
if policy.same_origin_redirects and _origin(uri) != _origin(client_id):
|
|
158
|
+
raise InvalidDocument(f"redirect_uri {uri!r} is not on the client_id's origin")
|
|
159
|
+
# Any other scheme is a private-use scheme for native apps (com.example.app:/cb).
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _check_jwks(jwks: Any) -> None:
|
|
163
|
+
if not isinstance(jwks, dict) or not isinstance(jwks.get("keys"), list):
|
|
164
|
+
raise InvalidDocument('jwks must be an object with a "keys" array')
|
|
165
|
+
for key in jwks["keys"]:
|
|
166
|
+
if not isinstance(key, dict):
|
|
167
|
+
raise InvalidDocument("each key in jwks must be an object")
|
|
168
|
+
leaked = PRIVATE_JWK_MEMBERS.intersection(key)
|
|
169
|
+
if leaked:
|
|
170
|
+
raise InvalidDocument(
|
|
171
|
+
"jwks contains private or symmetric key material "
|
|
172
|
+
f"({', '.join(sorted(leaked))}); publish public keys only")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _choose_auth_method(doc: Mapping[str, Any], policy: Policy) -> str:
|
|
176
|
+
single = _opt_str(doc, "token_endpoint_auth_method")
|
|
177
|
+
several = _str_list(doc, "token_endpoint_auth_methods_supported")
|
|
178
|
+
declared = ([single] if single else []) + (several or [])
|
|
179
|
+
bad = SYMMETRIC_AUTH_METHODS.intersection(declared)
|
|
180
|
+
if bad:
|
|
181
|
+
raise InvalidDocument(
|
|
182
|
+
f"shared-secret auth method {sorted(bad)[0]!r} is not allowed in a public document")
|
|
183
|
+
if single and several is not None and single not in several:
|
|
184
|
+
raise InvalidDocument(
|
|
185
|
+
"token_endpoint_auth_method must be one of token_endpoint_auth_methods_supported")
|
|
186
|
+
if several is not None:
|
|
187
|
+
candidates = several
|
|
188
|
+
elif single:
|
|
189
|
+
candidates = [single]
|
|
190
|
+
else:
|
|
191
|
+
# The RFC 7591 default (client_secret_basic) must NOT be applied.
|
|
192
|
+
candidates = ["none"]
|
|
193
|
+
for method in candidates:
|
|
194
|
+
if method in policy.auth_methods:
|
|
195
|
+
return method
|
|
196
|
+
raise InvalidDocument(
|
|
197
|
+
f"no supported token endpoint auth method: client offers {candidates}, "
|
|
198
|
+
f"this server supports {list(policy.auth_methods)}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _intersect(doc_values: Optional[List[str]], default: Sequence[str],
|
|
202
|
+
supported: Sequence[str], key: str) -> Tuple[str, ...]:
|
|
203
|
+
values = default if doc_values is None else doc_values
|
|
204
|
+
common = tuple(dict.fromkeys(v for v in values if v in supported))
|
|
205
|
+
if not common:
|
|
206
|
+
raise InvalidDocument(
|
|
207
|
+
f"none of the client's {key} {list(values)} are supported (server: {list(supported)})")
|
|
208
|
+
return common
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_document(doc: Any, client_id: str, policy: Optional[Policy] = None) -> ClientMetadata:
|
|
212
|
+
"""Check a parsed document fetched from ``client_id`` and return the client.
|
|
213
|
+
|
|
214
|
+
Raises :class:`InvalidClientId`, :class:`ClientIdMismatch` or
|
|
215
|
+
:class:`InvalidDocument`. Unknown properties are ignored, as the spec and
|
|
216
|
+
RFC 7591 require.
|
|
217
|
+
"""
|
|
218
|
+
policy = policy or Policy()
|
|
219
|
+
check_client_id_url(client_id, allow_query=policy.allow_query)
|
|
220
|
+
if client_id in policy.forbidden_client_ids:
|
|
221
|
+
raise InvalidClientId("client_id must not equal the identifier of a trusted server")
|
|
222
|
+
if not policy.host_allowed(client_id_host(client_id)):
|
|
223
|
+
raise InvalidClientId(f"client_id host {client_id_host(client_id)!r} is not allowed here")
|
|
224
|
+
if not isinstance(doc, dict):
|
|
225
|
+
raise InvalidDocument("the metadata document must be a JSON object")
|
|
226
|
+
|
|
227
|
+
doc_id = doc.get("client_id")
|
|
228
|
+
if doc_id != client_id:
|
|
229
|
+
raise ClientIdMismatch(
|
|
230
|
+
f"document client_id {doc_id!r} does not exactly match the URL it was fetched "
|
|
231
|
+
f"from {client_id!r}")
|
|
232
|
+
|
|
233
|
+
for key in ("client_secret", "client_secret_expires_at"):
|
|
234
|
+
if key in doc:
|
|
235
|
+
raise InvalidDocument(f"{key} must not appear in a public metadata document")
|
|
236
|
+
|
|
237
|
+
method = _choose_auth_method(doc, policy)
|
|
238
|
+
|
|
239
|
+
jwks = doc.get("jwks")
|
|
240
|
+
jwks_uri = _opt_str(doc, "jwks_uri")
|
|
241
|
+
if jwks is not None and jwks_uri is not None:
|
|
242
|
+
raise InvalidDocument("jwks and jwks_uri must not both be present")
|
|
243
|
+
if jwks is not None:
|
|
244
|
+
_check_jwks(jwks)
|
|
245
|
+
if jwks_uri is not None and not jwks_uri.startswith("https://"):
|
|
246
|
+
raise InvalidDocument("jwks_uri must be an https URL")
|
|
247
|
+
if method == "private_key_jwt" and jwks is None and jwks_uri is None:
|
|
248
|
+
raise InvalidDocument("private_key_jwt needs jwks or jwks_uri in the document")
|
|
249
|
+
|
|
250
|
+
grant_types = _intersect(_str_list(doc, "grant_types"), ["authorization_code"],
|
|
251
|
+
policy.grant_types, "grant_types")
|
|
252
|
+
response_types = _intersect(_str_list(doc, "response_types"), ["code"],
|
|
253
|
+
policy.response_types, "response_types")
|
|
254
|
+
|
|
255
|
+
redirect_uris = _str_list(doc, "redirect_uris") or []
|
|
256
|
+
for uri in redirect_uris:
|
|
257
|
+
_check_redirect(uri, client_id, policy)
|
|
258
|
+
if "authorization_code" in grant_types and not redirect_uris:
|
|
259
|
+
raise InvalidDocument("redirect_uris is required for the authorization_code grant")
|
|
260
|
+
|
|
261
|
+
warnings: List[str] = []
|
|
262
|
+
informative = {}
|
|
263
|
+
for key in INFORMATIVE_URIS:
|
|
264
|
+
value = doc.get(key)
|
|
265
|
+
if value is None:
|
|
266
|
+
continue
|
|
267
|
+
if isinstance(value, str) and value.startswith("https://"):
|
|
268
|
+
informative[key] = value
|
|
269
|
+
else:
|
|
270
|
+
warnings.append(f"{key} ignored: only https URLs are shown to users")
|
|
271
|
+
|
|
272
|
+
name = doc.get("client_name")
|
|
273
|
+
if name is not None and not (isinstance(name, str) and name.strip()):
|
|
274
|
+
warnings.append("client_name ignored: not a non-empty string")
|
|
275
|
+
name = None
|
|
276
|
+
|
|
277
|
+
scope = doc.get("scope")
|
|
278
|
+
if scope is not None and not isinstance(scope, str):
|
|
279
|
+
warnings.append("scope ignored: not a string")
|
|
280
|
+
scope = None
|
|
281
|
+
|
|
282
|
+
if "?" in client_id:
|
|
283
|
+
warnings.append("client_id contains a query; the spec says it SHOULD NOT")
|
|
284
|
+
|
|
285
|
+
return ClientMetadata(
|
|
286
|
+
client_id=client_id,
|
|
287
|
+
redirect_uris=tuple(redirect_uris),
|
|
288
|
+
grant_types=grant_types,
|
|
289
|
+
response_types=response_types,
|
|
290
|
+
token_endpoint_auth_method=method,
|
|
291
|
+
client_name=name,
|
|
292
|
+
scope=scope,
|
|
293
|
+
jwks=MappingProxyType(jwks) if jwks is not None else None,
|
|
294
|
+
jwks_uri=jwks_uri,
|
|
295
|
+
raw=MappingProxyType(dict(doc)),
|
|
296
|
+
warnings=tuple(warnings),
|
|
297
|
+
allow_localhost=policy.allow_localhost,
|
|
298
|
+
**informative,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def make_document(client_id: str, *, client_name: str, redirect_uris: Iterable[str],
|
|
303
|
+
grant_types: Iterable[str] = ("authorization_code", "refresh_token"),
|
|
304
|
+
response_types: Iterable[str] = ("code",),
|
|
305
|
+
token_endpoint_auth_method: str = "none",
|
|
306
|
+
**extra: Any) -> dict:
|
|
307
|
+
"""Build a metadata document for a client to publish, and check it first.
|
|
308
|
+
|
|
309
|
+
Raises the same errors as :func:`validate_document` if the result would be
|
|
310
|
+
rejected by a compliant authorization server.
|
|
311
|
+
"""
|
|
312
|
+
doc = {
|
|
313
|
+
"client_id": client_id,
|
|
314
|
+
"client_name": client_name,
|
|
315
|
+
"redirect_uris": list(redirect_uris),
|
|
316
|
+
"grant_types": list(grant_types),
|
|
317
|
+
"response_types": list(response_types),
|
|
318
|
+
"token_endpoint_auth_method": token_endpoint_auth_method,
|
|
319
|
+
}
|
|
320
|
+
doc.update(extra)
|
|
321
|
+
permissive = Policy(
|
|
322
|
+
grant_types=tuple(doc["grant_types"]),
|
|
323
|
+
response_types=tuple(doc["response_types"]),
|
|
324
|
+
auth_methods=(token_endpoint_auth_method,),
|
|
325
|
+
)
|
|
326
|
+
validate_document(doc, client_id, permissive)
|
|
327
|
+
return doc
|
cimdkit/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Errors raised while resolving a Client ID Metadata Document.
|
|
2
|
+
|
|
3
|
+
Every error carries the OAuth error code to return (``invalid_client``) and
|
|
4
|
+
whether the failure is *transient* (network trouble: retry later, keep any
|
|
5
|
+
cached metadata, answer 503) or *terminal* (the document is missing, wrong or
|
|
6
|
+
rejected: drop cached metadata, answer ``invalid_client``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CIMDError(Exception):
|
|
15
|
+
"""Base class. The message is safe to show to the person signing in."""
|
|
16
|
+
|
|
17
|
+
#: OAuth error code for the token endpoint / authorization error page.
|
|
18
|
+
error = "invalid_client"
|
|
19
|
+
#: True for network problems: retry later and keep cached metadata.
|
|
20
|
+
transient = False
|
|
21
|
+
#: True when the spec says the client's refresh tokens should be revoked.
|
|
22
|
+
revoke_tokens = False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def status_code(self) -> int:
|
|
26
|
+
"""HTTP status the spec suggests at the token endpoint."""
|
|
27
|
+
return 503 if self.transient else 400
|
|
28
|
+
|
|
29
|
+
def to_oauth_error(self) -> Dict[str, str]:
|
|
30
|
+
"""``{"error": ..., "error_description": ...}`` for a JSON error body."""
|
|
31
|
+
return {"error": self.error, "error_description": str(self)}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidClientId(CIMDError):
|
|
35
|
+
"""The client_id is not a valid Client Identifier URL, or is not allowed."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InvalidDocument(CIMDError):
|
|
39
|
+
"""The document was fetched but breaks the spec or this server's policy."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClientIdMismatch(InvalidDocument):
|
|
43
|
+
"""The document's ``client_id`` is not exactly the URL it was fetched from."""
|
|
44
|
+
|
|
45
|
+
revoke_tokens = True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidRedirectUri(CIMDError):
|
|
49
|
+
"""The redirect_uri in an authorization request is not registered.
|
|
50
|
+
|
|
51
|
+
Never redirect to it: show the error to the user instead.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
error = "invalid_request"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FetchError(CIMDError):
|
|
58
|
+
"""The document could not be used: wrong status, too large, not JSON..."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BlockedAddress(FetchError):
|
|
62
|
+
"""The host resolves to a private or special-use address (SSRF guard)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TransientFetchError(CIMDError):
|
|
66
|
+
"""Timeout, DNS or connection failure, or a 5xx/429 answer. Try again later."""
|
|
67
|
+
|
|
68
|
+
transient = True
|
cimdkit/fetch.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Fetch a Client ID Metadata Document the way the spec requires, safely.
|
|
2
|
+
|
|
3
|
+
* https only, no redirects followed, ``Accept: application/json``;
|
|
4
|
+
* every address the host resolves to must be public (no loopback, private,
|
|
5
|
+
link-local, CGNAT, multicast or reserved ranges, IPv4-mapped and NAT64
|
|
6
|
+
forms included), and the connection goes to the address that was checked,
|
|
7
|
+
so DNS rebinding cannot swap it afterwards;
|
|
8
|
+
* at most ``max_bytes`` (5 KB by default) are read, under one overall deadline;
|
|
9
|
+
* the body must be a JSON object with no duplicate keys.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import http.client
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import socket
|
|
18
|
+
import ssl
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Callable, Dict, List, Mapping, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from .errors import BlockedAddress, FetchError, TransientFetchError
|
|
24
|
+
from .urls import _split, _split_netloc, check_client_id_url
|
|
25
|
+
|
|
26
|
+
DEFAULT_MAX_BYTES = 5 * 1024
|
|
27
|
+
DEFAULT_TIMEOUT = 5.0
|
|
28
|
+
USER_AGENT = "cimdkit (+https://github.com/srikrishnaL/kitforge)"
|
|
29
|
+
|
|
30
|
+
_NAT64 = ipaddress.ip_network("64:ff9b::/96")
|
|
31
|
+
_TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
32
|
+
|
|
33
|
+
Resolver = Callable[[str, int], Sequence[str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Response:
|
|
38
|
+
status: int
|
|
39
|
+
headers: Dict[str, str]
|
|
40
|
+
body: bytes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Transport = Callable[..., Response]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FetchResult:
|
|
48
|
+
document: dict
|
|
49
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
50
|
+
address: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_public_address(addr: str) -> bool:
|
|
54
|
+
"""False for any special-use address (RFC 6890) an attacker could aim us at."""
|
|
55
|
+
try:
|
|
56
|
+
ip = ipaddress.ip_address(addr.split("%", 1)[0])
|
|
57
|
+
except ValueError:
|
|
58
|
+
return False
|
|
59
|
+
if ip.version == 6:
|
|
60
|
+
if ip.ipv4_mapped is not None:
|
|
61
|
+
return is_public_address(str(ip.ipv4_mapped))
|
|
62
|
+
if ip in _NAT64:
|
|
63
|
+
return is_public_address(str(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)))
|
|
64
|
+
if ip.sixtofour is not None and not is_public_address(str(ip.sixtofour)):
|
|
65
|
+
return False
|
|
66
|
+
if ip.teredo is not None:
|
|
67
|
+
return False
|
|
68
|
+
return bool(ip.is_global) and not ip.is_multicast
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_loopback(addr: str) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
ip = ipaddress.ip_address(addr.split("%", 1)[0])
|
|
74
|
+
except ValueError:
|
|
75
|
+
return False
|
|
76
|
+
if ip.version == 6 and ip.ipv4_mapped is not None:
|
|
77
|
+
ip = ip.ipv4_mapped
|
|
78
|
+
return ip.is_loopback
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def system_resolver(host: str, port: int) -> List[str]:
|
|
82
|
+
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
83
|
+
return list(dict.fromkeys(info[4][0] for info in infos))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
87
|
+
"""HTTPS to a fixed IP, with SNI and certificate checks for the real host name."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, host: str, port: int, address: str, timeout: float,
|
|
90
|
+
context: ssl.SSLContext) -> None:
|
|
91
|
+
super().__init__(host, port, timeout=timeout, context=context)
|
|
92
|
+
self._address = address
|
|
93
|
+
self._ctx = context
|
|
94
|
+
|
|
95
|
+
def connect(self) -> None: # pragma: no cover - exercised by the TLS test
|
|
96
|
+
sock = socket.create_connection((self._address, self.port), self.timeout)
|
|
97
|
+
self.sock = self._ctx.wrap_socket(sock, server_hostname=self.host)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def https_transport(*, host: str, port: int, address: str, target: str,
|
|
101
|
+
headers: Mapping[str, str], timeout: float, max_bytes: int,
|
|
102
|
+
ssl_context: Optional[ssl.SSLContext] = None) -> Response:
|
|
103
|
+
"""Default transport: one GET over a pinned TLS connection, read with a deadline."""
|
|
104
|
+
deadline = time.monotonic() + timeout
|
|
105
|
+
ctx = ssl_context or ssl.create_default_context()
|
|
106
|
+
conn = _PinnedHTTPSConnection(host, port, address, timeout, ctx)
|
|
107
|
+
try:
|
|
108
|
+
conn.request("GET", target, headers=dict(headers))
|
|
109
|
+
resp = conn.getresponse()
|
|
110
|
+
got: Dict[str, str] = {}
|
|
111
|
+
for k, v in resp.getheaders():
|
|
112
|
+
k = k.lower()
|
|
113
|
+
got[k] = f"{got[k]}, {v}" if k in got else v
|
|
114
|
+
length = got.get("content-length", "")
|
|
115
|
+
if resp.status == 200 and length.isdigit() and int(length) > max_bytes:
|
|
116
|
+
raise FetchError(f"metadata document is {length} bytes; the limit is {max_bytes}")
|
|
117
|
+
chunks: List[bytes] = []
|
|
118
|
+
size = 0
|
|
119
|
+
while size <= max_bytes:
|
|
120
|
+
if time.monotonic() > deadline:
|
|
121
|
+
raise TransientFetchError("timed out reading the metadata document")
|
|
122
|
+
chunk = resp.read(min(1024, max_bytes + 1 - size))
|
|
123
|
+
if not chunk:
|
|
124
|
+
break
|
|
125
|
+
chunks.append(chunk)
|
|
126
|
+
size += len(chunk)
|
|
127
|
+
return Response(resp.status, got, b"".join(chunks))
|
|
128
|
+
finally:
|
|
129
|
+
conn.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _no_duplicates(pairs):
|
|
133
|
+
out = {}
|
|
134
|
+
for key, value in pairs:
|
|
135
|
+
if key in out:
|
|
136
|
+
raise FetchError(f"metadata document repeats the property {key!r}")
|
|
137
|
+
out[key] = value
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_json_type(content_type: str) -> bool:
|
|
142
|
+
mime = content_type.split(";", 1)[0].strip().lower()
|
|
143
|
+
return mime == "application/json" or (mime.startswith("application/") and mime.endswith("+json"))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fetch_document(client_id: str, *, timeout: float = DEFAULT_TIMEOUT,
|
|
147
|
+
max_bytes: int = DEFAULT_MAX_BYTES, allow_loopback: bool = False,
|
|
148
|
+
require_json_content_type: bool = True,
|
|
149
|
+
resolver: Optional[Resolver] = None,
|
|
150
|
+
transport: Optional[Transport] = None,
|
|
151
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
152
|
+
user_agent: str = USER_AGENT) -> FetchResult:
|
|
153
|
+
"""Fetch and parse the document at ``client_id``. It is not validated here.
|
|
154
|
+
|
|
155
|
+
``allow_loopback`` lets a development server fetch from 127.0.0.1/::1; the
|
|
156
|
+
spec forbids that in production. ``resolver`` and ``transport`` are hooks
|
|
157
|
+
for tests and custom networking.
|
|
158
|
+
"""
|
|
159
|
+
check_client_id_url(client_id)
|
|
160
|
+
_, netloc, rest = _split(client_id)
|
|
161
|
+
host, port_s, _ = _split_netloc(netloc)
|
|
162
|
+
port = int(port_s) if port_s else 443
|
|
163
|
+
bare = host[1:-1] if host.startswith("[") else host
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
ipaddress.ip_address(bare)
|
|
167
|
+
addresses: Sequence[str] = [bare]
|
|
168
|
+
except ValueError:
|
|
169
|
+
try:
|
|
170
|
+
addresses = (resolver or system_resolver)(bare, port)
|
|
171
|
+
except (OSError, UnicodeError) as exc:
|
|
172
|
+
raise TransientFetchError(f"could not resolve {bare}: {exc}") from None
|
|
173
|
+
if not addresses:
|
|
174
|
+
raise TransientFetchError(f"{bare} has no addresses")
|
|
175
|
+
for addr in addresses:
|
|
176
|
+
if is_public_address(addr) or (allow_loopback and _is_loopback(addr)):
|
|
177
|
+
continue
|
|
178
|
+
raise BlockedAddress(
|
|
179
|
+
f"{bare} resolves to {addr}, a private or special-use address; refusing to fetch it")
|
|
180
|
+
|
|
181
|
+
headers = {"Accept": "application/json", "User-Agent": user_agent, "Host": netloc}
|
|
182
|
+
try:
|
|
183
|
+
resp = (transport or https_transport)(
|
|
184
|
+
host=bare, port=port, address=addresses[0], target=rest, headers=headers,
|
|
185
|
+
timeout=timeout, max_bytes=max_bytes, ssl_context=ssl_context)
|
|
186
|
+
except (FetchError, TransientFetchError):
|
|
187
|
+
raise
|
|
188
|
+
except ssl.SSLCertVerificationError as exc:
|
|
189
|
+
raise FetchError(f"TLS certificate for {bare} is not valid: {exc.verify_message}") from None
|
|
190
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
191
|
+
raise TransientFetchError(f"could not fetch {client_id}: {exc}") from None
|
|
192
|
+
|
|
193
|
+
if resp.status != 200:
|
|
194
|
+
if 300 <= resp.status < 400:
|
|
195
|
+
raise FetchError(f"{client_id} answered {resp.status}; redirects are not followed")
|
|
196
|
+
if resp.status in _TRANSIENT_STATUS:
|
|
197
|
+
raise TransientFetchError(f"{client_id} answered {resp.status}")
|
|
198
|
+
raise FetchError(f"{client_id} answered {resp.status}, not 200")
|
|
199
|
+
if len(resp.body) > max_bytes:
|
|
200
|
+
raise FetchError(f"metadata document is larger than {max_bytes} bytes")
|
|
201
|
+
ctype = resp.headers.get("content-type", "")
|
|
202
|
+
if require_json_content_type and not _is_json_type(ctype):
|
|
203
|
+
raise FetchError(f"metadata document is served as {ctype or 'no content type'!r}, "
|
|
204
|
+
"not application/json")
|
|
205
|
+
try:
|
|
206
|
+
doc = json.loads(resp.body.decode("utf-8"), object_pairs_hook=_no_duplicates)
|
|
207
|
+
except FetchError:
|
|
208
|
+
raise
|
|
209
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
210
|
+
raise FetchError(f"metadata document is not valid JSON: {exc}") from None
|
|
211
|
+
if not isinstance(doc, dict):
|
|
212
|
+
raise FetchError("metadata document must be a JSON object")
|
|
213
|
+
return FetchResult(doc, resp.headers, addresses[0])
|
cimdkit/resolver.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Resolve a client_id to validated metadata, with spec-compliant caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from typing import Callable, Dict, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from .document import ClientMetadata, Policy, validate_document
|
|
12
|
+
from .errors import CIMDError, InvalidClientId, TransientFetchError
|
|
13
|
+
from .fetch import FetchResult, fetch_document
|
|
14
|
+
from .urls import check_client_id_url, client_id_host
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _directives(cache_control: str) -> Dict[str, Optional[str]]:
|
|
18
|
+
out: Dict[str, Optional[str]] = {}
|
|
19
|
+
for part in cache_control.split(","):
|
|
20
|
+
name, eq, value = part.strip().partition("=")
|
|
21
|
+
if name:
|
|
22
|
+
out[name.strip().lower()] = value.strip().strip('"') if eq else None
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _http_date(value: Optional[str]) -> Optional[float]:
|
|
27
|
+
if not value:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return parsedate_to_datetime(value).timestamp()
|
|
31
|
+
except (TypeError, ValueError, IndexError, OverflowError):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cache_ttl(headers: Mapping[str, str], *, now: float, default_ttl: float = 300,
|
|
36
|
+
min_ttl: float = 0, max_ttl: float = 86400) -> float:
|
|
37
|
+
"""Seconds to keep a document, from its HTTP cache headers (RFC 9111).
|
|
38
|
+
|
|
39
|
+
``no-store``/``no-cache`` mean 0 (unless ``min_ttl`` says otherwise),
|
|
40
|
+
``s-maxage`` beats ``max-age`` (an authorization server is a shared cache),
|
|
41
|
+
``Age`` is subtracted, ``Expires`` is used only without either, and
|
|
42
|
+
``default_ttl`` applies when nothing is said. The result is clamped to
|
|
43
|
+
``[min_ttl, max_ttl]``.
|
|
44
|
+
"""
|
|
45
|
+
headers = {k.lower(): v for k, v in headers.items()}
|
|
46
|
+
cc = _directives(headers.get("cache-control", ""))
|
|
47
|
+
if "no-store" in cc or "no-cache" in cc or "private" in cc:
|
|
48
|
+
ttl: float = 0
|
|
49
|
+
else:
|
|
50
|
+
raw = cc.get("s-maxage") if "s-maxage" in cc else cc.get("max-age")
|
|
51
|
+
if raw is not None or "s-maxage" in cc or "max-age" in cc:
|
|
52
|
+
ttl = int(raw) if raw and raw.isdigit() else 0
|
|
53
|
+
age = headers.get("age", "")
|
|
54
|
+
if age.isdigit():
|
|
55
|
+
ttl -= int(age)
|
|
56
|
+
elif "expires" in headers:
|
|
57
|
+
expires = _http_date(headers["expires"])
|
|
58
|
+
date = _http_date(headers.get("date")) or now
|
|
59
|
+
ttl = expires - date if expires is not None else 0
|
|
60
|
+
else:
|
|
61
|
+
ttl = default_ttl
|
|
62
|
+
return max(min_ttl, min(max_ttl, ttl))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class _Entry:
|
|
67
|
+
metadata: ClientMetadata
|
|
68
|
+
expires: float
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ClientResolver:
|
|
72
|
+
"""Fetch, validate and cache Client ID Metadata Documents.
|
|
73
|
+
|
|
74
|
+
>>> resolver = ClientResolver(Policy(forbidden_client_ids=("https://auth.example.com",)))
|
|
75
|
+
>>> client = resolver.resolve(request.args["client_id"]) # doctest: +SKIP
|
|
76
|
+
>>> client.check_redirect_uri(request.args.get("redirect_uri")) # doctest: +SKIP
|
|
77
|
+
|
|
78
|
+
* A valid document is cached for as long as its cache headers allow,
|
|
79
|
+
clamped to ``[min_ttl, max_ttl]``. Invalid documents and errors are never cached.
|
|
80
|
+
* On a transient failure (timeout, 5xx) a previously valid document is
|
|
81
|
+
kept and returned, as the spec asks; it is retried after ``retry_after``.
|
|
82
|
+
* On a terminal failure the cached document is dropped and the error raised.
|
|
83
|
+
Check ``error.revoke_tokens`` to see if the client's refresh tokens must go.
|
|
84
|
+
|
|
85
|
+
Extra keyword arguments go to :func:`cimdkit.fetch_document`
|
|
86
|
+
(``timeout``, ``max_bytes``, ``allow_loopback``...). Thread-safe.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, policy: Optional[Policy] = None, *,
|
|
90
|
+
fetch: Optional[Callable[[str], FetchResult]] = None,
|
|
91
|
+
default_ttl: float = 300, min_ttl: float = 0, max_ttl: float = 86400,
|
|
92
|
+
retry_after: float = 30, clock: Callable[[], float] = time.time,
|
|
93
|
+
**fetch_options) -> None:
|
|
94
|
+
self.policy = policy or Policy()
|
|
95
|
+
self._fetch = fetch or (lambda cid: fetch_document(cid, **fetch_options))
|
|
96
|
+
self.default_ttl, self.min_ttl, self.max_ttl = default_ttl, min_ttl, max_ttl
|
|
97
|
+
self.retry_after = retry_after
|
|
98
|
+
self._clock = clock
|
|
99
|
+
self._cache: Dict[str, _Entry] = {}
|
|
100
|
+
self._lock = threading.Lock()
|
|
101
|
+
|
|
102
|
+
def cached(self, client_id: str) -> Optional[ClientMetadata]:
|
|
103
|
+
"""The cached metadata, fresh or stale, without fetching."""
|
|
104
|
+
with self._lock:
|
|
105
|
+
entry = self._cache.get(client_id)
|
|
106
|
+
return entry.metadata if entry else None
|
|
107
|
+
|
|
108
|
+
def invalidate(self, client_id: str) -> None:
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._cache.pop(client_id, None)
|
|
111
|
+
|
|
112
|
+
def resolve(self, client_id: str, *, force: bool = False) -> ClientMetadata:
|
|
113
|
+
"""Return validated metadata for ``client_id`` or raise a :class:`CIMDError`."""
|
|
114
|
+
now = self._clock()
|
|
115
|
+
with self._lock:
|
|
116
|
+
entry = self._cache.get(client_id)
|
|
117
|
+
if entry and not force and now < entry.expires:
|
|
118
|
+
return entry.metadata
|
|
119
|
+
try:
|
|
120
|
+
# Check the URL and host policy before any network traffic.
|
|
121
|
+
check_client_id_url(client_id, allow_query=self.policy.allow_query)
|
|
122
|
+
if not self.policy.host_allowed(client_id_host(client_id)):
|
|
123
|
+
raise InvalidClientId(
|
|
124
|
+
f"client_id host {client_id_host(client_id)!r} is not allowed here")
|
|
125
|
+
result = self._fetch(client_id)
|
|
126
|
+
metadata = validate_document(result.document, client_id, self.policy)
|
|
127
|
+
except TransientFetchError:
|
|
128
|
+
if entry is None:
|
|
129
|
+
raise
|
|
130
|
+
with self._lock:
|
|
131
|
+
entry.expires = now + self.retry_after
|
|
132
|
+
return entry.metadata
|
|
133
|
+
except CIMDError:
|
|
134
|
+
self.invalidate(client_id)
|
|
135
|
+
raise
|
|
136
|
+
ttl = cache_ttl(result.headers, now=now, default_ttl=self.default_ttl,
|
|
137
|
+
min_ttl=self.min_ttl, max_ttl=self.max_ttl)
|
|
138
|
+
with self._lock:
|
|
139
|
+
if ttl > 0:
|
|
140
|
+
self._cache[client_id] = _Entry(metadata, now + ttl)
|
|
141
|
+
else:
|
|
142
|
+
self._cache.pop(client_id, None)
|
|
143
|
+
return metadata
|
cimdkit/urls.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Client Identifier URL checks and loopback-aware redirect_uri matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Iterable, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .errors import InvalidClientId
|
|
9
|
+
|
|
10
|
+
#: Hosts that get the RFC 8252 / RFC 9700 "any port" exception for http redirects.
|
|
11
|
+
LOOPBACK_IPS = ("127.0.0.1", "[::1]")
|
|
12
|
+
|
|
13
|
+
_DOT_SEGMENT = re.compile(r"^(?:\.|%2e){1,2}$", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_client_id_url(value: object) -> bool:
|
|
17
|
+
"""True if ``value`` should be treated as a Client Identifier URL.
|
|
18
|
+
|
|
19
|
+
Use it to route between pre-registered clients (random ids) and CIMD
|
|
20
|
+
clients: the spec asks servers never to issue ids starting with https://.
|
|
21
|
+
"""
|
|
22
|
+
return isinstance(value, str) and value.startswith("https://")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _split_netloc(netloc: str) -> Tuple[str, Optional[str], Optional[str]]:
|
|
26
|
+
"""Split ``user@host:port`` into (host, port, userinfo) without normalizing."""
|
|
27
|
+
userinfo = None
|
|
28
|
+
if "@" in netloc:
|
|
29
|
+
userinfo, _, netloc = netloc.rpartition("@")
|
|
30
|
+
if netloc.startswith("["):
|
|
31
|
+
end = netloc.find("]")
|
|
32
|
+
if end == -1:
|
|
33
|
+
raise ValueError("unclosed IPv6 bracket")
|
|
34
|
+
host, rest = netloc[: end + 1], netloc[end + 1 :]
|
|
35
|
+
if rest and not rest.startswith(":"):
|
|
36
|
+
raise ValueError("junk after IPv6 address")
|
|
37
|
+
port = rest[1:] if rest else None
|
|
38
|
+
elif ":" in netloc:
|
|
39
|
+
host, _, port = netloc.partition(":")
|
|
40
|
+
else:
|
|
41
|
+
host, port = netloc, None
|
|
42
|
+
return host, port, userinfo
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _split(url: str) -> Tuple[str, str, str]:
|
|
46
|
+
"""Split an absolute URL into (scheme, netloc, rest) exactly as written."""
|
|
47
|
+
scheme, sep, after = url.partition("://")
|
|
48
|
+
if not sep:
|
|
49
|
+
raise ValueError("not an absolute URL with an authority")
|
|
50
|
+
cut = len(after)
|
|
51
|
+
for ch in "/?#":
|
|
52
|
+
i = after.find(ch)
|
|
53
|
+
if i != -1:
|
|
54
|
+
cut = min(cut, i)
|
|
55
|
+
return scheme, after[:cut], after[cut:]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _valid_port(port: Optional[str]) -> bool:
|
|
59
|
+
return port is None or (port.isdigit() and port.isascii() and 0 < int(port) < 65536)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_client_id_url(value: object, *, allow_query: bool = True) -> str:
|
|
63
|
+
"""Raise :class:`InvalidClientId` unless ``value`` is a valid Client Identifier URL.
|
|
64
|
+
|
|
65
|
+
Rules (draft-ietf-oauth-client-id-metadata-document, section 3): https
|
|
66
|
+
scheme, no userinfo, a path other than ``/``, no ``.`` or ``..`` segments,
|
|
67
|
+
no fragment, and (SHOULD) no query. The URL is not normalized: the server
|
|
68
|
+
must compare it as an exact string.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(value, str) or not value:
|
|
71
|
+
raise InvalidClientId("client_id must be a non-empty string")
|
|
72
|
+
if any(ord(c) <= 0x20 or ord(c) == 0x7F for c in value):
|
|
73
|
+
raise InvalidClientId("client_id contains spaces or control characters")
|
|
74
|
+
if not value.startswith("https://"):
|
|
75
|
+
raise InvalidClientId("client_id URL must start with https://")
|
|
76
|
+
if "#" in value:
|
|
77
|
+
raise InvalidClientId("client_id URL must not contain a fragment")
|
|
78
|
+
try:
|
|
79
|
+
_, netloc, rest = _split(value)
|
|
80
|
+
host, port, userinfo = _split_netloc(netloc)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise InvalidClientId(f"client_id URL is malformed: {exc}") from None
|
|
83
|
+
if userinfo is not None:
|
|
84
|
+
raise InvalidClientId("client_id URL must not contain a username or password")
|
|
85
|
+
if not host:
|
|
86
|
+
raise InvalidClientId("client_id URL has no host")
|
|
87
|
+
if not _valid_port(port):
|
|
88
|
+
raise InvalidClientId("client_id URL has an invalid port")
|
|
89
|
+
path, _, query = rest.partition("?")
|
|
90
|
+
if path in ("", "/"):
|
|
91
|
+
raise InvalidClientId("client_id URL must have a path other than /")
|
|
92
|
+
if any(_DOT_SEGMENT.match(seg) for seg in path.split("/")):
|
|
93
|
+
raise InvalidClientId("client_id URL must not contain . or .. path segments")
|
|
94
|
+
if "?" in rest and not allow_query:
|
|
95
|
+
raise InvalidClientId("client_id URL must not contain a query")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def client_id_host(client_id: str) -> str:
|
|
100
|
+
"""The host to show on the consent screen (the spec asks servers to show it)."""
|
|
101
|
+
_, netloc, _ = _split(client_id)
|
|
102
|
+
host, _, _ = _split_netloc(netloc)
|
|
103
|
+
return host.lower()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_loopback_redirect(uri: str, *, allow_localhost: bool = True) -> bool:
|
|
107
|
+
"""True for ``http://127.0.0.1``, ``http://[::1]`` (and ``http://localhost``) URIs."""
|
|
108
|
+
try:
|
|
109
|
+
scheme, netloc, _ = _split(uri)
|
|
110
|
+
host, port, userinfo = _split_netloc(netloc)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return False
|
|
113
|
+
if scheme != "http" or userinfo is not None or not _valid_port(port):
|
|
114
|
+
return False
|
|
115
|
+
hosts = LOOPBACK_IPS + (("localhost",) if allow_localhost else ())
|
|
116
|
+
return host.lower() in hosts
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def redirect_uri_matches(requested: str, registered: str, *, allow_localhost: bool = True) -> bool:
|
|
120
|
+
"""Exact string match, except that loopback http redirects may use any port.
|
|
121
|
+
|
|
122
|
+
Native apps and CLIs (Claude Code, VS Code, MCP Inspector) listen on a random
|
|
123
|
+
port, so ``http://127.0.0.1:53123/callback`` matches a registered
|
|
124
|
+
``http://127.0.0.1/callback`` or ``http://127.0.0.1:8080/callback``
|
|
125
|
+
(RFC 8252 section 7.3, RFC 9700 section 4.1.3). Host, path and query
|
|
126
|
+
must still match exactly.
|
|
127
|
+
"""
|
|
128
|
+
if requested == registered:
|
|
129
|
+
return True
|
|
130
|
+
if not (is_loopback_redirect(requested, allow_localhost=allow_localhost)
|
|
131
|
+
and is_loopback_redirect(registered, allow_localhost=allow_localhost)):
|
|
132
|
+
return False
|
|
133
|
+
_, r_netloc, r_rest = _split(requested)
|
|
134
|
+
_, g_netloc, g_rest = _split(registered)
|
|
135
|
+
r_host = _split_netloc(r_netloc)[0].lower()
|
|
136
|
+
g_host = _split_netloc(g_netloc)[0].lower()
|
|
137
|
+
return r_host == g_host and r_rest == g_rest
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def find_redirect_uri(requested: str, registered: Iterable[str], *,
|
|
141
|
+
allow_localhost: bool = True) -> Optional[str]:
|
|
142
|
+
"""The registered URI that ``requested`` matches, or None."""
|
|
143
|
+
for uri in registered:
|
|
144
|
+
if redirect_uri_matches(requested, uri, allow_localhost=allow_localhost):
|
|
145
|
+
return uri
|
|
146
|
+
return None
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cimdkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OAuth Client ID Metadata Documents (CIMD) for authorization servers: safe fetch, spec validation, caching and redirect_uri matching for MCP clients
|
|
5
|
+
Project-URL: Homepage, https://github.com/srikrishnaL/kitforge/tree/main/packages/cimdkit
|
|
6
|
+
Project-URL: Source, https://github.com/srikrishnaL/kitforge
|
|
7
|
+
Project-URL: Issues, https://github.com/srikrishnaL/kitforge/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/srikrishnaL/kitforge/blob/main/packages/cimdkit/CHANGELOG.md
|
|
9
|
+
Author: Sri Krishna Lingamaneni
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: authorization server,cimd,client id metadata document,client_id,dynamic client registration,mcp,model context protocol,oauth,oauth2,ssrf
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# cimdkit
|
|
27
|
+
|
|
28
|
+
OAuth **Client ID Metadata Documents** (CIMD) for Python authorization servers.
|
|
29
|
+
|
|
30
|
+
MCP clients such as Claude Code and VS Code no longer register with your OAuth server: they
|
|
31
|
+
send a URL as their `client_id`, and your server has to fetch that URL, validate the JSON it
|
|
32
|
+
finds there and remember it. Doing that safely is fiddly (SSRF, redirects, exact-match rules,
|
|
33
|
+
caching, the loopback port exception), and every server is writing it again from scratch.
|
|
34
|
+
cimdkit does it in one call, with no dependencies.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install cimdkit
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Python 3.9+. Works with any web framework and any OAuth library (Authlib, FastMCP's OAuth
|
|
41
|
+
proxy, your own).
|
|
42
|
+
|
|
43
|
+
## Use it in your `/authorize` endpoint
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from cimdkit import ClientResolver, CIMDError, Policy, SERVER_METADATA, is_client_id_url
|
|
47
|
+
|
|
48
|
+
resolver = ClientResolver(Policy(
|
|
49
|
+
forbidden_client_ids=("https://auth.example.com",), # your own issuer
|
|
50
|
+
auth_methods=("none", "private_key_jwt"),
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
def authorize(params):
|
|
54
|
+
client_id = params["client_id"]
|
|
55
|
+
if not is_client_id_url(client_id):
|
|
56
|
+
return handle_preregistered_client(params) # your existing clients
|
|
57
|
+
try:
|
|
58
|
+
client = resolver.resolve(client_id) # fetch + validate + cache
|
|
59
|
+
redirect_uri = client.check_redirect_uri(params.get("redirect_uri"))
|
|
60
|
+
except CIMDError as err:
|
|
61
|
+
# Never redirect on these errors: show them to the user.
|
|
62
|
+
return render_error(err.to_oauth_error(), status=err.status_code)
|
|
63
|
+
return render_consent(name=client.display_name, host=client.host, logo=client.logo_uri,
|
|
64
|
+
redirect_uri=redirect_uri)
|
|
65
|
+
|
|
66
|
+
# And advertise support in /.well-known/oauth-authorization-server:
|
|
67
|
+
metadata.update(SERVER_METADATA) # {"client_id_metadata_document_supported": True}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
At the token endpoint, call `resolver.resolve(client_id)` again (it is served from the cache)
|
|
71
|
+
and use `client.token_endpoint_auth_method`: `"none"` means a public PKCE client, so reject any
|
|
72
|
+
client authentication it sends; `"private_key_jwt"` means verify its assertion against
|
|
73
|
+
`client.jwks` or `client.jwks_uri`. On a `TransientFetchError` answer 503; on any other
|
|
74
|
+
`CIMDError` answer `invalid_client`, and if `err.revoke_tokens` is true, revoke the client's
|
|
75
|
+
refresh tokens (the document now names a different client).
|
|
76
|
+
|
|
77
|
+
## What it checks
|
|
78
|
+
|
|
79
|
+
**The URL** (`check_client_id_url`): https, a path other than `/`, no userinfo, no `.`/`..`
|
|
80
|
+
segments (including `%2e`), no fragment, a valid port. Compared as an exact string, never
|
|
81
|
+
normalized.
|
|
82
|
+
|
|
83
|
+
**The fetch** (`fetch_document`):
|
|
84
|
+
|
|
85
|
+
- Every address the host resolves to must be public. Loopback, private, link-local (including
|
|
86
|
+
the cloud metadata address `169.254.169.254`), CGNAT, multicast and reserved ranges are
|
|
87
|
+
refused, also when hidden as IPv4-mapped IPv6, NAT64 or 6to4.
|
|
88
|
+
- The connection goes to the exact address that was checked, with SNI and certificate checks
|
|
89
|
+
for the real host name, so DNS rebinding cannot swap the address between check and connect.
|
|
90
|
+
- Redirects are not followed. Only `200` is accepted; `5xx`, `408` and `429` are reported as
|
|
91
|
+
transient.
|
|
92
|
+
- `Accept: application/json`, at most 5 KB read, one overall deadline, `application/json` (or
|
|
93
|
+
`application/*+json`) content type, and a JSON object with no duplicate keys.
|
|
94
|
+
|
|
95
|
+
**The document** (`validate_document`):
|
|
96
|
+
|
|
97
|
+
- `client_id` must exactly equal the URL it was fetched from.
|
|
98
|
+
- No `client_secret`, no shared-secret auth methods (`client_secret_basic`, `_post`, `_jwt`),
|
|
99
|
+
no private key material in `jwks`, not both `jwks` and `jwks_uri`.
|
|
100
|
+
- With no auth method declared, the method is `none` (not RFC 7591's `client_secret_basic`).
|
|
101
|
+
With `token_endpoint_auth_methods_supported`, the client's first method that you support is
|
|
102
|
+
chosen.
|
|
103
|
+
- `grant_types` and `response_types` are intersected with what you support; an empty result
|
|
104
|
+
is an error.
|
|
105
|
+
- `redirect_uris`: absolute, no fragment, no `javascript:`/`data:`/`file:`, plain `http` only
|
|
106
|
+
on loopback. Private-use schemes for native apps (`com.example.app:/cb`) are allowed.
|
|
107
|
+
- Unknown properties are ignored, not rejected. Informative URLs (`logo_uri`, `client_uri`...)
|
|
108
|
+
that are not https are dropped with a warning instead of failing the whole sign-in.
|
|
109
|
+
|
|
110
|
+
**Redirect URIs** (`client.check_redirect_uri`): exact match, except that
|
|
111
|
+
`http://127.0.0.1`, `http://[::1]` and `http://localhost` redirects may use any port, as RFC
|
|
112
|
+
8252 and RFC 9700 require for native apps and CLIs that listen on a random port.
|
|
113
|
+
|
|
114
|
+
**Caching** (`ClientResolver`): valid documents are kept for as long as `Cache-Control`
|
|
115
|
+
(`s-maxage`, `max-age`, `Age`, `no-store`...) or `Expires` allow, clamped to
|
|
116
|
+
`[min_ttl, max_ttl]` (default 0 s to 24 h, 5 minutes when the response says nothing). Errors
|
|
117
|
+
and invalid documents are never cached. A transient failure keeps serving the last good
|
|
118
|
+
document; a terminal failure drops it.
|
|
119
|
+
|
|
120
|
+
**Your policy** (`Policy`): supported grant types, response types and auth methods, ids the
|
|
121
|
+
client_id must never equal (your issuer, trusted resource servers), `allowed_hosts`
|
|
122
|
+
(`"*.example.com"`) to accept only trusted client domains, same-origin redirects, and whether
|
|
123
|
+
`localhost` counts as loopback.
|
|
124
|
+
|
|
125
|
+
## Check your own client's document
|
|
126
|
+
|
|
127
|
+
If you publish a CIMD for your MCP client, check it the way servers will:
|
|
128
|
+
|
|
129
|
+
```console
|
|
130
|
+
$ cimdkit check https://app.example.com/oauth/client.json --redirect-uri http://127.0.0.1:53682/callback
|
|
131
|
+
OK https://app.example.com/oauth/client.json
|
|
132
|
+
name Example Agent (host app.example.com)
|
|
133
|
+
auth method none
|
|
134
|
+
grant types authorization_code, refresh_token
|
|
135
|
+
redirect_uri http://127.0.0.1/callback
|
|
136
|
+
cache 3600 s
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`--file client.json` checks a local file before you deploy it, and `--json` prints the result
|
|
140
|
+
for CI. Or build the document in Python and get an error if any server would reject it:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from cimdkit import make_document
|
|
144
|
+
|
|
145
|
+
doc = make_document("https://app.example.com/oauth/client.json",
|
|
146
|
+
client_name="Example Agent",
|
|
147
|
+
redirect_uris=["http://127.0.0.1/callback"],
|
|
148
|
+
logo_uri="https://app.example.com/logo.png")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## For development
|
|
152
|
+
|
|
153
|
+
`ClientResolver(allow_loopback=True)` (or `cimdkit check --allow-loopback`) lets a server on
|
|
154
|
+
your machine fetch documents from `127.0.0.1`/`::1`. The spec forbids this in production.
|
|
155
|
+
|
|
156
|
+
## Limits
|
|
157
|
+
|
|
158
|
+
- It resolves and validates clients. It does not issue codes or tokens, verify
|
|
159
|
+
`private_key_jwt` assertions, or fetch `jwks_uri` and logos for you.
|
|
160
|
+
- The fetch connects directly and ignores `HTTPS_PROXY`. Pass your own `transport=` to
|
|
161
|
+
`ClientResolver`/`fetch_document` if your server must use a proxy.
|
|
162
|
+
- The cache is in memory, per process. Pass `fetch=` or wrap `resolve` to share one.
|
|
163
|
+
- It follows draft-ietf-oauth-client-id-metadata-document as of November 2026. The draft can
|
|
164
|
+
still change.
|
|
165
|
+
|
|
166
|
+
## Why this exists
|
|
167
|
+
|
|
168
|
+
MCP's authorization spec made CIMD the default way for clients to identify themselves
|
|
169
|
+
([SEP-991](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991)), and
|
|
170
|
+
authorization servers in every language are being asked to support it:
|
|
171
|
+
|
|
172
|
+
- [modelcontextprotocol/python-sdk#1801](https://github.com/modelcontextprotocol/python-sdk/issues/1801): implement server-side CIMD support
|
|
173
|
+
- [authlib/authlib#900](https://github.com/authlib/authlib/issues/900): support Client ID Metadata Document?
|
|
174
|
+
- [ory/hydra#4061](https://github.com/ory/hydra/issues/4061), [supabase/auth#2850](https://github.com/supabase/auth/issues/2850), [spring-security#18375](https://github.com/spring-projects/spring-security/issues/18375), [LibreChat#16627](https://github.com/LibreChat-AI/LibreChat/issues/16627), [doorkeeper#1875](https://github.com/doorkeeper-gem/doorkeeper/issues/1875)
|
|
175
|
+
|
|
176
|
+
The first implementations keep hitting the same bugs, each one a test here:
|
|
177
|
+
unknown properties rejected ([keycloak#51236](https://github.com/keycloak/keycloak/issues/51236)),
|
|
178
|
+
loopback redirect ports
|
|
179
|
+
([claude-code#37747](https://github.com/anthropics/claude-code/issues/37747)), private-use
|
|
180
|
+
redirect schemes rejected ([fastmcp#6336](https://github.com/PrefectHQ/fastmcp/issues/6336)), a
|
|
181
|
+
malformed port causing a 500 ([fastmcp#5764](https://github.com/PrefectHQ/fastmcp/issues/5764)),
|
|
182
|
+
no way to limit CIMD to trusted domains
|
|
183
|
+
([fastmcp#6153](https://github.com/PrefectHQ/fastmcp/issues/6153)), and partial client_id URL
|
|
184
|
+
validation ([python-sdk#3768](https://github.com/modelcontextprotocol/python-sdk/issues/3768)).
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cimdkit/__init__.py,sha256=kqVlNW9zsa7gBAeK6-I_WKzBL9i3tHQtZskqdL43A2I,1537
|
|
2
|
+
cimdkit/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
cimdkit/cli.py,sha256=JTS8yEioa6ZNBuchI-9qMM2SOsIj0JzETIFd5-vBoXY,4251
|
|
4
|
+
cimdkit/document.py,sha256=QrcA7R-ProP2lrqffm5pSkHAfTGXQdmJdHOdl8HEI6s,13543
|
|
5
|
+
cimdkit/errors.py,sha256=n6dzyW7eeTMEolcuaTdqHKSgwmFLGpio1VxLz6K-R1o,2157
|
|
6
|
+
cimdkit/fetch.py,sha256=Brd3fpsdhRDFjd9ecmfS5bj69A1CAPoKMNvsZgYBUdk,8514
|
|
7
|
+
cimdkit/resolver.py,sha256=l3ynHscAEb-wzSVHZWUaQ15rRyCTKTOmTwccUSmbqgg,6069
|
|
8
|
+
cimdkit/urls.py,sha256=my7BGfsToFtzxuXXnF3XVIcYGssRHYX7ZQXftZG_zWc,5961
|
|
9
|
+
cimdkit-0.1.0.dist-info/METADATA,sha256=PnXj6E7hh4GguEzl2QH1tCtzRwbiArZlfDSI0EEz9wk,9668
|
|
10
|
+
cimdkit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
cimdkit-0.1.0.dist-info/entry_points.txt,sha256=4XHQyFhqT992dLDJaZFm8CZd-e29MwiVOqOpBJQw1kg,45
|
|
12
|
+
cimdkit-0.1.0.dist-info/licenses/LICENSE,sha256=fl9U1idgeyJw-8GWneEYxVzZM_C29cBR63IAlNv7KjE,1080
|
|
13
|
+
cimdkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sri Krishna Lingamaneni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|