cimdkit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ node_modules/
11
+ coverage/
12
+ .DS_Store
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-08)
4
+
5
+ First release.
6
+
7
+ - `ClientResolver`: resolve a URL `client_id` to validated client metadata, cached by the
8
+ response's HTTP cache headers, keeping the last good document on transient failures and
9
+ dropping it on terminal ones.
10
+ - `fetch_document`: https only, no redirects, `Accept: application/json`, 5 KB limit, one
11
+ deadline, SSRF guard on every resolved address (IPv4-mapped, NAT64 and 6to4 included) with
12
+ the connection pinned to the checked address.
13
+ - `validate_document`: exact client_id match, no secrets or shared-secret auth methods, no
14
+ private keys, auth method selection with the `none` default, grant and response type
15
+ intersection, redirect URI rules, unknown properties ignored.
16
+ - `redirect_uri_matches` / `ClientMetadata.check_redirect_uri`: exact match with the loopback
17
+ any-port exception.
18
+ - `Policy`: supported methods and grants, forbidden ids (your issuer), `allowed_hosts`,
19
+ same-origin redirects.
20
+ - `make_document` and the `cimdkit check` command for client authors publishing a document.
cimdkit-0.1.0/LICENSE ADDED
@@ -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.
cimdkit-0.1.0/PKG-INFO ADDED
@@ -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,163 @@
1
+ # cimdkit
2
+
3
+ OAuth **Client ID Metadata Documents** (CIMD) for Python authorization servers.
4
+
5
+ MCP clients such as Claude Code and VS Code no longer register with your OAuth server: they
6
+ send a URL as their `client_id`, and your server has to fetch that URL, validate the JSON it
7
+ finds there and remember it. Doing that safely is fiddly (SSRF, redirects, exact-match rules,
8
+ caching, the loopback port exception), and every server is writing it again from scratch.
9
+ cimdkit does it in one call, with no dependencies.
10
+
11
+ ```bash
12
+ pip install cimdkit
13
+ ```
14
+
15
+ Python 3.9+. Works with any web framework and any OAuth library (Authlib, FastMCP's OAuth
16
+ proxy, your own).
17
+
18
+ ## Use it in your `/authorize` endpoint
19
+
20
+ ```python
21
+ from cimdkit import ClientResolver, CIMDError, Policy, SERVER_METADATA, is_client_id_url
22
+
23
+ resolver = ClientResolver(Policy(
24
+ forbidden_client_ids=("https://auth.example.com",), # your own issuer
25
+ auth_methods=("none", "private_key_jwt"),
26
+ ))
27
+
28
+ def authorize(params):
29
+ client_id = params["client_id"]
30
+ if not is_client_id_url(client_id):
31
+ return handle_preregistered_client(params) # your existing clients
32
+ try:
33
+ client = resolver.resolve(client_id) # fetch + validate + cache
34
+ redirect_uri = client.check_redirect_uri(params.get("redirect_uri"))
35
+ except CIMDError as err:
36
+ # Never redirect on these errors: show them to the user.
37
+ return render_error(err.to_oauth_error(), status=err.status_code)
38
+ return render_consent(name=client.display_name, host=client.host, logo=client.logo_uri,
39
+ redirect_uri=redirect_uri)
40
+
41
+ # And advertise support in /.well-known/oauth-authorization-server:
42
+ metadata.update(SERVER_METADATA) # {"client_id_metadata_document_supported": True}
43
+ ```
44
+
45
+ At the token endpoint, call `resolver.resolve(client_id)` again (it is served from the cache)
46
+ and use `client.token_endpoint_auth_method`: `"none"` means a public PKCE client, so reject any
47
+ client authentication it sends; `"private_key_jwt"` means verify its assertion against
48
+ `client.jwks` or `client.jwks_uri`. On a `TransientFetchError` answer 503; on any other
49
+ `CIMDError` answer `invalid_client`, and if `err.revoke_tokens` is true, revoke the client's
50
+ refresh tokens (the document now names a different client).
51
+
52
+ ## What it checks
53
+
54
+ **The URL** (`check_client_id_url`): https, a path other than `/`, no userinfo, no `.`/`..`
55
+ segments (including `%2e`), no fragment, a valid port. Compared as an exact string, never
56
+ normalized.
57
+
58
+ **The fetch** (`fetch_document`):
59
+
60
+ - Every address the host resolves to must be public. Loopback, private, link-local (including
61
+ the cloud metadata address `169.254.169.254`), CGNAT, multicast and reserved ranges are
62
+ refused, also when hidden as IPv4-mapped IPv6, NAT64 or 6to4.
63
+ - The connection goes to the exact address that was checked, with SNI and certificate checks
64
+ for the real host name, so DNS rebinding cannot swap the address between check and connect.
65
+ - Redirects are not followed. Only `200` is accepted; `5xx`, `408` and `429` are reported as
66
+ transient.
67
+ - `Accept: application/json`, at most 5 KB read, one overall deadline, `application/json` (or
68
+ `application/*+json`) content type, and a JSON object with no duplicate keys.
69
+
70
+ **The document** (`validate_document`):
71
+
72
+ - `client_id` must exactly equal the URL it was fetched from.
73
+ - No `client_secret`, no shared-secret auth methods (`client_secret_basic`, `_post`, `_jwt`),
74
+ no private key material in `jwks`, not both `jwks` and `jwks_uri`.
75
+ - With no auth method declared, the method is `none` (not RFC 7591's `client_secret_basic`).
76
+ With `token_endpoint_auth_methods_supported`, the client's first method that you support is
77
+ chosen.
78
+ - `grant_types` and `response_types` are intersected with what you support; an empty result
79
+ is an error.
80
+ - `redirect_uris`: absolute, no fragment, no `javascript:`/`data:`/`file:`, plain `http` only
81
+ on loopback. Private-use schemes for native apps (`com.example.app:/cb`) are allowed.
82
+ - Unknown properties are ignored, not rejected. Informative URLs (`logo_uri`, `client_uri`...)
83
+ that are not https are dropped with a warning instead of failing the whole sign-in.
84
+
85
+ **Redirect URIs** (`client.check_redirect_uri`): exact match, except that
86
+ `http://127.0.0.1`, `http://[::1]` and `http://localhost` redirects may use any port, as RFC
87
+ 8252 and RFC 9700 require for native apps and CLIs that listen on a random port.
88
+
89
+ **Caching** (`ClientResolver`): valid documents are kept for as long as `Cache-Control`
90
+ (`s-maxage`, `max-age`, `Age`, `no-store`...) or `Expires` allow, clamped to
91
+ `[min_ttl, max_ttl]` (default 0 s to 24 h, 5 minutes when the response says nothing). Errors
92
+ and invalid documents are never cached. A transient failure keeps serving the last good
93
+ document; a terminal failure drops it.
94
+
95
+ **Your policy** (`Policy`): supported grant types, response types and auth methods, ids the
96
+ client_id must never equal (your issuer, trusted resource servers), `allowed_hosts`
97
+ (`"*.example.com"`) to accept only trusted client domains, same-origin redirects, and whether
98
+ `localhost` counts as loopback.
99
+
100
+ ## Check your own client's document
101
+
102
+ If you publish a CIMD for your MCP client, check it the way servers will:
103
+
104
+ ```console
105
+ $ cimdkit check https://app.example.com/oauth/client.json --redirect-uri http://127.0.0.1:53682/callback
106
+ OK https://app.example.com/oauth/client.json
107
+ name Example Agent (host app.example.com)
108
+ auth method none
109
+ grant types authorization_code, refresh_token
110
+ redirect_uri http://127.0.0.1/callback
111
+ cache 3600 s
112
+ ```
113
+
114
+ `--file client.json` checks a local file before you deploy it, and `--json` prints the result
115
+ for CI. Or build the document in Python and get an error if any server would reject it:
116
+
117
+ ```python
118
+ from cimdkit import make_document
119
+
120
+ doc = make_document("https://app.example.com/oauth/client.json",
121
+ client_name="Example Agent",
122
+ redirect_uris=["http://127.0.0.1/callback"],
123
+ logo_uri="https://app.example.com/logo.png")
124
+ ```
125
+
126
+ ## For development
127
+
128
+ `ClientResolver(allow_loopback=True)` (or `cimdkit check --allow-loopback`) lets a server on
129
+ your machine fetch documents from `127.0.0.1`/`::1`. The spec forbids this in production.
130
+
131
+ ## Limits
132
+
133
+ - It resolves and validates clients. It does not issue codes or tokens, verify
134
+ `private_key_jwt` assertions, or fetch `jwks_uri` and logos for you.
135
+ - The fetch connects directly and ignores `HTTPS_PROXY`. Pass your own `transport=` to
136
+ `ClientResolver`/`fetch_document` if your server must use a proxy.
137
+ - The cache is in memory, per process. Pass `fetch=` or wrap `resolve` to share one.
138
+ - It follows draft-ietf-oauth-client-id-metadata-document as of November 2026. The draft can
139
+ still change.
140
+
141
+ ## Why this exists
142
+
143
+ MCP's authorization spec made CIMD the default way for clients to identify themselves
144
+ ([SEP-991](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991)), and
145
+ authorization servers in every language are being asked to support it:
146
+
147
+ - [modelcontextprotocol/python-sdk#1801](https://github.com/modelcontextprotocol/python-sdk/issues/1801): implement server-side CIMD support
148
+ - [authlib/authlib#900](https://github.com/authlib/authlib/issues/900): support Client ID Metadata Document?
149
+ - [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)
150
+
151
+ The first implementations keep hitting the same bugs, each one a test here:
152
+ unknown properties rejected ([keycloak#51236](https://github.com/keycloak/keycloak/issues/51236)),
153
+ loopback redirect ports
154
+ ([claude-code#37747](https://github.com/anthropics/claude-code/issues/37747)), private-use
155
+ redirect schemes rejected ([fastmcp#6336](https://github.com/PrefectHQ/fastmcp/issues/6336)), a
156
+ malformed port causing a 500 ([fastmcp#5764](https://github.com/PrefectHQ/fastmcp/issues/5764)),
157
+ no way to limit CIMD to trusted domains
158
+ ([fastmcp#6153](https://github.com/PrefectHQ/fastmcp/issues/6153)), and partial client_id URL
159
+ validation ([python-sdk#3768](https://github.com/modelcontextprotocol/python-sdk/issues/3768)).
160
+
161
+ ## License
162
+
163
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cimdkit"
7
+ version = "0.1.0"
8
+ description = "OAuth Client ID Metadata Documents (CIMD) for authorization servers: safe fetch, spec validation, caching and redirect_uri matching for MCP clients"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Sri Krishna Lingamaneni" }]
14
+ keywords = [
15
+ "oauth", "oauth2", "cimd", "client id metadata document", "client_id", "mcp",
16
+ "model context protocol", "authorization server", "dynamic client registration", "ssrf",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Topic :: Internet :: WWW/HTTP",
25
+ "Topic :: Security",
26
+ "Topic :: Software Development :: Libraries",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=7"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/srikrishnaL/kitforge/tree/main/packages/cimdkit"
35
+ Source = "https://github.com/srikrishnaL/kitforge"
36
+ Issues = "https://github.com/srikrishnaL/kitforge/issues"
37
+ Changelog = "https://github.com/srikrishnaL/kitforge/blob/main/packages/cimdkit/CHANGELOG.md"
38
+
39
+ [project.scripts]
40
+ cimdkit = "cimdkit.cli:main"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/cimdkit"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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