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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cimdkit = cimdkit.cli:main
@@ -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.