From f726dd0bc6030158a2f524427b89f0bb3ce19a69 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Wed, 28 Oct 2026 06:49:07 +0200 Subject: [PATCH 1/8] feat(agents): add AI agents and MCP support (CIMD and DCR) Add kinde_sdk.agents for Kinde "AI agents and MCP" applications: - KindeAgentAuth: resource server verification (introspection by default, local JWT or both as options), protected resource metadata, RFC 6750 challenges, permission checks and token revocation. - KindeAgentClient: agent client with discovery, CIMD, pre-registered clients or DCR, PKCE sign-in, refresh, step-up and token storage. - CIMD helpers: build_client_metadata_document, client_metadata_response and validate_client_metadata_url. - MCP adapters behind the [mcp] extra: KindeTokenVerifier (sets resource, so the MCP SDK accepts tokens), kinde_auth_settings and KindeOAuthClientProvider. - FastAPI and Flask integrations, examples and README section. Kinde answers introspection of an inactive token with 401 token_inactive rather than 200 {"active": false}; this is mapped to a 401 invalid_token so agents refresh or sign in again. --- README.md | 145 +++ examples/agents/cli_agent.py | 49 + examples/agents/client_metadata_host.py | 27 + examples/agents/fastapi_resource_server.py | 31 + examples/agents/mcp_server.py | 61 ++ examples/agents/smoke_check.py | 198 ++++ kinde_fastapi/agents.py | 164 ++++ kinde_flask/agents.py | 176 ++++ kinde_sdk/agents/__init__.py | 90 ++ kinde_sdk/agents/client.py | 862 ++++++++++++++++++ kinde_sdk/agents/client_metadata.py | 122 +++ kinde_sdk/agents/config.py | 175 ++++ kinde_sdk/agents/context.py | 123 +++ kinde_sdk/agents/errors.py | 106 +++ kinde_sdk/agents/mcp/__init__.py | 33 + kinde_sdk/agents/mcp/client.py | 110 +++ kinde_sdk/agents/mcp/server.py | 119 +++ kinde_sdk/agents/resource_server.py | 607 ++++++++++++ kinde_sdk/agents/storage.py | 132 +++ kinde_sdk/agents/www_authenticate.py | 138 +++ pyproject.toml | 5 + testv2/testv2_agents/__init__.py | 0 testv2/testv2_agents/kinde_fake.py | 314 +++++++ testv2/testv2_agents/test_agent_client.py | 433 +++++++++ .../test_framework_integrations.py | 162 ++++ testv2/testv2_agents/test_mcp_adapters.py | 255 ++++++ testv2/testv2_agents/test_resource_server.py | 437 +++++++++ .../test_www_authenticate_and_cimd.py | 129 +++ 28 files changed, 5203 insertions(+) create mode 100644 examples/agents/cli_agent.py create mode 100644 examples/agents/client_metadata_host.py create mode 100644 examples/agents/fastapi_resource_server.py create mode 100644 examples/agents/mcp_server.py create mode 100644 examples/agents/smoke_check.py create mode 100644 kinde_fastapi/agents.py create mode 100644 kinde_flask/agents.py create mode 100644 kinde_sdk/agents/__init__.py create mode 100644 kinde_sdk/agents/client.py create mode 100644 kinde_sdk/agents/client_metadata.py create mode 100644 kinde_sdk/agents/config.py create mode 100644 kinde_sdk/agents/context.py create mode 100644 kinde_sdk/agents/errors.py create mode 100644 kinde_sdk/agents/mcp/__init__.py create mode 100644 kinde_sdk/agents/mcp/client.py create mode 100644 kinde_sdk/agents/mcp/server.py create mode 100644 kinde_sdk/agents/resource_server.py create mode 100644 kinde_sdk/agents/storage.py create mode 100644 kinde_sdk/agents/www_authenticate.py create mode 100644 testv2/testv2_agents/__init__.py create mode 100644 testv2/testv2_agents/kinde_fake.py create mode 100644 testv2/testv2_agents/test_agent_client.py create mode 100644 testv2/testv2_agents/test_framework_integrations.py create mode 100644 testv2/testv2_agents/test_mcp_adapters.py create mode 100644 testv2/testv2_agents/test_resource_server.py create mode 100644 testv2/testv2_agents/test_www_authenticate_and_cimd.py diff --git a/README.md b/README.md index cc680c50..d9e57ca1 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,151 @@ For both FastAPI and Flask integrations: 5. Implement proper session management 6. Consider implementing CSRF protection +# AI agents and MCP (CIMD and DCR) + +`kinde_sdk.agents` lets you protect your own MCP server (or any API) with Kinde, and build agents that sign users in to such servers. It is separate from the login flow above and changes nothing about it. + +Agents identify themselves in one of two ways: + +- **Client ID Metadata Document (CIMD), preferred.** The agent publishes a small JSON document on its own domain, and the document's URL is its client ID. There's no registration step. Claude and ChatGPT already work this way. +- **Dynamic Client Registration (DCR), the fallback.** The agent registers with Kinde and gets back a client ID and secret. It's for agents that can't host a document. + +## Set up Kinde + +1. Create an application of type **AI agents and MCP** and set its **Resource URL** to your server's URL, for example `https://api.example.com/mcp`. The application has no callback URLs, on purpose: agents bring their own. +2. If you run your own agent, add the domain that serves its metadata document to **Trusted agent hosts** (bare domains, up to 10). Vendor agents such as Claude and ChatGPT are already trusted. +3. Set the environment variables. These belong to the AI agents and MCP application, not your login application: + +```bash +KINDE_HOST=https://yourbiz.kinde.com +KINDE_AGENTS_CLIENT_ID= +KINDE_AGENTS_CLIENT_SECRET= +KINDE_AGENTS_RESOURCE_URL=https://api.example.com/mcp +``` + +`KINDE_AGENTS_RESOURCE_URL` must match the Resource URL in Kinde exactly, character for character. + +## Protect an MCP server + +Install the extra (needs Python 3.10 or newer): + +```bash +pip install "kinde-python-sdk[mcp]" +``` + +```python +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError + +from kinde_sdk.agents import KindeAgentAuth, has_permission +from kinde_sdk.agents.mcp import KindeTokenVerifier, get_agent_auth_context, kinde_auth_settings + +auth = KindeAgentAuth() # reads the environment variables above + +server = MCPServer( + "Docs server", + token_verifier=KindeTokenVerifier(auth), + auth=kinde_auth_settings(auth), +) + +@server.tool() +async def read_docs() -> str: + if not has_permission(get_agent_auth_context(), "read:docs"): + raise ToolError("You don't have permission to read docs.") + return "..." +``` + +`kinde_auth_settings` sets the values the MCP SDK needs: the scoped issuer, `required_scopes=["offline"]` (agents request what's listed here, and without `offline` they get no refresh token and users sign in again every 24 hours), and `validate_token_resource=True`. + +Check permissions inside each tool with `has_permission`, and fail with a tool error. Don't answer with an HTTP 403: agents treat that as "sign in again", and signing in again can't grant a permission. Use permissions and roles managed in Kinde rather than email allowlists. + +## Protect a FastAPI or Flask API + +```python +from fastapi import Depends, FastAPI +from kinde_fastapi.agents import FastAPIAgentAuth +from kinde_sdk.agents import AgentAuthContext + +app = FastAPI() +agents = FastAPIAgentAuth() # serves /.well-known/oauth-protected-resource[/path] +agents.install(app) + +@app.get("/api/docs") +async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read:docs"))): + return {"user": auth.user_id, "agent": auth.client_id} +``` + +```python +from flask import Flask +from kinde_flask.agents import FlaskAgentAuth, current_agent_auth + +app = Flask(__name__) +agents = FlaskAgentAuth(app=app) + +@app.get("/api/docs") +@agents.require_permission("read:docs") +def docs(): + return {"user": current_agent_auth().user_id} +``` + +Requests without a valid token get `401` with a `WWW-Authenticate` challenge pointing at the protected resource metadata, which is how agents discover Kinde. If Kinde can't be reached, the response is `503`, so agents don't restart their sign-in. + +### How tokens are verified + +Tokens are checked with Kinde's introspection endpoint by default, using the application's client ID and secret, so a revoked token stops working immediately. To verify signatures locally instead (no network call per request, but a revoked token stays valid until it expires, 24 hours by default), pass `verification="jwt"`. `"jwt+introspection"` does both. `introspection_cache_seconds` (up to 60) caches successful introspections to cut traffic. + +To cut off an agent, revoke its token with `auth.revoke_token(token)`. This revokes both the access token and refresh token of the pair. + +## Build an agent + +`KindeAgentClient` signs a user in to a protected server and calls it. It uses the CIMD document when the server supports it and you set `client_metadata_url`, and registers dynamically otherwise. + +```python +from kinde_sdk.agents import FileAgentStorage, KindeAgentClient + +async with KindeAgentClient( + "https://api.example.com/mcp", + client_metadata_url="https://agent.example.com/oauth/client-metadata.json", + client_name="Example Agent", + storage=FileAgentStorage("~/.example-agent/tokens.json"), +) as client: + await client.connect() # opens the browser if a sign-in is needed + response = await client.request("POST", json={...}) +``` + +A CLI agent uses a loopback redirect (`http://127.0.0.1/callback`, port picked automatically). A server-hosted agent passes its own `redirect_uri` and uses `begin_authorization()` and `complete_authorization()` in its login and callback routes. For DCR, prefer a fixed port (`http://127.0.0.1:8765/callback`), because a registration is tied to its exact redirect URI. + +Agents built on the official MCP client use the adapter instead: + +```python +from kinde_sdk.agents.mcp import KindeOAuthClientProvider + +provider = KindeOAuthClientProvider( + "https://api.example.com/mcp", + client_metadata_url="https://agent.example.com/oauth/client-metadata.json", + client_name="Example Agent", + redirect_handler=..., callback_handler=..., +) +``` + +### Publish the client metadata document + +```python +from kinde_sdk.agents import build_client_metadata_document +from kinde_fastapi.agents import client_metadata_router # or kinde_flask.agents.client_metadata_blueprint + +document = build_client_metadata_document( + "https://agent.example.com/oauth/client-metadata.json", # must be the exact URL it's served at + "Example Agent", + ["http://127.0.0.1/callback", "https://agent.example.com/oauth/callback"], +) +app.include_router(client_metadata_router(document)) +``` + +Kinde fetches the document from the public internet, so it can't be served from `localhost`; use a tunnel with a real domain when testing. Kinde honours redirect URIs that are https on the document's own host, or http loopback on any port. It drops custom schemes (`myapp://`) and other hosts, and the builder raises an error for those. + +See [examples/agents](examples/agents) for complete examples. + # Kinde Management API Module This module provides a client for the Kinde Management API, allowing you to manage users, organizations, roles, permissions, and feature flags programmatically. diff --git a/examples/agents/cli_agent.py b/examples/agents/cli_agent.py new file mode 100644 index 00000000..70970650 --- /dev/null +++ b/examples/agents/cli_agent.py @@ -0,0 +1,49 @@ +""" +A CLI agent that signs a user in to a protected MCP server and calls it. + + export RESOURCE_URL=https:///mcp + # CIMD (preferred): the URL where client_metadata_host.py serves the document, + # whose host is in the Kinde application's Trusted agent hosts. Leave unset to use DCR. + export CLIENT_METADATA_URL=https://agent.onzs.co.za/oauth/client-metadata.json + python examples/agents/cli_agent.py + +The browser opens for sign-in. Tokens are kept in ~/.kinde-example-agent/tokens.json. +""" + +import asyncio +import os + +from kinde_sdk.agents import FileAgentStorage, KindeAgentClient + + +async def main() -> None: + async with KindeAgentClient( + os.environ["RESOURCE_URL"], + client_metadata_url=os.getenv("CLIENT_METADATA_URL"), + client_name="Kinde example agent", + # A fixed port, so a DCR registration (tied to its exact redirect URI) is reused between runs + redirect_uri="http://127.0.0.1:8765/callback", + storage=FileAgentStorage("~/.kinde-example-agent/tokens.json"), + ) as client: + await client.connect() + print("Signed in:", await client.get_token_info()) + + response = await client.request( + "POST", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "kinde-example-agent", "version": "1"}, + }, + }, + headers={"Accept": "application/json, text/event-stream"}, + ) + print(response.status_code, response.text) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agents/client_metadata_host.py b/examples/agents/client_metadata_host.py new file mode 100644 index 00000000..bbe6ca27 --- /dev/null +++ b/examples/agents/client_metadata_host.py @@ -0,0 +1,27 @@ +""" +Serves an agent's client ID metadata document (CIMD). + +Kinde fetches this from the public internet, so run it on (or tunnel it to) the +public host in CLIENT_METADATA_URL, and add that host to the Kinde application's +Trusted agent hosts. + + export CLIENT_METADATA_URL=https://agent.onzs.co.za/oauth/client-metadata.json + uvicorn examples.agents.client_metadata_host:app --port 8080 +""" + +import os + +from fastapi import FastAPI + +from kinde_fastapi.agents import client_metadata_router +from kinde_sdk.agents import build_client_metadata_document + +document = build_client_metadata_document( + os.environ["CLIENT_METADATA_URL"], + "Kinde example agent", + # Loopback works on any port for CIMD; cli_agent.py uses 8765 + ["http://127.0.0.1/callback"], +) + +app = FastAPI() +app.include_router(client_metadata_router(document)) diff --git a/examples/agents/fastapi_resource_server.py b/examples/agents/fastapi_resource_server.py new file mode 100644 index 00000000..b5978ad3 --- /dev/null +++ b/examples/agents/fastapi_resource_server.py @@ -0,0 +1,31 @@ +""" +A REST API protected by a Kinde "AI agents and MCP" application, with FastAPI. + + pip install "kinde-python-sdk[fastapi]" + export KINDE_HOST=https://yourbiz.kinde.com + export KINDE_AGENTS_CLIENT_ID=... KINDE_AGENTS_CLIENT_SECRET=... + export KINDE_AGENTS_RESOURCE_URL=http://localhost:3001/api + uvicorn examples.agents.fastapi_resource_server:app --port 3001 + + curl -i http://localhost:3001/api/docs # 401 with a WWW-Authenticate challenge + curl http://localhost:3001/.well-known/oauth-protected-resource/api +""" + +from fastapi import Depends, FastAPI + +from kinde_fastapi.agents import FastAPIAgentAuth +from kinde_sdk.agents import AgentAuthContext + +app = FastAPI() +agents = FastAPIAgentAuth() +agents.install(app) + + +@app.get("/api/me") +async def me(auth: AgentAuthContext = Depends(agents.require())): + return {"user": auth.user_id, "agent": auth.client_id, "permissions": auth.permissions} + + +@app.get("/api/docs") +async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read:docs"))): + return {"docs": "...", "user": auth.user_id} diff --git a/examples/agents/mcp_server.py b/examples/agents/mcp_server.py new file mode 100644 index 00000000..14a33533 --- /dev/null +++ b/examples/agents/mcp_server.py @@ -0,0 +1,61 @@ +""" +An MCP server protected by a Kinde "AI agents and MCP" application. + + pip install "kinde-python-sdk[mcp]" uvicorn + export KINDE_HOST=https://yourbiz.kinde.com + export KINDE_AGENTS_CLIENT_ID=... # the AI agents and MCP application + export KINDE_AGENTS_CLIENT_SECRET=... + export KINDE_AGENTS_RESOURCE_URL=https:///mcp + python examples/agents/mcp_server.py + +Connect with MCP Inspector (npx @modelcontextprotocol/inspector) or Claude Code +(claude mcp add --transport http kinde-test https:///mcp). +""" + +from urllib.parse import urlsplit + +import uvicorn +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.transport_security import TransportSecuritySettings + +from kinde_sdk.agents import KindeAgentAuth, has_permission +from kinde_sdk.agents.mcp import ( + KindeTokenVerifier, + get_agent_auth_context, + kinde_auth_settings, + mount_authorization_server_metadata, +) + +auth = KindeAgentAuth() + +server = MCPServer( + "Kinde example server", + token_verifier=KindeTokenVerifier(auth), + auth=kinde_auth_settings(auth), +) +# Optional: some older MCP clients look for this on the server's own origin +mount_authorization_server_metadata(server, auth) + + +@server.tool() +async def whoami() -> str: + """Who is signed in, and through which agent.""" + context = get_agent_auth_context() + return f"User {context.user_id} via {context.client_id} with permissions {context.permissions}" + + +@server.tool() +async def read_docs() -> str: + """Needs the read:docs permission in Kinde.""" + if not has_permission(get_agent_auth_context(), "read:docs"): + # A tool error, not an HTTP 403: signing in again can't grant a permission + raise ToolError("You don't have permission to read docs.") + return "The docs." + + +if __name__ == "__main__": + # Accept the public host from the Resource URL (for example a tunnel) as well as local requests + public_host = urlsplit(auth.config.resource_url).netloc + security = TransportSecuritySettings(allowed_hosts=[public_host, "localhost:3001", "127.0.0.1:3001"]) + uvicorn.run(server.streamable_http_app(transport_security=security), host="127.0.0.1", port=3001) diff --git a/examples/agents/smoke_check.py b/examples/agents/smoke_check.py new file mode 100644 index 00000000..559847c8 --- /dev/null +++ b/examples/agents/smoke_check.py @@ -0,0 +1,198 @@ +""" +Smoke checks for a Kinde "AI agents and MCP" setup, run against real Kinde and your local server. + +Reads the same environment as the examples (KINDE_HOST, KINDE_AGENTS_CLIENT_ID, +KINDE_AGENTS_CLIENT_SECRET, KINDE_AGENTS_RESOURCE_URL). Start mcp_server.py first. + + python examples/agents/smoke_check.py + python examples/agents/smoke_check.py --cimd-url https://agent.onzs.co.za/oauth/client-metadata.json + python examples/agents/smoke_check.py --token "$ACCESS_TOKEN" # a token an agent received + +Tokens and secrets are never printed. Exits non-zero if any check fails. +""" + +import argparse +import json +import sys + +import httpx +import jwt + +from kinde_sdk.agents import AgentAuthConfig, find_bearer_challenge, validate_client_metadata_url + +results = {"PASS": 0, "WARN": 0, "FAIL": 0} + + +def report(status: str, message: str) -> None: + results[status] += 1 + print(f" [{status}] {message}") + + +def check(condition: bool, message: str, warn_only: bool = False) -> bool: + report("PASS" if condition else ("WARN" if warn_only else "FAIL"), message) + return condition + + +def get_json(client: httpx.Client, url: str): + try: + response = client.get(url) + except httpx.HTTPError as exc: + report("FAIL", f"GET {url}: {type(exc).__name__}") + return None, None + try: + return response, response.json() + except ValueError: + return response, None + + +def check_kinde_discovery(client: httpx.Client, config: AgentAuthConfig) -> None: + print(f"\n1. Kinde discovery: {config.authorization_server_metadata_url}") + response, document = get_json(client, config.authorization_server_metadata_url) + if response is None: + return + if not check( + response.status_code == 200, f"HTTP {response.status_code} (404 means the flag is off or the app ID is wrong)" + ): + return + check(document.get("issuer") == config.issuer, f"issuer is the scoped issuer {config.issuer}") + check( + document.get("client_id_metadata_document_supported") is True, "client_id_metadata_document_supported is true" + ) + check(bool(document.get("registration_endpoint")), "registration_endpoint is advertised (DCR)") + check("openid" not in (document.get("scopes_supported") or []), "openid is not offered") + check("offline" in (document.get("scopes_supported") or []), "offline is offered") + + +def check_protected_resource(client: httpx.Client, config: AgentAuthConfig) -> None: + print(f"\n2. Your protected resource metadata: {config.protected_resource_metadata_url}") + response, document = get_json(client, config.protected_resource_metadata_url) + if response is None: + report("FAIL", "Is mcp_server.py running?") + return + if not check(response.status_code == 200, f"HTTP {response.status_code}"): + return + check(document.get("resource") == config.resource_url, "resource matches KINDE_AGENTS_RESOURCE_URL exactly") + check(document.get("authorization_servers") == [config.issuer], "authorization_servers is [scoped issuer]") + check("offline" in (document.get("scopes_supported") or []), "scopes_supported includes offline") + + +def check_challenge(client: httpx.Client, config: AgentAuthConfig) -> None: + print(f"\n3. Challenge from {config.resource_url}") + headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + for label, extra, expected_error in ( + ("no token", {}, None), + ("bad token", {"Authorization": "Bearer not-a-real-token"}, "invalid_token"), + ): + try: + response = client.post(config.resource_url, headers={**headers, **extra}, content=b"{}") + except httpx.HTTPError as exc: + report("FAIL", f"{label}: {type(exc).__name__}") + continue + if not check(response.status_code == 401, f"{label}: HTTP {response.status_code} (expected 401)"): + if response.status_code >= 500: + print(" A 5xx here means the server couldn't verify with Kinde: check the server log for") + print(" 'introspection' errors (wrong app ID or secret, or the feature flag is off).") + continue + challenge = find_bearer_challenge(response.headers.get_list("www-authenticate")) + check( + challenge is not None and challenge.get("resource_metadata") == config.protected_resource_metadata_url, + f"{label}: WWW-Authenticate points at the metadata URL", + ) + if expected_error: + check( + challenge is not None and challenge.get("error") == expected_error, f"{label}: error={expected_error}" + ) + + +def check_cimd(client: httpx.Client, url: str) -> None: + print(f"\n4. Client ID metadata document: {url}") + try: + validate_client_metadata_url(url) + except ValueError as exc: + report("FAIL", str(exc)) + return + try: + response = client.get(url) + except httpx.HTTPError as exc: + report("FAIL", f"GET failed: {type(exc).__name__}") + return + if not check(response.status_code == 200, f"HTTP {response.status_code} with no redirect"): + return + check(response.headers.get("content-type", "").startswith("application/json"), "Content-Type is application/json") + check(len(response.content) < 64 * 1024, "under 64 KiB") + check("max-age" in response.headers.get("cache-control", ""), "Cache-Control has max-age", warn_only=True) + try: + document = response.json() + except ValueError: + report("FAIL", "body is not JSON") + return + check(document.get("client_id") == url, "client_id equals the URL it's served at") + check(bool(document.get("client_name")), "client_name is set") + check(bool(document.get("redirect_uris")), "redirect_uris is not empty") + print(" Reminder: the host must be in the application's Trusted agent hosts.") + + +def check_token(client: httpx.Client, config: AgentAuthConfig, token: str) -> None: + print("\n5. Access token") + try: + claims = jwt.decode(token, options={"verify_signature": False}) + except jwt.PyJWTError: + report("FAIL", "not a JWT") + return + audience = claims.get("aud") + audiences = [audience] if isinstance(audience, str) else audience or [] + scopes = claims.get("scp") if isinstance(claims.get("scp"), list) else str(claims.get("scope") or "").split() + print(f" iss={claims.get('iss')} aud={audiences} azp={claims.get('azp')} scopes={scopes}") + check(str(claims.get("iss", "")).rstrip("/") == config.domain, "iss is the Kinde domain (not the scoped issuer)") + check(config.resource_url in audiences, "aud contains the Resource URL") + check("openid" not in scopes, "no openid scope") + check("offline" in scopes, "token scopes include offline (kinde_auth_settings requires it)") + + response = client.post( + config.introspection_url, + data={"token": token}, + auth=(config.client_id, config.client_secret or ""), + ) + if response.status_code != 200: + # Kinde answers 401 token_inactive for a bad token, and 401 request_unauthorized for bad app credentials + try: + error = response.json().get("error") + except ValueError: + error = None + report("FAIL", f"introspection HTTP {response.status_code} {error or ''}".rstrip()) + return + data = response.json() + if not check(data.get("active") is True, "introspection says active"): + return + introspected_aud = data.get("aud") if isinstance(data.get("aud"), list) else [data.get("aud")] + check(config.resource_url in introspected_aud, "introspection aud contains the Resource URL") + check( + "offline" in str(data.get("scope") or "").split(), + "introspection scope includes offline (the SDK reads scopes from here)", + ) + print(f" introspection fields: {sorted(k for k in data if k != 'token')}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--cimd-url", help="your agent's client ID metadata document URL") + parser.add_argument("--token", help="an access token an agent received (decoded and introspected)") + args = parser.parse_args() + + config = AgentAuthConfig.from_env() + print(f"Domain {config.domain}, application {config.client_id}, resource {config.resource_url}") + with httpx.Client(timeout=10, follow_redirects=False) as client: + check_kinde_discovery(client, config) + check_protected_resource(client, config) + check_challenge(client, config) + if args.cimd_url: + check_cimd(client, args.cimd_url) + if args.token: + check_token(client, config, args.token) + + print("\n" + json.dumps(results)) + return 1 if results["FAIL"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/kinde_fastapi/agents.py b/kinde_fastapi/agents.py new file mode 100644 index 00000000..9fe25df8 --- /dev/null +++ b/kinde_fastapi/agents.py @@ -0,0 +1,164 @@ +""" +FastAPI integration for Kinde "AI agents and MCP" applications. + +Thin wiring over ``kinde_sdk.agents``: it mounts the metadata routes and turns +the core's errors into responses. No protocol logic lives here. + + from kinde_fastapi.agents import FastAPIAgentAuth + from kinde_sdk.agents import AgentAuthContext + + agents = FastAPIAgentAuth() # reads KINDE_HOST and KINDE_AGENTS_* + agents.install(app) + + @app.get("/api/docs") + async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read:docs"))): + ... +""" + +from typing import Any, Callable, Dict, Optional +from urllib.parse import urlsplit + +from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, Response + +from kinde_sdk.agents.client_metadata import client_metadata_response +from kinde_sdk.agents.config import AUTHORIZATION_SERVER_WELL_KNOWN, PROTECTED_RESOURCE_WELL_KNOWN +from kinde_sdk.agents.context import AgentAuthContext, require_permission, require_scope +from kinde_sdk.agents.errors import AgentAuthError +from kinde_sdk.agents.resource_server import KindeAgentAuth + +# Browser-based MCP clients fetch metadata cross-origin +_CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +} + + +class AgentAuthHTTPException(HTTPException): + """Carries the core's error response. :meth:`FastAPIAgentAuth.install` renders its body as is.""" + + def __init__(self, status_code: int, body: Dict[str, str], headers: Dict[str, str]): + super().__init__(status_code=status_code, detail=body, headers=headers) + self.body = body + + +async def agent_auth_exception_handler(request: Request, exc: AgentAuthHTTPException) -> Response: + return JSONResponse(exc.body, status_code=exc.status_code, headers=exc.headers) + + +class FastAPIAgentAuth: + """FastAPI dependencies and metadata routes for a :class:`KindeAgentAuth`. + + Args: + auth: The core helper. Built from the environment when omitted. + mirror_authorization_server_metadata: Also serve + ``/.well-known/oauth-authorization-server`` for older MCP clients + that look on the resource server's origin. + """ + + def __init__( + self, + auth: Optional[KindeAgentAuth] = None, + *, + mirror_authorization_server_metadata: bool = True, + ): + self.auth = auth if auth is not None else KindeAgentAuth() + self.router = APIRouter() + self._add_metadata_routes(mirror_authorization_server_metadata) + + def install(self, app: FastAPI) -> None: + """Mount the metadata routes and render auth errors as ``{"error", "error_description"}``.""" + app.include_router(self.router) + app.add_exception_handler(AgentAuthHTTPException, agent_auth_exception_handler) + + def _add_metadata_routes(self, mirror: bool) -> None: + async def protected_resource_metadata(request: Request) -> Response: + if request.method == "OPTIONS": + return Response(status_code=204, headers=_CORS_HEADERS) + return JSONResponse( + self.auth.protected_resource_metadata(), + headers={**_CORS_HEADERS, "Cache-Control": "max-age=3600"}, + ) + + paths = [self.auth.config.protected_resource_metadata_path] + if PROTECTED_RESOURCE_WELL_KNOWN not in paths: + paths.append(PROTECTED_RESOURCE_WELL_KNOWN) + for path in paths: + self.router.add_api_route( + path, protected_resource_metadata, methods=["GET", "OPTIONS"], include_in_schema=False + ) + + if mirror: + + async def authorization_server_metadata(request: Request) -> Response: + if request.method == "OPTIONS": + return Response(status_code=204, headers=_CORS_HEADERS) + document = await self.auth.fetch_authorization_server_metadata_async() + return JSONResponse(document, headers={**_CORS_HEADERS, "Cache-Control": "max-age=300"}) + + self.router.add_api_route( + AUTHORIZATION_SERVER_WELL_KNOWN, + authorization_server_metadata, + methods=["GET", "OPTIONS"], + include_in_schema=False, + ) + + def _http_error(self, error: AgentAuthError) -> AgentAuthHTTPException: + response = self.auth.error_response(error) + return AgentAuthHTTPException(response.status_code, response.body, response.headers) + + def require(self, *scopes: str) -> Callable[..., Any]: + """A dependency that verifies the bearer token and returns the :class:`AgentAuthContext`. + + ``scopes`` are OAuth scopes (``offline``, ``profile``, ``email``); a + missing one answers 403 ``insufficient_scope``. Check Kinde permissions + with :meth:`require_permission` or ``has_permission`` instead. + """ + + async def dependency(request: Request) -> AgentAuthContext: + try: + context = await self.auth.authenticate_async(request.headers.get("authorization")) + if scopes: + require_scope(context, *scopes) + except AgentAuthError as exc: + raise self._http_error(exc) from exc + request.state.kinde_agent_auth = context + return context + + return dependency + + def require_permission(self, *permissions: str) -> Callable[..., Any]: + """A dependency for REST routes: like :meth:`require`, plus a plain 403 when a Kinde permission is missing. + + Inside an MCP tool, use ``has_permission`` and return a tool error instead. + """ + authenticate = self.require() + + async def dependency(request: Request) -> AgentAuthContext: + context = await authenticate(request) + try: + require_permission(context, *permissions) + except AgentAuthError as exc: + raise self._http_error(exc) from exc + return context + + return dependency + + +def client_metadata_router(document: Dict[str, Any], path: Optional[str] = None) -> APIRouter: + """Serve a client ID metadata document (built with ``build_client_metadata_document``). + + ``path`` defaults to the path of the document's ``client_id``, which must be + the exact URL the document is served at. + """ + body, headers = client_metadata_response(document) + router = APIRouter() + + async def client_metadata() -> Response: + return Response(content=body, headers=headers, media_type="application/json") + + router.add_api_route( + path or urlsplit(document["client_id"]).path, client_metadata, methods=["GET"], include_in_schema=False + ) + return router diff --git a/kinde_flask/agents.py b/kinde_flask/agents.py new file mode 100644 index 00000000..b704ff7b --- /dev/null +++ b/kinde_flask/agents.py @@ -0,0 +1,176 @@ +""" +Flask integration for Kinde "AI agents and MCP" applications. + +Thin wiring over ``kinde_sdk.agents``: it registers the metadata routes and +turns the core's errors into responses. No protocol logic lives here. + + from kinde_flask.agents import FlaskAgentAuth, current_agent_auth + + agents = FlaskAgentAuth(app=app) # reads KINDE_HOST and KINDE_AGENTS_* + + @app.get("/api/docs") + @agents.require_permission("read:docs") + def docs(): + return {"user": current_agent_auth().user_id} +""" + +from functools import wraps +from typing import Any, Callable, Dict, Optional +from urllib.parse import urlsplit + +from flask import Blueprint, Flask, Response, current_app, g, jsonify, request + +from kinde_sdk.agents.client_metadata import client_metadata_response +from kinde_sdk.agents.config import AUTHORIZATION_SERVER_WELL_KNOWN, PROTECTED_RESOURCE_WELL_KNOWN +from kinde_sdk.agents.context import AgentAuthContext, require_permission, require_scope +from kinde_sdk.agents.errors import AgentAuthError +from kinde_sdk.agents.resource_server import KindeAgentAuth + +# Browser-based MCP clients fetch metadata cross-origin +_CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +} + + +def current_agent_auth() -> Optional[AgentAuthContext]: + """The :class:`AgentAuthContext` for the current request, set by ``require``.""" + return g.get("kinde_agent_auth") + + +class FlaskAgentAuth: + """Flask decorators and metadata routes for a :class:`KindeAgentAuth`. + + Args: + auth: The core helper. Built from the environment when omitted. + app: Register the routes now, or later with :meth:`init_app`. + mirror_authorization_server_metadata: Also serve + ``/.well-known/oauth-authorization-server`` for older MCP clients + that look on the resource server's origin. + """ + + def __init__( + self, + auth: Optional[KindeAgentAuth] = None, + app: Optional[Flask] = None, + *, + mirror_authorization_server_metadata: bool = True, + ): + self.auth = auth if auth is not None else KindeAgentAuth() + self.blueprint = self._metadata_blueprint(mirror_authorization_server_metadata) + if app is not None: + self.init_app(app) + + def init_app(self, app: Flask) -> None: + app.register_blueprint(self.blueprint) + + def _metadata_blueprint(self, mirror: bool) -> Blueprint: + blueprint = Blueprint("kinde_agent_auth", __name__) + + def protected_resource_metadata(): + if request.method == "OPTIONS": + return Response(status=204, headers=_CORS_HEADERS) + response = jsonify(self.auth.protected_resource_metadata()) + response.headers.update({**_CORS_HEADERS, "Cache-Control": "max-age=3600"}) + return response + + paths = [self.auth.config.protected_resource_metadata_path] + if PROTECTED_RESOURCE_WELL_KNOWN not in paths: + paths.append(PROTECTED_RESOURCE_WELL_KNOWN) + for index, path in enumerate(paths): + blueprint.add_url_rule( + path, + endpoint=f"protected_resource_metadata_{index}", + view_func=protected_resource_metadata, + methods=["GET", "OPTIONS"], + provide_automatic_options=False, + ) + + if mirror: + + def authorization_server_metadata(): + if request.method == "OPTIONS": + return Response(status=204, headers=_CORS_HEADERS) + response = jsonify(self.auth.fetch_authorization_server_metadata()) + response.headers.update({**_CORS_HEADERS, "Cache-Control": "max-age=300"}) + return response + + blueprint.add_url_rule( + AUTHORIZATION_SERVER_WELL_KNOWN, + endpoint="authorization_server_metadata", + view_func=authorization_server_metadata, + methods=["GET", "OPTIONS"], + provide_automatic_options=False, + ) + return blueprint + + def error_response(self, error: AgentAuthError) -> Response: + result = self.auth.error_response(error) + response = jsonify(result.body) + response.status_code = result.status_code + response.headers.update(result.headers) + return response + + def _authenticate(self, scopes) -> AgentAuthContext: + context = self.auth.authenticate(request.headers.get("Authorization")) + if scopes: + require_scope(context, *scopes) + g.kinde_agent_auth = context + return context + + def require(self, *scopes: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorate a view so it only runs with a valid bearer token. + + ``scopes`` are OAuth scopes (``offline``, ``profile``, ``email``); a + missing one answers 403 ``insufficient_scope``. The context is available + from :func:`current_agent_auth`. + """ + + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + @wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + self._authenticate(scopes) + except AgentAuthError as exc: + return self.error_response(exc) + return current_app.ensure_sync(view)(*args, **kwargs) + + return wrapper + + return decorator + + def require_permission(self, *permissions: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Like :meth:`require`, plus a plain 403 when a Kinde permission is missing. For REST routes. + + Inside an MCP tool, use ``has_permission`` and return a tool error instead. + """ + + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + @wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + require_permission(self._authenticate(()), *permissions) + except AgentAuthError as exc: + return self.error_response(exc) + return current_app.ensure_sync(view)(*args, **kwargs) + + return wrapper + + return decorator + + +def client_metadata_blueprint(document: Dict[str, Any], path: Optional[str] = None) -> Blueprint: + """Serve a client ID metadata document (built with ``build_client_metadata_document``). + + ``path`` defaults to the path of the document's ``client_id``, which must be + the exact URL the document is served at. + """ + body, headers = client_metadata_response(document) + blueprint = Blueprint("kinde_client_metadata", __name__) + + def client_metadata(): + return Response(body, headers=headers) + + blueprint.add_url_rule(path or urlsplit(document["client_id"]).path, view_func=client_metadata, methods=["GET"]) + return blueprint diff --git a/kinde_sdk/agents/__init__.py b/kinde_sdk/agents/__init__.py new file mode 100644 index 00000000..dbc63bef --- /dev/null +++ b/kinde_sdk/agents/__init__.py @@ -0,0 +1,90 @@ +""" +Kinde "AI agents and MCP" support: CIMD and Dynamic Client Registration. + +Two halves, both opt-in and separate from the SDK's normal login flow: + +- Resource server (:class:`KindeAgentAuth`): protect your MCP server or API. + Serves protected resource metadata, answers 401 with the right challenge, + verifies tokens (introspection by default) and checks permissions. +- Agent client (:class:`KindeAgentClient`): sign a user in to a protected + server, preferring a client ID metadata document over registration. + +The adapters for the official ``mcp`` package are in ``kinde_sdk.agents.mcp`` +(install ``kinde-python-sdk[mcp]``). This package never imports ``mcp``. +""" + +from kinde_sdk.agents.client import AuthorizationRequiredError, KindeAgentClient, PendingAuthorization +from kinde_sdk.agents.client_metadata import ( + build_client_metadata_document, + client_metadata_response, + validate_client_metadata_url, +) +from kinde_sdk.agents.config import AgentAuthConfig +from kinde_sdk.agents.context import ( + AgentAuthContext, + has_permission, + has_role, + has_scope, + require_permission, + require_scope, +) +from kinde_sdk.agents.errors import ( + AgentAuthError, + AgentClientError, + AgentConfigError, + AuthenticationRequired, + AuthorizationError, + DiscoveryError, + InsufficientScopeError, + InvalidTokenError, + PermissionDeniedError, + RegistrationError, + TokenRequestError, + VerificationUnavailableError, +) +from kinde_sdk.agents.resource_server import AgentAuthResponse, KindeAgentAuth +from kinde_sdk.agents.storage import AgentClientStorage, FileAgentStorage, InMemoryAgentStorage +from kinde_sdk.agents.www_authenticate import build_challenge, find_bearer_challenge, parse_www_authenticate + + +def create_kinde_agent_auth(config=None, **options) -> KindeAgentAuth: + """Alias for :class:`KindeAgentAuth`, matching ``createKindeAgentAuth`` in the other SDKs.""" + return KindeAgentAuth(config, **options) + + +__all__ = [ + "AgentAuthConfig", + "AgentAuthContext", + "AgentAuthError", + "AgentAuthResponse", + "AgentClientError", + "AgentClientStorage", + "AgentConfigError", + "AuthenticationRequired", + "AuthorizationError", + "AuthorizationRequiredError", + "DiscoveryError", + "FileAgentStorage", + "InMemoryAgentStorage", + "InsufficientScopeError", + "InvalidTokenError", + "KindeAgentAuth", + "KindeAgentClient", + "PendingAuthorization", + "PermissionDeniedError", + "RegistrationError", + "TokenRequestError", + "VerificationUnavailableError", + "build_challenge", + "build_client_metadata_document", + "client_metadata_response", + "create_kinde_agent_auth", + "find_bearer_challenge", + "has_permission", + "has_role", + "has_scope", + "parse_www_authenticate", + "require_permission", + "require_scope", + "validate_client_metadata_url", +] diff --git a/kinde_sdk/agents/client.py b/kinde_sdk/agents/client.py new file mode 100644 index 00000000..a701d8f1 --- /dev/null +++ b/kinde_sdk/agents/client.py @@ -0,0 +1,862 @@ +""" +Agent-client helpers: sign a user in to an MCP server or API protected by an OAuth resource server. + +Works against any spec-compliant server (RFC 9728, RFC 8414, RFC 7591, RFC 7636, +RFC 8707 and the CIMD draft), with Kinde's quirks handled: no ``openid`` scope, +no ID token, a secret always returned by registration, and a refresh token that +may or may not rotate. + +Agents built on the official ``mcp`` client should use +``kinde_sdk.agents.mcp.KindeOAuthClientProvider`` instead. +""" + +import asyncio +import base64 +import hashlib +import html +import logging +import secrets +import time +import webbrowser +from dataclasses import asdict, dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple +from urllib.parse import parse_qs, quote_plus, urlencode, urlsplit + +import httpx + +from kinde_sdk.agents.client_metadata import validate_client_metadata_url +from kinde_sdk.agents.config import ( + AUTHORIZATION_SERVER_WELL_KNOWN, + PROTECTED_RESOURCE_WELL_KNOWN, + is_loopback_host, +) +from kinde_sdk.agents.errors import ( + AgentClientError, + AgentConfigError, + AuthorizationError, + DiscoveryError, + RegistrationError, + TokenRequestError, +) +from kinde_sdk.agents.storage import AgentClientStorage, InMemoryAgentStorage +from kinde_sdk.agents.www_authenticate import find_bearer_challenge + +logger = logging.getLogger("kinde_sdk.agents") + +_METADATA_CACHE_SECONDS = 300 +_REFRESH_MARGIN_SECONDS = 60 +_REREGISTER_MARGIN_SECONDS = 86400 +_DEFAULT_PORTS = {"http": 80, "https": 443} + + +class AuthorizationRequiredError(AgentClientError): + """The user has to sign in, and this client can't open a browser on its own. + + Server-hosted agents handle it with :meth:`KindeAgentClient.begin_authorization` + and :meth:`KindeAgentClient.complete_authorization`. + """ + + +def _origin(url: str) -> Tuple[str, str, Optional[int]]: + parts = urlsplit(url) + return parts.scheme.lower(), (parts.hostname or "").lower(), parts.port or _DEFAULT_PORTS.get(parts.scheme.lower()) + + +def _require_secure(url: str, what: str) -> None: + parts = urlsplit(url) + if parts.scheme == "https" and parts.hostname: + return + if parts.scheme == "http" and is_loopback_host(parts.hostname): + return + raise DiscoveryError(f"Refusing a non-https {what}: {url}") + + +def resource_covers(prm_resource: str, resource_url: str) -> bool: + """Whether metadata for ``prm_resource`` applies to ``resource_url``: same origin, same or parent path.""" + if _origin(prm_resource) != _origin(resource_url): + return False + base = urlsplit(prm_resource).path.rstrip("/") + path = urlsplit(resource_url).path.rstrip("/") + return path == base or path.startswith(base + "/") + + +def protected_resource_metadata_urls(resource_url: str) -> List[str]: + """The path-suffixed then root RFC 9728 URLs for a resource.""" + parts = urlsplit(resource_url) + origin = f"{parts.scheme}://{parts.netloc}" + path = parts.path.rstrip("/") + urls = [origin + PROTECTED_RESOURCE_WELL_KNOWN + path] if path else [] + urls.append(origin + PROTECTED_RESOURCE_WELL_KNOWN) + return urls + + +def authorization_server_metadata_urls(issuer: str) -> List[str]: + """RFC 8414 path insertion first (what Kinde serves), then the OIDC discovery forms.""" + parts = urlsplit(issuer) + origin = f"{parts.scheme}://{parts.netloc}" + path = parts.path.rstrip("/") + if not path: + return [origin + AUTHORIZATION_SERVER_WELL_KNOWN, origin + "/.well-known/openid-configuration"] + return [ + origin + AUTHORIZATION_SERVER_WELL_KNOWN + path, + origin + "/.well-known/openid-configuration" + path, + origin + path + "/.well-known/openid-configuration", + ] + + +def _oauth_error(response: httpx.Response) -> Tuple[Optional[str], Optional[str]]: + try: + body = response.json() + except ValueError: + return None, None + if not isinstance(body, dict): + return None, None + return body.get("error"), body.get("error_description") + + +def _pkce_pair() -> Tuple[str, str]: + # 86 characters from the unreserved set: 512 bits of entropy + verifier = secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + return verifier, challenge + + +@dataclass +class PendingAuthorization: + """One authorization attempt. Keep it server-side (for example in the session) until the callback. + + Use :meth:`to_dict` and :meth:`from_dict` to store it. It holds the PKCE + verifier, so never send it to the browser. + """ + + authorization_url: str + state: str + redirect_uri: str + scope: str + issuer: str + client_id: str + code_verifier: str = field(repr=False) + registration_key: Optional[str] = None + created_at: float = field(default_factory=time.time) + used: bool = False + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PendingAuthorization": + return cls(**data) + + +class _LoopbackListener: + """Accepts one browser redirect on a loopback address (RFC 8252 §7.3), then stops.""" + + def __init__(self, redirect_uri: str): + parts = urlsplit(redirect_uri) + if parts.scheme != "http" or not is_loopback_host(parts.hostname): + raise AgentConfigError(f"A loopback redirect URI is required, got {redirect_uri!r}") + self._host = parts.hostname + self._netloc_host = f"[{parts.hostname}]" if ":" in parts.hostname else parts.hostname + self._port = parts.port or 0 + self._bound_port = self._port + self._path = parts.path or "/" + self._server: Optional[asyncio.AbstractServer] = None + self._result: Optional[asyncio.Future] = None + self.redirect_uri = redirect_uri + + async def start(self) -> None: + self._result = asyncio.get_running_loop().create_future() + bind = "127.0.0.1" if self._host == "localhost" else self._host + self._server = await asyncio.start_server(self._handle, bind, self._port) + self._bound_port = self._server.sockets[0].getsockname()[1] + self.redirect_uri = f"http://{self._netloc_host}:{self._bound_port}{self._path}" + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + request_line = (await asyncio.wait_for(reader.readline(), 10)).decode("latin-1") + while (await asyncio.wait_for(reader.readline(), 10)) not in (b"\r\n", b"\n", b""): + pass + method, _, rest = request_line.partition(" ") + target = rest.rsplit(" ", 1)[0] + if method != "GET" or urlsplit(target).path != self._path or self._result.done(): + writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + return + query = parse_qs(urlsplit(target).query) + failed = "error" in query + message = "Sign-in failed. You can close this tab." if failed else "Signed in. You can close this tab." + body = f"Kinde

{html.escape(message)}

".encode() + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n" + + f"Content-Length: {len(body)}\r\n\r\n".encode() + + body + ) + self._result.set_result(f"http://{self._netloc_host}:{self._bound_port}{target}") + except (asyncio.TimeoutError, ConnectionError, UnicodeDecodeError): + pass + finally: + try: + await writer.drain() + except ConnectionError: + pass + writer.close() + + async def wait(self, timeout: float) -> str: + try: + return await asyncio.wait_for(asyncio.shield(self._result), timeout) + except asyncio.TimeoutError as exc: + raise AuthorizationError("Timed out waiting for the browser to finish signing in") from exc + + async def close(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + +class KindeAgentClient: + """Signs a user in to a protected MCP server or API, then calls it with a bearer token. + + Client identity is chosen in this order: the CIMD document URL when the + authorization server supports it, a pre-registered ``client_id``, then + dynamic client registration. + + CLI agent with a loopback redirect:: + + client = KindeAgentClient( + "https://api.example.com/mcp", + client_metadata_url="https://agent.example.com/oauth/client-metadata.json", + client_name="Example Agent", + ) + await client.connect() # opens the browser if a sign-in is needed + response = await client.request("POST", json={...}) + + Server-hosted agent: pass ``redirect_uri`` and use :meth:`begin_authorization` + and :meth:`complete_authorization` in your login and callback routes. + """ + + def __init__( + self, + resource_url: str, + *, + client_metadata_url: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + token_endpoint_auth_method: Optional[str] = None, + client_name: str = "Kinde agent", + redirect_uri: Optional[str] = None, + scopes: Optional[Sequence[str]] = None, + storage: Optional[AgentClientStorage] = None, + account: str = "default", + open_browser: Optional[Callable[[str], Any]] = None, + authorization_handler: Optional[Callable[[str], Awaitable[str]]] = None, + http_client: Optional[httpx.AsyncClient] = None, + timeout_seconds: float = 10.0, + callback_timeout_seconds: float = 300.0, + ): + _require_secure_resource(resource_url) + if client_metadata_url is not None: + validate_client_metadata_url(client_metadata_url) + if client_secret and not client_id: + raise AgentConfigError("client_secret needs a client_id") + self.resource_url = resource_url + self.client_metadata_url = client_metadata_url + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint_auth_method = token_endpoint_auth_method or ( + "client_secret_post" if client_secret else "none" + ) + self.client_name = client_name + # No port means "pick a free one" for a loopback listener + self.redirect_uri = redirect_uri or "http://127.0.0.1/callback" + self.scopes = list(scopes) if scopes else ["offline"] + self.storage = storage or InMemoryAgentStorage() + self.account = account + self.open_browser = open_browser or webbrowser.open + self.authorization_handler = authorization_handler + self.callback_timeout_seconds = callback_timeout_seconds + self._http = http_client or httpx.AsyncClient(timeout=timeout_seconds) + self._owns_http = http_client is None + self._prm: Optional[Dict[str, Any]] = None + self._as_metadata: Optional[Dict[str, Any]] = None + self._metadata_at = 0.0 + self._locks: Dict[str, asyncio.Lock] = {} + + # -- Lifecycle ------------------------------------------------------------ + + async def aclose(self) -> None: + if self._owns_http: + await self._http.aclose() + + async def __aenter__(self) -> "KindeAgentClient": + return self + + async def __aexit__(self, *exc_info) -> None: + await self.aclose() + + def _lock_for(self, name: str) -> asyncio.Lock: + # Created lazily so they bind to the running event loop + lock = self._locks.get(name) + if lock is None: + lock = self._locks[name] = asyncio.Lock() + return lock + + @property + def _token_key(self) -> str: + return f"{self.resource_url}|{self.account}" + + @property + def issuer(self) -> Optional[str]: + return self._as_metadata.get("issuer") if self._as_metadata else None + + # -- Discovery ------------------------------------------------------------ + + async def discover( + self, challenge_response: Optional[httpx.Response] = None, *, force: bool = False + ) -> Dict[str, Any]: + """Find the authorization server for the resource and return its metadata (cached 5 minutes).""" + fresh = self._as_metadata is not None and time.monotonic() - self._metadata_at < _METADATA_CACHE_SECONDS + if fresh and not force and challenge_response is None: + return self._as_metadata + + prm = await self._fetch_protected_resource_metadata(challenge_response) + resource = prm.get("resource") + if not isinstance(resource, str) or not resource_covers(resource, self.resource_url): + raise DiscoveryError(f"The protected resource metadata is for a different resource: {resource!r}") + servers = prm.get("authorization_servers") + if not isinstance(servers, list) or not servers or not isinstance(servers[0], str): + raise DiscoveryError("The protected resource metadata lists no authorization server") + issuer = servers[0] + _require_secure(issuer, "authorization server") + + metadata = await self._fetch_authorization_server_metadata(issuer) + stored = await self.storage.get_tokens(self._token_key) + if stored and stored.get("issuer") != issuer: + # Tokens are bound to the issuer that granted them + logger.info("The authorization server changed; discarding stored tokens") + await self.storage.delete_tokens(self._token_key) + self._prm, self._as_metadata, self._metadata_at = prm, metadata, time.monotonic() + return metadata + + async def _get_json(self, url: str) -> Optional[Dict[str, Any]]: + try: + response = await self._http.get(url, headers={"Accept": "application/json"}) + except httpx.HTTPError as exc: + raise DiscoveryError(f"Could not fetch {url}: {type(exc).__name__}") from exc + if response.status_code != 200: + return None + try: + body = response.json() + except ValueError: + return None + return body if isinstance(body, dict) else None + + async def _fetch_protected_resource_metadata(self, challenge_response: Optional[httpx.Response]) -> Dict[str, Any]: + candidates: List[str] = [] + if challenge_response is not None: + challenge = find_bearer_challenge(challenge_response.headers.get_list("www-authenticate")) + if challenge and challenge.get("resource_metadata"): + candidates.append(challenge.get("resource_metadata")) + for url in protected_resource_metadata_urls(self.resource_url): + if url not in candidates: + candidates.append(url) + for url in candidates: + _require_secure(url, "protected resource metadata URL") + document = await self._get_json(url) + if document is not None: + return document + if challenge_response is None: + # No well-known document: ask the resource itself for a challenge + try: + probe = await self._http.get(self.resource_url) + except httpx.HTTPError as exc: + raise DiscoveryError(f"Could not reach {self.resource_url}: {type(exc).__name__}") from exc + challenge = find_bearer_challenge(probe.headers.get_list("www-authenticate")) + if probe.status_code == 401 and challenge and challenge.get("resource_metadata") not in (None, *candidates): + return await self._fetch_protected_resource_metadata(probe) + raise DiscoveryError(f"No protected resource metadata found for {self.resource_url}") + + async def _fetch_authorization_server_metadata(self, issuer: str) -> Dict[str, Any]: + for url in authorization_server_metadata_urls(issuer): + metadata = await self._get_json(url) + if metadata is None: + continue + # RFC 8414 §3.3: the document must name the issuer it was fetched for + if metadata.get("issuer") != issuer: + raise DiscoveryError( + f"Authorization server metadata issuer mismatch: {metadata.get('issuer')!r} != {issuer!r}" + ) + if "S256" not in (metadata.get("code_challenge_methods_supported") or []): + raise DiscoveryError("The authorization server does not advertise PKCE S256") + for name in ("authorization_endpoint", "token_endpoint"): + if not isinstance(metadata.get(name), str): + raise DiscoveryError(f"The authorization server metadata has no {name}") + for name in ("authorization_endpoint", "token_endpoint", "registration_endpoint", "revocation_endpoint"): + if metadata.get(name): + _require_secure(metadata[name], name.replace("_", " ")) + return metadata + raise DiscoveryError(f"No authorization server metadata found for {issuer}") + + # -- Client identity ------------------------------------------------------ + + def _effective_scope(self, scopes: Optional[Sequence[str]] = None) -> str: + requested = list(scopes) if scopes else list(self.scopes) + supported = (self._as_metadata or {}).get("scopes_supported") + if isinstance(supported, list) and "openid" not in supported and "openid" in requested: + # Kinde's agent issuer refuses openid with invalid_scope + logger.debug("Dropping the openid scope, which the authorization server does not support") + requested = [scope for scope in requested if scope != "openid"] + seen: List[str] = [] + for scope in requested: + if scope not in seen: + seen.append(scope) + return " ".join(seen) + + async def _client_identity(self, redirect_uri: str) -> Dict[str, Any]: + metadata = self._as_metadata or {} + if self.client_metadata_url and metadata.get("client_id_metadata_document_supported") is True: + return {"client_id": self.client_metadata_url, "auth_method": "none"} + if self.client_id: + return { + "client_id": self.client_id, + "client_secret": self.client_secret, + "auth_method": self.token_endpoint_auth_method, + } + if not metadata.get("registration_endpoint"): + raise AgentConfigError( + "The authorization server supports neither client ID metadata documents nor registration; " + "configure client_id" + ) + key = f"{metadata['issuer']}|{redirect_uri}" + registration = await self.storage.get_registration(key) + if registration: + expires_at = registration.get("client_secret_expires_at") or 0 + if not expires_at or expires_at - time.time() > _REREGISTER_MARGIN_SECONDS: + return _identity_from_registration(registration, key) + return _identity_from_registration(await self._register(redirect_uri, key), key) + + async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: + endpoint = self._as_metadata["registration_endpoint"] + request = { + "redirect_uris": [redirect_uri], + "client_name": self.client_name, + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + # Spec-correct for a public client; Kinde answers client_secret_post and a secret regardless + "token_endpoint_auth_method": "none", + } + try: + response = await self._http.post(endpoint, json=request) + except httpx.HTTPError as exc: + raise RegistrationError(f"Could not reach the registration endpoint: {type(exc).__name__}") from exc + error, description = _oauth_error(response) + if response.status_code == 429: + raise RegistrationError("Too many registrations; try again later", error=error or "too_many_registrations") + if response.status_code == 404: + # The application behind this issuer is gone + await self.storage.delete_tokens(self._token_key) + await self.storage.delete_registration(key) + raise RegistrationError("The authorization server no longer exists", error=error) + if response.status_code not in (200, 201): + if error == "invalid_redirect_uri": + # The description names the host to add to the application's Trusted agent hosts + raise RegistrationError( + description or "The redirect URI was refused", error=error, error_description=description + ) + raise RegistrationError( + f"Registration failed with HTTP {response.status_code}: {description or error or 'no details'}", + error=error, + error_description=description, + ) + body = response.json() + if not isinstance(body, dict) or not body.get("client_id"): + raise RegistrationError("The registration response has no client_id") + secret = body.get("client_secret") + registration = { + "client_id": body["client_id"], + "client_secret": secret, + # Use what came back, not what was asked for + "token_endpoint_auth_method": body.get("token_endpoint_auth_method") + or ("client_secret_post" if secret else "none"), + "client_secret_expires_at": body.get("client_secret_expires_at") or 0, + "redirect_uri": redirect_uri, + "issuer": self._as_metadata["issuer"], + } + await self.storage.set_registration(key, registration) + return registration + + async def _identity_for( + self, client_id: Optional[str], registration_key: Optional[str] + ) -> Optional[Dict[str, Any]]: + """The identity (with its secret) that a token or authorization attempt was issued to.""" + if registration_key: + registration = await self.storage.get_registration(registration_key) + if not registration or registration.get("client_id") != client_id: + return None + return _identity_from_registration(registration, registration_key) + if self.client_id and client_id == self.client_id: + return { + "client_id": self.client_id, + "client_secret": self.client_secret, + "auth_method": self.token_endpoint_auth_method, + } + return {"client_id": client_id, "auth_method": "none"} + + async def _identity_for_tokens(self, tokens: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return await self._identity_for(tokens.get("client_id"), tokens.get("registration_key")) + + @staticmethod + def _apply_client_auth(identity: Dict[str, Any], data: Dict[str, str], headers: Dict[str, str]) -> None: + method = identity.get("auth_method") or "none" + secret = identity.get("client_secret") + if method == "client_secret_basic" and secret: + raw = f"{quote_plus(identity['client_id'])}:{quote_plus(secret)}" + headers["Authorization"] = "Basic " + base64.b64encode(raw.encode()).decode() + return + data["client_id"] = identity["client_id"] + if method == "client_secret_post" and secret: + data["client_secret"] = secret + + # -- Authorization -------------------------------------------------------- + + async def begin_authorization( + self, *, redirect_uri: Optional[str] = None, scopes: Optional[Sequence[str]] = None + ) -> PendingAuthorization: + """Start a sign-in: returns the URL to send the user to and the state to keep until the callback.""" + metadata = await self.discover() + redirect_uri = redirect_uri or self.redirect_uri + identity = await self._client_identity(redirect_uri) + verifier, challenge = _pkce_pair() + state = secrets.token_urlsafe(32) + scope = self._effective_scope(scopes) + params = { + "response_type": "code", + "client_id": identity["client_id"], + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + # RFC 8707, which MCP requires; Kinde ignores it and sets the audience from the path + "resource": self.resource_url, + } + if scope: + params["scope"] = scope + endpoint = metadata["authorization_endpoint"] + url = endpoint + ("&" if "?" in endpoint else "?") + urlencode(params) + return PendingAuthorization( + authorization_url=url, + state=state, + redirect_uri=redirect_uri, + scope=scope, + issuer=metadata["issuer"], + client_id=identity["client_id"], + code_verifier=verifier, + registration_key=identity.get("registration_key"), + ) + + async def complete_authorization(self, pending: PendingAuthorization, callback_url: str) -> None: + """Finish a sign-in from the full callback URL the browser was sent to.""" + if pending.used: + raise AuthorizationError("This authorization attempt was already used") + pending.used = True + query = parse_qs(urlsplit(callback_url).query) + state = (query.get("state") or [""])[0] + if not secrets.compare_digest(state.encode(), pending.state.encode()): + raise AuthorizationError("The state on the callback does not match") + error = (query.get("error") or [None])[0] + if error: + description = (query.get("error_description") or [None])[0] + message = f"Authorization failed: {error}" + (f" ({description})" if description else "") + if error == "invalid_client" and self.client_metadata_url: + message += ". Check the client metadata host is in the application's Trusted agent hosts." + raise AuthorizationError(message, error=error, error_description=description) + issuer = (query.get("iss") or [None])[0] + if issuer is not None and issuer != pending.issuer: + # RFC 9207: a different issuer means a mix-up attack or misconfiguration + raise AuthorizationError("The callback came from a different authorization server") + code = (query.get("code") or [None])[0] + if not code: + raise AuthorizationError("The callback has no authorization code") + + metadata = await self.discover() + # The code is bound to the client that started this attempt, so never re-resolve or re-register here + identity = await self._identity_for(pending.client_id, pending.registration_key) + if identity is None: + raise AuthorizationError("The client registration for this attempt is gone; start again") + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": pending.redirect_uri, + "code_verifier": pending.code_verifier, + "resource": self.resource_url, + } + headers = {"Accept": "application/json"} + self._apply_client_auth(identity, data, headers) + response = await self._post_token(metadata["token_endpoint"], data, headers) + if response.status_code != 200: + error, description = _oauth_error(response) + if error == "invalid_client" and identity.get("registration_key"): + await self.storage.delete_registration(identity["registration_key"]) + raise TokenRequestError( + f"The code exchange failed: {error or response.status_code}", error=error, error_description=description + ) + await self._store_token_response(response, identity, pending.redirect_uri, previous=None) + + async def authorize( + self, *, scopes: Optional[Sequence[str]] = None, challenge_response: Optional[httpx.Response] = None + ) -> None: + """Run a full sign-in now, through ``authorization_handler`` or a loopback listener and the system browser.""" + await self.discover(challenge_response) + if self.authorization_handler is not None: + pending = await self.begin_authorization(scopes=scopes) + callback_url = await self.authorization_handler(pending.authorization_url) + await self.complete_authorization(pending, callback_url) + return + parts = urlsplit(self.redirect_uri) + if not (parts.scheme == "http" and is_loopback_host(parts.hostname)): + raise AuthorizationRequiredError( + "The user must sign in. Send them to begin_authorization().authorization_url, " + "then call complete_authorization() from your callback route." + ) + listener = _LoopbackListener(self.redirect_uri) + await listener.start() + try: + pending = await self.begin_authorization(redirect_uri=listener.redirect_uri, scopes=scopes) + result = self.open_browser(pending.authorization_url) + if asyncio.iscoroutine(result): + await result + callback_url = await listener.wait(self.callback_timeout_seconds) + finally: + await listener.close() + await self.complete_authorization(pending, callback_url) + + # -- Tokens --------------------------------------------------------------- + + async def _post_token(self, endpoint: str, data: Dict[str, str], headers: Dict[str, str]) -> httpx.Response: + try: + return await self._http.post(endpoint, data=data, headers=headers) + except httpx.HTTPError as exc: + raise TokenRequestError(f"Could not reach the token endpoint: {type(exc).__name__}") from exc + + async def _store_token_response( + self, + response: httpx.Response, + identity: Dict[str, Any], + redirect_uri: Optional[str], + previous: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + try: + body = response.json() + except ValueError as exc: + raise TokenRequestError("The token response was not JSON") from exc + if not isinstance(body, dict) or not body.get("access_token"): + raise TokenRequestError("The token response has no access_token") + # No id_token is expected: Kinde's agent issuer never returns one + expires_in = body.get("expires_in") + tokens = { + "access_token": body["access_token"], + "token_type": body.get("token_type", "bearer"), + # Replace a rotated refresh token; keep the old one when the server doesn't send one + "refresh_token": body.get("refresh_token") or (previous or {}).get("refresh_token"), + "expires_at": time.time() + float(expires_in) if isinstance(expires_in, (int, float)) else None, + "scope": body.get("scope") or (previous or {}).get("scope"), + "issuer": self._as_metadata["issuer"], + "resource_url": self.resource_url, + "client_id": identity["client_id"], + "registration_key": identity.get("registration_key"), + "redirect_uri": redirect_uri, + } + await self.storage.set_tokens(self._token_key, tokens) + return tokens + + @staticmethod + def _expiring(tokens: Dict[str, Any]) -> bool: + expires_at = tokens.get("expires_at") + return expires_at is not None and expires_at - time.time() < _REFRESH_MARGIN_SECONDS + + async def refresh(self, *, stale_access_token: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Refresh the stored tokens. Returns ``None`` when a new sign-in is needed. + + Concurrent callers share one refresh request: whoever waits behind it + picks up the tokens it stored. + """ + async with self._lock_for("_refresh_lock"): + tokens = await self.storage.get_tokens(self._token_key) + if not tokens: + return None + if stale_access_token and tokens.get("access_token") != stale_access_token and not self._expiring(tokens): + return tokens # someone else refreshed while we waited + if not tokens.get("refresh_token"): + return None + metadata = await self.discover() + identity = await self._identity_for_tokens(tokens) + if identity is None: + await self.storage.delete_tokens(self._token_key) + return None + data = { + "grant_type": "refresh_token", + "refresh_token": tokens["refresh_token"], + "resource": self.resource_url, + } + headers = {"Accept": "application/json"} + self._apply_client_auth(identity, data, headers) + response = await self._post_token(metadata["token_endpoint"], data, headers) + if response.status_code == 200: + return await self._store_token_response(response, identity, tokens.get("redirect_uri"), previous=tokens) + error, description = _oauth_error(response) + if error == "invalid_grant": + # Revoked, expired after 15 days, or the application was deleted + await self.storage.delete_tokens(self._token_key) + return None + if error == "invalid_client": + # The registration expired: register again and sign in again + if identity.get("registration_key"): + await self.storage.delete_registration(identity["registration_key"]) + await self.storage.delete_tokens(self._token_key) + return None + raise TokenRequestError( + f"The refresh failed: {error or response.status_code}", error=error, error_description=description + ) + + async def _usable_tokens(self) -> Dict[str, Any]: + tokens = await self.storage.get_tokens(self._token_key) + if tokens and self._expiring(tokens): + tokens = await self.refresh(stale_access_token=tokens["access_token"]) + if not tokens: + tokens = await self._sign_in(None) + return tokens + + async def _sign_in( + self, + stale_access_token: Optional[str], + *, + scopes: Optional[Sequence[str]] = None, + challenge_response: Optional[httpx.Response] = None, + ) -> Dict[str, Any]: + """Run one sign-in for every caller that needs it, so concurrent requests open one browser, not many.""" + async with self._lock_for("_sign_in_lock"): + tokens = await self.storage.get_tokens(self._token_key) + if ( + tokens + and tokens.get("access_token") != stale_access_token + and not self._expiring(tokens) + and set(scopes or ()) <= set((tokens.get("scope") or "").split()) + ): + return tokens # another caller signed in while we waited + await self.authorize(scopes=scopes, challenge_response=challenge_response) + tokens = await self.storage.get_tokens(self._token_key) + if not tokens: + raise AuthorizationRequiredError("No tokens after signing in") + return tokens + + async def connect(self) -> None: + """Make sure the client holds usable tokens, signing the user in if needed.""" + await self._usable_tokens() + + async def get_token_info(self) -> Optional[Dict[str, Any]]: + """What the client holds, without the raw tokens.""" + tokens = await self.storage.get_tokens(self._token_key) + if not tokens: + return None + return { + "scopes": (tokens.get("scope") or "").split(), + "expires_at": tokens.get("expires_at"), + "client_id": tokens.get("client_id"), + "issuer": tokens.get("issuer"), + "has_refresh_token": bool(tokens.get("refresh_token")), + } + + async def get_access_token(self) -> str: + """A valid access token for the resource, refreshing or signing in first if needed.""" + return (await self._usable_tokens())["access_token"] + + # -- Calling the resource ------------------------------------------------- + + def _resolve(self, url: str) -> str: + if not url: + return self.resource_url + if url.startswith("/"): + parts = urlsplit(self.resource_url) + return f"{parts.scheme}://{parts.netloc}{url}" + if _origin(url) != _origin(self.resource_url): + # The token is for this resource only + raise AgentConfigError(f"Refusing to send the access token to another origin: {url}") + return url + + async def request(self, method: str, url: str = "", **kwargs: Any) -> httpx.Response: + """Call the resource with the bearer token, refreshing, re-authorizing and stepping up as needed. + + ``url`` is empty (the resource URL itself), a path on the resource's + origin, or an absolute URL on that origin. + """ + target = self._resolve(url) + headers = dict(kwargs.pop("headers", None) or {}) + tokens = await self._usable_tokens() + + async def send(access_token: str) -> httpx.Response: + return await self._http.request( + method, target, headers={**headers, "Authorization": f"Bearer {access_token}"}, **kwargs + ) + + response = await send(tokens["access_token"]) + if response.status_code == 401: + refreshed = await self.refresh(stale_access_token=tokens["access_token"]) + if refreshed: + response = await send(refreshed["access_token"]) + if response.status_code == 401: + tokens = await self._sign_in(tokens["access_token"], challenge_response=response) + response = await send(tokens["access_token"]) + if response.status_code == 403: + challenge = find_bearer_challenge(response.headers.get_list("www-authenticate")) + if challenge and challenge.get("error") == "insufficient_scope": + # Step up once: we are signed in, but need more scope + current = (await self.storage.get_tokens(self._token_key) or {}).get("scope") or " ".join(self.scopes) + wanted = list(dict.fromkeys(current.split() + (challenge.get("scope") or "").split())) + tokens = await self._sign_in(tokens["access_token"], scopes=wanted, challenge_response=response) + response = await send(tokens["access_token"]) + return response + + async def disconnect(self, *, forget: bool = False) -> None: + """Revoke the tokens (best effort) and clear them. ``forget`` also drops the DCR registration.""" + tokens = await self.storage.get_tokens(self._token_key) + if tokens: + identity = await self._identity_for_tokens(tokens) + try: + metadata = await self.discover() + except AgentClientError: + metadata = {} + endpoint = metadata.get("revocation_endpoint") + if endpoint and identity: + # On Kinde revoking either token of the pair revokes both + token, hint = ( + (tokens["refresh_token"], "refresh_token") + if tokens.get("refresh_token") + else (tokens["access_token"], "access_token") + ) + data = {"token": token, "token_type_hint": hint} + headers: Dict[str, str] = {} + self._apply_client_auth(identity, data, headers) + try: + response = await self._http.post(endpoint, data=data, headers=headers) + if response.status_code != 200: + logger.info("Token revocation returned HTTP %s", response.status_code) + except httpx.HTTPError as exc: + logger.info("Token revocation failed: %s", type(exc).__name__) + await self.storage.delete_tokens(self._token_key) + if forget and tokens.get("registration_key"): + await self.storage.delete_registration(tokens["registration_key"]) + + +def _identity_from_registration(registration: Dict[str, Any], key: str) -> Dict[str, Any]: + return { + "client_id": registration["client_id"], + "client_secret": registration.get("client_secret"), + "auth_method": registration.get("token_endpoint_auth_method") or "none", + "registration_key": key, + } + + +def _require_secure_resource(resource_url: str) -> None: + try: + _require_secure(resource_url, "resource URL") + except DiscoveryError as exc: + raise AgentConfigError(str(exc)) from exc diff --git a/kinde_sdk/agents/client_metadata.py b/kinde_sdk/agents/client_metadata.py new file mode 100644 index 00000000..048b00a8 --- /dev/null +++ b/kinde_sdk/agents/client_metadata.py @@ -0,0 +1,122 @@ +""" +Client ID Metadata Documents (CIMD): the agent publishes this JSON, and its URL is the ``client_id``. + +draft-ietf-oauth-client-id-metadata-document. Kinde fetches the document from +the public internet, so the host serving it must be listed in the application's +Trusted agent hosts (vendor agents such as Claude and ChatGPT are pre-trusted). +""" + +import json +import warnings +from typing import Any, Dict, Iterable, Optional, Tuple +from urllib.parse import urlsplit + +from kinde_sdk.agents.config import is_loopback_host +from kinde_sdk.agents.errors import AgentConfigError + +MAX_DOCUMENT_BYTES = 64 * 1024 + + +def validate_client_metadata_url(url: str) -> None: + """Raise :class:`AgentConfigError` unless ``url`` can be a CIMD ``client_id``.""" + parts = urlsplit(url or "") + if parts.scheme != "https" or not parts.hostname: + raise AgentConfigError(f"The client metadata URL must be an https URL: {url!r}") + if parts.path in ("", "/"): + raise AgentConfigError(f"The client metadata URL needs a path, not a bare origin: {url!r}") + if parts.username or parts.password: + raise AgentConfigError(f"The client metadata URL must not contain userinfo: {url!r}") + if parts.query or parts.fragment: + raise AgentConfigError(f"The client metadata URL must not have a query or fragment: {url!r}") + if is_loopback_host(parts.hostname): + raise AgentConfigError("Kinde can't fetch a client metadata document from localhost; use a public host") + + +def _redirect_uri_problem(redirect_uri: str, document_host: str) -> Optional[Tuple[bool, str]]: + """Return (fatal, reason) when Kinde would drop this redirect URI for a customer-trusted host.""" + parts = urlsplit(redirect_uri) + if parts.fragment: + return True, f"redirect URI {redirect_uri!r} must not have a fragment" + if parts.username or parts.password: + return True, f"redirect URI {redirect_uri!r} must not contain userinfo" + if parts.scheme == "http" and is_loopback_host(parts.hostname): + return None # any port is honoured (RFC 8252 §7.3) + if parts.scheme == "https" and parts.hostname: + if parts.hostname.lower() == document_host: + return None + return False, ( + f"redirect URI {redirect_uri!r} is not on the document's host ({document_host}); " + "Kinde drops it for customer-hosted agents" + ) + return False, ( + f"redirect URI {redirect_uri!r} uses an unsupported scheme; Kinde honours only https on the " + "document's host and http loopback, and rejects custom schemes" + ) + + +def build_client_metadata_document( + client_id: str, + client_name: str, + redirect_uris: Iterable[str], + *, + client_uri: Optional[str] = None, + logo_uri: Optional[str] = None, + strict: bool = True, + **extra: Any, +) -> Dict[str, Any]: + """Build and validate a client ID metadata document. + + Args: + client_id: The https URL the document will be served at, byte for byte. + client_name: Shown to the user on the consent screen. Required. + redirect_uris: Callbacks. Kinde honours https on the same host as + ``client_id`` and ``http://127.0.0.1`` / ``localhost`` / ``[::1]`` on any port. + strict: When true (default), redirect URIs Kinde would drop raise an + error. When false they only warn, for documents also used with other + authorization servers. + extra: Other RFC 7591 metadata, for example ``tos_uri`` or ``contacts``. + """ + validate_client_metadata_url(client_id) + if not client_name or not str(client_name).strip(): + raise AgentConfigError("client_name is required") + uris = list(redirect_uris) + if not uris: + raise AgentConfigError("At least one redirect URI is required") + document_host = urlsplit(client_id).hostname.lower() + for uri in uris: + problem = _redirect_uri_problem(uri, document_host) + if problem is None: + continue + fatal, reason = problem + if fatal or strict: + raise AgentConfigError(reason) + warnings.warn(reason, stacklevel=2) + + document: Dict[str, Any] = { + "client_id": client_id, + "client_name": client_name, + "redirect_uris": uris, + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + if client_uri: + document["client_uri"] = client_uri + if logo_uri: + document["logo_uri"] = logo_uri + document.update(extra) + if len(json.dumps(document).encode()) > MAX_DOCUMENT_BYTES: + raise AgentConfigError("The client metadata document must be under 64 KiB") + return document + + +def client_metadata_response(document: Dict[str, Any]) -> Tuple[bytes, Dict[str, str]]: + """The body and headers to serve a document with: JSON, cacheable for an hour, never a redirect.""" + body = json.dumps(document, separators=(",", ":")).encode() + if len(body) > MAX_DOCUMENT_BYTES: + raise AgentConfigError("The client metadata document must be under 64 KiB") + return body, { + "Content-Type": "application/json", + # Kinde clamps this to between 5 minutes and 24 hours + "Cache-Control": "public, max-age=3600", + } diff --git a/kinde_sdk/agents/config.py b/kinde_sdk/agents/config.py new file mode 100644 index 00000000..5b707da4 --- /dev/null +++ b/kinde_sdk/agents/config.py @@ -0,0 +1,175 @@ +""" +Configuration for the resource-server helpers. + +The application here is the Kinde application of type "AI agents and MCP", not +the SDK's normal login application. Its issuer and endpoints are derived from +the domain and its client ID, so users never type them. +""" + +import os +from dataclasses import dataclass, field +from typing import List, Optional, Sequence +from urllib.parse import urlsplit + +from kinde_sdk.agents.errors import AgentConfigError + +VERIFICATION_INTROSPECTION = "introspection" +VERIFICATION_JWT = "jwt" +VERIFICATION_JWT_AND_INTROSPECTION = "jwt+introspection" +VERIFICATION_STRATEGIES = ( + VERIFICATION_INTROSPECTION, + VERIFICATION_JWT, + VERIFICATION_JWT_AND_INTROSPECTION, +) + +PROTECTED_RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource" +AUTHORIZATION_SERVER_WELL_KNOWN = "/.well-known/oauth-authorization-server" + +_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1") + + +def is_loopback_host(host: Optional[str]) -> bool: + return (host or "").lower() in _LOOPBACK_HOSTS + + +def normalize_domain(domain: str) -> str: + """Return the Kinde domain as ``https://host``, without a trailing slash. + + Accepts a bare host (``yourbiz.kinde.com``) or a URL. A custom domain is fine. + """ + if not domain or not domain.strip(): + raise AgentConfigError("A Kinde domain is required (set KINDE_HOST or pass domain=...)") + value = domain.strip() + if "://" not in value: + value = "https://" + value + parts = urlsplit(value) + if not parts.hostname: + raise AgentConfigError(f"Invalid Kinde domain: {domain!r}") + if parts.scheme != "https" and not (parts.scheme == "http" and is_loopback_host(parts.hostname)): + raise AgentConfigError(f"The Kinde domain must use https: {domain!r}") + if parts.path not in ("", "/") or parts.query or parts.fragment: + raise AgentConfigError(f"The Kinde domain must not have a path, query or fragment: {domain!r}") + return f"{parts.scheme}://{parts.netloc}" + + +def _validate_resource_url(resource_url: str) -> str: + # Kept byte for byte: Kinde stores and compares the Resource URL exactly as configured + if not resource_url: + raise AgentConfigError("resource_url is required (set KINDE_AGENTS_RESOURCE_URL)") + parts = urlsplit(resource_url) + if parts.scheme not in ("http", "https") or not parts.hostname: + raise AgentConfigError(f"resource_url must be an absolute http(s) URL: {resource_url!r}") + if parts.scheme == "http" and not is_loopback_host(parts.hostname): + raise AgentConfigError(f"resource_url must use https unless it is on localhost: {resource_url!r}") + if parts.fragment or parts.username or parts.password: + raise AgentConfigError(f"resource_url must not have a fragment or userinfo: {resource_url!r}") + return resource_url + + +def _with_offline(scopes: Optional[Sequence[str]]) -> List[str]: + # Without offline, MCP clients never ask for a refresh token and users sign in every 24 hours + result = list(scopes) if scopes else [] + if "offline" not in result: + result.insert(0, "offline") + return result + + +@dataclass +class AgentAuthConfig: + """Settings for a resource server protected by a Kinde "AI agents and MCP" application. + + Args: + domain: The Kinde domain, for example ``https://yourbiz.kinde.com`` or a custom domain. + client_id: The AI agents and MCP application's client ID (not the login application's). + client_secret: That application's client secret. Used for introspection and admin revoke. + resource_url: The Resource URL configured on the application, byte for byte. + verification: ``"introspection"`` (default), ``"jwt"`` or ``"jwt+introspection"``. + scopes_supported: Advertised in the protected resource metadata. ``offline`` is always added. + resource_name: Optional human-readable name advertised in the metadata. + clock_tolerance_seconds: Leeway for ``exp`` and ``nbf``. + introspection_cache_seconds: Cache active introspection results this long (0 disables, max 60). + timeout_seconds: Timeout for every request to Kinde. + """ + + domain: str + client_id: str + resource_url: str + client_secret: Optional[str] = field(default=None, repr=False) + verification: str = VERIFICATION_INTROSPECTION + scopes_supported: List[str] = field(default_factory=lambda: ["offline"]) + resource_name: Optional[str] = None + clock_tolerance_seconds: int = 30 + introspection_cache_seconds: int = 0 + timeout_seconds: float = 5.0 + + def __post_init__(self): + self.domain = normalize_domain(self.domain) + if not self.client_id: + raise AgentConfigError("client_id is required (set KINDE_AGENTS_CLIENT_ID)") + self.resource_url = _validate_resource_url(self.resource_url) + if self.verification not in VERIFICATION_STRATEGIES: + raise AgentConfigError( + f"verification must be one of {', '.join(VERIFICATION_STRATEGIES)}, got {self.verification!r}" + ) + if self.verification != VERIFICATION_JWT and not self.client_secret: + raise AgentConfigError( + "client_secret is required for introspection (set KINDE_AGENTS_CLIENT_SECRET), " + "or use verification='jwt'" + ) + self.scopes_supported = _with_offline(self.scopes_supported) + if not 0 <= self.introspection_cache_seconds <= 60: + raise AgentConfigError("introspection_cache_seconds must be between 0 and 60") + + @classmethod + def from_env(cls, **overrides) -> "AgentAuthConfig": + """Build the configuration from environment variables, with keyword overrides. + + Reads ``KINDE_AGENTS_DOMAIN`` (falling back to ``KINDE_HOST``), + ``KINDE_AGENTS_CLIENT_ID``, ``KINDE_AGENTS_CLIENT_SECRET`` and + ``KINDE_AGENTS_RESOURCE_URL``. ``KINDE_CLIENT_ID`` is the login + application and is deliberately not used. + """ + values = { + "domain": os.getenv("KINDE_AGENTS_DOMAIN") or os.getenv("KINDE_HOST"), + "client_id": os.getenv("KINDE_AGENTS_CLIENT_ID"), + "client_secret": os.getenv("KINDE_AGENTS_CLIENT_SECRET"), + "resource_url": os.getenv("KINDE_AGENTS_RESOURCE_URL"), + } + values.update({key: value for key, value in overrides.items() if value is not None}) + if not values.get("domain"): + raise AgentConfigError("A Kinde domain is required (set KINDE_AGENTS_DOMAIN or KINDE_HOST)") + return cls(**values) + + @property + def issuer(self) -> str: + return f"{self.domain}/oauth2/agents/{self.client_id}" + + @property + def authorization_server_metadata_url(self) -> str: + # RFC 8414 path insertion: the well-known segment goes between host and issuer path + return f"{self.domain}{AUTHORIZATION_SERVER_WELL_KNOWN}/oauth2/agents/{self.client_id}" + + @property + def jwks_url(self) -> str: + return f"{self.domain}/.well-known/jwks" + + @property + def introspection_url(self) -> str: + return f"{self.issuer}/introspect" + + @property + def revocation_url(self) -> str: + return f"{self.issuer}/revoke" + + @property + def protected_resource_metadata_path(self) -> str: + """Path-suffixed metadata path, for example ``/.well-known/oauth-protected-resource/mcp``.""" + path = urlsplit(self.resource_url).path + if path == "/": + path = "" + return PROTECTED_RESOURCE_WELL_KNOWN + path + + @property + def protected_resource_metadata_url(self) -> str: + parts = urlsplit(self.resource_url) + return f"{parts.scheme}://{parts.netloc}{self.protected_resource_metadata_path}" diff --git a/kinde_sdk/agents/context.py b/kinde_sdk/agents/context.py new file mode 100644 index 00000000..4d9dec5d --- /dev/null +++ b/kinde_sdk/agents/context.py @@ -0,0 +1,123 @@ +""" +The verified identity handed to route and tool handlers, and permission checks on it. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional + +from kinde_sdk.agents.errors import InsufficientScopeError, PermissionDeniedError + + +def _scopes_from(claims: Mapping[str, Any]) -> List[str]: + scp = claims.get("scp") + if isinstance(scp, list): + return [str(scope) for scope in scp] + scope = claims.get("scope") or scp + if isinstance(scope, str): + return scope.split() + return [] + + +def _role_keys(claims: Mapping[str, Any]) -> List[str]: + roles = claims.get("roles") + if not isinstance(roles, list): + return [] + keys = [] + for role in roles: + if isinstance(role, Mapping): + key = role.get("key") + if key: + keys.append(str(key)) + elif isinstance(role, str): + keys.append(role) + return keys + + +@dataclass +class AgentAuthContext: + """Who is calling: the Kinde user, the agent acting for them, and what they may do. + + ``client_id`` identifies the agent: a CIMD metadata document URL, or a DCR + registrant ID. Identity comes from the verified access token, never from the + user profile endpoint, which can't resolve agent tokens. + """ + + token: str = field(repr=False) + user_id: Optional[str] + client_id: Optional[str] + expires_at: Optional[int] + scopes: List[str] = field(default_factory=list) + permissions: List[str] = field(default_factory=list) + roles: List[str] = field(default_factory=list) + email: Optional[str] = None + org_code: Optional[str] = None + audience: List[str] = field(default_factory=list) + claims: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_claims( + cls, + token: str, + claims: Mapping[str, Any], + introspection: Optional[Mapping[str, Any]] = None, + ) -> "AgentAuthContext": + """Build a context from token claims, preferring introspection values where Kinde returned them.""" + merged: Dict[str, Any] = dict(claims) + if introspection: + for key in ("sub", "client_id", "scope", "exp", "aud"): + if introspection.get(key) is not None: + merged[key] = introspection[key] + if introspection and introspection.get("scope") is not None: + scopes = str(introspection["scope"]).split() + else: + scopes = _scopes_from(claims) + audience = merged.get("aud") + if isinstance(audience, str): + audience = [audience] + permissions = merged.get("permissions") + exp = merged.get("exp") + return cls( + token=token, + user_id=merged.get("sub"), + # azp names the agent on Kinde tokens; client_id is the RFC 7662/9068 name for it + client_id=claims.get("azp") or merged.get("client_id"), + expires_at=int(exp) if isinstance(exp, (int, float)) else None, + scopes=scopes, + permissions=[str(p) for p in permissions] if isinstance(permissions, list) else [], + roles=_role_keys(merged), + email=merged.get("email"), + org_code=merged.get("org_code"), + audience=[str(a) for a in audience] if isinstance(audience, list) else [], + claims=merged, + ) + + +def has_permission(context: Optional[AgentAuthContext], permission: str) -> bool: + """Return whether the caller holds a Kinde permission. + + Use it inside each MCP tool, and answer a failure with a tool error + (``isError``), not an HTTP 403: signing in again can't grant a permission. + """ + return context is not None and permission in context.permissions + + +def has_role(context: Optional[AgentAuthContext], role: str) -> bool: + return context is not None and role in context.roles + + +def has_scope(context: Optional[AgentAuthContext], scope: str) -> bool: + return context is not None and scope in context.scopes + + +def require_permission(context: Optional[AgentAuthContext], *permissions: str) -> None: + """Raise :class:`PermissionDeniedError` (a plain 403) unless every permission is held.""" + missing = [p for p in permissions if not has_permission(context, p)] + if missing: + raise PermissionDeniedError(missing) + + +def require_scope(context: Optional[AgentAuthContext], *scopes: str) -> None: + """Raise :class:`InsufficientScopeError` (403 ``insufficient_scope``) unless every OAuth scope is held.""" + missing = [s for s in scopes if not has_scope(context, s)] + if missing: + raise InsufficientScopeError(missing) diff --git a/kinde_sdk/agents/errors.py b/kinde_sdk/agents/errors.py new file mode 100644 index 00000000..7ad42148 --- /dev/null +++ b/kinde_sdk/agents/errors.py @@ -0,0 +1,106 @@ +""" +Exceptions raised by the agents core. + +The resource-server errors carry the HTTP status and RFC 6750 error code that a +framework integration should answer with, so the integrations never decide +those themselves. +""" + +from typing import Optional + + +class AgentAuthError(Exception): + """Base class for every error raised by ``kinde_sdk.agents``.""" + + +class AgentConfigError(AgentAuthError, ValueError): + """The agents configuration is missing a value or has an invalid one.""" + + +class AuthenticationRequired(AgentAuthError): + """The request carried no ``Authorization`` header (answer 401, no ``error``).""" + + status_code = 401 + error: Optional[str] = None + + def __init__(self, description: str = "Authentication required"): + super().__init__(description) + self.description = description + + +class InvalidTokenError(AgentAuthError): + """The bearer token is malformed, inactive, expired or for another resource (answer 401).""" + + status_code = 401 + error = "invalid_token" + + def __init__(self, description: str = "The access token is invalid"): + super().__init__(description) + self.description = description + + +class InsufficientScopeError(AgentAuthError): + """The token lacks an OAuth scope (answer 403 ``insufficient_scope``). + + Only for OAuth scopes (``offline``, ``profile``, ``email``). A missing Kinde + permission is :class:`PermissionDeniedError`, never this. + """ + + status_code = 403 + error = "insufficient_scope" + + def __init__(self, scopes, description: Optional[str] = None): + self.scopes = list(scopes) + self.description = description or "Required scope: " + " ".join(self.scopes) + super().__init__(self.description) + + +class PermissionDeniedError(AgentAuthError): + """The token lacks a Kinde permission (answer a plain 403, no challenge).""" + + status_code = 403 + error = "forbidden" + + def __init__(self, permissions, description: Optional[str] = None): + self.permissions = list(permissions) + self.description = description or "Missing permission: " + ", ".join(self.permissions) + super().__init__(self.description) + + +class VerificationUnavailableError(AgentAuthError): + """Kinde could not be reached, or refused the application's credentials (answer 503). + + Never answered with 401: that would make the agent restart its login. + """ + + status_code = 503 + error = "temporarily_unavailable" + + def __init__(self, description: str = "Token verification is temporarily unavailable"): + super().__init__(description) + self.description = description + + +class AgentClientError(AgentAuthError): + """An agent-client step failed (discovery, registration, authorization or token exchange).""" + + def __init__(self, message: str, *, error: Optional[str] = None, error_description: Optional[str] = None): + super().__init__(message) + self.error = error + self.error_description = error_description + + +class DiscoveryError(AgentClientError): + """Protected resource or authorization server metadata was missing or invalid.""" + + +class RegistrationError(AgentClientError): + """Dynamic client registration failed.""" + + +class AuthorizationError(AgentClientError): + """The authorization step failed (for example a ``state`` mismatch or an ``error`` on the callback).""" + + +class TokenRequestError(AgentClientError): + """The token endpoint refused a code exchange or a refresh.""" diff --git a/kinde_sdk/agents/mcp/__init__.py b/kinde_sdk/agents/mcp/__init__.py new file mode 100644 index 00000000..f096b3b1 --- /dev/null +++ b/kinde_sdk/agents/mcp/__init__.py @@ -0,0 +1,33 @@ +""" +Adapters that plug the Kinde agents core into the official ``mcp`` package. + +Install with ``pip install "kinde-python-sdk[mcp]"`` (Python 3.10 or newer). + +- :class:`KindeTokenVerifier`: the ``token_verifier`` for an ``MCPServer``. +- :func:`kinde_auth_settings`: the matching ``AuthSettings``. +- :class:`KindeOAuthClientProvider`: the auth hook for an MCP client. +""" + +try: + import mcp # noqa: F401 +except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError( + 'kinde_sdk.agents.mcp needs the mcp package. Install it with: pip install "kinde-python-sdk[mcp]"' + ) from exc + +from kinde_sdk.agents.mcp.client import KindeOAuthClientProvider, KindeTokenStorage +from kinde_sdk.agents.mcp.server import ( + KindeTokenVerifier, + get_agent_auth_context, + kinde_auth_settings, + mount_authorization_server_metadata, +) + +__all__ = [ + "KindeOAuthClientProvider", + "KindeTokenStorage", + "KindeTokenVerifier", + "get_agent_auth_context", + "kinde_auth_settings", + "mount_authorization_server_metadata", +] diff --git a/kinde_sdk/agents/mcp/client.py b/kinde_sdk/agents/mcp/client.py new file mode 100644 index 00000000..818bbeb9 --- /dev/null +++ b/kinde_sdk/agents/mcp/client.py @@ -0,0 +1,110 @@ +""" +Client-side MCP adapter: an ``OAuthClientProvider`` with Kinde's defaults. + +The MCP SDK already does discovery, PKCE, CIMD-or-registration and refresh. +This adapter sets the client metadata Kinde expects, keeps ``openid`` out of +the scope and ``offline`` in it, and stores tokens in the core's storage. + + provider = KindeOAuthClientProvider( + "https://api.example.com/mcp", + client_metadata_url="https://agent.example.com/oauth/client-metadata.json", + client_name="Example Agent", + redirect_uri="http://127.0.0.1:8765/callback", + redirect_handler=open_in_browser, + callback_handler=wait_for_callback, + ) + async with streamable_http_client(url, auth=provider) as streams: ... +""" + +from typing import Any, Optional, Sequence + +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + +from kinde_sdk.agents.client_metadata import validate_client_metadata_url +from kinde_sdk.agents.storage import AgentClientStorage, InMemoryAgentStorage + + +class KindeTokenStorage: + """The MCP SDK's ``TokenStorage`` on top of the core's :class:`AgentClientStorage`. + + Lets an MCP client keep its tokens and registration in, for example, + :class:`FileAgentStorage`. + """ + + def __init__(self, storage: Optional[AgentClientStorage] = None, key: str = "default"): + self.storage = storage or InMemoryAgentStorage() + self.key = f"mcp|{key}" + + async def get_tokens(self) -> Optional[OAuthToken]: + data = await self.storage.get_tokens(self.key) + return OAuthToken.model_validate(data) if data else None + + async def set_tokens(self, tokens: OAuthToken) -> None: + await self.storage.set_tokens(self.key, tokens.model_dump(mode="json", exclude_none=True)) + + async def get_client_info(self) -> Optional[OAuthClientInformationFull]: + data = await self.storage.get_registration(self.key) + return OAuthClientInformationFull.model_validate(data) if data else None + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + await self.storage.set_registration(self.key, client_info.model_dump(mode="json", exclude_none=True)) + + +class KindeOAuthClientProvider(OAuthClientProvider): + """``OAuthClientProvider`` for agents signing in through Kinde (or any MCP authorization server). + + Prefers the client ID metadata document when ``client_metadata_url`` is set + and the server supports it, and registers dynamically otherwise. + """ + + def __init__( + self, + server_url: str, + *, + client_name: str = "Kinde agent", + redirect_uri: str = "http://127.0.0.1:8765/callback", + client_metadata_url: Optional[str] = None, + scopes: Sequence[str] = ("offline",), + storage: Optional[Any] = None, + client_metadata: Optional[OAuthClientMetadata] = None, + **options: Any, + ): + if client_metadata_url is not None: + validate_client_metadata_url(client_metadata_url) + self._default_scopes = [scope for scope in scopes if scope != "openid"] or ["offline"] + metadata = client_metadata or OAuthClientMetadata( + client_name=client_name, + redirect_uris=[redirect_uri], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + # Kinde returns client_secret_post and a secret on registration anyway; the SDK uses what comes back + token_endpoint_auth_method="none", + scope=" ".join(self._default_scopes), + ) + super().__init__( + server_url=server_url, + client_metadata=metadata, + storage=storage if storage is not None else KindeTokenStorage(key=server_url), + client_metadata_url=client_metadata_url, + **options, + ) + + def _kinde_scope(self, selected: Optional[str]) -> str: + """The scope to request: never ``openid`` where unsupported, and ``offline`` where supported.""" + metadata = self.context.oauth_metadata + supported = list(metadata.scopes_supported) if metadata and metadata.scopes_supported is not None else None + scopes = (selected or "").split() or list(self._default_scopes) + if supported is not None: + # Kinde's agent issuer refuses openid with invalid_scope + if "openid" not in supported: + scopes = [scope for scope in scopes if scope != "openid"] + if "offline" in supported and "offline" not in scopes: + scopes.insert(0, "offline") + return " ".join(dict.fromkeys(scopes)) + + async def _perform_authorization(self): + # The MCP SDK picks the scope from the challenge or metadata just before this step. + # This relies on a private hook, so the mcp extra is pinned below 3.0. + self.context.client_metadata.scope = self._kinde_scope(self.context.client_metadata.scope) + return await super()._perform_authorization() diff --git a/kinde_sdk/agents/mcp/server.py b/kinde_sdk/agents/mcp/server.py new file mode 100644 index 00000000..c2ad29ee --- /dev/null +++ b/kinde_sdk/agents/mcp/server.py @@ -0,0 +1,119 @@ +""" +Server-side MCP adapter: a ``TokenVerifier`` backed by :class:`KindeAgentAuth`. + + auth = KindeAgentAuth() # reads KINDE_HOST and KINDE_AGENTS_* + server = MCPServer( + "My server", + token_verifier=KindeTokenVerifier(auth), + auth=kinde_auth_settings(auth), + ) + + @server.tool() + async def read_docs() -> str: + if not has_permission(get_agent_auth_context(), "read:docs"): + raise ToolError("You don't have permission to read docs.") +""" + +from typing import Any, List, Optional, Sequence + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from pydantic import AnyHttpUrl +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from kinde_sdk.agents.context import AgentAuthContext +from kinde_sdk.agents.errors import InvalidTokenError +from kinde_sdk.agents.resource_server import KindeAgentAuth + +_CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +} + + +class KindeTokenVerifier(TokenVerifier): + """Verifies bearer tokens for an ``MCPServer`` using Kinde (introspection by default). + + - A bad token returns ``None``, which the MCP SDK answers with 401, so the + agent refreshes or signs in again. + - A Kinde outage raises :class:`VerificationUnavailableError` instead, so the + agent doesn't restart its login over a problem it can't fix. + - ``resource`` is always set: with ``validate_token_resource`` the MCP SDK + rejects every token whose ``resource`` is missing or different. + """ + + def __init__(self, auth: Optional[KindeAgentAuth] = None, **options: Any): + self.auth = auth if auth is not None else KindeAgentAuth(**options) + + async def verify_token(self, token: str) -> Optional[AccessToken]: + try: + context = await self.auth.verify_token_async(token) + except InvalidTokenError: + return None + return AccessToken( + token=token, + client_id=context.client_id or "", + scopes=context.scopes, + expires_at=context.expires_at, + # The verifier confirmed aud contains the Resource URL; report that entry + resource=self.auth.config.resource_url, + subject=context.user_id, + claims=context.claims, + ) + + +def kinde_auth_settings( + auth: KindeAgentAuth, + *, + required_scopes: Optional[Sequence[str]] = None, + **settings: Any, +) -> AuthSettings: + """``AuthSettings`` for an ``MCPServer`` protected by Kinde. + + ``required_scopes`` defaults to ``["offline"]``. The MCP SDK publishes this + list as ``scopes_supported`` in the protected resource metadata, and agents + request what it lists: without ``offline`` they never get a refresh token + and users sign in again every 24 hours. Note that the SDK also requires + every listed scope on each token. + """ + scopes: List[str] = list(required_scopes) if required_scopes is not None else ["offline"] + options = { + "issuer_url": AnyHttpUrl(auth.config.issuer), + "resource_server_url": AnyHttpUrl(auth.config.resource_url), + "required_scopes": scopes, + "validate_token_resource": True, + } + options.update(settings) + return AuthSettings(**options) + + +def get_agent_auth_context() -> Optional[AgentAuthContext]: + """The Kinde auth context for the current MCP request, for ``has_permission`` in tool handlers.""" + access_token = get_access_token() + if access_token is None: + return None + claims = dict(access_token.claims or {}) + context = AgentAuthContext.from_claims(access_token.token, claims) + # Scopes and expiry as the verifier reported them + context.scopes = list(access_token.scopes) + context.expires_at = access_token.expires_at + return context + + +def mount_authorization_server_metadata(server: Any, auth: KindeAgentAuth) -> None: + """Serve ``/.well-known/oauth-authorization-server`` on the MCP server's own origin. + + Optional. Current MCP clients follow ``authorization_servers`` to Kinde, but + some older ones look here. The document mirrors Kinde's and always includes + ``client_id_metadata_document_supported: true``. + """ + + @server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"]) + async def authorization_server_metadata(request: Request) -> Response: + if request.method == "OPTIONS": + return Response(status_code=204, headers=_CORS_HEADERS) + document = await auth.fetch_authorization_server_metadata_async() + return JSONResponse(document, headers={**_CORS_HEADERS, "Cache-Control": "max-age=300"}) diff --git a/kinde_sdk/agents/resource_server.py b/kinde_sdk/agents/resource_server.py new file mode 100644 index 00000000..1db6747d --- /dev/null +++ b/kinde_sdk/agents/resource_server.py @@ -0,0 +1,607 @@ +""" +Resource-server helpers: protect an MCP server or API with a Kinde "AI agents and MCP" application. + +Everything protocol-related lives here so the FastAPI, Flask and MCP integrations +only adapt request and response types. Every method that talks to Kinde has a +sync form and an ``_async`` form. +""" + +import base64 +import hashlib +import logging +import re +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Union +from urllib.parse import quote_plus + +import httpx +import jwt + +from kinde_sdk.agents.config import ( + VERIFICATION_JWT, + VERIFICATION_JWT_AND_INTROSPECTION, + AgentAuthConfig, +) +from kinde_sdk.agents.context import AgentAuthContext +from kinde_sdk.agents.errors import ( + AgentAuthError, + AuthenticationRequired, + InsufficientScopeError, + InvalidTokenError, + PermissionDeniedError, + VerificationUnavailableError, +) +from kinde_sdk.agents.www_authenticate import build_challenge + +logger = logging.getLogger("kinde_sdk.agents") + +# RFC 6750 §2.1 b64token +_BEARER_TOKEN = re.compile(r"^[A-Za-z0-9\-._~+/]+=*$") + +_AS_METADATA_CACHE_SECONDS = 300 +_JWKS_CACHE_SECONDS = 3600 +_JWKS_MIN_REFETCH_SECONDS = 60 +_JWKS_RETRY_SECONDS = 5 +_INTROSPECTION_CACHE_MAX_ENTRIES = 10_000 +# Error codes Kinde's introspection endpoint uses for a bad token (as opposed to bad application credentials) +_INACTIVE_TOKEN_ERRORS = frozenset({"token_inactive", "invalid_token"}) + + +@dataclass +class AgentAuthResponse: + """An HTTP error response for a framework integration to send as is.""" + + status_code: int + headers: Dict[str, str] = field(default_factory=dict) + body: Dict[str, str] = field(default_factory=dict) + + +class _JwksCache: + """Signing keys by ``kid``, refetched at most once a minute when an unknown ``kid`` shows up. + + The rate limit matters: a public MCP endpoint receiving tokens with made-up + ``kid`` values must not turn into a flood of JWKS requests to Kinde. + """ + + def __init__(self, url: str): + self.url = url + self._keys: Dict[str, Any] = {} + self._fetched_at: Optional[float] = None + self._last_attempt: Optional[float] = None + self._last_unknown_kid_fetch: Optional[float] = None + self._lock = threading.Lock() + + def _should_fetch(self, kid: str, now: float) -> bool: + if self._fetched_at is None or now - self._fetched_at >= _JWKS_CACHE_SECONDS: + # Missing or stale: retry, but not on every request while Kinde is down + return self._last_attempt is None or now - self._last_attempt >= _JWKS_RETRY_SECONDS + if kid in self._keys: + return False + # Unknown kid (a key rotation, or a forged token): at most one refetch a minute + if self._last_unknown_kid_fetch is not None and now - self._last_unknown_kid_fetch < _JWKS_MIN_REFETCH_SECONDS: + return False + self._last_unknown_kid_fetch = now + return True + + def _store(self, response: httpx.Response, now: float) -> None: + if response.status_code != 200: + raise VerificationUnavailableError(f"Kinde JWKS request failed with HTTP {response.status_code}") + try: + document = response.json() + except ValueError as exc: + raise VerificationUnavailableError("Kinde JWKS response was not JSON") from exc + keys = {} + for jwk in document.get("keys", []) if isinstance(document, dict) else []: + kid = jwk.get("kid") if isinstance(jwk, dict) else None + if not kid: + continue + try: + keys[kid] = jwt.PyJWK(jwk).key + except (jwt.PyJWKError, jwt.InvalidKeyError, ValueError, TypeError): + logger.debug("Skipping unusable JWKS key %s", kid) + self._keys = keys + self._fetched_at = now + + def _fetch_failed(self, exc: Exception) -> None: + # Keep verifying with the keys we have; only fail when there are none + if self._fetched_at is None: + if isinstance(exc, VerificationUnavailableError): + raise exc + raise VerificationUnavailableError("Could not reach Kinde for signing keys") from exc + logger.warning("Kinde JWKS refresh failed; using cached keys") + + def _lookup(self, kid: str) -> Any: + key = self._keys.get(kid) + if key is None: + if self._fetched_at is None: + raise VerificationUnavailableError("Kinde signing keys are unavailable") + raise InvalidTokenError("The access token was signed by an unknown key") + return key + + def get_key(self, kid: str, client: httpx.Client) -> Any: + with self._lock: + now = time.monotonic() + if self._should_fetch(kid, now): + self._last_attempt = now + try: + self._store(client.get(self.url), now) + except (httpx.HTTPError, VerificationUnavailableError) as exc: + self._fetch_failed(exc) + return self._lookup(kid) + + async def get_key_async(self, kid: str, client: httpx.AsyncClient) -> Any: + now = time.monotonic() + if self._should_fetch(kid, now): + self._last_attempt = now + try: + self._store(await client.get(self.url), now) + except (httpx.HTTPError, VerificationUnavailableError) as exc: + self._fetch_failed(exc) + return self._lookup(kid) + + +class KindeAgentAuth: + """Resource-server helpers for one Kinde "AI agents and MCP" application. + + Build it from an :class:`AgentAuthConfig`, from keyword arguments, or from + the environment (``KindeAgentAuth()`` reads ``KINDE_AGENTS_*`` and ``KINDE_HOST``):: + + auth = KindeAgentAuth( + domain="https://yourbiz.kinde.com", + client_id="", + client_secret="", + resource_url="https://api.example.com/mcp", + ) + context = await auth.authenticate_async(request.headers.get("authorization")) + + ``http_client`` and ``async_http_client`` may be passed to share connection + pools or for tests. Clients created here are closed by :meth:`close` and + :meth:`aclose`. + """ + + def __init__( + self, + config: Optional[AgentAuthConfig] = None, + *, + http_client: Optional[httpx.Client] = None, + async_http_client: Optional[httpx.AsyncClient] = None, + clock: Callable[[], float] = time.time, + **options: Any, + ): + if config is None: + config = AgentAuthConfig.from_env(**options) + elif options: + raise TypeError("Pass either config or keyword options, not both") + self.config = config + self._clock = clock + self._http_client = http_client + self._owns_http_client = http_client is None + self._async_http_client = async_http_client + self._owns_async_http_client = async_http_client is None + self._jwks = _JwksCache(config.jwks_url) + self._introspection_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._as_metadata_cache: Optional[Tuple[float, Dict[str, Any]]] = None + + # -- HTTP clients --------------------------------------------------------- + + def _client(self) -> httpx.Client: + if self._http_client is None: + self._http_client = httpx.Client(timeout=self.config.timeout_seconds) + return self._http_client + + def _async_client(self) -> httpx.AsyncClient: + if self._async_http_client is None: + self._async_http_client = httpx.AsyncClient(timeout=self.config.timeout_seconds) + return self._async_http_client + + def close(self) -> None: + if self._owns_http_client and self._http_client is not None: + self._http_client.close() + self._http_client = None + + async def aclose(self) -> None: + if self._owns_async_http_client and self._async_http_client is not None: + await self._async_http_client.aclose() + self._async_http_client = None + self.close() + + # -- Metadata ------------------------------------------------------------- + + def protected_resource_metadata(self) -> Dict[str, Any]: + """The RFC 9728 document for both well-known metadata paths. + + ``authorization_servers`` holds the scoped issuer, which is what steers + agents to this application's endpoints and makes CIMD resolve. + """ + document: Dict[str, Any] = { + "resource": self.config.resource_url, + "authorization_servers": [self.config.issuer], + "bearer_methods_supported": ["header"], + "scopes_supported": list(self.config.scopes_supported), + } + if self.config.resource_name: + document["resource_name"] = self.config.resource_name + return document + + def authorization_server_metadata(self) -> Dict[str, Any]: + """A static copy of Kinde's scoped authorization server metadata, built from the derived URLs.""" + issuer = self.config.issuer + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/authorize", + "token_endpoint": f"{issuer}/token", + "registration_endpoint": f"{issuer}/register", + "introspection_endpoint": self.config.introspection_url, + "revocation_endpoint": self.config.revocation_url, + "jwks_uri": self.config.jwks_url, + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["offline", "profile", "email"], + "subject_types_supported": ["public"], + "resource_indicators_supported": True, + "client_id_metadata_document_supported": True, + } + + def _cached_as_metadata(self) -> Optional[Dict[str, Any]]: + if self._as_metadata_cache and time.monotonic() - self._as_metadata_cache[0] < _AS_METADATA_CACHE_SECONDS: + return dict(self._as_metadata_cache[1]) + return None + + def _store_as_metadata(self, response: Optional[httpx.Response]) -> Dict[str, Any]: + document = None + if response is not None and response.status_code == 200: + try: + document = response.json() + except ValueError: + document = None + if not isinstance(document, dict): + if response is not None: + logger.warning( + "Kinde authorization server metadata returned HTTP %s; serving the static copy", + response.status_code, + ) + # Not cached, so the next request tries Kinde again + return self.authorization_server_metadata() + # Without this flag agents fall back to registration, which MCP has deprecated + document["client_id_metadata_document_supported"] = True + self._as_metadata_cache = (time.monotonic(), document) + return dict(document) + + def fetch_authorization_server_metadata(self) -> Dict[str, Any]: + """Kinde's scoped metadata (cached 5 minutes), falling back to the static copy if Kinde is unreachable. + + For the optional ``/.well-known/oauth-authorization-server`` mirror that + some older MCP clients fetch from the resource server's own origin. + """ + cached = self._cached_as_metadata() + if cached is not None: + return cached + try: + response = self._client().get(self.config.authorization_server_metadata_url) + except httpx.HTTPError: + logger.warning("Could not reach Kinde for authorization server metadata; serving the static copy") + response = None + return self._store_as_metadata(response) + + async def fetch_authorization_server_metadata_async(self) -> Dict[str, Any]: + cached = self._cached_as_metadata() + if cached is not None: + return cached + try: + response = await self._async_client().get(self.config.authorization_server_metadata_url) + except httpx.HTTPError: + logger.warning("Could not reach Kinde for authorization server metadata; serving the static copy") + response = None + return self._store_as_metadata(response) + + # -- Challenges ----------------------------------------------------------- + + def challenge(self, error: Optional[AgentAuthError] = None) -> Optional[str]: + """The ``WWW-Authenticate`` value for an error, or ``None`` when the response carries no challenge.""" + prm_url = self.config.protected_resource_metadata_url + if error is None or isinstance(error, AuthenticationRequired): + return build_challenge("Bearer", [("resource_metadata", prm_url)]) + if isinstance(error, InvalidTokenError): + return build_challenge( + "Bearer", + [ + ("error", "invalid_token"), + ("error_description", error.description), + ("resource_metadata", prm_url), + ], + ) + if isinstance(error, InsufficientScopeError): + return build_challenge( + "Bearer", + [ + ("error", "insufficient_scope"), + ("scope", " ".join(error.scopes)), + ("resource_metadata", prm_url), + ], + ) + # Missing permissions and Kinde outages are never challenges: a challenge makes the agent sign in again + return None + + def error_response(self, error: AgentAuthError) -> AgentAuthResponse: + """Map an error from :meth:`authenticate` (or a permission check) to the response to send.""" + status = getattr(error, "status_code", 500) + headers: Dict[str, str] = {} + challenge = self.challenge(error) + if challenge is not None: + headers["WWW-Authenticate"] = challenge + headers["Access-Control-Expose-Headers"] = "WWW-Authenticate" + if isinstance(error, AuthenticationRequired): + body = {"error": "unauthorized", "error_description": error.description} + elif isinstance( + error, (InvalidTokenError, InsufficientScopeError, PermissionDeniedError, VerificationUnavailableError) + ): + body = {"error": error.error, "error_description": error.description} + else: + status = 500 + body = {"error": "server_error", "error_description": "Authentication failed"} + return AgentAuthResponse(status_code=status, headers=headers, body=body) + + # -- Bearer extraction ---------------------------------------------------- + + @staticmethod + def extract_bearer_token(authorization: Optional[str]) -> str: + """Return the token from an ``Authorization`` header value. + + Raises :class:`AuthenticationRequired` when there is no header and + :class:`InvalidTokenError` when it isn't ``Bearer ``. Tokens in a + query string or cookie are never accepted. + """ + if authorization is None or not authorization.strip(): + raise AuthenticationRequired() + scheme, separator, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not separator: + raise InvalidTokenError("The Authorization header must use the Bearer scheme") + if not _BEARER_TOKEN.match(token): + raise InvalidTokenError("The Authorization header is malformed") + return token + + def authenticate(self, authorization: Optional[str]) -> AgentAuthContext: + """Extract and verify the bearer token from an ``Authorization`` header value.""" + return self.verify_token(self.extract_bearer_token(authorization)) + + async def authenticate_async(self, authorization: Optional[str]) -> AgentAuthContext: + return await self.verify_token_async(self.extract_bearer_token(authorization)) + + # -- Verification --------------------------------------------------------- + + def verify_token(self, token: str) -> AgentAuthContext: + """Verify an access token with the configured strategy. + + Raises :class:`InvalidTokenError` for a bad token and + :class:`VerificationUnavailableError` when Kinde can't be reached. + """ + strategy = self.config.verification + claims = None + if strategy in (VERIFICATION_JWT, VERIFICATION_JWT_AND_INTROSPECTION): + claims = self._verify_jwt(token, self._jwks.get_key(self._kid(token), self._client())) + if strategy == VERIFICATION_JWT: + return AgentAuthContext.from_claims(token, claims) + data = self._cached_introspection(token) + if data is None: + data = self._handle_introspection(self._post_introspection(token), token) + return self._context_from_introspection(token, data, claims) + + async def verify_token_async(self, token: str) -> AgentAuthContext: + strategy = self.config.verification + claims = None + if strategy in (VERIFICATION_JWT, VERIFICATION_JWT_AND_INTROSPECTION): + key = await self._jwks.get_key_async(self._kid(token), self._async_client()) + claims = self._verify_jwt(token, key) + if strategy == VERIFICATION_JWT: + return AgentAuthContext.from_claims(token, claims) + data = self._cached_introspection(token) + if data is None: + data = self._handle_introspection(await self._post_introspection_async(token), token) + return self._context_from_introspection(token, data, claims) + + def _application_auth_header(self) -> str: + # RFC 6749 §2.3.1: form-urlencode each part before joining + credentials = f"{quote_plus(self.config.client_id)}:{quote_plus(self.config.client_secret or '')}" + return "Basic " + base64.b64encode(credentials.encode()).decode() + + def _post_introspection(self, token: str) -> Union[httpx.Response, Exception]: + try: + return self._client().post( + self.config.introspection_url, + data={"token": token}, + headers={"Authorization": self._application_auth_header(), "Accept": "application/json"}, + ) + except httpx.HTTPError as exc: + return exc + + async def _post_introspection_async(self, token: str) -> Union[httpx.Response, Exception]: + try: + return await self._async_client().post( + self.config.introspection_url, + data={"token": token}, + headers={"Authorization": self._application_auth_header(), "Accept": "application/json"}, + ) + except httpx.HTTPError as exc: + return exc + + def _handle_introspection(self, response: Union[httpx.Response, Exception], token: str) -> Dict[str, Any]: + if isinstance(response, Exception): + logger.warning("Kinde introspection request failed: %s", type(response).__name__) + raise VerificationUnavailableError("Could not reach Kinde to verify the token") + if response.status_code in (400, 401, 403): + error = _oauth_error_code(response) + # Kinde answers an inactive token with 401 token_inactive, not 200 {"active": false} + if error in _INACTIVE_TOKEN_ERRORS: + raise InvalidTokenError("The access token is not active") + # Anything else (request_unauthorized, invalid_client) means the application's credentials are wrong + logger.error( + "Kinde refused the introspection request (HTTP %s, %s). Check KINDE_AGENTS_CLIENT_ID " + "and KINDE_AGENTS_CLIENT_SECRET belong to the AI agents and MCP application.", + response.status_code, + error or "no error code", + ) + raise VerificationUnavailableError("Token verification is misconfigured") + if response.status_code == 404: + logger.error( + "Kinde introspection endpoint returned 404: %s does not name a live AI agents and MCP application", + self.config.introspection_url, + ) + raise VerificationUnavailableError("Token verification is misconfigured") + if response.status_code != 200: + logger.warning("Kinde introspection returned HTTP %s", response.status_code) + raise VerificationUnavailableError() + try: + data = response.json() + except ValueError as exc: + raise VerificationUnavailableError("Kinde introspection response was not JSON") from exc + if not isinstance(data, dict) or data.get("active") is not True: + raise InvalidTokenError("The access token is not active") + audience = data.get("aud") + audiences = [audience] if isinstance(audience, str) else audience if isinstance(audience, list) else [] + # Kinde already enforces this; checking again guards against a misrouted introspection call + if self.config.resource_url not in audiences: + raise InvalidTokenError("The access token was not issued for this resource") + exp = data.get("exp") + if isinstance(exp, (int, float)) and exp <= self._clock() - self.config.clock_tolerance_seconds: + raise InvalidTokenError("The access token has expired") + self._remember_introspection(token, data) + return data + + def _context_from_introspection( + self, token: str, data: Mapping[str, Any], verified_claims: Optional[Mapping[str, Any]] + ) -> AgentAuthContext: + claims = verified_claims + if claims is None: + # Safe without a signature check: Kinde has just said the token is active. + # Introspection doesn't carry permissions, roles or org_code; the token does. + try: + claims = jwt.decode(token, options={"verify_signature": False}) + except jwt.PyJWTError: + claims = {} + return AgentAuthContext.from_claims(token, claims, introspection=data) + + # -- Introspection cache -------------------------------------------------- + + @staticmethod + def _cache_key(token: str) -> str: + # Keyed by a hash so raw tokens never sit in the cache + return hashlib.sha256(token.encode()).hexdigest() + + def _cached_introspection(self, token: str) -> Optional[Dict[str, Any]]: + if not self.config.introspection_cache_seconds: + return None + entry = self._introspection_cache.get(self._cache_key(token)) + if entry is None: + return None + if entry[0] <= self._clock(): + self._introspection_cache.pop(self._cache_key(token), None) + return None + return entry[1] + + def _remember_introspection(self, token: str, data: Dict[str, Any]) -> None: + if not self.config.introspection_cache_seconds: + return + now = self._clock() + expires = now + self.config.introspection_cache_seconds + exp = data.get("exp") + if isinstance(exp, (int, float)): + expires = min(expires, float(exp)) + if len(self._introspection_cache) >= _INTROSPECTION_CACHE_MAX_ENTRIES: + self._introspection_cache = {k: v for k, v in self._introspection_cache.items() if v[0] > now} + if len(self._introspection_cache) >= _INTROSPECTION_CACHE_MAX_ENTRIES: + self._introspection_cache.clear() + self._introspection_cache[self._cache_key(token)] = (expires, data) + + # -- Local JWT verification ----------------------------------------------- + + @staticmethod + def _kid(token: str) -> str: + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError as exc: + raise InvalidTokenError("The access token is not a valid JWT") from exc + # Reject "none", HS* and everything else before touching any key + if header.get("alg") != "RS256": + raise InvalidTokenError("The access token uses an unsupported algorithm") + kid = header.get("kid") + if not kid or not isinstance(kid, str): + raise InvalidTokenError("The access token has no key ID") + return kid + + def _verify_jwt(self, token: str, key: Any) -> Dict[str, Any]: + try: + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + # PyJWT matches when the aud array contains this value, comparing exactly + audience=self.config.resource_url, + leeway=self.config.clock_tolerance_seconds, + options={"require": ["exp", "iss", "aud"], "verify_iss": False}, + ) + except jwt.ExpiredSignatureError as exc: + raise InvalidTokenError("The access token has expired") from exc + except jwt.InvalidAudienceError as exc: + raise InvalidTokenError("The access token was not issued for this resource") from exc + except jwt.ImmatureSignatureError as exc: + raise InvalidTokenError("The access token is not valid yet") from exc + except jwt.PyJWTError as exc: + raise InvalidTokenError("The access token is invalid") from exc + # iss is the Kinde domain, not the scoped issuer: comparing with the scoped issuer rejects every token + issuer = claims.get("iss") + if not isinstance(issuer, str) or _strip_one_slash(issuer) != self.config.domain: + raise InvalidTokenError("The access token has the wrong issuer") + return claims + + # -- Admin revoke --------------------------------------------------------- + + def revoke_token(self, token: str, token_type_hint: Optional[str] = None) -> None: + """Revoke an agent's token with the application's credentials. + + Revoking either token of a pair revokes both. Only works for tokens + issued for this application's Resource URL. + """ + data = {"token": token} + if token_type_hint: + data["token_type_hint"] = token_type_hint + try: + response = self._client().post( + self.config.revocation_url, data=data, headers={"Authorization": self._application_auth_header()} + ) + except httpx.HTTPError as exc: + raise VerificationUnavailableError("Could not reach Kinde to revoke the token") from exc + self._check_revocation(response, token) + + async def revoke_token_async(self, token: str, token_type_hint: Optional[str] = None) -> None: + data = {"token": token} + if token_type_hint: + data["token_type_hint"] = token_type_hint + try: + response = await self._async_client().post( + self.config.revocation_url, data=data, headers={"Authorization": self._application_auth_header()} + ) + except httpx.HTTPError as exc: + raise VerificationUnavailableError("Could not reach Kinde to revoke the token") from exc + self._check_revocation(response, token) + + def _check_revocation(self, response: httpx.Response, token: str) -> None: + if response.status_code != 200: + raise AgentAuthError(f"Kinde token revocation failed with HTTP {response.status_code}") + self._introspection_cache.pop(self._cache_key(token), None) + + +def _oauth_error_code(response: httpx.Response) -> Optional[str]: + try: + body = response.json() + except ValueError: + return None + error = body.get("error") if isinstance(body, dict) else None + return error if isinstance(error, str) else None + + +def _strip_one_slash(value: str) -> str: + return value[:-1] if value.endswith("/") else value diff --git a/kinde_sdk/agents/storage.py b/kinde_sdk/agents/storage.py new file mode 100644 index 00000000..d06557f4 --- /dev/null +++ b/kinde_sdk/agents/storage.py @@ -0,0 +1,132 @@ +""" +Pluggable storage for the agent client: tokens and dynamic client registrations. + +Tokens are keyed by resource and account, and carry the issuer they came from. +Registrations are keyed by issuer and redirect URI. Values are plain JSON-safe +dicts so any store (a database, an encrypted session, a keychain) can hold them. +""" + +import asyncio +import json +import os +import tempfile +from typing import Any, Dict, Optional, Protocol + + +class AgentClientStorage(Protocol): + async def get_tokens(self, key: str) -> Optional[Dict[str, Any]]: ... + + async def set_tokens(self, key: str, tokens: Dict[str, Any]) -> None: ... + + async def delete_tokens(self, key: str) -> None: ... + + async def get_registration(self, key: str) -> Optional[Dict[str, Any]]: ... + + async def set_registration(self, key: str, registration: Dict[str, Any]) -> None: ... + + async def delete_registration(self, key: str) -> None: ... + + +class InMemoryAgentStorage: + """Keeps everything in the process. For tests and short-lived agents.""" + + def __init__(self): + self._tokens: Dict[str, Dict[str, Any]] = {} + self._registrations: Dict[str, Dict[str, Any]] = {} + + async def get_tokens(self, key: str) -> Optional[Dict[str, Any]]: + value = self._tokens.get(key) + return dict(value) if value is not None else None + + async def set_tokens(self, key: str, tokens: Dict[str, Any]) -> None: + self._tokens[key] = dict(tokens) + + async def delete_tokens(self, key: str) -> None: + self._tokens.pop(key, None) + + async def get_registration(self, key: str) -> Optional[Dict[str, Any]]: + value = self._registrations.get(key) + return dict(value) if value is not None else None + + async def set_registration(self, key: str, registration: Dict[str, Any]) -> None: + self._registrations[key] = dict(registration) + + async def delete_registration(self, key: str) -> None: + self._registrations.pop(key, None) + + +class FileAgentStorage: + """Keeps everything in one JSON file readable only by the current user (mode 0600). + + Suitable for CLI agents on a single-user machine. The file holds refresh + tokens and client secrets in plain text, so prefer the OS keychain or an + encrypted store where one is available. + """ + + def __init__(self, path: str): + self.path = os.path.expanduser(path) + self._lock: Optional[asyncio.Lock] = None + + def _read(self) -> Dict[str, Dict[str, Any]]: + try: + with open(self.path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError: + return {"tokens": {}, "registrations": {}} + data.setdefault("tokens", {}) + data.setdefault("registrations", {}) + return data + + def _write(self, data: Dict[str, Dict[str, Any]]) -> None: + directory = os.path.dirname(os.path.abspath(self.path)) + os.makedirs(directory, mode=0o700, exist_ok=True) + # Write then rename so a crash never leaves a half-written file + fd, temp_path = tempfile.mkstemp(dir=directory, prefix=".kinde-agent-") + try: + os.chmod(temp_path, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle) + os.replace(temp_path, self.path) + except BaseException: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + def _guard(self) -> asyncio.Lock: + # Created lazily so it binds to the running event loop (Python 3.9) + if self._lock is None: + self._lock = asyncio.Lock() + return self._lock + + async def _get(self, section: str, key: str) -> Optional[Dict[str, Any]]: + async with self._guard(): + return self._read()[section].get(key) + + async def _set(self, section: str, key: str, value: Optional[Dict[str, Any]]) -> None: + async with self._guard(): + data = self._read() + if value is None: + data[section].pop(key, None) + else: + data[section][key] = value + self._write(data) + + async def get_tokens(self, key: str) -> Optional[Dict[str, Any]]: + return await self._get("tokens", key) + + async def set_tokens(self, key: str, tokens: Dict[str, Any]) -> None: + await self._set("tokens", key, tokens) + + async def delete_tokens(self, key: str) -> None: + await self._set("tokens", key, None) + + async def get_registration(self, key: str) -> Optional[Dict[str, Any]]: + return await self._get("registrations", key) + + async def set_registration(self, key: str, registration: Dict[str, Any]) -> None: + await self._set("registrations", key, registration) + + async def delete_registration(self, key: str) -> None: + await self._set("registrations", key, None) diff --git a/kinde_sdk/agents/www_authenticate.py b/kinde_sdk/agents/www_authenticate.py new file mode 100644 index 00000000..8e1c918c --- /dev/null +++ b/kinde_sdk/agents/www_authenticate.py @@ -0,0 +1,138 @@ +""" +Building and parsing ``WWW-Authenticate`` headers (RFC 9110 §11.6.1, RFC 6750 §3). +""" + +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Tuple, Union + +_TOKEN_CHARS = frozenset("!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") +_TOKEN68_EXTRA = frozenset("/") + + +def quote(value: str) -> str: + """Return ``value`` as an RFC 9110 quoted-string, escaping ``"`` and ``\\``.""" + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def build_challenge(scheme: str = "Bearer", params: Union[Dict[str, str], Iterable[Tuple[str, str]]] = ()) -> str: + """Build one challenge, quoting every parameter value. ``None`` values are skipped.""" + items = params.items() if isinstance(params, dict) else params + rendered = ", ".join(f"{name}={quote(value)}" for name, value in items if value is not None) + return f"{scheme} {rendered}" if rendered else scheme + + +@dataclass +class Challenge: + scheme: str + params: Dict[str, str] = field(default_factory=dict) # names lower-cased + token68: Optional[str] = None + + def get(self, name: str) -> Optional[str]: + return self.params.get(name.lower()) + + +class _Scanner: + def __init__(self, text: str): + self.text = text + self.pos = 0 + + def peek(self) -> str: + return self.text[self.pos] if self.pos < len(self.text) else "" + + def skip_spaces(self) -> None: + while self.peek() in (" ", "\t"): + self.pos += 1 + + def skip_separators(self) -> None: + while self.peek() in (" ", "\t", ","): + self.pos += 1 + + def token(self, extra=frozenset()) -> str: + start = self.pos + while self.peek() and (self.peek() in _TOKEN_CHARS or self.peek() in extra): + self.pos += 1 + return self.text[start : self.pos] + + def quoted_string(self) -> str: + # Assumes the opening quote is at self.pos. An unterminated string runs to the end. + self.pos += 1 + chars = [] + while self.pos < len(self.text): + char = self.text[self.pos] + if char == "\\" and self.pos + 1 < len(self.text): + chars.append(self.text[self.pos + 1]) + self.pos += 2 + continue + self.pos += 1 + if char == '"': + break + chars.append(char) + return "".join(chars) + + +def parse_www_authenticate(headers: Union[str, Iterable[str], None]) -> List[Challenge]: + """Parse one or more ``WWW-Authenticate`` header values into challenges. + + Handles several challenges in one header, quoted strings with escaped quotes, + commas inside quotes, token68 credentials and case-insensitive names. + Malformed input yields the challenges that could be read, never an exception. + """ + if headers is None: + return [] + values = [headers] if isinstance(headers, str) else list(headers) + challenges: List[Challenge] = [] + for value in values: + challenges.extend(_parse_one(value)) + return challenges + + +def _parse_one(text: str) -> List[Challenge]: + scanner = _Scanner(text) + challenges: List[Challenge] = [] + current: Optional[Challenge] = None + while True: + scanner.skip_separators() + if not scanner.peek(): + break + name = scanner.token() + if not name: + # Not a token: skip one character so malformed input can't loop forever + scanner.pos += 1 + continue + scanner.skip_spaces() + if scanner.peek() == "=": + scanner.pos += 1 + scanner.skip_spaces() + value = scanner.quoted_string() if scanner.peek() == '"' else scanner.token() + if current is not None: # a parameter before any scheme is ignored + current.params[name.lower()] = value + continue + current = Challenge(scheme=name) + challenges.append(current) + current.token68 = _read_token68(scanner) + return challenges + + +def _read_token68(scanner: _Scanner) -> Optional[str]: + """Read a token68 right after a scheme, or leave the position unchanged when there is none.""" + start = scanner.pos + scanner.token(_TOKEN68_EXTRA) + while scanner.peek() == "=": + scanner.pos += 1 + candidate = scanner.text[start : scanner.pos] + end = scanner.pos + scanner.skip_spaces() + # A token68 stands alone. "name=value" (something follows the "=") is a parameter instead. + if candidate and scanner.peek() in ("", ","): + scanner.pos = end + return candidate + scanner.pos = start + return None + + +def find_bearer_challenge(headers: Union[str, Iterable[str], None]) -> Optional[Challenge]: + """Return the first ``Bearer`` challenge, comparing the scheme case-insensitively.""" + for challenge in parse_www_authenticate(headers): + if challenge.scheme.lower() == "bearer": + return challenge + return None diff --git a/pyproject.toml b/pyproject.toml index 25384197..6b95cf87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,11 @@ flask = [ # kinde_flask) needs; 0.8.0 restored it. "flask-session >=0.6.0, !=0.7.0, <1.0.0", ] +# MCP adapters in kinde_sdk.agents.mcp. The mcp package needs Python 3.10+. +# Capped below 3.0: the client adapter hooks into OAuthClientProvider internals. +mcp = [ + "mcp >=2.3.0, <3.0.0; python_version >= '3.10'", +] dev = [ # pytest 9 requires Python >=3.10; pytest 9.0.3 fixes CVE-2025-71176 "pytest >=9.0.3; python_version >= '3.10'", diff --git a/testv2/testv2_agents/__init__.py b/testv2/testv2_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testv2/testv2_agents/kinde_fake.py b/testv2/testv2_agents/kinde_fake.py new file mode 100644 index 00000000..6763b8d3 --- /dev/null +++ b/testv2/testv2_agents/kinde_fake.py @@ -0,0 +1,314 @@ +""" +An in-process stand-in for Kinde's scoped agents issuer, following the server contract. + +It behaves like the real server where SDKs most often go wrong: introspection +only answers the application's own Basic credentials, tokens carry the bare +domain as ``iss`` and the Resource URL in ``aud``, ``openid`` is refused, +registration always returns a secret, and refresh doesn't rotate. +""" + +import base64 +import hashlib +import json +import secrets +import time +from typing import Any, Dict, List, Optional +from urllib.parse import parse_qs, unquote_plus, urlsplit + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa + +DOMAIN = "https://test.kinde.com" +APP_ID = "0123456789abcdef0123456789abcdef" +APP_SECRET = "app-secret" +RESOURCE_URL = "https://api.example.com/mcp" +ISSUER = f"{DOMAIN}/oauth2/agents/{APP_ID}" +KID = "test-key" + +_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +_OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def jwks() -> Dict[str, Any]: + key = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(_PRIVATE_KEY.public_key())) + key.update({"kid": KID, "alg": "RS256", "use": "sig"}) + return {"keys": [key]} + + +def make_token( + *, + iss: str = DOMAIN, + aud: Any = None, + exp_in: int = 3600, + kid: str = KID, + key: Any = None, + algorithm: str = "RS256", + **claims: Any, +) -> str: + now = int(time.time()) + payload = { + "iss": iss, + "aud": [RESOURCE_URL] if aud is None else aud, + "sub": "kp_user", + "azp": "https://claude.ai/oauth/claude-code-client-metadata", + "scp": ["offline"], + "iat": now, + "exp": now + exp_in, + "jti": secrets.token_hex(8), + } + payload.update(claims) + if algorithm == "RS256": + return jwt.encode(payload, key or _PRIVATE_KEY, algorithm="RS256", headers={"kid": kid}) + return jwt.encode( + payload, "a-shared-secret-that-is-long-enough-for-hs256", algorithm=algorithm, headers={"kid": kid} + ) + + +def other_key(): + return _OTHER_KEY + + +def authorization_server_metadata() -> Dict[str, Any]: + return { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authorize", + "token_endpoint": f"{ISSUER}/token", + "registration_endpoint": f"{ISSUER}/register", + "introspection_endpoint": f"{ISSUER}/introspect", + "revocation_endpoint": f"{ISSUER}/revoke", + "jwks_uri": f"{DOMAIN}/.well-known/jwks", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["offline", "profile", "email"], + "subject_types_supported": ["public"], + "resource_indicators_supported": True, + "client_id_metadata_document_supported": True, + } + + +class FakeKinde: + """Routes for the scoped issuer plus a protected resource, recording every request.""" + + def __init__(self): + self.requests: List[httpx.Request] = [] + self.active_tokens: Dict[str, Dict[str, Any]] = {} + self.registrations: Dict[str, Dict[str, Any]] = {} + self.codes: Dict[str, Dict[str, Any]] = {} + self.refresh_tokens: Dict[str, Dict[str, Any]] = {} + self.revoked: List[str] = [] + self.as_metadata = authorization_server_metadata() + self.introspection_status: Optional[int] = None + self.introspection_raises = False + self.inactive_as_200 = False # the RFC 7662 answer the contract describes + self.jwks_status = 200 + self.register_status: Optional[int] = None + self.register_error: Optional[Dict[str, str]] = None + self.prm: Optional[Dict[str, Any]] = { + "resource": RESOURCE_URL, + "authorization_servers": [ISSUER], + "bearer_methods_supported": ["header"], + "scopes_supported": ["offline"], + } + self.serve_prm_at_well_known = True + self.resource_required_scope: Optional[str] = None + self.rotate_refresh_tokens = False + self.secret_expires_in = 30 * 86400 + + # -- helpers -------------------------------------------------------------- + + def paths(self) -> List[str]: + return [f"{r.method} {r.url.copy_with(query=None)}" for r in self.requests] + + def count(self, path_suffix: str) -> int: + return sum(1 for r in self.requests if r.url.path.endswith(path_suffix)) + + def issue(self, *, scope: str = "offline", client_id: str = "https://agent.example.com/cimd.json", **claims) -> str: + token = make_token(azp=client_id, scp=scope.split(), **claims) + self.active_tokens[token] = { + "active": True, + "client_id": client_id, + "sub": claims.get("sub", "kp_user"), + "scope": scope, + "aud": [RESOURCE_URL], + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + } + return token + + def transport(self) -> httpx.MockTransport: + return httpx.MockTransport(self.handle) + + def async_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=self.transport()) + + def client(self) -> httpx.Client: + return httpx.Client(transport=self.transport()) + + # -- routing -------------------------------------------------------------- + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + url = str(request.url.copy_with(query=None)) + path = request.url.path + if url == f"{DOMAIN}/.well-known/oauth-authorization-server/oauth2/agents/{APP_ID}": + return httpx.Response(200, json=self.as_metadata) + if url == f"{DOMAIN}/.well-known/jwks": + return httpx.Response(self.jwks_status, json=jwks()) + if url == f"{ISSUER}/introspect": + return self._introspect(request) + if url == f"{ISSUER}/register": + return self._register(request) + if url == f"{ISSUER}/token": + return self._token(request) + if url == f"{ISSUER}/revoke": + form = _form(request) + self.revoked.append(form.get("token", "")) + self.active_tokens.pop(form.get("token", ""), None) + return httpx.Response(200) + if path.startswith("/oauth2/") and not path.startswith(f"/oauth2/agents/{APP_ID}"): + raise AssertionError(f"Request to a shared Kinde endpoint: {url}") + if request.url.host == "api.example.com": + return self._resource(request) + return httpx.Response(404) + + def _introspect(self, request: httpx.Request) -> httpx.Response: + if self.introspection_raises: + raise httpx.ConnectError("boom", request=request) + if self.introspection_status is not None: + return httpx.Response(self.introspection_status) + # These answers were observed on a live Kinde business (2026-10-27). They differ from the + # skill's contract, which says 200 {"active": false} in both cases. + expected = "Basic " + base64.b64encode(f"{APP_ID}:{APP_SECRET}".encode()).decode() + if request.headers.get("authorization") != expected: + return httpx.Response( + 401, + json={ + "error": "request_unauthorized", + "error_description": "The request could not be authorized. Client credentials are invalid.", + }, + ) + data = self.active_tokens.get(_form(request).get("token", "")) + if data: + return httpx.Response(200, json=data) + if self.inactive_as_200: + return httpx.Response(200, json={"active": False}) + return httpx.Response( + 401, + json={ + "error": "token_inactive", + "error_description": "Token is inactive because it is malformed, expired or otherwise invalid.", + }, + ) + + def _register(self, request: httpx.Request) -> httpx.Response: + if self.register_status is not None: + return httpx.Response(self.register_status, json=self.register_error or {}) + body = json.loads(request.content) + client_id = APP_ID + secrets.token_hex(16) + registration = { + "client_id": client_id, + # Kinde always issues a secret and reports client_secret_post, whatever was asked for + "client_secret": secrets.token_hex(16), + "client_id_issued_at": int(time.time()), + "client_secret_expires_at": int(time.time()) + self.secret_expires_in, + "redirect_uris": body["redirect_uris"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + } + self.registrations[client_id] = registration + return httpx.Response(201, json=registration) + + def authorize(self, authorization_url: str) -> str: + """Act as the browser plus the user: approve and return the callback URL.""" + query = {k: v[0] for k, v in parse_qs(urlsplit(authorization_url).query).items()} + assert authorization_url.startswith(f"{ISSUER}/authorize?") + if "openid" in query.get("scope", "").split(): + return f"{query['redirect_uri']}?error=invalid_scope&state={query['state']}" + assert query["code_challenge_method"] == "S256" + assert len(query["state"]) >= 8 + code = secrets.token_hex(8) + self.codes[code] = query + return f"{query['redirect_uri']}?code={code}&state={query['state']}&iss={ISSUER}" + + def _client_ok(self, form: Dict[str, str]) -> bool: + client_id = form.get("client_id", "") + if client_id.startswith("https://"): + return "client_secret" not in form + registration = self.registrations.get(client_id) + return registration is not None and form.get("client_secret") == registration["client_secret"] + + def _token(self, request: httpx.Request) -> httpx.Response: + form = _form(request) + if not self._client_ok(form): + return httpx.Response(401, json={"error": "invalid_client"}) + if form["grant_type"] == "authorization_code": + query = self.codes.pop(form.get("code", ""), None) + if query is None or query["redirect_uri"] != form.get("redirect_uri"): + return httpx.Response(400, json={"error": "invalid_grant"}) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(form["code_verifier"].encode()).digest()).rstrip(b"=").decode() + ) + if challenge != query["code_challenge"]: + return httpx.Response(400, json={"error": "invalid_grant"}) + scope = query.get("scope", "") + refresh = secrets.token_hex(8) if "offline" in scope.split() else None + if refresh: + self.refresh_tokens[refresh] = {"client_id": form["client_id"], "scope": scope} + access = self.issue(scope=scope, client_id=form["client_id"]) + body = {"access_token": access, "token_type": "bearer", "expires_in": 86400, "scope": scope} + if refresh: + body["refresh_token"] = refresh + return httpx.Response(200, json=body) + if form["grant_type"] == "refresh_token": + grant = self.refresh_tokens.get(form.get("refresh_token", "")) + if grant is None or grant["client_id"] != form["client_id"]: + return httpx.Response(400, json={"error": "invalid_grant"}) + access = self.issue(scope=grant["scope"], client_id=form["client_id"]) + body = {"access_token": access, "token_type": "bearer", "expires_in": 86400, "scope": grant["scope"]} + if self.rotate_refresh_tokens: + new_refresh = secrets.token_hex(8) + self.refresh_tokens[new_refresh] = self.refresh_tokens.pop(form["refresh_token"]) + body["refresh_token"] = new_refresh + return httpx.Response(200, json=body) + return httpx.Response(400, json={"error": "unsupported_grant_type"}) + + def _resource(self, request: httpx.Request) -> httpx.Response: + if request.url.path.startswith("/.well-known/oauth-protected-resource"): + if self.serve_prm_at_well_known and self.prm is not None: + return httpx.Response(200, json=self.prm) + return httpx.Response(404) + if request.url.path == "/hidden-prm" and self.prm is not None: + return httpx.Response(200, json=self.prm) + challenge_url = ( + "https://api.example.com/.well-known/oauth-protected-resource/mcp" + if self.serve_prm_at_well_known + else "https://api.example.com/hidden-prm" + ) + authorization = request.headers.get("authorization", "") + token = authorization[7:] if authorization.lower().startswith("bearer ") else None + data = self.active_tokens.get(token or "") + if not data: + error = ', error="invalid_token"' if token else "" + return httpx.Response( + 401, headers={"WWW-Authenticate": f'Bearer resource_metadata="{challenge_url}"{error}'} + ) + if self.resource_required_scope and self.resource_required_scope not in data["scope"].split(): + return httpx.Response( + 403, + headers={ + "WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{self.resource_required_scope}", ' + f'resource_metadata="{challenge_url}"' + }, + ) + return httpx.Response(200, json={"ok": True, "sub": data["sub"]}) + + +def _form(request: httpx.Request) -> Dict[str, str]: + return { + k: unquote_plus(v) + for k, v in (pair.split("=", 1) for pair in request.content.decode().split("&") if "=" in pair) + } diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py new file mode 100644 index 00000000..26ef151e --- /dev/null +++ b/testv2/testv2_agents/test_agent_client.py @@ -0,0 +1,433 @@ +import asyncio +import os +import stat +import time +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest + +from kinde_sdk.agents import ( + AgentConfigError, + AuthorizationError, + AuthorizationRequiredError, + DiscoveryError, + FileAgentStorage, + InMemoryAgentStorage, + KindeAgentClient, + PendingAuthorization, + RegistrationError, +) +from kinde_sdk.agents.client import ( + authorization_server_metadata_urls, + protected_resource_metadata_urls, + resource_covers, +) +from testv2.testv2_agents.kinde_fake import APP_ID, DOMAIN, ISSUER, RESOURCE_URL, FakeKinde + +CIMD_URL = "https://agent.onzs.co.za/oauth/client-metadata.json" +REDIRECT = "http://127.0.0.1:8765/callback" + + +@pytest.fixture +def fake(): + return FakeKinde() + + +def make_client(fake: FakeKinde, **options) -> KindeAgentClient: + async def browser(url: str) -> str: + return fake.authorize(url) + + options.setdefault("authorization_handler", browser) + options.setdefault("redirect_uri", REDIRECT) + return KindeAgentClient(RESOURCE_URL, http_client=fake.async_client(), **options) + + +def form_of(request: httpx.Request) -> dict: + return {k: v[0] for k, v in parse_qs(request.content.decode()).items()} + + +def query_of(url: str) -> dict: + return {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()} + + +class TestHelpers: + def test_metadata_urls(self): + assert protected_resource_metadata_urls("https://api.example.com/mcp/") == [ + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + "https://api.example.com/.well-known/oauth-protected-resource", + ] + assert authorization_server_metadata_urls(ISSUER) == [ + f"{DOMAIN}/.well-known/oauth-authorization-server/oauth2/agents/{APP_ID}", + f"{DOMAIN}/.well-known/openid-configuration/oauth2/agents/{APP_ID}", + f"{ISSUER}/.well-known/openid-configuration", + ] + + @pytest.mark.parametrize( + "prm_resource, expected", + [ + ("https://api.example.com/mcp", True), + ("https://api.example.com/mcp/", True), + ("https://api.example.com/", True), + ("https://API.example.com:443/mcp", True), + ("https://api.example.com/other", False), + ("https://api.example.com/mc", False), + ("https://evil.example.com/mcp", False), + ("http://api.example.com/mcp", False), + ], + ) + def test_resource_covers(self, prm_resource, expected): + assert resource_covers(prm_resource, RESOURCE_URL) is expected + + def test_rejects_insecure_resource(self): + with pytest.raises(AgentConfigError): + KindeAgentClient("http://api.example.com/mcp") + + +class TestCimdFlow: + async def test_full_flow_uses_cimd_without_registration(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + response = await client.request("POST", json={"jsonrpc": "2.0"}) + assert response.status_code == 200 + assert fake.count("/register") == 0 + + token_request = next(r for r in fake.requests if r.url.path.endswith("/token")) + form = form_of(token_request) + assert form["client_id"] == CIMD_URL + assert "client_secret" not in form + assert form["resource"] == RESOURCE_URL + assert "authorization" not in token_request.headers + + info = await client.get_token_info() + assert info["client_id"] == CIMD_URL + assert info["scopes"] == ["offline"] + assert info["has_refresh_token"] is True + assert "access_token" not in info + + async def test_authorization_request(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + pending = await client.begin_authorization() + query = query_of(pending.authorization_url) + assert pending.authorization_url.startswith(f"{ISSUER}/authorize?") + assert query["client_id"] == CIMD_URL + assert query["scope"] == "offline" + assert query["code_challenge_method"] == "S256" + assert len(query["state"]) >= 8 + assert query["resource"] == RESOURCE_URL + assert pending.code_verifier not in repr(pending) + + async def test_discovery_uses_path_insertion_only(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + await client.discover() + urls = [str(r.url) for r in fake.requests] + assert urls == [ + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + f"{DOMAIN}/.well-known/oauth-authorization-server/oauth2/agents/{APP_ID}", + ] + + async def test_never_sends_openid(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL, scopes=["openid", "offline", "profile"]) + pending = await client.begin_authorization() + assert query_of(pending.authorization_url)["scope"] == "offline profile" + + async def test_falls_back_to_dcr_when_cimd_unsupported(self, fake): + fake.as_metadata["client_id_metadata_document_supported"] = False + client = make_client(fake, client_metadata_url=CIMD_URL) + await client.connect() + assert fake.count("/register") == 1 + + +class TestDcrFlow: + async def test_registers_and_uses_returned_secret(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage, client_name="Onzs Agent") + await client.connect() + + register = next(r for r in fake.requests if r.url.path.endswith("/register")) + import json + + body = json.loads(register.content) + assert body["token_endpoint_auth_method"] == "none" + assert body["redirect_uris"] == [REDIRECT] + assert body["client_name"] == "Onzs Agent" + assert "scope" not in body and "resource" not in body + + token_form = form_of(next(r for r in fake.requests if r.url.path.endswith("/token"))) + registration = await storage.get_registration(f"{ISSUER}|{REDIRECT}") + assert token_form["client_id"] == registration["client_id"] + assert token_form["client_secret"] == registration["client_secret"] + assert registration["token_endpoint_auth_method"] == "client_secret_post" + + async def test_reuses_stored_registration(self, fake): + storage = InMemoryAgentStorage() + await make_client(fake, storage=storage).connect() + await storage.delete_tokens(f"{RESOURCE_URL}|default") + await make_client(fake, storage=storage).connect() + assert fake.count("/register") == 1 + + async def test_reregisters_when_expiring_within_a_day(self, fake): + fake.secret_expires_in = 3600 + storage = InMemoryAgentStorage() + await make_client(fake, storage=storage).connect() + await storage.delete_tokens(f"{RESOURCE_URL}|default") + await make_client(fake, storage=storage).connect() + assert fake.count("/register") == 2 + # The code is exchanged by the same client that started the attempt + exchange = [r for r in fake.requests if b"grant_type=authorization_code" in r.content][-1] + assert form_of(exchange)["client_id"] == list(fake.registrations)[-1] + + async def test_invalid_redirect_uri_surfaces_description(self, fake): + fake.register_status = 400 + fake.register_error = { + "error": "invalid_redirect_uri", + "error_description": "redirect host agent.example.org is not a trusted agent host", + } + with pytest.raises(RegistrationError, match="agent.example.org is not a trusted agent host"): + await make_client(fake).connect() + + async def test_too_many_registrations(self, fake): + fake.register_status = 429 + with pytest.raises(RegistrationError, match="try again later"): + await make_client(fake).connect() + + async def test_invalid_client_on_refresh_reregisters(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage) + await client.connect() + fake.registrations.clear() # Kinde forgot the registration (expired) + tokens = await storage.get_tokens(f"{RESOURCE_URL}|default") + tokens["expires_at"] = time.time() - 10 + await storage.set_tokens(f"{RESOURCE_URL}|default", tokens) + response = await client.request("GET") + assert response.status_code == 200 + assert fake.count("/register") == 2 + + +class TestTokens: + async def test_proactive_refresh_keeps_unrotated_refresh_token(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) + await client.connect() + key = f"{RESOURCE_URL}|default" + before = await storage.get_tokens(key) + await storage.set_tokens(key, {**before, "expires_at": time.time() + 30}) + await client.request("GET") + after = await storage.get_tokens(key) + assert after["access_token"] != before["access_token"] + assert after["refresh_token"] == before["refresh_token"] + refresh = next(r for r in fake.requests if r.url.path.endswith("/token") and b"refresh_token" in r.content) + assert form_of(refresh)["resource"] == RESOURCE_URL + + async def test_rotated_refresh_token_replaces_old(self, fake): + fake.rotate_refresh_tokens = True + storage = InMemoryAgentStorage() + client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) + await client.connect() + key = f"{RESOURCE_URL}|default" + before = await storage.get_tokens(key) + await client.refresh() + assert (await storage.get_tokens(key))["refresh_token"] != before["refresh_token"] + + async def test_concurrent_401s_share_one_refresh(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + await client.connect() + fake.active_tokens.clear() # the resource now rejects the access token + responses = await asyncio.gather(*(client.request("GET") for _ in range(5))) + assert all(r.status_code == 200 for r in responses) + refreshes = [ + r for r in fake.requests if r.url.path.endswith("/token") and b"grant_type=refresh_token" in r.content + ] + assert len(refreshes) == 1 + + async def test_invalid_grant_restarts_sign_in(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + await client.connect() + fake.active_tokens.clear() + fake.refresh_tokens.clear() + response = await client.request("GET") + assert response.status_code == 200 + assert fake.count("/authorize") == 0 # the handler stands in for the browser + assert len([r for r in fake.requests if b"authorization_code" in r.content]) == 2 + + async def test_step_up_on_insufficient_scope(self, fake): + fake.resource_required_scope = "email" + client = make_client(fake, client_metadata_url=CIMD_URL) + response = await client.request("GET") + assert response.status_code == 200 + assert (await client.get_token_info())["scopes"] == ["offline", "email"] + + async def test_token_never_sent_to_another_origin(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + with pytest.raises(AgentConfigError): + await client.request("GET", "https://evil.example.com/steal") + + async def test_relative_path_on_resource_origin(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + response = await client.request("GET", "/mcp/tools") + assert str(response.request.url) == "https://api.example.com/mcp/tools" + + async def test_disconnect_revokes_and_clears(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage) + await client.connect() + refresh_token = (await storage.get_tokens(f"{RESOURCE_URL}|default"))["refresh_token"] + await client.disconnect(forget=True) + revoke = fake.requests[-1] + form = form_of(revoke) + assert str(revoke.url) == f"{ISSUER}/revoke" + assert form["token"] == refresh_token + assert form["token_type_hint"] == "refresh_token" + assert "client_secret" in form # DCR client: same auth as the token endpoint + assert await client.get_token_info() is None + assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + + async def test_tokens_dropped_when_issuer_changes(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) + await client.connect() + tokens = await storage.get_tokens(f"{RESOURCE_URL}|default") + await storage.set_tokens(f"{RESOURCE_URL}|default", {**tokens, "issuer": "https://old.example.com"}) + await client.discover(force=True) + assert await storage.get_tokens(f"{RESOURCE_URL}|default") is None + + +class TestDiscovery: + async def test_falls_back_to_challenge_when_well_known_missing(self, fake): + fake.serve_prm_at_well_known = False + client = make_client(fake, client_metadata_url=CIMD_URL) + await client.discover() + assert any(r.url.path == "/hidden-prm" for r in fake.requests) + + async def test_prm_for_other_resource_aborts(self, fake): + fake.prm["resource"] = "https://other.example.com/mcp" + with pytest.raises(DiscoveryError, match="different resource"): + await make_client(fake).discover() + + async def test_issuer_mismatch_aborts(self, fake): + fake.as_metadata["issuer"] = DOMAIN + with pytest.raises(DiscoveryError, match="issuer mismatch"): + await make_client(fake).discover() + + async def test_missing_s256_aborts(self, fake): + del fake.as_metadata["code_challenge_methods_supported"] + with pytest.raises(DiscoveryError, match="S256"): + await make_client(fake).discover() + + async def test_metadata_cached(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + await client.discover() + await client.discover() + assert len(fake.requests) == 2 + + +class TestAuthorizationStep: + async def test_state_mismatch(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + pending = await client.begin_authorization() + callback = fake.authorize(pending.authorization_url).replace(pending.state, "x" * 43) + with pytest.raises(AuthorizationError, match="state"): + await client.complete_authorization(pending, callback) + + async def test_pending_is_single_use_and_serialisable(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + pending = PendingAuthorization.from_dict((await client.begin_authorization()).to_dict()) + callback = fake.authorize(pending.authorization_url) + await client.complete_authorization(pending, callback) + with pytest.raises(AuthorizationError, match="already used"): + await client.complete_authorization(pending, callback) + + async def test_error_on_callback(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + pending = await client.begin_authorization() + callback = f"{REDIRECT}?error=invalid_client&error_description=untrusted&state={pending.state}" + with pytest.raises(AuthorizationError, match="Trusted agent hosts"): + await client.complete_authorization(pending, callback) + + async def test_wrong_iss_on_callback(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + pending = await client.begin_authorization() + callback = fake.authorize(pending.authorization_url).replace(f"iss={ISSUER}", "iss=https://evil.example.com") + with pytest.raises(AuthorizationError, match="different authorization server"): + await client.complete_authorization(pending, callback) + + async def test_server_hosted_agent_without_handler(self, fake): + client = KindeAgentClient( + RESOURCE_URL, + client_metadata_url=CIMD_URL, + redirect_uri="https://agent.onzs.co.za/oauth/callback", + http_client=fake.async_client(), + ) + with pytest.raises(AuthorizationRequiredError): + await client.connect() + pending = await client.begin_authorization() + await client.complete_authorization(pending, fake.authorize(pending.authorization_url)) + assert (await client.request("GET")).status_code == 200 + + async def test_loopback_listener(self, fake): + opened = [] + + async def open_browser(url: str): + opened.append(url) + callback = fake.authorize(url) + + async def follow(): + async with httpx.AsyncClient() as browser: + await browser.get(callback) + + asyncio.get_running_loop().create_task(follow()) + + client = KindeAgentClient( + RESOURCE_URL, + client_metadata_url=CIMD_URL, + open_browser=open_browser, + http_client=fake.async_client(), + callback_timeout_seconds=5, + ) + await client.connect() + redirect_uri = query_of(opened[0])["redirect_uri"] + assert redirect_uri.startswith("http://127.0.0.1:") and redirect_uri.endswith("/callback") + assert (await client.get_token_info())["client_id"] == CIMD_URL + + +class TestFileStorage: + async def test_round_trip_with_private_permissions(self, tmp_path): + path = tmp_path / "agent" / "tokens.json" + storage = FileAgentStorage(str(path)) + await storage.set_tokens("k", {"access_token": "a"}) + await storage.set_registration("r", {"client_id": "c"}) + assert await FileAgentStorage(str(path)).get_tokens("k") == {"access_token": "a"} + assert await storage.get_registration("r") == {"client_id": "c"} + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + await storage.delete_tokens("k") + assert await storage.get_tokens("k") is None + + +class TestConcurrentSignIn: + async def test_concurrent_first_requests_sign_in_once(self, fake): + signins = [] + + async def browser(url: str) -> str: + signins.append(url) + await asyncio.sleep(0.01) + return fake.authorize(url) + + client = make_client(fake, client_metadata_url=CIMD_URL, authorization_handler=browser) + responses = await asyncio.gather(*(client.request("GET") for _ in range(5))) + assert all(r.status_code == 200 for r in responses) + assert len(signins) == 1 + + async def test_concurrent_dead_tokens_sign_in_once(self, fake): + signins = [] + + async def browser(url: str) -> str: + signins.append(url) + await asyncio.sleep(0.01) + return fake.authorize(url) + + client = make_client(fake, client_metadata_url=CIMD_URL, authorization_handler=browser) + await client.connect() + fake.active_tokens.clear() + fake.refresh_tokens.clear() + responses = await asyncio.gather(*(client.request("GET") for _ in range(5))) + assert all(r.status_code == 200 for r in responses) + assert len(signins) == 2 # the first connect, then one shared sign-in diff --git a/testv2/testv2_agents/test_framework_integrations.py b/testv2/testv2_agents/test_framework_integrations.py new file mode 100644 index 00000000..b9cc592b --- /dev/null +++ b/testv2/testv2_agents/test_framework_integrations.py @@ -0,0 +1,162 @@ +import pytest +from fastapi import Depends, FastAPI +from flask import Flask +from starlette.testclient import TestClient + +from kinde_sdk.agents import ( + AgentAuthConfig, + AgentAuthContext, + KindeAgentAuth, + build_client_metadata_document, + find_bearer_challenge, +) +from kinde_fastapi.agents import FastAPIAgentAuth, client_metadata_router +from kinde_flask.agents import FlaskAgentAuth, client_metadata_blueprint, current_agent_auth +from testv2.testv2_agents.kinde_fake import APP_ID, APP_SECRET, ISSUER, RESOURCE_URL, FakeKinde, make_token + +PRM_PATH = "/.well-known/oauth-protected-resource/mcp" +CIMD = build_client_metadata_document( + "https://agent.onzs.co.za/oauth/client-metadata.json", "Onzs Agent", ["http://127.0.0.1/callback"] +) + + +@pytest.fixture +def fake(): + return FakeKinde() + + +@pytest.fixture +def auth(fake): + config = AgentAuthConfig( + domain="test.kinde.com", client_id=APP_ID, client_secret=APP_SECRET, resource_url=RESOURCE_URL + ) + return KindeAgentAuth(config, http_client=fake.client(), async_http_client=fake.async_client()) + + +@pytest.fixture +def fastapi_client(auth): + app = FastAPI() + agents = FastAPIAgentAuth(auth) + agents.install(app) + app.include_router(client_metadata_router(CIMD)) + + @app.get("/mcp") + async def mcp(context: AgentAuthContext = Depends(agents.require())): + return {"user": context.user_id} + + @app.get("/docs-api") + async def docs(context: AgentAuthContext = Depends(agents.require_permission("read:docs"))): + return {"user": context.user_id} + + @app.get("/email") + async def email(context: AgentAuthContext = Depends(agents.require("email"))): + return {"user": context.user_id} + + return TestClient(app) + + +@pytest.fixture +def flask_client(auth): + app = Flask(__name__) + agents = FlaskAgentAuth(auth, app=app) + app.register_blueprint(client_metadata_blueprint(CIMD)) + + @app.get("/mcp") + @agents.require() + def mcp(): + return {"user": current_agent_auth().user_id} + + @app.get("/docs-api") + @agents.require_permission("read:docs") + async def docs(): + return {"user": current_agent_auth().user_id} + + @app.get("/email") + @agents.require("email") + def email(): + return {"user": current_agent_auth().user_id} + + return app.test_client() + + +# Both integrations must answer identically, so every test runs against each +@pytest.fixture(params=["fastapi", "flask"]) +def client(request, fastapi_client, flask_client): + return fastapi_client if request.param == "fastapi" else flask_client + + +def body(response): + return response.json() if hasattr(response, "json") and callable(response.json) else response.get_json() + + +class TestIntegrations: + def test_prm_at_path_suffixed_and_root(self, client): + for path in (PRM_PATH, "/.well-known/oauth-protected-resource"): + response = client.get(path) + assert response.status_code == 200 + assert body(response)["authorization_servers"] == [ISSUER] + assert body(response)["scopes_supported"] == ["offline"] + assert response.headers["Access-Control-Allow-Origin"] == "*" + + def test_prm_preflight(self, client): + response = client.options(PRM_PATH) + assert response.status_code == 204 + assert "GET" in response.headers["Access-Control-Allow-Methods"] + + def test_as_mirror(self, client): + response = client.get("/.well-known/oauth-authorization-server") + assert body(response)["client_id_metadata_document_supported"] is True + + def test_no_token(self, client): + response = client.get("/mcp") + assert response.status_code == 401 + challenge = find_bearer_challenge(response.headers["WWW-Authenticate"]) + assert challenge.get("resource_metadata") == f"https://api.example.com{PRM_PATH}" + assert challenge.get("error") is None + assert response.headers["Access-Control-Expose-Headers"] == "WWW-Authenticate" + assert body(response)["error"] == "unauthorized" + + def test_invalid_token(self, client): + response = client.get("/mcp", headers={"Authorization": f"Bearer {make_token()}"}) + assert response.status_code == 401 + assert find_bearer_challenge(response.headers["WWW-Authenticate"]).get("error") == "invalid_token" + assert body(response) == {"error": "invalid_token", "error_description": "The access token is not active"} + + def test_valid_token(self, client, fake): + response = client.get("/mcp", headers={"Authorization": f"Bearer {fake.issue()}"}) + assert response.status_code == 200 + assert body(response) == {"user": "kp_user"} + + def test_missing_permission_is_plain_403(self, client, fake): + response = client.get("/docs-api", headers={"Authorization": f"Bearer {fake.issue()}"}) + assert response.status_code == 403 + assert "WWW-Authenticate" not in response.headers + assert body(response)["error"] == "forbidden" + + def test_permission_granted(self, client, fake): + token = fake.issue(permissions=["read:docs"]) + assert client.get("/docs-api", headers={"Authorization": f"Bearer {token}"}).status_code == 200 + + def test_missing_scope_is_insufficient_scope(self, client, fake): + response = client.get("/email", headers={"Authorization": f"Bearer {fake.issue()}"}) + assert response.status_code == 403 + challenge = find_bearer_challenge(response.headers["WWW-Authenticate"]) + assert challenge.get("error") == "insufficient_scope" + assert challenge.get("scope") == "email" + + def test_kinde_outage_is_503(self, client, fake): + fake.introspection_status = 502 + response = client.get("/mcp", headers={"Authorization": f"Bearer {fake.issue()}"}) + assert response.status_code == 503 + assert "WWW-Authenticate" not in response.headers + + def test_query_string_token_ignored(self, client, fake): + response = client.get(f"/mcp?access_token={fake.issue()}") + assert response.status_code == 401 + + def test_client_metadata_document(self, client): + response = client.get("/oauth/client-metadata.json") + assert response.status_code == 200 + assert body(response) == CIMD + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "public, max-age=3600" diff --git a/testv2/testv2_agents/test_mcp_adapters.py b/testv2/testv2_agents/test_mcp_adapters.py new file mode 100644 index 00000000..d1c64425 --- /dev/null +++ b/testv2/testv2_agents/test_mcp_adapters.py @@ -0,0 +1,255 @@ +""" +The MCP adapters, run inside a real ``MCPServer`` from the ``mcp`` package. + +Skipped when the ``mcp`` extra isn't installed (it needs Python 3.10+). +""" + +import sys + +import pytest + +if sys.version_info < (3, 10): + pytest.skip("the mcp package needs Python 3.10+", allow_module_level=True) +pytest.importorskip("mcp") + +from mcp.server.mcpserver import MCPServer # noqa: E402 +from mcp.server.mcpserver.exceptions import ToolError # noqa: E402 +from mcp.server.transport_security import TransportSecuritySettings # noqa: E402 +from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, OAuthToken # noqa: E402 +from starlette.testclient import TestClient # noqa: E402 + +from kinde_sdk.agents import AgentAuthConfig, KindeAgentAuth, find_bearer_challenge, has_permission # noqa: E402 +from kinde_sdk.agents.mcp import ( # noqa: E402 + KindeOAuthClientProvider, + KindeTokenStorage, + KindeTokenVerifier, + get_agent_auth_context, + kinde_auth_settings, + mount_authorization_server_metadata, +) +from testv2.testv2_agents.kinde_fake import ( # noqa: E402 + APP_ID, + APP_SECRET, + ISSUER, + RESOURCE_URL, + FakeKinde, + authorization_server_metadata, + make_token, +) + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}}, +} +HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + + +@pytest.fixture +def fake(): + return FakeKinde() + + +@pytest.fixture +def auth(fake): + config = AgentAuthConfig( + domain="test.kinde.com", client_id=APP_ID, client_secret=APP_SECRET, resource_url=RESOURCE_URL + ) + return KindeAgentAuth(config, async_http_client=fake.async_client()) + + +def make_server(auth): + server = MCPServer("Test", token_verifier=KindeTokenVerifier(auth), auth=kinde_auth_settings(auth)) + + @server.tool() + async def whoami() -> str: + context = get_agent_auth_context() + if not has_permission(context, "read:docs"): + raise ToolError("You don't have permission to read docs.") + return f"{context.user_id} via {context.client_id}" + + mount_authorization_server_metadata(server, auth) + app = server.streamable_http_app( + stateless_http=True, + json_response=True, + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) + return app + + +class TestTokenVerifier: + async def test_access_token_fields(self, fake, auth): + token = fake.issue(permissions=["read:docs"]) + access = await KindeTokenVerifier(auth).verify_token(token) + assert access.resource == RESOURCE_URL + assert access.expires_at + assert access.subject == "kp_user" + assert access.client_id == "https://agent.example.com/cimd.json" + assert access.scopes == ["offline"] + assert access.claims["permissions"] == ["read:docs"] + + async def test_invalid_token_is_none(self, auth): + assert await KindeTokenVerifier(auth).verify_token(make_token()) is None + + def test_auth_settings(self, auth): + settings = kinde_auth_settings(auth) + assert str(settings.issuer_url) == ISSUER + assert str(settings.resource_server_url) == RESOURCE_URL + assert settings.required_scopes == ["offline"] + assert settings.validate_token_resource is True + + +class TestInsideMCPServer: + def test_no_token_is_401_with_challenge(self, auth): + with TestClient(make_server(auth)) as client: + response = client.post("/mcp", json=INITIALIZE, headers=HEADERS) + assert response.status_code == 401 + challenge = find_bearer_challenge(response.headers["www-authenticate"]) + assert challenge.get("resource_metadata") == "https://api.example.com/.well-known/oauth-protected-resource/mcp" + + def test_invalid_token_is_401_not_500(self, auth): + with TestClient(make_server(auth)) as client: + response = client.post( + "/mcp", json=INITIALIZE, headers={**HEADERS, "Authorization": f"Bearer {make_token()}"} + ) + assert response.status_code == 401 + + def test_valid_token_and_permission_check_in_tool(self, fake, auth): + allowed = fake.issue(permissions=["read:docs"]) + denied = fake.issue() + call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "whoami", "arguments": {}}} + with TestClient(make_server(auth)) as client: + for token in (allowed, denied): + assert ( + client.post( + "/mcp", json=INITIALIZE, headers={**HEADERS, "Authorization": f"Bearer {token}"} + ).status_code + == 200 + ) + ok = client.post("/mcp", json=call, headers={**HEADERS, "Authorization": f"Bearer {allowed}"}) + refused = client.post("/mcp", json=call, headers={**HEADERS, "Authorization": f"Bearer {denied}"}) + assert ok.status_code == 200 + assert ok.json()["result"]["content"][0]["text"] == "kp_user via https://agent.example.com/cimd.json" + # A missing permission is a tool error, never an HTTP 403 that would loop the agent through login + assert refused.status_code == 200 + assert refused.json()["result"]["isError"] is True + + def test_kinde_outage_is_not_401(self, fake, auth): + fake.introspection_raises = True + with TestClient(make_server(auth), raise_server_exceptions=False) as client: + response = client.post( + "/mcp", json=INITIALIZE, headers={**HEADERS, "Authorization": f"Bearer {fake.issue()}"} + ) + assert response.status_code >= 500 + + def test_metadata_routes(self, auth): + with TestClient(make_server(auth)) as client: + prm = client.get("/.well-known/oauth-protected-resource/mcp").json() + as_metadata = client.get("/.well-known/oauth-authorization-server").json() + assert prm["authorization_servers"] == [ISSUER] + assert prm["resource"] == RESOURCE_URL + # Without offline here, agents never get a refresh token + assert prm["scopes_supported"] == ["offline"] + assert as_metadata["client_id_metadata_document_supported"] is True + + +class TestClientProvider: + def provider(self, **options): + return KindeOAuthClientProvider( + RESOURCE_URL, + client_name="Onzs Agent", + client_metadata_url="https://agent.onzs.co.za/oauth/client-metadata.json", + **options, + ) + + def test_client_metadata_defaults(self): + metadata = self.provider().context.client_metadata + assert metadata.scope == "offline" + assert metadata.token_endpoint_auth_method == "none" + assert metadata.grant_types == ["authorization_code", "refresh_token"] + assert [str(uri) for uri in metadata.redirect_uris] == ["http://127.0.0.1:8765/callback"] + + def test_scope_strips_openid_and_adds_offline_on_kinde(self): + provider = self.provider() + provider.context.oauth_metadata = OAuthMetadata.model_validate(authorization_server_metadata()) + assert provider._kinde_scope("openid profile email") == "offline profile email" + assert provider._kinde_scope(None) == "offline" + + def test_scope_untouched_on_servers_that_support_openid(self): + provider = self.provider() + metadata = {**authorization_server_metadata(), "scopes_supported": ["openid", "profile"]} + provider.context.oauth_metadata = OAuthMetadata.model_validate(metadata) + assert provider._kinde_scope("openid profile") == "openid profile" + + def test_rejects_bad_metadata_url(self): + from kinde_sdk.agents import AgentConfigError + + with pytest.raises(AgentConfigError): + KindeOAuthClientProvider(RESOURCE_URL, client_metadata_url="https://agent.onzs.co.za/") + + async def run_flow(self, fake, prm_scopes, **options): + """Drive the real MCP client auth flow against the fake Kinde; return the authorization URL.""" + import httpx + import httpx2 + from mcp.shared.auth import AuthorizationCodeResult + + fake.prm["scopes_supported"] = prm_scopes + seen = {} + + def transport(request): + response = fake.handle( + httpx.Request(request.method, str(request.url), headers=request.headers.raw, content=request.read()) + ) + return httpx2.Response( + response.status_code, headers=response.headers.raw, content=response.content, request=request + ) + + async def redirect_handler(url): + seen["url"] = url + + async def callback_handler(): + callback = fake.authorize(seen["url"]) + query = {k: v[0] for k, v in __import__("urllib.parse").parse.parse_qs(callback.split("?", 1)[1]).items()} + return AuthorizationCodeResult(code=query["code"], state=query["state"], iss=query.get("iss")) + + provider = self.provider(redirect_handler=redirect_handler, callback_handler=callback_handler, **options) + async with httpx2.AsyncClient(transport=httpx2.MockTransport(transport), auth=provider) as client: + response = await client.get(RESOURCE_URL) + assert response.status_code == 200 + return seen["url"] + + async def test_real_flow_uses_cimd_and_offline(self, fake): + from urllib.parse import parse_qs, urlsplit + + url = await self.run_flow(fake, ["offline"]) + query = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()} + assert query["client_id"] == "https://agent.onzs.co.za/oauth/client-metadata.json" + assert query["scope"] == "offline" + assert fake.count("/register") == 0 + + async def test_real_flow_strips_openid_from_a_challenge(self, fake): + from urllib.parse import parse_qs, urlsplit + + # A resource advertising openid (as a generic OIDC-minded server might) must not break Kinde sign-in + url = await self.run_flow(fake, ["openid", "profile"]) + scope = parse_qs(urlsplit(url).query)["scope"][0] + assert scope.split() == ["offline", "profile"] + + async def test_real_flow_falls_back_to_dcr(self, fake): + fake.as_metadata["client_id_metadata_document_supported"] = False + await self.run_flow(fake, ["offline"]) + assert fake.count("/register") == 1 + + async def test_token_storage_round_trip(self, tmp_path): + from kinde_sdk.agents import FileAgentStorage + + storage = KindeTokenStorage(FileAgentStorage(str(tmp_path / "tokens.json")), key=RESOURCE_URL) + await storage.set_tokens(OAuthToken(access_token="a", refresh_token="r", expires_in=60, scope="offline")) + await storage.set_client_info( + OAuthClientInformationFull( + client_id="c", client_secret="s", redirect_uris=["http://127.0.0.1:8765/callback"] + ) + ) + assert (await storage.get_tokens()).refresh_token == "r" + assert (await storage.get_client_info()).client_secret == "s" diff --git a/testv2/testv2_agents/test_resource_server.py b/testv2/testv2_agents/test_resource_server.py new file mode 100644 index 00000000..7d50f50a --- /dev/null +++ b/testv2/testv2_agents/test_resource_server.py @@ -0,0 +1,437 @@ +import base64 +import time + +import httpx +import pytest + +from kinde_sdk.agents import ( + AgentAuthConfig, + AgentConfigError, + AuthenticationRequired, + InsufficientScopeError, + InvalidTokenError, + KindeAgentAuth, + PermissionDeniedError, + VerificationUnavailableError, + find_bearer_challenge, + has_permission, + require_permission, +) +from testv2.testv2_agents.kinde_fake import ( + APP_ID, + APP_SECRET, + DOMAIN, + ISSUER, + RESOURCE_URL, + FakeKinde, + make_token, + other_key, +) + + +def make_auth(fake: FakeKinde, **options) -> KindeAgentAuth: + config = AgentAuthConfig( + domain=options.pop("domain", "test.kinde.com"), + client_id=APP_ID, + client_secret=APP_SECRET, + resource_url=options.pop("resource_url", RESOURCE_URL), + **options, + ) + return KindeAgentAuth(config, http_client=fake.client(), async_http_client=fake.async_client()) + + +@pytest.fixture +def fake(): + return FakeKinde() + + +class TestConfig: + def test_derived_urls(self, fake): + config = make_auth(fake).config + assert config.domain == DOMAIN + assert config.issuer == ISSUER + assert config.authorization_server_metadata_url == ( + f"{DOMAIN}/.well-known/oauth-authorization-server/oauth2/agents/{APP_ID}" + ) + assert config.introspection_url == f"{ISSUER}/introspect" + assert config.revocation_url == f"{ISSUER}/revoke" + assert config.jwks_url == f"{DOMAIN}/.well-known/jwks" + assert config.protected_resource_metadata_path == "/.well-known/oauth-protected-resource/mcp" + assert config.protected_resource_metadata_url == ( + "https://api.example.com/.well-known/oauth-protected-resource/mcp" + ) + + def test_root_resource_has_root_metadata_path(self, fake): + config = make_auth(fake, resource_url="https://api.example.com/").config + assert config.protected_resource_metadata_path == "/.well-known/oauth-protected-resource" + + def test_domain_normalisation(self): + for value in ("test.kinde.com", "https://test.kinde.com/", "https://test.kinde.com"): + config = AgentAuthConfig(domain=value, client_id=APP_ID, client_secret="x", resource_url=RESOURCE_URL) + assert config.domain == DOMAIN + + @pytest.mark.parametrize( + "options", + [ + {"client_id": ""}, + {"resource_url": "ftp://api.example.com/mcp"}, + {"resource_url": "http://api.example.com/mcp"}, + {"domain": "http://test.kinde.com"}, + {"domain": "https://test.kinde.com/oauth2"}, + {"client_secret": None}, + {"verification": "magic"}, + ], + ) + def test_rejects_bad_config(self, options): + values = {"domain": DOMAIN, "client_id": APP_ID, "client_secret": "x", "resource_url": RESOURCE_URL} + values.update(options) + with pytest.raises(AgentConfigError): + AgentAuthConfig(**values) + + def test_jwt_verification_needs_no_secret(self): + config = AgentAuthConfig(domain=DOMAIN, client_id=APP_ID, resource_url=RESOURCE_URL, verification="jwt") + assert config.client_secret is None + + def test_loopback_resource_allowed_over_http(self): + config = AgentAuthConfig( + domain=DOMAIN, client_id=APP_ID, client_secret="x", resource_url="http://localhost:3001/mcp" + ) + assert ( + config.protected_resource_metadata_url == "http://localhost:3001/.well-known/oauth-protected-resource/mcp" + ) + + def test_from_env_uses_agents_variables_not_login_app(self, monkeypatch): + monkeypatch.setenv("KINDE_HOST", "https://test.kinde.com") + monkeypatch.setenv("KINDE_CLIENT_ID", "login-app") + monkeypatch.setenv("KINDE_AGENTS_CLIENT_ID", APP_ID) + monkeypatch.setenv("KINDE_AGENTS_CLIENT_SECRET", "secret") + monkeypatch.setenv("KINDE_AGENTS_RESOURCE_URL", RESOURCE_URL) + config = AgentAuthConfig.from_env() + assert config.client_id == APP_ID + assert config.issuer == ISSUER + + def test_secret_not_in_repr(self, fake): + assert APP_SECRET not in repr(make_auth(fake).config) + + +class TestMetadata: + def test_protected_resource_metadata(self, fake): + document = make_auth(fake, resource_name="Docs").protected_resource_metadata() + assert document == { + "resource": RESOURCE_URL, + "authorization_servers": [ISSUER], + "bearer_methods_supported": ["header"], + "scopes_supported": ["offline"], + "resource_name": "Docs", + } + + def test_offline_always_advertised(self, fake): + document = make_auth(fake, scopes_supported=["profile", "email"]).protected_resource_metadata() + assert document["scopes_supported"] == ["offline", "profile", "email"] + + def test_static_as_metadata_supports_cimd(self, fake): + document = make_auth(fake).authorization_server_metadata() + assert document["issuer"] == ISSUER + assert document["client_id_metadata_document_supported"] is True + assert "openid" not in document["scopes_supported"] + + def test_as_metadata_proxy_is_cached_and_forces_cimd_flag(self, fake): + fake.as_metadata = {**fake.as_metadata, "client_id_metadata_document_supported": False, "extra": 1} + auth = make_auth(fake) + first = auth.fetch_authorization_server_metadata() + second = auth.fetch_authorization_server_metadata() + assert first["client_id_metadata_document_supported"] is True + assert first["extra"] == 1 + assert second == first + assert fake.count("/oauth2/agents/" + APP_ID) == 1 + + async def test_as_metadata_proxy_falls_back_to_static(self): + def down(request): + raise httpx.ConnectError("down", request=request) + + config = AgentAuthConfig(domain=DOMAIN, client_id=APP_ID, client_secret="x", resource_url=RESOURCE_URL) + auth = KindeAgentAuth(config, async_http_client=httpx.AsyncClient(transport=httpx.MockTransport(down))) + document = await auth.fetch_authorization_server_metadata_async() + assert document["issuer"] == ISSUER + assert document["client_id_metadata_document_supported"] is True + + +class TestChallenges: + def test_missing_header_challenge_has_no_error(self, fake): + auth = make_auth(fake) + with pytest.raises(AuthenticationRequired) as caught: + auth.authenticate(None) + response = auth.error_response(caught.value) + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == ( + 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + ) + assert response.headers["Access-Control-Expose-Headers"] == "WWW-Authenticate" + + @pytest.mark.parametrize( + "header", ["Basic dXNlcjpwYXNz", "Bearer", "Bearer two-spaces", "Bearer a b", "Token abc"] + ) + def test_malformed_header_is_invalid_token(self, fake, header): + auth = make_auth(fake) + with pytest.raises(InvalidTokenError) as caught: + auth.authenticate(header) + response = auth.error_response(caught.value) + assert response.status_code == 401 + challenge = find_bearer_challenge(response.headers["WWW-Authenticate"]) + assert challenge.get("error") == "invalid_token" + assert challenge.get("resource_metadata").endswith("/oauth-protected-resource/mcp") + assert fake.requests == [] + + def test_scheme_is_case_insensitive(self, fake): + token = fake.issue() + assert make_auth(fake).authenticate(f"bearer {token}").user_id == "kp_user" + + def test_insufficient_scope_challenge(self, fake): + response = make_auth(fake).error_response(InsufficientScopeError(["email"])) + assert response.status_code == 403 + challenge = find_bearer_challenge(response.headers["WWW-Authenticate"]) + assert challenge.get("error") == "insufficient_scope" + assert challenge.get("scope") == "email" + + def test_missing_permission_is_plain_403_without_challenge(self, fake): + response = make_auth(fake).error_response(PermissionDeniedError(["read:docs"])) + assert response.status_code == 403 + assert "WWW-Authenticate" not in response.headers + assert response.body["error"] == "forbidden" + + def test_outage_is_503_without_challenge(self, fake): + response = make_auth(fake).error_response(VerificationUnavailableError()) + assert response.status_code == 503 + assert "WWW-Authenticate" not in response.headers + + +class TestIntrospection: + def test_active_token(self, fake): + token = fake.issue( + scope="offline email", + permissions=["read:docs"], + roles=[{"id": "r1", "key": "admin", "name": "Admin"}], + org_code="org_123", + email="user@example.com", + ) + context = make_auth(fake).verify_token(token) + assert context.user_id == "kp_user" + assert context.client_id == "https://agent.example.com/cimd.json" + assert context.scopes == ["offline", "email"] + assert context.permissions == ["read:docs"] + assert context.roles == ["admin"] + assert context.org_code == "org_123" + assert context.email == "user@example.com" + assert context.expires_at + assert has_permission(context, "read:docs") + assert not has_permission(context, "write:docs") + assert token not in repr(context) + + def test_sends_application_basic_credentials(self, fake): + token = fake.issue() + make_auth(fake).verify_token(token) + request = fake.requests[-1] + assert str(request.url) == f"{ISSUER}/introspect" + expected = base64.b64encode(f"{APP_ID}:{APP_SECRET}".encode()).decode() + assert request.headers["authorization"] == f"Basic {expected}" + assert request.content == f"token={token}".encode() + + async def test_async_active_token(self, fake): + token = fake.issue() + context = await make_auth(fake).verify_token_async(token) + assert context.user_id == "kp_user" + + def test_inactive_token_as_kinde_answers_it(self, fake): + # Kinde answers 401 token_inactive: a bad token, not bad application credentials + auth = make_auth(fake) + with pytest.raises(InvalidTokenError): + auth.verify_token(make_token()) + assert auth.error_response(InvalidTokenError()).status_code == 401 + + def test_wrong_application_credentials_are_misconfiguration(self, fake, caplog): + config = AgentAuthConfig(domain=DOMAIN, client_id=APP_ID, client_secret="wrong", resource_url=RESOURCE_URL) + auth = KindeAgentAuth(config, http_client=fake.client()) + with pytest.raises(VerificationUnavailableError): + auth.verify_token(fake.issue()) + assert "request_unauthorized" in caplog.text + + def test_inactive_token_as_rfc_7662_answers_it(self, fake): + fake.inactive_as_200 = True + with pytest.raises(InvalidTokenError): + make_auth(fake).verify_token(make_token()) + + def test_inactive_token(self, fake): + with pytest.raises(InvalidTokenError): + make_auth(fake).verify_token(make_token()) + + def test_wrong_audience_from_introspection(self, fake): + token = fake.issue() + fake.active_tokens[token]["aud"] = ["https://other.example.com/mcp"] + with pytest.raises(InvalidTokenError): + make_auth(fake).verify_token(token) + + def test_expired_by_introspection(self, fake): + token = fake.issue() + fake.active_tokens[token]["exp"] = int(time.time()) - 120 + with pytest.raises(InvalidTokenError): + make_auth(fake).verify_token(token) + + @pytest.mark.parametrize("status", [500, 502, 429]) + def test_kinde_errors_are_unavailable_not_invalid(self, fake, status): + fake.introspection_status = status + with pytest.raises(VerificationUnavailableError): + make_auth(fake).verify_token(fake.issue()) + + @pytest.mark.parametrize("status", [401, 404]) + def test_misconfiguration_is_logged_and_unavailable(self, fake, status, caplog): + fake.introspection_status = status + with pytest.raises(VerificationUnavailableError): + make_auth(fake).verify_token(fake.issue()) + assert any(record.levelname == "ERROR" for record in caplog.records) + + async def test_network_failure_is_unavailable(self, fake): + fake.introspection_raises = True + with pytest.raises(VerificationUnavailableError): + await make_auth(fake).verify_token_async(fake.issue()) + + def test_positive_results_cached_by_hash(self, fake): + token = fake.issue() + auth = make_auth(fake, introspection_cache_seconds=30) + auth.verify_token(token) + auth.verify_token(token) + assert fake.count("/introspect") == 1 + assert token not in auth._introspection_cache + + def test_negative_results_not_cached(self, fake): + auth = make_auth(fake, introspection_cache_seconds=30) + token = make_token() + for _ in range(2): + with pytest.raises(InvalidTokenError): + auth.verify_token(token) + assert fake.count("/introspect") == 2 + + def test_cache_off_by_default(self, fake): + token = fake.issue() + auth = make_auth(fake) + auth.verify_token(token) + auth.verify_token(token) + assert fake.count("/introspect") == 2 + + def test_revoke_uses_application_credentials_and_clears_cache(self, fake): + token = fake.issue() + auth = make_auth(fake, introspection_cache_seconds=30) + auth.verify_token(token) + auth.revoke_token(token, "access_token") + request = fake.requests[-1] + assert str(request.url) == f"{ISSUER}/revoke" + assert request.headers["authorization"].startswith("Basic ") + with pytest.raises(InvalidTokenError): + auth.verify_token(token) + + +class TestJwtVerification: + def jwt_auth(self, fake, strategy="jwt"): + return make_auth(fake, verification=strategy) + + def test_valid_token(self, fake): + context = self.jwt_auth(fake).verify_token(make_token(permissions=["read:docs"])) + assert context.user_id == "kp_user" + assert context.permissions == ["read:docs"] + assert fake.count("/introspect") == 0 + + def test_scoped_issuer_is_rejected(self, fake): + # The most common bug: iss is the domain, not the scoped issuer + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(make_token(iss=ISSUER)) + + def test_domain_with_trailing_slash_accepted(self, fake): + assert self.jwt_auth(fake).verify_token(make_token(iss=DOMAIN + "/")).user_id == "kp_user" + + def test_other_audience_rejected(self, fake): + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(make_token(aud=["https://other.example.com/mcp"])) + + def test_audience_array_with_extra_entries(self, fake): + token = make_token(aud=["https://linked-api.example.com", RESOURCE_URL]) + assert self.jwt_auth(fake).verify_token(token).audience == ["https://linked-api.example.com", RESOURCE_URL] + + def test_audience_compared_exactly(self, fake): + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(make_token(aud=[RESOURCE_URL + "/"])) + + def test_expired_beyond_leeway(self, fake): + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(make_token(exp_in=-120)) + + def test_expired_within_leeway(self, fake): + assert self.jwt_auth(fake).verify_token(make_token(exp_in=-5)).user_id == "kp_user" + + def test_not_yet_valid(self, fake): + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(make_token(nbf=int(time.time()) + 600)) + + @pytest.mark.parametrize("algorithm", ["HS256", "none"]) + def test_non_rs256_rejected(self, fake, algorithm): + if algorithm == "none": + import jwt as pyjwt + + token = pyjwt.encode( + {"iss": DOMAIN, "aud": [RESOURCE_URL], "exp": int(time.time()) + 60}, + None, + algorithm="none", + headers={"kid": "test-key"}, + ) + else: + token = make_token(algorithm=algorithm) + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(token) + assert fake.count("/jwks") == 0 + + def test_wrong_signature(self, fake): + with pytest.raises(InvalidTokenError): + self.jwt_auth(fake).verify_token(make_token(key=other_key())) + + def test_unknown_kid_refetches_once_then_rate_limits(self, fake): + auth = self.jwt_auth(fake) + auth.verify_token(make_token()) + assert fake.count("/jwks") == 1 + for _ in range(5): + with pytest.raises(InvalidTokenError): + auth.verify_token(make_token(kid="unknown", key=other_key())) + assert fake.count("/jwks") == 2 + + async def test_jwks_unavailable(self, fake): + fake.jwks_status = 500 + with pytest.raises(VerificationUnavailableError): + await self.jwt_auth(fake).verify_token_async(make_token()) + + def test_stale_jwks_refresh_failure_keeps_cached_keys(self, fake, monkeypatch): + from kinde_sdk.agents import resource_server + + auth = self.jwt_auth(fake) + auth.verify_token(make_token()) + fake.jwks_status = 500 + monkeypatch.setattr(resource_server, "_JWKS_CACHE_SECONDS", 0) + monkeypatch.setattr(resource_server, "_JWKS_RETRY_SECONDS", 0) + assert auth.verify_token(make_token()).user_id == "kp_user" + assert fake.count("/jwks") == 2 + + def test_jwt_plus_introspection_requires_both(self, fake): + auth = self.jwt_auth(fake, "jwt+introspection") + with pytest.raises(InvalidTokenError): + auth.verify_token(make_token()) # signed correctly, but Kinde says inactive + token = fake.issue() + assert auth.verify_token(token).user_id == "kp_user" + assert fake.count("/introspect") == 2 + + +class TestPermissions: + def test_require_permission(self, fake): + context = make_auth(fake).verify_token(fake.issue(permissions=["read:docs"])) + require_permission(context, "read:docs") + with pytest.raises(PermissionDeniedError) as caught: + require_permission(context, "read:docs", "write:docs") + assert caught.value.permissions == ["write:docs"] + + def test_has_permission_without_context(self): + assert has_permission(None, "read:docs") is False diff --git a/testv2/testv2_agents/test_www_authenticate_and_cimd.py b/testv2/testv2_agents/test_www_authenticate_and_cimd.py new file mode 100644 index 00000000..a000c621 --- /dev/null +++ b/testv2/testv2_agents/test_www_authenticate_and_cimd.py @@ -0,0 +1,129 @@ +import json + +import pytest + +from kinde_sdk.agents import ( + AgentConfigError, + build_challenge, + build_client_metadata_document, + client_metadata_response, + find_bearer_challenge, + parse_www_authenticate, +) + +CIMD_URL = "https://agent.onzs.co.za/oauth/client-metadata.json" + + +class TestWwwAuthenticate: + def test_build_quotes_and_escapes(self): + header = build_challenge("Bearer", [("error", "invalid_token"), ("error_description", 'say "hi" \\ bye')]) + assert header == 'Bearer error="invalid_token", error_description="say \\"hi\\" \\\\ bye"' + assert find_bearer_challenge(header).get("error_description") == 'say "hi" \\ bye' + + def test_build_without_params(self): + assert build_challenge("Bearer") == "Bearer" + + def test_several_challenges_with_commas_inside_quotes(self): + challenges = parse_www_authenticate( + 'Basic realm="a, b", Bearer error="invalid_token", error_description="bad, very bad", ' + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + ) + assert [c.scheme for c in challenges] == ["Basic", "Bearer"] + assert challenges[0].get("realm") == "a, b" + bearer = challenges[1] + assert bearer.get("error_description") == "bad, very bad" + assert bearer.get("resource_metadata").endswith("/oauth-protected-resource/mcp") + + def test_case_insensitive_scheme_and_names(self): + challenge = find_bearer_challenge('BEARER Resource_Metadata="https://x/y"') + assert challenge.get("resource_metadata") == "https://x/y" + + def test_several_header_values(self): + challenge = find_bearer_challenge(["Negotiate abc==", 'Bearer scope="offline email"']) + assert challenge.get("scope") == "offline email" + + def test_token68(self): + challenges = parse_www_authenticate("Negotiate a87421000492aa874209af8bc028==, Bearer") + assert challenges[0].token68 == "a87421000492aa874209af8bc028==" + assert challenges[1].scheme == "Bearer" + + def test_unquoted_values(self): + assert find_bearer_challenge("Bearer error=invalid_token").get("error") == "invalid_token" + + @pytest.mark.parametrize("header", ["", ",,,", "=x", 'Bearer error="unterminated', "\x00\x01"]) + def test_malformed_input_never_raises(self, header): + parse_www_authenticate(header) + + def test_none(self): + assert parse_www_authenticate(None) == [] + assert find_bearer_challenge(None) is None + + +class TestClientMetadataDocument: + def test_builds_document(self): + document = build_client_metadata_document( + CIMD_URL, + "Onzs Agent", + ["https://agent.onzs.co.za/oauth/callback", "http://127.0.0.1/callback", "http://localhost:8765/cb"], + client_uri="https://agent.onzs.co.za", + ) + assert document == { + "client_id": CIMD_URL, + "client_name": "Onzs Agent", + "redirect_uris": [ + "https://agent.onzs.co.za/oauth/callback", + "http://127.0.0.1/callback", + "http://localhost:8765/cb", + ], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "client_uri": "https://agent.onzs.co.za", + } + + @pytest.mark.parametrize( + "client_id", + [ + "https://agent.onzs.co.za", + "https://agent.onzs.co.za/", + "http://agent.onzs.co.za/cimd.json", + "https://user@agent.onzs.co.za/cimd.json", + "https://agent.onzs.co.za/cimd.json?v=1", + "https://agent.onzs.co.za/cimd.json#x", + "https://localhost/cimd.json", + ], + ) + def test_rejects_bad_client_id(self, client_id): + with pytest.raises(AgentConfigError): + build_client_metadata_document(client_id, "Agent", ["http://127.0.0.1/callback"]) + + def test_requires_name_and_redirects(self): + with pytest.raises(AgentConfigError): + build_client_metadata_document(CIMD_URL, " ", ["http://127.0.0.1/callback"]) + with pytest.raises(AgentConfigError): + build_client_metadata_document(CIMD_URL, "Agent", []) + + @pytest.mark.parametrize( + "redirect_uri", + ["https://other.example.com/callback", "myapp://callback", "http://agent.onzs.co.za/callback"], + ) + def test_redirects_kinde_would_drop(self, redirect_uri): + with pytest.raises(AgentConfigError): + build_client_metadata_document(CIMD_URL, "Agent", [redirect_uri]) + with pytest.warns(UserWarning): + build_client_metadata_document(CIMD_URL, "Agent", [redirect_uri], strict=False) + + @pytest.mark.parametrize("redirect_uri", ["https://agent.onzs.co.za/cb#frag", "https://u:p@agent.onzs.co.za/cb"]) + def test_fragment_and_userinfo_always_fatal(self, redirect_uri): + with pytest.raises(AgentConfigError): + build_client_metadata_document(CIMD_URL, "Agent", [redirect_uri], strict=False) + + def test_response_headers(self): + document = build_client_metadata_document(CIMD_URL, "Agent", ["http://127.0.0.1/callback"]) + body, headers = client_metadata_response(document) + assert json.loads(body) == document + assert headers == {"Content-Type": "application/json", "Cache-Control": "public, max-age=3600"} + + def test_size_limit(self): + with pytest.raises(AgentConfigError): + build_client_metadata_document(CIMD_URL, "Agent", ["http://127.0.0.1/callback"], padding="x" * 70000) From 6caf54806a67b581d433807994c27fbcd6c4730d Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Wed, 28 Oct 2026 07:27:25 +0200 Subject: [PATCH 2/8] fix(agents): address review feedback Agent client: - Refuse the code exchange if the authorization server changed after sign-in started. - Reload tokens after discovery in refresh(), so tokens dropped on an issuer change are never sent to the new issuer. - Loopback listener ignores callbacks with the wrong state. - Ignore a loopback resource_metadata URL from a remote resource. - Raise RegistrationError for a non-JSON registration response. - disconnect(forget=True) drops the registration without stored tokens. Resource server: - Treat a JWKS response without a keys list as unavailable (503). - Answer 503, not 401, for an unknown kid while JWKS refresh is failing. - Clear the introspection cache on revocation. Also: reject userinfo in the Kinde domain and invalid loopback ports in redirect URIs, decode percent-escapes in the CIMD route path, lock FileAgentStorage across processes, harden smoke_check.py, and install the mcp extra in CI so the MCP adapter tests run. --- .github/workflows/ci.yml | 2 + examples/agents/smoke_check.py | 33 ++++++-- kinde_fastapi/agents.py | 8 +- kinde_flask/agents.py | 6 +- kinde_sdk/agents/client.py | 57 ++++++++++++-- kinde_sdk/agents/client_metadata.py | 4 + kinde_sdk/agents/config.py | 3 + kinde_sdk/agents/resource_server.py | 14 +++- kinde_sdk/agents/storage.py | 49 ++++++++++-- testv2/testv2_agents/kinde_fake.py | 3 +- testv2/testv2_agents/test_agent_client.py | 77 +++++++++++++++++++ .../test_framework_integrations.py | 26 +++++++ testv2/testv2_agents/test_resource_server.py | 30 ++++++++ .../test_www_authenticate_and_cimd.py | 12 ++- 14 files changed, 290 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5388da7c..e972a1cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt + # The MCP adapter tests skip without the mcp extra, which needs Python 3.10+ + pip install "mcp >=2.3.0, <3.0.0; python_version >= '3.10'" - name: Run tests run: pytest --cov=kinde_sdk --cov-branch --cov-report=xml diff --git a/examples/agents/smoke_check.py b/examples/agents/smoke_check.py index 559847c8..ba337336 100644 --- a/examples/agents/smoke_check.py +++ b/examples/agents/smoke_check.py @@ -40,9 +40,10 @@ def get_json(client: httpx.Client, url: str): report("FAIL", f"GET {url}: {type(exc).__name__}") return None, None try: - return response, response.json() + body = response.json() except ValueError: return response, None + return response, body if isinstance(body, dict) else None def check_kinde_discovery(client: httpx.Client, config: AgentAuthConfig) -> None: @@ -54,6 +55,8 @@ def check_kinde_discovery(client: httpx.Client, config: AgentAuthConfig) -> None response.status_code == 200, f"HTTP {response.status_code} (404 means the flag is off or the app ID is wrong)" ): return + if not check(document is not None, "the body is a JSON object"): + return check(document.get("issuer") == config.issuer, f"issuer is the scoped issuer {config.issuer}") check( document.get("client_id_metadata_document_supported") is True, "client_id_metadata_document_supported is true" @@ -71,6 +74,8 @@ def check_protected_resource(client: httpx.Client, config: AgentAuthConfig) -> N return if not check(response.status_code == 200, f"HTTP {response.status_code}"): return + if not check(document is not None, "the body is a JSON object"): + return check(document.get("resource") == config.resource_url, "resource matches KINDE_AGENTS_RESOURCE_URL exactly") check(document.get("authorization_servers") == [config.issuer], "authorization_servers is [scoped issuer]") check("offline" in (document.get("scopes_supported") or []), "scopes_supported includes offline") @@ -124,7 +129,9 @@ def check_cimd(client: httpx.Client, url: str) -> None: try: document = response.json() except ValueError: - report("FAIL", "body is not JSON") + document = None + if not isinstance(document, dict): + report("FAIL", "body is not a JSON object") return check(document.get("client_id") == url, "client_id equals the URL it's served at") check(bool(document.get("client_name")), "client_name is set") @@ -148,11 +155,15 @@ def check_token(client: httpx.Client, config: AgentAuthConfig, token: str) -> No check("openid" not in scopes, "no openid scope") check("offline" in scopes, "token scopes include offline (kinde_auth_settings requires it)") - response = client.post( - config.introspection_url, - data={"token": token}, - auth=(config.client_id, config.client_secret or ""), - ) + try: + response = client.post( + config.introspection_url, + data={"token": token}, + auth=(config.client_id, config.client_secret or ""), + ) + except httpx.HTTPError as exc: + report("FAIL", f"introspection request failed: {type(exc).__name__}") + return if response.status_code != 200: # Kinde answers 401 token_inactive for a bad token, and 401 request_unauthorized for bad app credentials try: @@ -161,7 +172,13 @@ def check_token(client: httpx.Client, config: AgentAuthConfig, token: str) -> No error = None report("FAIL", f"introspection HTTP {response.status_code} {error or ''}".rstrip()) return - data = response.json() + try: + data = response.json() + except ValueError: + data = None + if not isinstance(data, dict): + report("FAIL", "introspection body is not a JSON object") + return if not check(data.get("active") is True, "introspection says active"): return introspected_aud = data.get("aud") if isinstance(data.get("aud"), list) else [data.get("aud")] diff --git a/kinde_fastapi/agents.py b/kinde_fastapi/agents.py index 9fe25df8..e36d5805 100644 --- a/kinde_fastapi/agents.py +++ b/kinde_fastapi/agents.py @@ -16,7 +16,7 @@ async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read: """ from typing import Any, Callable, Dict, Optional -from urllib.parse import urlsplit +from urllib.parse import unquote, urlsplit from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response @@ -158,7 +158,7 @@ def client_metadata_router(document: Dict[str, Any], path: Optional[str] = None) async def client_metadata() -> Response: return Response(content=body, headers=headers, media_type="application/json") - router.add_api_route( - path or urlsplit(document["client_id"]).path, client_metadata, methods=["GET"], include_in_schema=False - ) + # Routes match the decoded request path, so decode any %-escapes in the client_id + route = path or unquote(urlsplit(document["client_id"]).path) + router.add_api_route(route, client_metadata, methods=["GET"], include_in_schema=False) return router diff --git a/kinde_flask/agents.py b/kinde_flask/agents.py index b704ff7b..a6f35215 100644 --- a/kinde_flask/agents.py +++ b/kinde_flask/agents.py @@ -16,7 +16,7 @@ def docs(): from functools import wraps from typing import Any, Callable, Dict, Optional -from urllib.parse import urlsplit +from urllib.parse import unquote, urlsplit from flask import Blueprint, Flask, Response, current_app, g, jsonify, request @@ -172,5 +172,7 @@ def client_metadata_blueprint(document: Dict[str, Any], path: Optional[str] = No def client_metadata(): return Response(body, headers=headers) - blueprint.add_url_rule(path or urlsplit(document["client_id"]).path, view_func=client_metadata, methods=["GET"]) + # Routes match the decoded request path, so decode any %-escapes in the client_id + route = path or unquote(urlsplit(document["client_id"]).path) + blueprint.add_url_rule(route, view_func=client_metadata, methods=["GET"]) return blueprint diff --git a/kinde_sdk/agents/client.py b/kinde_sdk/agents/client.py index a701d8f1..3b3deb22 100644 --- a/kinde_sdk/agents/client.py +++ b/kinde_sdk/agents/client.py @@ -163,6 +163,8 @@ def __init__(self, redirect_uri: str): self._server: Optional[asyncio.AbstractServer] = None self._result: Optional[asyncio.Future] = None self.redirect_uri = redirect_uri + # Set once the authorization request exists; callbacks with any other state are ignored + self.expected_state: Optional[str] = None async def start(self) -> None: self._result = asyncio.get_running_loop().create_future() @@ -178,10 +180,18 @@ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrit pass method, _, rest = request_line.partition(" ") target = rest.rsplit(" ", 1)[0] - if method != "GET" or urlsplit(target).path != self._path or self._result.done(): + query = parse_qs(urlsplit(target).query) + state = (query.get("state") or [""])[0] + if ( + method != "GET" + or urlsplit(target).path != self._path + or self._result.done() + or self.expected_state is None + or not secrets.compare_digest(state.encode(), self.expected_state.encode()) + ): + # Another local process can't end the sign-in by calling the callback first writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") return - query = parse_qs(urlsplit(target).query) failed = "error" in query message = "Sign-in failed. You can close this tab." if failed else "Signed in. You can close this tab." body = f"Kinde

{html.escape(message)}

".encode() @@ -354,8 +364,9 @@ async def _fetch_protected_resource_metadata(self, challenge_response: Optional[ candidates: List[str] = [] if challenge_response is not None: challenge = find_bearer_challenge(challenge_response.headers.get_list("www-authenticate")) - if challenge and challenge.get("resource_metadata"): - candidates.append(challenge.get("resource_metadata")) + metadata_url = challenge.get("resource_metadata") if challenge else None + if metadata_url and self._may_fetch(metadata_url): + candidates.append(metadata_url) for url in protected_resource_metadata_urls(self.resource_url): if url not in candidates: candidates.append(url) @@ -371,10 +382,20 @@ async def _fetch_protected_resource_metadata(self, challenge_response: Optional[ except httpx.HTTPError as exc: raise DiscoveryError(f"Could not reach {self.resource_url}: {type(exc).__name__}") from exc challenge = find_bearer_challenge(probe.headers.get_list("www-authenticate")) - if probe.status_code == 401 and challenge and challenge.get("resource_metadata") not in (None, *candidates): + metadata_url = challenge.get("resource_metadata") if challenge else None + if probe.status_code == 401 and metadata_url not in (None, *candidates) and self._may_fetch(metadata_url): return await self._fetch_protected_resource_metadata(probe) raise DiscoveryError(f"No protected resource metadata found for {self.resource_url}") + def _may_fetch(self, metadata_url: str) -> bool: + # A remote resource must not point the agent at a service on this machine + if is_loopback_host(urlsplit(metadata_url).hostname) and not is_loopback_host( + urlsplit(self.resource_url).hostname + ): + logger.warning("Ignoring a loopback resource_metadata URL from a remote resource: %s", metadata_url) + return False + return True + async def _fetch_authorization_server_metadata(self, issuer: str) -> Dict[str, Any]: for url in authorization_server_metadata_urls(issuer): metadata = await self._get_json(url) @@ -467,7 +488,10 @@ async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: error=error, error_description=description, ) - body = response.json() + try: + body = response.json() + except ValueError as exc: + raise RegistrationError("The registration response was not JSON") from exc if not isinstance(body, dict) or not body.get("client_id"): raise RegistrationError("The registration response has no client_id") secret = body.get("client_secret") @@ -578,6 +602,9 @@ async def complete_authorization(self, pending: PendingAuthorization, callback_u raise AuthorizationError("The callback has no authorization code") metadata = await self.discover() + if metadata.get("issuer") != pending.issuer: + # Never send the code and verifier to an authorization server other than the one that issued the code + raise AuthorizationError("The authorization server changed since sign-in started; start again") # The code is bound to the client that started this attempt, so never re-resolve or re-register here identity = await self._identity_for(pending.client_id, pending.registration_key) if identity is None: @@ -621,6 +648,7 @@ async def authorize( await listener.start() try: pending = await self.begin_authorization(redirect_uri=listener.redirect_uri, scopes=scopes) + listener.expected_state = pending.state result = self.open_browser(pending.authorization_url) if asyncio.iscoroutine(result): await result @@ -688,6 +716,10 @@ async def refresh(self, *, stale_access_token: Optional[str] = None) -> Optional if not tokens.get("refresh_token"): return None metadata = await self.discover() + # discover() drops the tokens when the issuer changed: never send them to the new one + tokens = await self.storage.get_tokens(self._token_key) + if not tokens or not tokens.get("refresh_token") or tokens.get("issuer") != metadata.get("issuer"): + return None identity = await self._identity_for_tokens(tokens) if identity is None: await self.storage.delete_tokens(self._token_key) @@ -816,8 +848,19 @@ async def send(access_token: str) -> httpx.Response: return response async def disconnect(self, *, forget: bool = False) -> None: - """Revoke the tokens (best effort) and clear them. ``forget`` also drops the DCR registration.""" + """Revoke the tokens (best effort) and clear them. + + ``forget`` also drops the DCR registration for this redirect URI, even when + no tokens are stored. + """ tokens = await self.storage.get_tokens(self._token_key) + if forget and not tokens: + try: + metadata = await self.discover() + except AgentClientError: + return + await self.storage.delete_registration(f"{metadata['issuer']}|{self.redirect_uri}") + return if tokens: identity = await self._identity_for_tokens(tokens) try: diff --git a/kinde_sdk/agents/client_metadata.py b/kinde_sdk/agents/client_metadata.py index 048b00a8..af7318a0 100644 --- a/kinde_sdk/agents/client_metadata.py +++ b/kinde_sdk/agents/client_metadata.py @@ -39,6 +39,10 @@ def _redirect_uri_problem(redirect_uri: str, document_host: str) -> Optional[Tup return True, f"redirect URI {redirect_uri!r} must not have a fragment" if parts.username or parts.password: return True, f"redirect URI {redirect_uri!r} must not contain userinfo" + try: + parts.port + except ValueError: + return True, f"redirect URI {redirect_uri!r} has an invalid port" if parts.scheme == "http" and is_loopback_host(parts.hostname): return None # any port is honoured (RFC 8252 §7.3) if parts.scheme == "https" and parts.hostname: diff --git a/kinde_sdk/agents/config.py b/kinde_sdk/agents/config.py index 5b707da4..8d00413a 100644 --- a/kinde_sdk/agents/config.py +++ b/kinde_sdk/agents/config.py @@ -45,6 +45,9 @@ def normalize_domain(domain: str) -> str: parts = urlsplit(value) if not parts.hostname: raise AgentConfigError(f"Invalid Kinde domain: {domain!r}") + if parts.username or parts.password: + # https://yourbiz.kinde.com@other.example would send tokens to other.example + raise AgentConfigError(f"The Kinde domain must not contain userinfo: {domain!r}") if parts.scheme != "https" and not (parts.scheme == "http" and is_loopback_host(parts.hostname)): raise AgentConfigError(f"The Kinde domain must use https: {domain!r}") if parts.path not in ("", "/") or parts.query or parts.fragment: diff --git a/kinde_sdk/agents/resource_server.py b/kinde_sdk/agents/resource_server.py index 1db6747d..1ba27aa4 100644 --- a/kinde_sdk/agents/resource_server.py +++ b/kinde_sdk/agents/resource_server.py @@ -71,6 +71,7 @@ def __init__(self, url: str): self._fetched_at: Optional[float] = None self._last_attempt: Optional[float] = None self._last_unknown_kid_fetch: Optional[float] = None + self._refresh_failed = False self._lock = threading.Lock() def _should_fetch(self, kid: str, now: float) -> bool: @@ -92,8 +93,11 @@ def _store(self, response: httpx.Response, now: float) -> None: document = response.json() except ValueError as exc: raise VerificationUnavailableError("Kinde JWKS response was not JSON") from exc + jwks = document.get("keys") if isinstance(document, dict) else None + if not isinstance(jwks, list): + raise VerificationUnavailableError("Kinde JWKS response has no keys list") keys = {} - for jwk in document.get("keys", []) if isinstance(document, dict) else []: + for jwk in jwks: kid = jwk.get("kid") if isinstance(jwk, dict) else None if not kid: continue @@ -103,9 +107,11 @@ def _store(self, response: httpx.Response, now: float) -> None: logger.debug("Skipping unusable JWKS key %s", kid) self._keys = keys self._fetched_at = now + self._refresh_failed = False def _fetch_failed(self, exc: Exception) -> None: # Keep verifying with the keys we have; only fail when there are none + self._refresh_failed = True if self._fetched_at is None: if isinstance(exc, VerificationUnavailableError): raise exc @@ -115,7 +121,8 @@ def _fetch_failed(self, exc: Exception) -> None: def _lookup(self, kid: str) -> Any: key = self._keys.get(kid) if key is None: - if self._fetched_at is None: + if self._fetched_at is None or self._refresh_failed: + # Kinde is unreachable, so the key may be a new one we couldn't fetch: an outage, not a bad token raise VerificationUnavailableError("Kinde signing keys are unavailable") raise InvalidTokenError("The access token was signed by an unknown key") return key @@ -591,7 +598,8 @@ async def revoke_token_async(self, token: str, token_type_hint: Optional[str] = def _check_revocation(self, response: httpx.Response, token: str) -> None: if response.status_code != 200: raise AgentAuthError(f"Kinde token revocation failed with HTTP {response.status_code}") - self._introspection_cache.pop(self._cache_key(token), None) + # Revoking either token of a pair revokes both, and the paired token's cache key can't be derived + self._introspection_cache.clear() def _oauth_error_code(response: httpx.Response) -> Optional[str]: diff --git a/kinde_sdk/agents/storage.py b/kinde_sdk/agents/storage.py index d06557f4..123a73a6 100644 --- a/kinde_sdk/agents/storage.py +++ b/kinde_sdk/agents/storage.py @@ -7,10 +7,17 @@ """ import asyncio +import contextlib import json import os import tempfile -from typing import Any, Dict, Optional, Protocol +from typing import Any, Dict, Iterator, Optional, Protocol + +try: # POSIX + import fcntl +except ImportError: # pragma: no cover - Windows + fcntl = None # type: ignore[assignment] + import msvcrt class AgentClientStorage(Protocol): @@ -61,6 +68,10 @@ class FileAgentStorage: Suitable for CLI agents on a single-user machine. The file holds refresh tokens and client secrets in plain text, so prefer the OS keychain or an encrypted store where one is available. + + Several processes may share one file: each update holds an OS lock on a + ``.lock`` file next to it, so one process never overwrites another's + rotated refresh token or registration. """ def __init__(self, path: str): @@ -94,6 +105,26 @@ def _write(self, data: Dict[str, Dict[str, Any]]) -> None: pass raise + @contextlib.contextmanager + def _file_lock(self) -> Iterator[None]: + # Serializes read-modify-write across processes; the asyncio lock covers this process + directory = os.path.dirname(os.path.abspath(self.path)) + os.makedirs(directory, mode=0o700, exist_ok=True) + fd = os.open(self.path + ".lock", os.O_RDWR | os.O_CREAT, 0o600) + try: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_EX) + else: # pragma: no cover - Windows + msvcrt.locking(fd, msvcrt.LK_LOCK, 1) + yield + finally: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_UN) + else: # pragma: no cover - Windows + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + os.close(fd) + def _guard(self) -> asyncio.Lock: # Created lazily so it binds to the running event loop (Python 3.9) if self._lock is None: @@ -106,12 +137,16 @@ async def _get(self, section: str, key: str) -> Optional[Dict[str, Any]]: async def _set(self, section: str, key: str, value: Optional[Dict[str, Any]]) -> None: async with self._guard(): - data = self._read() - if value is None: - data[section].pop(key, None) - else: - data[section][key] = value - self._write(data) + with self._file_lock(): + self._update(section, key, value) + + def _update(self, section: str, key: str, value: Optional[Dict[str, Any]]) -> None: + data = self._read() + if value is None: + data[section].pop(key, None) + else: + data[section][key] = value + self._write(data) async def get_tokens(self, key: str) -> Optional[Dict[str, Any]]: return await self._get("tokens", key) diff --git a/testv2/testv2_agents/kinde_fake.py b/testv2/testv2_agents/kinde_fake.py index 6763b8d3..0c60c2e9 100644 --- a/testv2/testv2_agents/kinde_fake.py +++ b/testv2/testv2_agents/kinde_fake.py @@ -104,6 +104,7 @@ def __init__(self): self.introspection_raises = False self.inactive_as_200 = False # the RFC 7662 answer the contract describes self.jwks_status = 200 + self.jwks_body: Optional[Any] = None # overrides the real key set when set self.register_status: Optional[int] = None self.register_error: Optional[Dict[str, str]] = None self.prm: Optional[Dict[str, Any]] = { @@ -156,7 +157,7 @@ def handle(self, request: httpx.Request) -> httpx.Response: if url == f"{DOMAIN}/.well-known/oauth-authorization-server/oauth2/agents/{APP_ID}": return httpx.Response(200, json=self.as_metadata) if url == f"{DOMAIN}/.well-known/jwks": - return httpx.Response(self.jwks_status, json=jwks()) + return httpx.Response(self.jwks_status, json=jwks() if self.jwks_body is None else self.jwks_body) if url == f"{ISSUER}/introspect": return self._introspect(request) if url == f"{ISSUER}/register": diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py index 26ef151e..4c6adcff 100644 --- a/testv2/testv2_agents/test_agent_client.py +++ b/testv2/testv2_agents/test_agent_client.py @@ -1,6 +1,7 @@ import asyncio import os import stat +import sys import time from urllib.parse import parse_qs, urlsplit @@ -185,6 +186,11 @@ async def test_invalid_redirect_uri_surfaces_description(self, fake): with pytest.raises(RegistrationError, match="agent.example.org is not a trusted agent host"): await make_client(fake).connect() + async def test_non_json_registration_response(self, fake): + fake._register = lambda request: httpx.Response(201, content=b"ok") + with pytest.raises(RegistrationError, match="not JSON"): + await make_client(fake).connect() + async def test_too_many_registrations(self, fake): fake.register_status = 429 with pytest.raises(RegistrationError, match="try again later"): @@ -281,6 +287,25 @@ async def test_disconnect_revokes_and_clears(self, fake): assert await client.get_token_info() is None assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + async def test_disconnect_forget_without_tokens_drops_registration(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage) + await client.connect() + await storage.delete_tokens(f"{RESOURCE_URL}|default") # e.g. after invalid_grant + await client.disconnect(forget=True) + assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + + async def test_refresh_never_sends_tokens_to_a_new_issuer(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) + await client.connect() + key = f"{RESOURCE_URL}|default" + tokens = await storage.get_tokens(key) + await storage.set_tokens(key, {**tokens, "issuer": "https://old.example.com"}) + client._metadata_at = 0 # metadata cache expired, so refresh() discovers again + assert await client.refresh() is None + assert not [r for r in fake.requests if b"grant_type=refresh_token" in r.content] + async def test_tokens_dropped_when_issuer_changes(self, fake): storage = InMemoryAgentStorage() client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) @@ -292,6 +317,14 @@ async def test_tokens_dropped_when_issuer_changes(self, fake): class TestDiscovery: + async def test_remote_resource_cannot_point_metadata_at_loopback(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + challenge = httpx.Response( + 401, headers={"WWW-Authenticate": 'Bearer resource_metadata="http://127.0.0.1:9/admin"'} + ) + await client.discover(challenge) + assert not [r for r in fake.requests if r.url.host == "127.0.0.1"] + async def test_falls_back_to_challenge_when_well_known_missing(self, fake): fake.serve_prm_at_well_known = False client = make_client(fake, client_metadata_url=CIMD_URL) @@ -321,6 +354,24 @@ async def test_metadata_cached(self, fake): class TestAuthorizationStep: + async def test_code_never_exchanged_with_a_new_issuer(self, fake): + client = make_client(fake, client_metadata_url=CIMD_URL) + pending = await client.begin_authorization() + callback = fake.authorize(pending.authorization_url) + other = { + **client._as_metadata, + "issuer": "https://evil.example.com", + "token_endpoint": "https://evil.example.com/t", + } + + async def discover(*args, **kwargs): + return other + + client.discover = discover + with pytest.raises(AuthorizationError, match="changed since sign-in started"): + await client.complete_authorization(pending, callback) + assert not [r for r in fake.requests if r.url.host == "evil.example.com"] + async def test_state_mismatch(self, fake): client = make_client(fake, client_metadata_url=CIMD_URL) pending = await client.begin_authorization() @@ -366,12 +417,17 @@ async def test_server_hosted_agent_without_handler(self, fake): async def test_loopback_listener(self, fake): opened = [] + forged = [] + async def open_browser(url: str): opened.append(url) callback = fake.authorize(url) async def follow(): async with httpx.AsyncClient() as browser: + # Another local process calls the callback first: ignored, and sign-in still completes + redirect = query_of(url)["redirect_uri"] + forged.append((await browser.get(f"{redirect}?code=x&state=wrong")).status_code) await browser.get(callback) asyncio.get_running_loop().create_task(follow()) @@ -385,6 +441,7 @@ async def follow(): ) await client.connect() redirect_uri = query_of(opened[0])["redirect_uri"] + assert forged == [404] assert redirect_uri.startswith("http://127.0.0.1:") and redirect_uri.endswith("/callback") assert (await client.get_token_info())["client_id"] == CIMD_URL @@ -401,6 +458,26 @@ async def test_round_trip_with_private_permissions(self, tmp_path): await storage.delete_tokens("k") assert await storage.get_tokens("k") is None + async def test_concurrent_processes_do_not_lose_updates(self, tmp_path): + path = str(tmp_path / "tokens.json") + script = ( + "import asyncio, sys\n" + "from kinde_sdk.agents import FileAgentStorage\n" + "async def main():\n" + " storage = FileAgentStorage(sys.argv[1])\n" + " for i in range(25):\n" + " await storage.set_tokens(f'{sys.argv[2]}-{i}', {'access_token': 'a'})\n" + "asyncio.run(main())\n" + ) + processes = [ + await asyncio.create_subprocess_exec(sys.executable, "-c", script, path, f"p{n}") for n in range(4) + ] + assert [await p.wait() for p in processes] == [0, 0, 0, 0] + storage = FileAgentStorage(path) + for n in range(4): + for i in range(25): + assert await storage.get_tokens(f"p{n}-{i}") is not None + class TestConcurrentSignIn: async def test_concurrent_first_requests_sign_in_once(self, fake): diff --git a/testv2/testv2_agents/test_framework_integrations.py b/testv2/testv2_agents/test_framework_integrations.py index b9cc592b..62a589b7 100644 --- a/testv2/testv2_agents/test_framework_integrations.py +++ b/testv2/testv2_agents/test_framework_integrations.py @@ -144,6 +144,11 @@ def test_missing_scope_is_insufficient_scope(self, client, fake): assert challenge.get("error") == "insufficient_scope" assert challenge.get("scope") == "email" + def test_granted_scope_passes(self, client, fake): + response = client.get("/email", headers={"Authorization": f"Bearer {fake.issue(scope='offline email')}"}) + assert response.status_code == 200 + assert body(response) == {"user": "kp_user"} + def test_kinde_outage_is_503(self, client, fake): fake.introspection_status = 502 response = client.get("/mcp", headers={"Authorization": f"Bearer {fake.issue()}"}) @@ -160,3 +165,24 @@ def test_client_metadata_document(self, client): assert body(response) == CIMD assert response.headers["Content-Type"] == "application/json" assert response.headers["Cache-Control"] == "public, max-age=3600" + + +ENCODED_CIMD = build_client_metadata_document( + "https://agent.onzs.co.za/oauth/%66oo%20bar.json", "Onzs Agent", ["http://127.0.0.1/callback"] +) + + +def test_fastapi_serves_percent_encoded_client_id_path(): + app = FastAPI() + app.include_router(client_metadata_router(ENCODED_CIMD)) + response = TestClient(app).get("/oauth/foo%20bar.json") + assert response.status_code == 200 + assert response.json() == ENCODED_CIMD + + +def test_flask_serves_percent_encoded_client_id_path(): + app = Flask(__name__) + app.register_blueprint(client_metadata_blueprint(ENCODED_CIMD)) + response = app.test_client().get("/oauth/foo%20bar.json") + assert response.status_code == 200 + assert response.get_json() == ENCODED_CIMD diff --git a/testv2/testv2_agents/test_resource_server.py b/testv2/testv2_agents/test_resource_server.py index 7d50f50a..5a5a0df4 100644 --- a/testv2/testv2_agents/test_resource_server.py +++ b/testv2/testv2_agents/test_resource_server.py @@ -78,6 +78,7 @@ def test_domain_normalisation(self): {"resource_url": "http://api.example.com/mcp"}, {"domain": "http://test.kinde.com"}, {"domain": "https://test.kinde.com/oauth2"}, + {"domain": "https://test.kinde.com@evil.example"}, {"client_secret": None}, {"verification": "magic"}, ], @@ -328,6 +329,15 @@ def test_revoke_uses_application_credentials_and_clears_cache(self, fake): with pytest.raises(InvalidTokenError): auth.verify_token(token) + def test_revoking_one_token_evicts_its_cached_pair(self, fake): + access_token = fake.issue() + auth = make_auth(fake, introspection_cache_seconds=30) + auth.verify_token(access_token) + auth.revoke_token("paired-refresh-token", "refresh_token") + fake.active_tokens.pop(access_token) # Kinde revokes both tokens of the pair + with pytest.raises(InvalidTokenError): + auth.verify_token(access_token) + class TestJwtVerification: def jwt_auth(self, fake, strategy="jwt"): @@ -416,6 +426,26 @@ def test_stale_jwks_refresh_failure_keeps_cached_keys(self, fake, monkeypatch): assert auth.verify_token(make_token()).user_id == "kp_user" assert fake.count("/jwks") == 2 + @pytest.mark.parametrize("keys", [None, 42, "keys", {"kid": "x"}]) + def test_jwks_without_a_keys_list_is_unavailable(self, fake, keys): + fake.jwks_body = {"keys": keys} + with pytest.raises(VerificationUnavailableError): + self.jwt_auth(fake).verify_token(make_token()) + + def test_unknown_kid_during_jwks_outage_is_unavailable(self, fake, monkeypatch): + from kinde_sdk.agents import resource_server + + auth = self.jwt_auth(fake) + auth.verify_token(make_token()) + fake.jwks_status = 500 + # The new key might be a rotation we couldn't fetch: 503, not 401 + with pytest.raises(VerificationUnavailableError): + auth.verify_token(make_token(kid="rotated", key=other_key())) + fake.jwks_status = 200 + monkeypatch.setattr(resource_server, "_JWKS_MIN_REFETCH_SECONDS", 0) + with pytest.raises(InvalidTokenError): + auth.verify_token(make_token(kid="rotated", key=other_key())) + def test_jwt_plus_introspection_requires_both(self, fake): auth = self.jwt_auth(fake, "jwt+introspection") with pytest.raises(InvalidTokenError): diff --git a/testv2/testv2_agents/test_www_authenticate_and_cimd.py b/testv2/testv2_agents/test_www_authenticate_and_cimd.py index a000c621..9690987b 100644 --- a/testv2/testv2_agents/test_www_authenticate_and_cimd.py +++ b/testv2/testv2_agents/test_www_authenticate_and_cimd.py @@ -113,8 +113,16 @@ def test_redirects_kinde_would_drop(self, redirect_uri): with pytest.warns(UserWarning): build_client_metadata_document(CIMD_URL, "Agent", [redirect_uri], strict=False) - @pytest.mark.parametrize("redirect_uri", ["https://agent.onzs.co.za/cb#frag", "https://u:p@agent.onzs.co.za/cb"]) - def test_fragment_and_userinfo_always_fatal(self, redirect_uri): + @pytest.mark.parametrize( + "redirect_uri", + [ + "https://agent.onzs.co.za/cb#frag", + "https://u:p@agent.onzs.co.za/cb", + "http://127.0.0.1:notaport/callback", + "http://127.0.0.1:99999/callback", + ], + ) + def test_fragment_userinfo_and_bad_port_always_fatal(self, redirect_uri): with pytest.raises(AgentConfigError): build_client_metadata_document(CIMD_URL, "Agent", [redirect_uri], strict=False) From a532b622e190ef8f8f7a362e6bfe87d7e9358839 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 29 Oct 2026 06:39:50 +0200 Subject: [PATCH 3/8] docs(agents): add docstrings --- examples/agents/smoke_check.py | 8 ++++++++ kinde_fastapi/agents.py | 7 +++++++ kinde_flask/agents.py | 11 +++++++++++ kinde_sdk/agents/client.py | 28 ++++++++++++++++++++++++++- kinde_sdk/agents/config.py | 4 ++++ kinde_sdk/agents/context.py | 4 ++++ kinde_sdk/agents/errors.py | 2 ++ kinde_sdk/agents/mcp/client.py | 4 ++++ kinde_sdk/agents/mcp/server.py | 3 +++ kinde_sdk/agents/resource_server.py | 29 +++++++++++++++++++++++++++- kinde_sdk/agents/storage.py | 8 +++++++- kinde_sdk/agents/www_authenticate.py | 6 ++++++ testv2/testv2_agents/kinde_fake.py | 10 ++++++++++ 13 files changed, 121 insertions(+), 3 deletions(-) diff --git a/examples/agents/smoke_check.py b/examples/agents/smoke_check.py index ba337336..4399e70b 100644 --- a/examples/agents/smoke_check.py +++ b/examples/agents/smoke_check.py @@ -29,11 +29,13 @@ def report(status: str, message: str) -> None: def check(condition: bool, message: str, warn_only: bool = False) -> bool: + """Report PASS, or FAIL (WARN when ``warn_only``), and return the condition.""" report("PASS" if condition else ("WARN" if warn_only else "FAIL"), message) return condition def get_json(client: httpx.Client, url: str): + """GET a URL; return the response and its JSON object body, or ``None`` for either.""" try: response = client.get(url) except httpx.HTTPError as exc: @@ -47,6 +49,7 @@ def get_json(client: httpx.Client, url: str): def check_kinde_discovery(client: httpx.Client, config: AgentAuthConfig) -> None: + """Check 1: Kinde's scoped discovery document advertises CIMD, DCR and ``offline``.""" print(f"\n1. Kinde discovery: {config.authorization_server_metadata_url}") response, document = get_json(client, config.authorization_server_metadata_url) if response is None: @@ -67,6 +70,7 @@ def check_kinde_discovery(client: httpx.Client, config: AgentAuthConfig) -> None def check_protected_resource(client: httpx.Client, config: AgentAuthConfig) -> None: + """Check 2: your server's protected resource metadata names the right resource and issuer.""" print(f"\n2. Your protected resource metadata: {config.protected_resource_metadata_url}") response, document = get_json(client, config.protected_resource_metadata_url) if response is None: @@ -82,6 +86,7 @@ def check_protected_resource(client: httpx.Client, config: AgentAuthConfig) -> N def check_challenge(client: httpx.Client, config: AgentAuthConfig) -> None: + """Check 3: requests with no token or a bad token get a 401 with the right challenge.""" print(f"\n3. Challenge from {config.resource_url}") headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} for label, extra, expected_error in ( @@ -110,6 +115,7 @@ def check_challenge(client: httpx.Client, config: AgentAuthConfig) -> None: def check_cimd(client: httpx.Client, url: str) -> None: + """Check 4: the client ID metadata document is served the way Kinde needs it.""" print(f"\n4. Client ID metadata document: {url}") try: validate_client_metadata_url(url) @@ -140,6 +146,7 @@ def check_cimd(client: httpx.Client, url: str) -> None: def check_token(client: httpx.Client, config: AgentAuthConfig, token: str) -> None: + """Check 5: decode an access token, introspect it, and check its audience and scopes.""" print("\n5. Access token") try: claims = jwt.decode(token, options={"verify_signature": False}) @@ -191,6 +198,7 @@ def check_token(client: httpx.Client, config: AgentAuthConfig, token: str) -> No def main() -> int: + """Run the checks and exit non-zero if any failed.""" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--cimd-url", help="your agent's client ID metadata document URL") parser.add_argument("--token", help="an access token an agent received (decoded and introspected)") diff --git a/kinde_fastapi/agents.py b/kinde_fastapi/agents.py index e36d5805..aa5118b7 100644 --- a/kinde_fastapi/agents.py +++ b/kinde_fastapi/agents.py @@ -63,6 +63,7 @@ def __init__( *, mirror_authorization_server_metadata: bool = True, ): + """Build the core helper from the environment unless one is passed, and create the metadata routes.""" self.auth = auth if auth is not None else KindeAgentAuth() self.router = APIRouter() self._add_metadata_routes(mirror_authorization_server_metadata) @@ -73,7 +74,10 @@ def install(self, app: FastAPI) -> None: app.add_exception_handler(AgentAuthHTTPException, agent_auth_exception_handler) def _add_metadata_routes(self, mirror: bool) -> None: + """Add the protected resource metadata routes, and the authorization server mirror if asked.""" + async def protected_resource_metadata(request: Request) -> Response: + """Serve the RFC 9728 document, answering CORS preflight requests.""" if request.method == "OPTIONS": return Response(status_code=204, headers=_CORS_HEADERS) return JSONResponse( @@ -92,6 +96,7 @@ async def protected_resource_metadata(request: Request) -> Response: if mirror: async def authorization_server_metadata(request: Request) -> Response: + """Serve Kinde's scoped authorization server metadata from this origin.""" if request.method == "OPTIONS": return Response(status_code=204, headers=_CORS_HEADERS) document = await self.auth.fetch_authorization_server_metadata_async() @@ -117,6 +122,7 @@ def require(self, *scopes: str) -> Callable[..., Any]: """ async def dependency(request: Request) -> AgentAuthContext: + """Verify the bearer token and required scopes, and store the context on ``request.state``.""" try: context = await self.auth.authenticate_async(request.headers.get("authorization")) if scopes: @@ -136,6 +142,7 @@ def require_permission(self, *permissions: str) -> Callable[..., Any]: authenticate = self.require() async def dependency(request: Request) -> AgentAuthContext: + """Verify the bearer token, then require the Kinde permissions.""" context = await authenticate(request) try: require_permission(context, *permissions) diff --git a/kinde_flask/agents.py b/kinde_flask/agents.py index a6f35215..1d14eaeb 100644 --- a/kinde_flask/agents.py +++ b/kinde_flask/agents.py @@ -57,6 +57,7 @@ def __init__( *, mirror_authorization_server_metadata: bool = True, ): + """Build the core helper from the environment unless one is passed, and register on ``app`` if given.""" self.auth = auth if auth is not None else KindeAgentAuth() self.blueprint = self._metadata_blueprint(mirror_authorization_server_metadata) if app is not None: @@ -66,9 +67,11 @@ def init_app(self, app: Flask) -> None: app.register_blueprint(self.blueprint) def _metadata_blueprint(self, mirror: bool) -> Blueprint: + """Build the blueprint serving the metadata routes, with the authorization server mirror if asked.""" blueprint = Blueprint("kinde_agent_auth", __name__) def protected_resource_metadata(): + """Serve the RFC 9728 document, answering CORS preflight requests.""" if request.method == "OPTIONS": return Response(status=204, headers=_CORS_HEADERS) response = jsonify(self.auth.protected_resource_metadata()) @@ -90,6 +93,7 @@ def protected_resource_metadata(): if mirror: def authorization_server_metadata(): + """Serve Kinde's scoped authorization server metadata from this origin.""" if request.method == "OPTIONS": return Response(status=204, headers=_CORS_HEADERS) response = jsonify(self.auth.fetch_authorization_server_metadata()) @@ -113,6 +117,7 @@ def error_response(self, error: AgentAuthError) -> Response: return response def _authenticate(self, scopes) -> AgentAuthContext: + """Verify the request's bearer token and scopes, and store the context on ``g``.""" context = self.auth.authenticate(request.headers.get("Authorization")) if scopes: require_scope(context, *scopes) @@ -128,8 +133,11 @@ def require(self, *scopes: str) -> Callable[[Callable[..., Any]], Callable[..., """ def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + """Wrap ``view`` so it runs only with a valid bearer token.""" + @wraps(view) def wrapper(*args: Any, **kwargs: Any) -> Any: + """Answer with the auth error, or run the view.""" try: self._authenticate(scopes) except AgentAuthError as exc: @@ -147,8 +155,11 @@ def require_permission(self, *permissions: str) -> Callable[[Callable[..., Any]] """ def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + """Wrap ``view`` so it runs only with a valid token carrying the permissions.""" + @wraps(view) def wrapper(*args: Any, **kwargs: Any) -> Any: + """Answer with the auth error, or run the view.""" try: require_permission(self._authenticate(()), *permissions) except AgentAuthError as exc: diff --git a/kinde_sdk/agents/client.py b/kinde_sdk/agents/client.py index 3b3deb22..90c5f5bb 100644 --- a/kinde_sdk/agents/client.py +++ b/kinde_sdk/agents/client.py @@ -58,11 +58,13 @@ class AuthorizationRequiredError(AgentClientError): def _origin(url: str) -> Tuple[str, str, Optional[int]]: + """Return (scheme, host, port) with the default port filled in, for same-origin checks.""" parts = urlsplit(url) return parts.scheme.lower(), (parts.hostname or "").lower(), parts.port or _DEFAULT_PORTS.get(parts.scheme.lower()) def _require_secure(url: str, what: str) -> None: + """Raise :class:`DiscoveryError` unless ``url`` is https, or http on a loopback host.""" parts = urlsplit(url) if parts.scheme == "https" and parts.hostname: return @@ -105,6 +107,7 @@ def authorization_server_metadata_urls(issuer: str) -> List[str]: def _oauth_error(response: httpx.Response) -> Tuple[Optional[str], Optional[str]]: + """Return the OAuth ``error`` and ``error_description`` from a response, if it has them.""" try: body = response.json() except ValueError: @@ -152,6 +155,7 @@ class _LoopbackListener: """Accepts one browser redirect on a loopback address (RFC 8252 §7.3), then stops.""" def __init__(self, redirect_uri: str): + """Check the redirect URI is http on a loopback host and remember where to listen.""" parts = urlsplit(redirect_uri) if parts.scheme != "http" or not is_loopback_host(parts.hostname): raise AgentConfigError(f"A loopback redirect URI is required, got {redirect_uri!r}") @@ -167,6 +171,7 @@ def __init__(self, redirect_uri: str): self.expected_state: Optional[str] = None async def start(self) -> None: + """Start listening, on an ephemeral port when the redirect URI has none.""" self._result = asyncio.get_running_loop().create_future() bind = "127.0.0.1" if self._host == "localhost" else self._host self._server = await asyncio.start_server(self._handle, bind, self._port) @@ -174,6 +179,7 @@ async def start(self) -> None: self.redirect_uri = f"http://{self._netloc_host}:{self._bound_port}{self._path}" async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Answer one HTTP request; complete the sign-in only for the callback path with the expected state.""" try: request_line = (await asyncio.wait_for(reader.readline(), 10)).decode("latin-1") while (await asyncio.wait_for(reader.readline(), 10)) not in (b"\r\n", b"\n", b""): @@ -211,12 +217,14 @@ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrit writer.close() async def wait(self, timeout: float) -> str: + """Wait for the callback URL, raising :class:`AuthorizationError` on timeout.""" try: return await asyncio.wait_for(asyncio.shield(self._result), timeout) except asyncio.TimeoutError as exc: raise AuthorizationError("Timed out waiting for the browser to finish signing in") from exc async def close(self) -> None: + """Stop listening.""" if self._server is not None: self._server.close() await self._server.wait_closed() @@ -263,6 +271,7 @@ def __init__( timeout_seconds: float = 10.0, callback_timeout_seconds: float = 300.0, ): + """Validate the configuration and set up storage and the HTTP client.""" _require_secure_resource(resource_url) if client_metadata_url is not None: validate_client_metadata_url(client_metadata_url) @@ -294,6 +303,7 @@ def __init__( # -- Lifecycle ------------------------------------------------------------ async def aclose(self) -> None: + """Close the HTTP client if this client created it.""" if self._owns_http: await self._http.aclose() @@ -304,7 +314,7 @@ async def __aexit__(self, *exc_info) -> None: await self.aclose() def _lock_for(self, name: str) -> asyncio.Lock: - # Created lazily so they bind to the running event loop + """Return the named lock, creating it lazily so it binds to the running event loop.""" lock = self._locks.get(name) if lock is None: lock = self._locks[name] = asyncio.Lock() @@ -316,6 +326,7 @@ def _token_key(self) -> str: @property def issuer(self) -> Optional[str]: + """The discovered authorization server's issuer, or ``None`` before discovery.""" return self._as_metadata.get("issuer") if self._as_metadata else None # -- Discovery ------------------------------------------------------------ @@ -348,6 +359,7 @@ async def discover( return metadata async def _get_json(self, url: str) -> Optional[Dict[str, Any]]: + """GET a URL; return its JSON object body, or ``None`` when it isn't one.""" try: response = await self._http.get(url, headers={"Accept": "application/json"}) except httpx.HTTPError as exc: @@ -361,6 +373,7 @@ async def _get_json(self, url: str) -> Optional[Dict[str, Any]]: return body if isinstance(body, dict) else None async def _fetch_protected_resource_metadata(self, challenge_response: Optional[httpx.Response]) -> Dict[str, Any]: + """Find the RFC 9728 document: challenge URL, then well-known URLs, then a probe.""" candidates: List[str] = [] if challenge_response is not None: challenge = find_bearer_challenge(challenge_response.headers.get_list("www-authenticate")) @@ -388,6 +401,7 @@ async def _fetch_protected_resource_metadata(self, challenge_response: Optional[ raise DiscoveryError(f"No protected resource metadata found for {self.resource_url}") def _may_fetch(self, metadata_url: str) -> bool: + """Whether a ``resource_metadata`` URL from a challenge is safe to fetch.""" # A remote resource must not point the agent at a service on this machine if is_loopback_host(urlsplit(metadata_url).hostname) and not is_loopback_host( urlsplit(self.resource_url).hostname @@ -397,6 +411,7 @@ def _may_fetch(self, metadata_url: str) -> bool: return True async def _fetch_authorization_server_metadata(self, issuer: str) -> Dict[str, Any]: + """Fetch and validate the issuer's metadata (issuer match, PKCE S256, https).""" for url in authorization_server_metadata_urls(issuer): metadata = await self._get_json(url) if metadata is None: @@ -420,6 +435,7 @@ async def _fetch_authorization_server_metadata(self, issuer: str) -> Dict[str, A # -- Client identity ------------------------------------------------------ def _effective_scope(self, scopes: Optional[Sequence[str]] = None) -> str: + """The scope to request: configured or given scopes, without ``openid`` if unsupported.""" requested = list(scopes) if scopes else list(self.scopes) supported = (self._as_metadata or {}).get("scopes_supported") if isinstance(supported, list) and "openid" not in supported and "openid" in requested: @@ -433,6 +449,7 @@ def _effective_scope(self, scopes: Optional[Sequence[str]] = None) -> str: return " ".join(seen) async def _client_identity(self, redirect_uri: str) -> Dict[str, Any]: + """Pick the client identity: CIMD URL, pre-registered client, or a (re)used DCR registration.""" metadata = self._as_metadata or {} if self.client_metadata_url and metadata.get("client_id_metadata_document_supported") is True: return {"client_id": self.client_metadata_url, "auth_method": "none"} @@ -456,6 +473,7 @@ async def _client_identity(self, redirect_uri: str) -> Dict[str, Any]: return _identity_from_registration(await self._register(redirect_uri, key), key) async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: + """Register this agent with DCR and store the registration.""" endpoint = self._as_metadata["registration_endpoint"] request = { "redirect_uris": [redirect_uri], @@ -530,6 +548,7 @@ async def _identity_for_tokens(self, tokens: Dict[str, Any]) -> Optional[Dict[st @staticmethod def _apply_client_auth(identity: Dict[str, Any], data: Dict[str, str], headers: Dict[str, str]) -> None: + """Add client authentication to a token request, using the method the server returned.""" method = identity.get("auth_method") or "none" secret = identity.get("client_secret") if method == "client_secret_basic" and secret: @@ -660,6 +679,7 @@ async def authorize( # -- Tokens --------------------------------------------------------------- async def _post_token(self, endpoint: str, data: Dict[str, str], headers: Dict[str, str]) -> httpx.Response: + """POST to the token endpoint, turning network errors into :class:`TokenRequestError`.""" try: return await self._http.post(endpoint, data=data, headers=headers) except httpx.HTTPError as exc: @@ -672,6 +692,7 @@ async def _store_token_response( redirect_uri: Optional[str], previous: Optional[Dict[str, Any]], ) -> Dict[str, Any]: + """Store a token response, keeping the previous refresh token when none is returned.""" try: body = response.json() except ValueError as exc: @@ -698,6 +719,7 @@ async def _store_token_response( @staticmethod def _expiring(tokens: Dict[str, Any]) -> bool: + """Whether the access token expires within the refresh margin.""" expires_at = tokens.get("expires_at") return expires_at is not None and expires_at - time.time() < _REFRESH_MARGIN_SECONDS @@ -750,6 +772,7 @@ async def refresh(self, *, stale_access_token: Optional[str] = None) -> Optional ) async def _usable_tokens(self) -> Dict[str, Any]: + """Return stored tokens, refreshing or signing in first when needed.""" tokens = await self.storage.get_tokens(self._token_key) if tokens and self._expiring(tokens): tokens = await self.refresh(stale_access_token=tokens["access_token"]) @@ -804,6 +827,7 @@ async def get_access_token(self) -> str: # -- Calling the resource ------------------------------------------------- def _resolve(self, url: str) -> str: + """Resolve ``url`` against the resource URL, refusing other origins.""" if not url: return self.resource_url if url.startswith("/"): @@ -890,6 +914,7 @@ async def disconnect(self, *, forget: bool = False) -> None: def _identity_from_registration(registration: Dict[str, Any], key: str) -> Dict[str, Any]: + """Turn a stored DCR registration into a client identity.""" return { "client_id": registration["client_id"], "client_secret": registration.get("client_secret"), @@ -899,6 +924,7 @@ def _identity_from_registration(registration: Dict[str, Any], key: str) -> Dict[ def _require_secure_resource(resource_url: str) -> None: + """Raise :class:`AgentConfigError` unless the resource URL is https or http loopback.""" try: _require_secure(resource_url, "resource URL") except DiscoveryError as exc: diff --git a/kinde_sdk/agents/config.py b/kinde_sdk/agents/config.py index 8d00413a..24f0145d 100644 --- a/kinde_sdk/agents/config.py +++ b/kinde_sdk/agents/config.py @@ -29,6 +29,7 @@ def is_loopback_host(host: Optional[str]) -> bool: + """Whether ``host`` is localhost, 127.0.0.1 or ::1.""" return (host or "").lower() in _LOOPBACK_HOSTS @@ -56,6 +57,7 @@ def normalize_domain(domain: str) -> str: def _validate_resource_url(resource_url: str) -> str: + """Check the Resource URL is https (http only on localhost) with no fragment or userinfo; return it as is.""" # Kept byte for byte: Kinde stores and compares the Resource URL exactly as configured if not resource_url: raise AgentConfigError("resource_url is required (set KINDE_AGENTS_RESOURCE_URL)") @@ -70,6 +72,7 @@ def _validate_resource_url(resource_url: str) -> str: def _with_offline(scopes: Optional[Sequence[str]]) -> List[str]: + """Return the scopes with ``offline`` first, so agents get refresh tokens.""" # Without offline, MCP clients never ask for a refresh token and users sign in every 24 hours result = list(scopes) if scopes else [] if "offline" not in result: @@ -106,6 +109,7 @@ class AgentAuthConfig: timeout_seconds: float = 5.0 def __post_init__(self): + """Normalise and validate the configuration.""" self.domain = normalize_domain(self.domain) if not self.client_id: raise AgentConfigError("client_id is required (set KINDE_AGENTS_CLIENT_ID)") diff --git a/kinde_sdk/agents/context.py b/kinde_sdk/agents/context.py index 4d9dec5d..ac10d48d 100644 --- a/kinde_sdk/agents/context.py +++ b/kinde_sdk/agents/context.py @@ -9,6 +9,7 @@ def _scopes_from(claims: Mapping[str, Any]) -> List[str]: + """Read scopes from ``scp`` (a list) or ``scope`` (a space-separated string).""" scp = claims.get("scp") if isinstance(scp, list): return [str(scope) for scope in scp] @@ -19,6 +20,7 @@ def _scopes_from(claims: Mapping[str, Any]) -> List[str]: def _role_keys(claims: Mapping[str, Any]) -> List[str]: + """Return the ``key`` of each role in the ``roles`` claim.""" roles = claims.get("roles") if not isinstance(roles, list): return [] @@ -102,10 +104,12 @@ def has_permission(context: Optional[AgentAuthContext], permission: str) -> bool def has_role(context: Optional[AgentAuthContext], role: str) -> bool: + """Whether the context has the Kinde role key. ``False`` when there's no context.""" return context is not None and role in context.roles def has_scope(context: Optional[AgentAuthContext], scope: str) -> bool: + """Whether the context has the OAuth scope. ``False`` when there's no context.""" return context is not None and scope in context.scopes diff --git a/kinde_sdk/agents/errors.py b/kinde_sdk/agents/errors.py index 7ad42148..bf64cf63 100644 --- a/kinde_sdk/agents/errors.py +++ b/kinde_sdk/agents/errors.py @@ -50,6 +50,7 @@ class InsufficientScopeError(AgentAuthError): error = "insufficient_scope" def __init__(self, scopes, description: Optional[str] = None): + """Record the missing scopes, for the ``scope`` parameter of the challenge.""" self.scopes = list(scopes) self.description = description or "Required scope: " + " ".join(self.scopes) super().__init__(self.description) @@ -62,6 +63,7 @@ class PermissionDeniedError(AgentAuthError): error = "forbidden" def __init__(self, permissions, description: Optional[str] = None): + """Record the missing permissions.""" self.permissions = list(permissions) self.description = description or "Missing permission: " + ", ".join(self.permissions) super().__init__(self.description) diff --git a/kinde_sdk/agents/mcp/client.py b/kinde_sdk/agents/mcp/client.py index 818bbeb9..42918174 100644 --- a/kinde_sdk/agents/mcp/client.py +++ b/kinde_sdk/agents/mcp/client.py @@ -33,10 +33,12 @@ class KindeTokenStorage: """ def __init__(self, storage: Optional[AgentClientStorage] = None, key: str = "default"): + """Store under ``mcp|`` in ``storage`` (in memory by default).""" self.storage = storage or InMemoryAgentStorage() self.key = f"mcp|{key}" async def get_tokens(self) -> Optional[OAuthToken]: + """Return the stored tokens, or ``None``.""" data = await self.storage.get_tokens(self.key) return OAuthToken.model_validate(data) if data else None @@ -44,6 +46,7 @@ async def set_tokens(self, tokens: OAuthToken) -> None: await self.storage.set_tokens(self.key, tokens.model_dump(mode="json", exclude_none=True)) async def get_client_info(self) -> Optional[OAuthClientInformationFull]: + """Return the stored client registration, or ``None``.""" data = await self.storage.get_registration(self.key) return OAuthClientInformationFull.model_validate(data) if data else None @@ -70,6 +73,7 @@ def __init__( client_metadata: Optional[OAuthClientMetadata] = None, **options: Any, ): + """Build the client metadata from the Kinde defaults unless ``client_metadata`` is given.""" if client_metadata_url is not None: validate_client_metadata_url(client_metadata_url) self._default_scopes = [scope for scope in scopes if scope != "openid"] or ["offline"] diff --git a/kinde_sdk/agents/mcp/server.py b/kinde_sdk/agents/mcp/server.py index c2ad29ee..6bae2616 100644 --- a/kinde_sdk/agents/mcp/server.py +++ b/kinde_sdk/agents/mcp/server.py @@ -46,9 +46,11 @@ class KindeTokenVerifier(TokenVerifier): """ def __init__(self, auth: Optional[KindeAgentAuth] = None, **options: Any): + """Use ``auth``, or build a :class:`KindeAgentAuth` from ``options`` or the environment.""" self.auth = auth if auth is not None else KindeAgentAuth(**options) async def verify_token(self, token: str) -> Optional[AccessToken]: + """Return an ``AccessToken`` for a valid token, ``None`` for an invalid one; raise on an outage.""" try: context = await self.auth.verify_token_async(token) except InvalidTokenError: @@ -113,6 +115,7 @@ def mount_authorization_server_metadata(server: Any, auth: KindeAgentAuth) -> No @server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"]) async def authorization_server_metadata(request: Request) -> Response: + """Serve Kinde's scoped authorization server metadata from this origin.""" if request.method == "OPTIONS": return Response(status_code=204, headers=_CORS_HEADERS) document = await auth.fetch_authorization_server_metadata_async() diff --git a/kinde_sdk/agents/resource_server.py b/kinde_sdk/agents/resource_server.py index 1ba27aa4..bcec4ec6 100644 --- a/kinde_sdk/agents/resource_server.py +++ b/kinde_sdk/agents/resource_server.py @@ -75,6 +75,7 @@ def __init__(self, url: str): self._lock = threading.Lock() def _should_fetch(self, kid: str, now: float) -> bool: + """Whether to fetch the JWKS now: missing or stale keys, or an unknown kid, within rate limits.""" if self._fetched_at is None or now - self._fetched_at >= _JWKS_CACHE_SECONDS: # Missing or stale: retry, but not on every request while Kinde is down return self._last_attempt is None or now - self._last_attempt >= _JWKS_RETRY_SECONDS @@ -87,6 +88,7 @@ def _should_fetch(self, kid: str, now: float) -> bool: return True def _store(self, response: httpx.Response, now: float) -> None: + """Store the keys from a JWKS response, raising :class:`VerificationUnavailableError` if it's unusable.""" if response.status_code != 200: raise VerificationUnavailableError(f"Kinde JWKS request failed with HTTP {response.status_code}") try: @@ -110,7 +112,7 @@ def _store(self, response: httpx.Response, now: float) -> None: self._refresh_failed = False def _fetch_failed(self, exc: Exception) -> None: - # Keep verifying with the keys we have; only fail when there are none + """Keep the cached keys after a failed fetch; raise only when there are none.""" self._refresh_failed = True if self._fetched_at is None: if isinstance(exc, VerificationUnavailableError): @@ -119,6 +121,7 @@ def _fetch_failed(self, exc: Exception) -> None: logger.warning("Kinde JWKS refresh failed; using cached keys") def _lookup(self, kid: str) -> Any: + """Return the key for ``kid``: 503 while keys can't be fetched, 401 when it's simply unknown.""" key = self._keys.get(kid) if key is None: if self._fetched_at is None or self._refresh_failed: @@ -128,6 +131,7 @@ def _lookup(self, kid: str) -> Any: return key def get_key(self, kid: str, client: httpx.Client) -> Any: + """Return the signing key for ``kid``, fetching the JWKS when needed.""" with self._lock: now = time.monotonic() if self._should_fetch(kid, now): @@ -139,6 +143,7 @@ def get_key(self, kid: str, client: httpx.Client) -> Any: return self._lookup(kid) async def get_key_async(self, kid: str, client: httpx.AsyncClient) -> Any: + """Async version of :meth:`get_key`.""" now = time.monotonic() if self._should_fetch(kid, now): self._last_attempt = now @@ -177,6 +182,7 @@ def __init__( clock: Callable[[], float] = time.time, **options: Any, ): + """Use ``config``, or build one from ``options`` or the environment.""" if config is None: config = AgentAuthConfig.from_env(**options) elif options: @@ -194,21 +200,25 @@ def __init__( # -- HTTP clients --------------------------------------------------------- def _client(self) -> httpx.Client: + """Return the sync HTTP client, creating it on first use.""" if self._http_client is None: self._http_client = httpx.Client(timeout=self.config.timeout_seconds) return self._http_client def _async_client(self) -> httpx.AsyncClient: + """Return the async HTTP client, creating it on first use.""" if self._async_http_client is None: self._async_http_client = httpx.AsyncClient(timeout=self.config.timeout_seconds) return self._async_http_client def close(self) -> None: + """Close the sync HTTP client if this helper created it.""" if self._owns_http_client and self._http_client is not None: self._http_client.close() self._http_client = None async def aclose(self) -> None: + """Close both HTTP clients if this helper created them.""" if self._owns_async_http_client and self._async_http_client is not None: await self._async_http_client.aclose() self._async_http_client = None @@ -254,11 +264,13 @@ def authorization_server_metadata(self) -> Dict[str, Any]: } def _cached_as_metadata(self) -> Optional[Dict[str, Any]]: + """Return a copy of the cached Kinde metadata while it's fresh.""" if self._as_metadata_cache and time.monotonic() - self._as_metadata_cache[0] < _AS_METADATA_CACHE_SECONDS: return dict(self._as_metadata_cache[1]) return None def _store_as_metadata(self, response: Optional[httpx.Response]) -> Dict[str, Any]: + """Cache Kinde's metadata from a response, or fall back to the static copy.""" document = None if response is not None and response.status_code == 200: try: @@ -295,6 +307,7 @@ def fetch_authorization_server_metadata(self) -> Dict[str, Any]: return self._store_as_metadata(response) async def fetch_authorization_server_metadata_async(self) -> Dict[str, Any]: + """Async version of :meth:`fetch_authorization_server_metadata`.""" cached = self._cached_as_metadata() if cached is not None: return cached @@ -398,6 +411,7 @@ def verify_token(self, token: str) -> AgentAuthContext: return self._context_from_introspection(token, data, claims) async def verify_token_async(self, token: str) -> AgentAuthContext: + """Async version of :meth:`verify_token`.""" strategy = self.config.verification claims = None if strategy in (VERIFICATION_JWT, VERIFICATION_JWT_AND_INTROSPECTION): @@ -411,11 +425,13 @@ async def verify_token_async(self, token: str) -> AgentAuthContext: return self._context_from_introspection(token, data, claims) def _application_auth_header(self) -> str: + """HTTP Basic credentials for the AI agents and MCP application.""" # RFC 6749 §2.3.1: form-urlencode each part before joining credentials = f"{quote_plus(self.config.client_id)}:{quote_plus(self.config.client_secret or '')}" return "Basic " + base64.b64encode(credentials.encode()).decode() def _post_introspection(self, token: str) -> Union[httpx.Response, Exception]: + """POST the token to Kinde's introspection endpoint; return the response or the network error.""" try: return self._client().post( self.config.introspection_url, @@ -426,6 +442,7 @@ def _post_introspection(self, token: str) -> Union[httpx.Response, Exception]: return exc async def _post_introspection_async(self, token: str) -> Union[httpx.Response, Exception]: + """Async version of :meth:`_post_introspection`.""" try: return await self._async_client().post( self.config.introspection_url, @@ -436,6 +453,7 @@ async def _post_introspection_async(self, token: str) -> Union[httpx.Response, E return exc def _handle_introspection(self, response: Union[httpx.Response, Exception], token: str) -> Dict[str, Any]: + """Map an introspection result to token data, or to an invalid-token or unavailable error.""" if isinstance(response, Exception): logger.warning("Kinde introspection request failed: %s", type(response).__name__) raise VerificationUnavailableError("Could not reach Kinde to verify the token") @@ -481,6 +499,7 @@ def _handle_introspection(self, response: Union[httpx.Response, Exception], toke def _context_from_introspection( self, token: str, data: Mapping[str, Any], verified_claims: Optional[Mapping[str, Any]] ) -> AgentAuthContext: + """Build the context from introspection data plus the token's claims.""" claims = verified_claims if claims is None: # Safe without a signature check: Kinde has just said the token is active. @@ -499,6 +518,7 @@ def _cache_key(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def _cached_introspection(self, token: str) -> Optional[Dict[str, Any]]: + """Return a cached introspection result that hasn't expired.""" if not self.config.introspection_cache_seconds: return None entry = self._introspection_cache.get(self._cache_key(token)) @@ -510,6 +530,7 @@ def _cached_introspection(self, token: str) -> Optional[Dict[str, Any]]: return entry[1] def _remember_introspection(self, token: str, data: Dict[str, Any]) -> None: + """Cache an active introspection result, no longer than the token lives.""" if not self.config.introspection_cache_seconds: return now = self._clock() @@ -527,6 +548,7 @@ def _remember_introspection(self, token: str, data: Dict[str, Any]) -> None: @staticmethod def _kid(token: str) -> str: + """Return the token's key ID, rejecting anything but RS256.""" try: header = jwt.get_unverified_header(token) except jwt.PyJWTError as exc: @@ -540,6 +562,7 @@ def _kid(token: str) -> str: return kid def _verify_jwt(self, token: str, key: Any) -> Dict[str, Any]: + """Verify the signature, audience, expiry and issuer, and return the claims.""" try: claims = jwt.decode( token, @@ -584,6 +607,7 @@ def revoke_token(self, token: str, token_type_hint: Optional[str] = None) -> Non self._check_revocation(response, token) async def revoke_token_async(self, token: str, token_type_hint: Optional[str] = None) -> None: + """Async version of :meth:`revoke_token`.""" data = {"token": token} if token_type_hint: data["token_type_hint"] = token_type_hint @@ -596,6 +620,7 @@ async def revoke_token_async(self, token: str, token_type_hint: Optional[str] = self._check_revocation(response, token) def _check_revocation(self, response: httpx.Response, token: str) -> None: + """Raise unless Kinde accepted the revocation, then clear the introspection cache.""" if response.status_code != 200: raise AgentAuthError(f"Kinde token revocation failed with HTTP {response.status_code}") # Revoking either token of a pair revokes both, and the paired token's cache key can't be derived @@ -603,6 +628,7 @@ def _check_revocation(self, response: httpx.Response, token: str) -> None: def _oauth_error_code(response: httpx.Response) -> Optional[str]: + """Return the OAuth ``error`` code from a response body, if any.""" try: body = response.json() except ValueError: @@ -612,4 +638,5 @@ def _oauth_error_code(response: httpx.Response) -> Optional[str]: def _strip_one_slash(value: str) -> str: + """Remove one trailing slash.""" return value[:-1] if value.endswith("/") else value diff --git a/kinde_sdk/agents/storage.py b/kinde_sdk/agents/storage.py index 123a73a6..d92a64d4 100644 --- a/kinde_sdk/agents/storage.py +++ b/kinde_sdk/agents/storage.py @@ -42,6 +42,7 @@ def __init__(self): self._registrations: Dict[str, Dict[str, Any]] = {} async def get_tokens(self, key: str) -> Optional[Dict[str, Any]]: + """Return a copy of the stored tokens, or ``None``.""" value = self._tokens.get(key) return dict(value) if value is not None else None @@ -52,6 +53,7 @@ async def delete_tokens(self, key: str) -> None: self._tokens.pop(key, None) async def get_registration(self, key: str) -> Optional[Dict[str, Any]]: + """Return a copy of the stored registration, or ``None``.""" value = self._registrations.get(key) return dict(value) if value is not None else None @@ -79,6 +81,7 @@ def __init__(self, path: str): self._lock: Optional[asyncio.Lock] = None def _read(self) -> Dict[str, Dict[str, Any]]: + """Load the file, or empty sections when it doesn't exist yet.""" try: with open(self.path, "r", encoding="utf-8") as handle: data = json.load(handle) @@ -89,6 +92,7 @@ def _read(self) -> Dict[str, Dict[str, Any]]: return data def _write(self, data: Dict[str, Dict[str, Any]]) -> None: + """Write the file atomically with mode 0600.""" directory = os.path.dirname(os.path.abspath(self.path)) os.makedirs(directory, mode=0o700, exist_ok=True) # Write then rename so a crash never leaves a half-written file @@ -107,6 +111,7 @@ def _write(self, data: Dict[str, Dict[str, Any]]) -> None: @contextlib.contextmanager def _file_lock(self) -> Iterator[None]: + """Hold an exclusive OS lock on ``.lock`` for one read-modify-write.""" # Serializes read-modify-write across processes; the asyncio lock covers this process directory = os.path.dirname(os.path.abspath(self.path)) os.makedirs(directory, mode=0o700, exist_ok=True) @@ -126,7 +131,7 @@ def _file_lock(self) -> Iterator[None]: os.close(fd) def _guard(self) -> asyncio.Lock: - # Created lazily so it binds to the running event loop (Python 3.9) + """Return the in-process lock, created lazily so it binds to the running event loop (Python 3.9).""" if self._lock is None: self._lock = asyncio.Lock() return self._lock @@ -141,6 +146,7 @@ async def _set(self, section: str, key: str, value: Optional[Dict[str, Any]]) -> self._update(section, key, value) def _update(self, section: str, key: str, value: Optional[Dict[str, Any]]) -> None: + """Set or delete one entry in a section and write the file.""" data = self._read() if value is None: data[section].pop(key, None) diff --git a/kinde_sdk/agents/www_authenticate.py b/kinde_sdk/agents/www_authenticate.py index 8e1c918c..0d507185 100644 --- a/kinde_sdk/agents/www_authenticate.py +++ b/kinde_sdk/agents/www_authenticate.py @@ -37,23 +37,28 @@ def __init__(self, text: str): self.pos = 0 def peek(self) -> str: + """Return the current character, or an empty string at the end.""" return self.text[self.pos] if self.pos < len(self.text) else "" def skip_spaces(self) -> None: + """Skip spaces and tabs.""" while self.peek() in (" ", "\t"): self.pos += 1 def skip_separators(self) -> None: + """Skip spaces, tabs and commas.""" while self.peek() in (" ", "\t", ","): self.pos += 1 def token(self, extra=frozenset()) -> str: + """Read an RFC 7230 token, also accepting the characters in ``extra``.""" start = self.pos while self.peek() and (self.peek() in _TOKEN_CHARS or self.peek() in extra): self.pos += 1 return self.text[start : self.pos] def quoted_string(self) -> str: + """Read a quoted string, unescaping backslashes.""" # Assumes the opening quote is at self.pos. An unterminated string runs to the end. self.pos += 1 chars = [] @@ -87,6 +92,7 @@ def parse_www_authenticate(headers: Union[str, Iterable[str], None]) -> List[Cha def _parse_one(text: str) -> List[Challenge]: + """Parse one header value into its challenges.""" scanner = _Scanner(text) challenges: List[Challenge] = [] current: Optional[Challenge] = None diff --git a/testv2/testv2_agents/kinde_fake.py b/testv2/testv2_agents/kinde_fake.py index 0c60c2e9..fa14d345 100644 --- a/testv2/testv2_agents/kinde_fake.py +++ b/testv2/testv2_agents/kinde_fake.py @@ -46,6 +46,7 @@ def make_token( algorithm: str = "RS256", **claims: Any, ) -> str: + """Sign a Kinde-shaped access token with the test key.""" now = int(time.time()) payload = { "iss": iss, @@ -121,9 +122,11 @@ def __init__(self): # -- helpers -------------------------------------------------------------- def paths(self) -> List[str]: + """The method and URL of every request seen, without query strings.""" return [f"{r.method} {r.url.copy_with(query=None)}" for r in self.requests] def count(self, path_suffix: str) -> int: + """How many requests went to a path ending in ``path_suffix``.""" return sum(1 for r in self.requests if r.url.path.endswith(path_suffix)) def issue(self, *, scope: str = "offline", client_id: str = "https://agent.example.com/cimd.json", **claims) -> str: @@ -151,6 +154,7 @@ def client(self) -> httpx.Client: # -- routing -------------------------------------------------------------- def handle(self, request: httpx.Request) -> httpx.Response: + """Route a request to the fake endpoint, failing on any shared ``/oauth2/*`` endpoint.""" self.requests.append(request) url = str(request.url.copy_with(query=None)) path = request.url.path @@ -176,6 +180,7 @@ def handle(self, request: httpx.Request) -> httpx.Response: return httpx.Response(404) def _introspect(self, request: httpx.Request) -> httpx.Response: + """Answer introspection as live Kinde does: 401 ``token_inactive`` for unknown tokens.""" if self.introspection_raises: raise httpx.ConnectError("boom", request=request) if self.introspection_status is not None: @@ -205,6 +210,7 @@ def _introspect(self, request: httpx.Request) -> httpx.Response: ) def _register(self, request: httpx.Request) -> httpx.Response: + """Answer DCR the way Kinde does: always a secret and ``client_secret_post``.""" if self.register_status is not None: return httpx.Response(self.register_status, json=self.register_error or {}) body = json.loads(request.content) @@ -236,6 +242,7 @@ def authorize(self, authorization_url: str) -> str: return f"{query['redirect_uri']}?code={code}&state={query['state']}&iss={ISSUER}" def _client_ok(self, form: Dict[str, str]) -> bool: + """Check client authentication: no secret for CIMD, the registered secret for DCR.""" client_id = form.get("client_id", "") if client_id.startswith("https://"): return "client_secret" not in form @@ -243,6 +250,7 @@ def _client_ok(self, form: Dict[str, str]) -> bool: return registration is not None and form.get("client_secret") == registration["client_secret"] def _token(self, request: httpx.Request) -> httpx.Response: + """Answer the authorization code and refresh token grants.""" form = _form(request) if not self._client_ok(form): return httpx.Response(401, json={"error": "invalid_client"}) @@ -278,6 +286,7 @@ def _token(self, request: httpx.Request) -> httpx.Response: return httpx.Response(400, json={"error": "unsupported_grant_type"}) def _resource(self, request: httpx.Request) -> httpx.Response: + """Act as the protected resource: metadata, 401 challenges and scope checks.""" if request.url.path.startswith("/.well-known/oauth-protected-resource"): if self.serve_prm_at_well_known and self.prm is not None: return httpx.Response(200, json=self.prm) @@ -309,6 +318,7 @@ def _resource(self, request: httpx.Request) -> httpx.Response: def _form(request: httpx.Request) -> Dict[str, str]: + """Decode a form-encoded request body.""" return { k: unquote_plus(v) for k, v in (pair.split("=", 1) for pair in request.content.decode().split("&") if "=" in pair) From bae6d8c4ba4c1907703a0d0a012740c4fe9a1ee8 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 29 Oct 2026 07:02:34 +0200 Subject: [PATCH 4/8] fix(agents): revoke only at the issuing server and let forget work offline disconnect() no longer sends tokens or the client secret to a different authorization server's revocation endpoint after an issuer change. disconnect(forget=True) now deletes every DCR registration the client used, from an index kept per resource and account, so it works without discovery and with the default portless redirect URI (whose registration is stored under the port the listener bound). Also: - Reject a DCR client_id that isn't a non-empty string. - Reject a CIMD route path that would become a route variable after decoding ("<" ">" for Flask, "{" "}" for FastAPI). - README: with introspection caching, revocation reaches other workers only when their cache entry expires. - smoke_check.py: form-encode introspection credentials (RFC 6749 2.3.1). --- README.md | 2 +- examples/agents/smoke_check.py | 11 +++- kinde_fastapi/agents.py | 5 +- kinde_flask/agents.py | 5 +- kinde_sdk/agents/client.py | 50 ++++++++++++------- testv2/testv2_agents/test_agent_client.py | 50 +++++++++++++++++++ .../test_framework_integrations.py | 13 +++++ 7 files changed, 114 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d9e57ca1..47dfbd9b 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ Requests without a valid token get `401` with a `WWW-Authenticate` challenge poi ### How tokens are verified -Tokens are checked with Kinde's introspection endpoint by default, using the application's client ID and secret, so a revoked token stops working immediately. To verify signatures locally instead (no network call per request, but a revoked token stays valid until it expires, 24 hours by default), pass `verification="jwt"`. `"jwt+introspection"` does both. `introspection_cache_seconds` (up to 60) caches successful introspections to cut traffic. +Tokens are checked with Kinde's introspection endpoint by default, using the application's client ID and secret, so a revoked token stops working immediately. To verify signatures locally instead (no network call per request, but a revoked token stays valid until it expires, 24 hours by default), pass `verification="jwt"`. `"jwt+introspection"` does both. `introspection_cache_seconds` (up to 60) caches successful introspections to cut traffic. With caching on, `revoke_token` clears only the calling process's cache: other workers keep accepting a revoked token until their cached entry expires, at most `introspection_cache_seconds` later. To cut off an agent, revoke its token with `auth.revoke_token(token)`. This revokes both the access token and refresh token of the pair. diff --git a/examples/agents/smoke_check.py b/examples/agents/smoke_check.py index 4399e70b..00fce311 100644 --- a/examples/agents/smoke_check.py +++ b/examples/agents/smoke_check.py @@ -12,8 +12,10 @@ """ import argparse +import base64 import json import sys +from urllib.parse import quote_plus import httpx import jwt @@ -34,6 +36,12 @@ def check(condition: bool, message: str, warn_only: bool = False) -> bool: return condition +def basic_auth(client_id: str, client_secret: str) -> str: + """HTTP Basic credentials with each part form-encoded first (RFC 6749 §2.3.1).""" + credentials = f"{quote_plus(client_id)}:{quote_plus(client_secret)}" + return "Basic " + base64.b64encode(credentials.encode()).decode() + + def get_json(client: httpx.Client, url: str): """GET a URL; return the response and its JSON object body, or ``None`` for either.""" try: @@ -166,7 +174,8 @@ def check_token(client: httpx.Client, config: AgentAuthConfig, token: str) -> No response = client.post( config.introspection_url, data={"token": token}, - auth=(config.client_id, config.client_secret or ""), + # RFC 6749 §2.3.1: form-encode the ID and secret before HTTP Basic, as the SDK does + headers={"Authorization": basic_auth(config.client_id, config.client_secret or "")}, ) except httpx.HTTPError as exc: report("FAIL", f"introspection request failed: {type(exc).__name__}") diff --git a/kinde_fastapi/agents.py b/kinde_fastapi/agents.py index aa5118b7..41c6f235 100644 --- a/kinde_fastapi/agents.py +++ b/kinde_fastapi/agents.py @@ -24,7 +24,7 @@ async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read: from kinde_sdk.agents.client_metadata import client_metadata_response from kinde_sdk.agents.config import AUTHORIZATION_SERVER_WELL_KNOWN, PROTECTED_RESOURCE_WELL_KNOWN from kinde_sdk.agents.context import AgentAuthContext, require_permission, require_scope -from kinde_sdk.agents.errors import AgentAuthError +from kinde_sdk.agents.errors import AgentAuthError, AgentConfigError from kinde_sdk.agents.resource_server import KindeAgentAuth # Browser-based MCP clients fetch metadata cross-origin @@ -167,5 +167,8 @@ async def client_metadata() -> Response: # Routes match the decoded request path, so decode any %-escapes in the client_id route = path or unquote(urlsplit(document["client_id"]).path) + if "{" in route or "}" in route: + # Starlette would read these as a path parameter + raise AgentConfigError(f"The client metadata path must not contain '{{' or '}}': {route!r}") router.add_api_route(route, client_metadata, methods=["GET"], include_in_schema=False) return router diff --git a/kinde_flask/agents.py b/kinde_flask/agents.py index 1d14eaeb..a364633e 100644 --- a/kinde_flask/agents.py +++ b/kinde_flask/agents.py @@ -23,7 +23,7 @@ def docs(): from kinde_sdk.agents.client_metadata import client_metadata_response from kinde_sdk.agents.config import AUTHORIZATION_SERVER_WELL_KNOWN, PROTECTED_RESOURCE_WELL_KNOWN from kinde_sdk.agents.context import AgentAuthContext, require_permission, require_scope -from kinde_sdk.agents.errors import AgentAuthError +from kinde_sdk.agents.errors import AgentAuthError, AgentConfigError from kinde_sdk.agents.resource_server import KindeAgentAuth # Browser-based MCP clients fetch metadata cross-origin @@ -185,5 +185,8 @@ def client_metadata(): # Routes match the decoded request path, so decode any %-escapes in the client_id route = path or unquote(urlsplit(document["client_id"]).path) + if "<" in route or ">" in route: + # Werkzeug would read these as a route variable + raise AgentConfigError(f"The client metadata path must not contain '<' or '>': {route!r}") blueprint.add_url_rule(route, view_func=client_metadata, methods=["GET"]) return blueprint diff --git a/kinde_sdk/agents/client.py b/kinde_sdk/agents/client.py index 90c5f5bb..79313ad3 100644 --- a/kinde_sdk/agents/client.py +++ b/kinde_sdk/agents/client.py @@ -466,11 +466,24 @@ async def _client_identity(self, redirect_uri: str) -> Dict[str, Any]: ) key = f"{metadata['issuer']}|{redirect_uri}" registration = await self.storage.get_registration(key) - if registration: - expires_at = registration.get("client_secret_expires_at") or 0 - if not expires_at or expires_at - time.time() > _REREGISTER_MARGIN_SECONDS: - return _identity_from_registration(registration, key) - return _identity_from_registration(await self._register(redirect_uri, key), key) + if not registration or ( + registration.get("client_secret_expires_at") + and registration["client_secret_expires_at"] - time.time() <= _REREGISTER_MARGIN_SECONDS + ): + registration = await self._register(redirect_uri, key) + await self._remember_registration_key(key) + return _identity_from_registration(registration, key) + + @property + def _registration_index_key(self) -> str: + return f"index|{self.resource_url}|{self.account}" + + async def _remember_registration_key(self, key: str) -> None: + """Record a registration this client used, so ``disconnect(forget=True)`` can find it offline.""" + index = await self.storage.get_registration(self._registration_index_key) or {} + keys = index.get("keys") if isinstance(index.get("keys"), list) else [] + if key not in keys: + await self.storage.set_registration(self._registration_index_key, {"keys": [*keys, key]}) async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: """Register this agent with DCR and store the registration.""" @@ -510,7 +523,7 @@ async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: body = response.json() except ValueError as exc: raise RegistrationError("The registration response was not JSON") from exc - if not isinstance(body, dict) or not body.get("client_id"): + if not isinstance(body, dict) or not isinstance(body.get("client_id"), str) or not body["client_id"]: raise RegistrationError("The registration response has no client_id") secret = body.get("client_secret") registration = { @@ -874,17 +887,10 @@ async def send(access_token: str) -> httpx.Response: async def disconnect(self, *, forget: bool = False) -> None: """Revoke the tokens (best effort) and clear them. - ``forget`` also drops the DCR registration for this redirect URI, even when - no tokens are stored. + ``forget`` also drops the DCR registrations this client used, even when no + tokens are stored or the authorization server can't be reached. """ tokens = await self.storage.get_tokens(self._token_key) - if forget and not tokens: - try: - metadata = await self.discover() - except AgentClientError: - return - await self.storage.delete_registration(f"{metadata['issuer']}|{self.redirect_uri}") - return if tokens: identity = await self._identity_for_tokens(tokens) try: @@ -892,7 +898,9 @@ async def disconnect(self, *, forget: bool = False) -> None: except AgentClientError: metadata = {} endpoint = metadata.get("revocation_endpoint") - if endpoint and identity: + # Never send the tokens or client secret to an issuer other than the one that granted them + same_issuer = metadata.get("issuer") == tokens.get("issuer") + if endpoint and identity and same_issuer: # On Kinde revoking either token of the pair revokes both token, hint = ( (tokens["refresh_token"], "refresh_token") @@ -909,8 +917,14 @@ async def disconnect(self, *, forget: bool = False) -> None: except httpx.HTTPError as exc: logger.info("Token revocation failed: %s", type(exc).__name__) await self.storage.delete_tokens(self._token_key) - if forget and tokens.get("registration_key"): - await self.storage.delete_registration(tokens["registration_key"]) + if forget: + index = await self.storage.get_registration(self._registration_index_key) or {} + keys = index.get("keys") if isinstance(index.get("keys"), list) else [] + if tokens and tokens.get("registration_key"): + keys = [*keys, tokens["registration_key"]] + for key in dict.fromkeys(keys): + await self.storage.delete_registration(key) + await self.storage.delete_registration(self._registration_index_key) def _identity_from_registration(registration: Dict[str, Any], key: str) -> Dict[str, Any]: diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py index 4c6adcff..2a78782e 100644 --- a/testv2/testv2_agents/test_agent_client.py +++ b/testv2/testv2_agents/test_agent_client.py @@ -191,6 +191,11 @@ async def test_non_json_registration_response(self, fake): with pytest.raises(RegistrationError, match="not JSON"): await make_client(fake).connect() + async def test_non_string_client_id_in_registration(self, fake): + fake._register = lambda request: httpx.Response(201, json={"client_id": 123, "client_secret": "s"}) + with pytest.raises(RegistrationError, match="no client_id"): + await make_client(fake).connect() + async def test_too_many_registrations(self, fake): fake.register_status = 429 with pytest.raises(RegistrationError, match="try again later"): @@ -295,6 +300,51 @@ async def test_disconnect_forget_without_tokens_drops_registration(self, fake): await client.disconnect(forget=True) assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + async def test_disconnect_forget_works_without_discovery(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage) + await client.connect() + await storage.delete_tokens(f"{RESOURCE_URL}|default") + fake.prm = None # discovery now fails + client._metadata_at = 0 + await client.disconnect(forget=True) + assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + assert await storage.get_registration(f"index|{RESOURCE_URL}|default") is None + + async def test_disconnect_forget_finds_registration_under_bound_port(self, fake): + async def open_browser(url: str): + callback = fake.authorize(url) + + async def follow(): + async with httpx.AsyncClient() as browser: + await browser.get(callback) + + asyncio.get_running_loop().create_task(follow()) + + storage = InMemoryAgentStorage() + # The default redirect has no port, so the registration is stored under the port the listener bound + client = KindeAgentClient( + RESOURCE_URL, storage=storage, open_browser=open_browser, http_client=fake.async_client() + ) + await client.connect() + key = (await storage.get_tokens(f"{RESOURCE_URL}|default"))["registration_key"] + assert key != f"{ISSUER}|http://127.0.0.1/callback" + await storage.delete_tokens(f"{RESOURCE_URL}|default") + await client.disconnect(forget=True) + assert await storage.get_registration(key) is None + + async def test_disconnect_never_revokes_at_a_new_issuer(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage) + await client.connect() + key = f"{RESOURCE_URL}|default" + tokens = await storage.get_tokens(key) + await storage.set_tokens(key, {**tokens, "issuer": "https://old.example.com"}) + client._metadata_at = 0 + await client.disconnect() + assert fake.count("/revoke") == 0 + assert await storage.get_tokens(key) is None + async def test_refresh_never_sends_tokens_to_a_new_issuer(self, fake): storage = InMemoryAgentStorage() client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) diff --git a/testv2/testv2_agents/test_framework_integrations.py b/testv2/testv2_agents/test_framework_integrations.py index 62a589b7..804bcd1a 100644 --- a/testv2/testv2_agents/test_framework_integrations.py +++ b/testv2/testv2_agents/test_framework_integrations.py @@ -5,6 +5,7 @@ from kinde_sdk.agents import ( AgentAuthConfig, + AgentConfigError, AgentAuthContext, KindeAgentAuth, build_client_metadata_document, @@ -186,3 +187,15 @@ def test_flask_serves_percent_encoded_client_id_path(): response = app.test_client().get("/oauth/foo%20bar.json") assert response.status_code == 200 assert response.get_json() == ENCODED_CIMD + + +@pytest.mark.parametrize( + "build, encoded", + [(client_metadata_router, "%7Bname%7D"), (client_metadata_blueprint, "%3Cname%3E")], +) +def test_client_id_path_that_would_become_a_route_variable(build, encoded): + document = build_client_metadata_document( + f"https://agent.onzs.co.za/oauth/{encoded}.json", "Onzs Agent", ["http://127.0.0.1/callback"] + ) + with pytest.raises(AgentConfigError): + build(document) From f6cf999b2a80ffcb35b72cc0456fe1a49c31b105 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Fri, 30 Oct 2026 06:14:01 +0200 Subject: [PATCH 5/8] Updated .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7c481a92..8fbf70db 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,8 @@ docs/_build/ # Local environment files (secrets, never commit) .env .env.* +*.env !.env.example -!.env.sample \ No newline at end of file +!.env.sample + +.DS_Store From a27211beb44deaeebd8a5e1cf20d7a84d28a5ea4 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Fri, 30 Oct 2026 06:25:27 +0200 Subject: [PATCH 6/8] fix(agents): give each account its own DCR registration Registrations were keyed by issuer and redirect URI, so accounts sharing a storage file shared one registration, and forget on one account broke the others. With the default port-less loopback redirect, every sign-in also added a new registration that was never cleaned up. Each account now keeps a single registration slot. A new redirect URI, issuer or expiring secret replaces it, forget deletes only that slot, and invalid_client cleanup leaves a newer registration alone. This also removes the registration index and its non-atomic update. Also reject a non-string client_secret from registration, and in FastAPI reject only real {name} path parameters rather than any brace. --- README.md | 2 +- kinde_fastapi/agents.py | 10 ++- kinde_sdk/agents/client.py | 59 +++++++------- testv2/testv2_agents/test_agent_client.py | 80 +++++++++++++++++-- .../test_framework_integrations.py | 19 +++++ 5 files changed, 133 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 47dfbd9b..7b697bb2 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ async with KindeAgentClient( response = await client.request("POST", json={...}) ``` -A CLI agent uses a loopback redirect (`http://127.0.0.1/callback`, port picked automatically). A server-hosted agent passes its own `redirect_uri` and uses `begin_authorization()` and `complete_authorization()` in its login and callback routes. For DCR, prefer a fixed port (`http://127.0.0.1:8765/callback`), because a registration is tied to its exact redirect URI. +A CLI agent uses a loopback redirect (`http://127.0.0.1/callback`, port picked automatically). A server-hosted agent passes its own `redirect_uri` and uses `begin_authorization()` and `complete_authorization()` in its login and callback routes. For DCR, prefer a fixed port (`http://127.0.0.1:8765/callback`), because a registration is tied to its exact redirect URI. Each account keeps one registration, and signing in on a new redirect URI replaces it. Two processes signing in the same account at the same moment on different ports can replace each other's registration; the loser gets an error and should sign in again. Agents built on the official MCP client use the adapter instead: diff --git a/kinde_fastapi/agents.py b/kinde_fastapi/agents.py index 41c6f235..e0527456 100644 --- a/kinde_fastapi/agents.py +++ b/kinde_fastapi/agents.py @@ -15,6 +15,7 @@ async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read: ... """ +import re from typing import Any, Callable, Dict, Optional from urllib.parse import unquote, urlsplit @@ -27,6 +28,9 @@ async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read: from kinde_sdk.agents.errors import AgentAuthError, AgentConfigError from kinde_sdk.agents.resource_server import KindeAgentAuth +# The pattern Starlette uses for path parameters, like {name} or {name:path} +_PATH_PARAM = re.compile(r"{[a-zA-Z_][a-zA-Z0-9_]*(:[a-zA-Z_][a-zA-Z0-9_]*)?}") + # Browser-based MCP clients fetch metadata cross-origin _CORS_HEADERS = { "Access-Control-Allow-Origin": "*", @@ -167,8 +171,8 @@ async def client_metadata() -> Response: # Routes match the decoded request path, so decode any %-escapes in the client_id route = path or unquote(urlsplit(document["client_id"]).path) - if "{" in route or "}" in route: - # Starlette would read these as a path parameter - raise AgentConfigError(f"The client metadata path must not contain '{{' or '}}': {route!r}") + if _PATH_PARAM.search(route): + # Starlette would read this as a path parameter, not literal text + raise AgentConfigError(f"The client metadata path must not contain a {{name}} segment: {route!r}") router.add_api_route(route, client_metadata, methods=["GET"], include_in_schema=False) return router diff --git a/kinde_sdk/agents/client.py b/kinde_sdk/agents/client.py index 79313ad3..1d1cb1b0 100644 --- a/kinde_sdk/agents/client.py +++ b/kinde_sdk/agents/client.py @@ -464,26 +464,35 @@ async def _client_identity(self, redirect_uri: str) -> Dict[str, Any]: "The authorization server supports neither client ID metadata documents nor registration; " "configure client_id" ) - key = f"{metadata['issuer']}|{redirect_uri}" + key = self._registration_key registration = await self.storage.get_registration(key) - if not registration or ( - registration.get("client_secret_expires_at") - and registration["client_secret_expires_at"] - time.time() <= _REREGISTER_MARGIN_SECONDS + if ( + not registration + # Kinde only wildcards loopback ports for CIMD, so a new redirect URI needs a new registration + or registration.get("redirect_uri") != redirect_uri + or registration.get("issuer") != metadata["issuer"] + or ( + registration.get("client_secret_expires_at") + and registration["client_secret_expires_at"] - time.time() <= _REREGISTER_MARGIN_SECONDS + ) ): + # Replaces this account's previous registration, so registrations don't pile up registration = await self._register(redirect_uri, key) - await self._remember_registration_key(key) return _identity_from_registration(registration, key) @property - def _registration_index_key(self) -> str: - return f"index|{self.resource_url}|{self.account}" - - async def _remember_registration_key(self, key: str) -> None: - """Record a registration this client used, so ``disconnect(forget=True)`` can find it offline.""" - index = await self.storage.get_registration(self._registration_index_key) or {} - keys = index.get("keys") if isinstance(index.get("keys"), list) else [] - if key not in keys: - await self.storage.set_registration(self._registration_index_key, {"keys": [*keys, key]}) + def _registration_key(self) -> str: + """Where this account's one DCR registration lives. Accounts never share a registration.""" + return f"dcr|{self.resource_url}|{self.account}" + + async def _drop_registration(self, identity: Dict[str, Any]) -> None: + """Delete the stored registration behind ``identity``, unless it was already replaced.""" + key = identity.get("registration_key") + if not key: + return + stored = await self.storage.get_registration(key) + if stored and stored.get("client_id") == identity.get("client_id"): + await self.storage.delete_registration(key) async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: """Register this agent with DCR and store the registration.""" @@ -526,6 +535,8 @@ async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: if not isinstance(body, dict) or not isinstance(body.get("client_id"), str) or not body["client_id"]: raise RegistrationError("The registration response has no client_id") secret = body.get("client_secret") + if secret is not None and (not isinstance(secret, str) or not secret): + raise RegistrationError("The registration response has an invalid client_secret") registration = { "client_id": body["client_id"], "client_secret": secret, @@ -653,8 +664,8 @@ async def complete_authorization(self, pending: PendingAuthorization, callback_u response = await self._post_token(metadata["token_endpoint"], data, headers) if response.status_code != 200: error, description = _oauth_error(response) - if error == "invalid_client" and identity.get("registration_key"): - await self.storage.delete_registration(identity["registration_key"]) + if error == "invalid_client": + await self._drop_registration(identity) raise TokenRequestError( f"The code exchange failed: {error or response.status_code}", error=error, error_description=description ) @@ -776,8 +787,7 @@ async def refresh(self, *, stale_access_token: Optional[str] = None) -> Optional return None if error == "invalid_client": # The registration expired: register again and sign in again - if identity.get("registration_key"): - await self.storage.delete_registration(identity["registration_key"]) + await self._drop_registration(identity) await self.storage.delete_tokens(self._token_key) return None raise TokenRequestError( @@ -887,8 +897,8 @@ async def send(access_token: str) -> httpx.Response: async def disconnect(self, *, forget: bool = False) -> None: """Revoke the tokens (best effort) and clear them. - ``forget`` also drops the DCR registrations this client used, even when no - tokens are stored or the authorization server can't be reached. + ``forget`` also drops this account's DCR registration, even when no tokens + are stored or the authorization server can't be reached. """ tokens = await self.storage.get_tokens(self._token_key) if tokens: @@ -918,13 +928,8 @@ async def disconnect(self, *, forget: bool = False) -> None: logger.info("Token revocation failed: %s", type(exc).__name__) await self.storage.delete_tokens(self._token_key) if forget: - index = await self.storage.get_registration(self._registration_index_key) or {} - keys = index.get("keys") if isinstance(index.get("keys"), list) else [] - if tokens and tokens.get("registration_key"): - keys = [*keys, tokens["registration_key"]] - for key in dict.fromkeys(keys): - await self.storage.delete_registration(key) - await self.storage.delete_registration(self._registration_index_key) + # Only this account's own registration: other accounts have their own + await self.storage.delete_registration(self._registration_key) def _identity_from_registration(registration: Dict[str, Any], key: str) -> Dict[str, Any]: diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py index 2a78782e..8107f9c7 100644 --- a/testv2/testv2_agents/test_agent_client.py +++ b/testv2/testv2_agents/test_agent_client.py @@ -28,6 +28,7 @@ CIMD_URL = "https://agent.onzs.co.za/oauth/client-metadata.json" REDIRECT = "http://127.0.0.1:8765/callback" +REGISTRATION_KEY = f"dcr|{RESOURCE_URL}|default" @pytest.fixture @@ -154,7 +155,7 @@ async def test_registers_and_uses_returned_secret(self, fake): assert "scope" not in body and "resource" not in body token_form = form_of(next(r for r in fake.requests if r.url.path.endswith("/token"))) - registration = await storage.get_registration(f"{ISSUER}|{REDIRECT}") + registration = await storage.get_registration(REGISTRATION_KEY) assert token_form["client_id"] == registration["client_id"] assert token_form["client_secret"] == registration["client_secret"] assert registration["token_endpoint_auth_method"] == "client_secret_post" @@ -196,6 +197,74 @@ async def test_non_string_client_id_in_registration(self, fake): with pytest.raises(RegistrationError, match="no client_id"): await make_client(fake).connect() + async def test_non_string_client_secret_in_registration(self, fake): + fake._register = lambda request: httpx.Response(201, json={"client_id": "c", "client_secret": 123}) + with pytest.raises(RegistrationError, match="invalid client_secret"): + await make_client(fake).connect() + + async def test_public_registration_without_secret(self, fake): + storage = InMemoryAgentStorage() + fake_register = fake._register + + def without_secret(request): + body = fake_register(request).json() + del body["client_secret"] + body["token_endpoint_auth_method"] = "none" + fake.registrations[body["client_id"]]["client_secret"] = None + return httpx.Response(201, json=body) + + fake._register = without_secret + await make_client(fake, storage=storage).connect() + registration = await storage.get_registration(REGISTRATION_KEY) + assert registration["client_secret"] is None + assert registration["token_endpoint_auth_method"] == "none" + exchange = next(r for r in fake.requests if b"grant_type=authorization_code" in r.content) + assert "client_secret" not in form_of(exchange) + + async def test_accounts_get_their_own_registrations(self, fake): + storage = InMemoryAgentStorage() + await make_client(fake, storage=storage, account="a").connect() + await make_client(fake, storage=storage, account="b").connect() + a = await storage.get_registration(f"dcr|{RESOURCE_URL}|a") + b = await storage.get_registration(f"dcr|{RESOURCE_URL}|b") + assert a["client_id"] != b["client_id"] + + async def test_forget_leaves_other_accounts_working(self, fake): + storage = InMemoryAgentStorage() + await make_client(fake, storage=storage, account="a").connect() + b = make_client(fake, storage=storage, account="b") + await b.connect() + await make_client(fake, storage=storage, account="a").disconnect(forget=True) + assert await storage.get_registration(f"dcr|{RESOURCE_URL}|a") is None + # b still refreshes with its own registration + tokens = await storage.get_tokens(f"{RESOURCE_URL}|b") + tokens["expires_at"] = time.time() - 10 + await storage.set_tokens(f"{RESOURCE_URL}|b", tokens) + assert await b.refresh(stale_access_token=tokens["access_token"]) is not None + assert fake.count("/register") == 2 + + async def test_new_redirect_uri_replaces_the_registration(self, fake): + storage = InMemoryAgentStorage() + await make_client(fake, storage=storage, redirect_uri="http://127.0.0.1:8001/callback").connect() + await storage.delete_tokens(f"{RESOURCE_URL}|default") + await make_client(fake, storage=storage, redirect_uri="http://127.0.0.1:8002/callback").connect() + assert fake.count("/register") == 2 + # One registration slot per account, holding the latest + registration = await storage.get_registration(REGISTRATION_KEY) + assert registration["redirect_uri"] == "http://127.0.0.1:8002/callback" + assert set(storage._registrations) == {REGISTRATION_KEY} + + async def test_invalid_client_keeps_a_newer_registration(self, fake): + storage = InMemoryAgentStorage() + client = make_client(fake, storage=storage) + await client.connect() + tokens = await storage.get_tokens(f"{RESOURCE_URL}|default") + # Another process re-registered this account since these tokens were issued + newer = {**(await storage.get_registration(REGISTRATION_KEY)), "client_id": "newer"} + await storage.set_registration(REGISTRATION_KEY, newer) + await client._drop_registration({"client_id": tokens["client_id"], "registration_key": REGISTRATION_KEY}) + assert (await storage.get_registration(REGISTRATION_KEY))["client_id"] == "newer" + async def test_too_many_registrations(self, fake): fake.register_status = 429 with pytest.raises(RegistrationError, match="try again later"): @@ -290,7 +359,7 @@ async def test_disconnect_revokes_and_clears(self, fake): assert form["token_type_hint"] == "refresh_token" assert "client_secret" in form # DCR client: same auth as the token endpoint assert await client.get_token_info() is None - assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + assert await storage.get_registration(REGISTRATION_KEY) is None async def test_disconnect_forget_without_tokens_drops_registration(self, fake): storage = InMemoryAgentStorage() @@ -298,7 +367,7 @@ async def test_disconnect_forget_without_tokens_drops_registration(self, fake): await client.connect() await storage.delete_tokens(f"{RESOURCE_URL}|default") # e.g. after invalid_grant await client.disconnect(forget=True) - assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None + assert await storage.get_registration(REGISTRATION_KEY) is None async def test_disconnect_forget_works_without_discovery(self, fake): storage = InMemoryAgentStorage() @@ -308,8 +377,7 @@ async def test_disconnect_forget_works_without_discovery(self, fake): fake.prm = None # discovery now fails client._metadata_at = 0 await client.disconnect(forget=True) - assert await storage.get_registration(f"{ISSUER}|{REDIRECT}") is None - assert await storage.get_registration(f"index|{RESOURCE_URL}|default") is None + assert await storage.get_registration(REGISTRATION_KEY) is None async def test_disconnect_forget_finds_registration_under_bound_port(self, fake): async def open_browser(url: str): @@ -328,7 +396,7 @@ async def follow(): ) await client.connect() key = (await storage.get_tokens(f"{RESOURCE_URL}|default"))["registration_key"] - assert key != f"{ISSUER}|http://127.0.0.1/callback" + assert (await storage.get_registration(key))["redirect_uri"] != "http://127.0.0.1/callback" await storage.delete_tokens(f"{RESOURCE_URL}|default") await client.disconnect(forget=True) assert await storage.get_registration(key) is None diff --git a/testv2/testv2_agents/test_framework_integrations.py b/testv2/testv2_agents/test_framework_integrations.py index 804bcd1a..609dcae2 100644 --- a/testv2/testv2_agents/test_framework_integrations.py +++ b/testv2/testv2_agents/test_framework_integrations.py @@ -199,3 +199,22 @@ def test_client_id_path_that_would_become_a_route_variable(build, encoded): ) with pytest.raises(AgentConfigError): build(document) + + +@pytest.mark.parametrize("encoded", ["%7Bconfig", "config%7D", "%7B%7D", "%7B1x%7D", "%7Ba-b%7D"]) +def test_fastapi_allows_braces_that_are_not_route_variables(encoded): + url = f"https://agent.onzs.co.za/oauth/{encoded}.json" + document = build_client_metadata_document(url, "Onzs Agent", ["http://127.0.0.1/callback"]) + app = FastAPI() + app.include_router(client_metadata_router(document)) + response = TestClient(app).get(url.replace("https://agent.onzs.co.za", "")) + assert response.status_code == 200 + assert response.json()["client_id"] == url + + +def test_fastapi_rejects_a_converter_route_variable(): + document = build_client_metadata_document( + "https://agent.onzs.co.za/oauth/%7Bname:path%7D", "Onzs Agent", ["http://127.0.0.1/callback"] + ) + with pytest.raises(AgentConfigError): + client_metadata_router(document) From a54fbc89f2503c0ebfb7b74a73580cd34bdd1c93 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Fri, 30 Oct 2026 07:16:34 +0200 Subject: [PATCH 7/8] test(agents): cover async verification, revocation and cache limits Add tests for paths Codecov flagged as untested: - async JWT and jwt+introspection verification, including a revoked token with a valid signature and a forged signature never reaching introspection - sync and async revocation: cache clearing, network failure and a refused revoke - the introspection cache size limit, both with and without expired entries to drop - invalid_client on the code exchange dropping the stored registration --- testv2/testv2_agents/test_agent_client.py | 16 ++++ testv2/testv2_agents/test_resource_server.py | 80 ++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py index 8107f9c7..bc10ad0c 100644 --- a/testv2/testv2_agents/test_agent_client.py +++ b/testv2/testv2_agents/test_agent_client.py @@ -18,6 +18,7 @@ KindeAgentClient, PendingAuthorization, RegistrationError, + TokenRequestError, ) from kinde_sdk.agents.client import ( authorization_server_metadata_urls, @@ -265,6 +266,21 @@ async def test_invalid_client_keeps_a_newer_registration(self, fake): await client._drop_registration({"client_id": tokens["client_id"], "registration_key": REGISTRATION_KEY}) assert (await storage.get_registration(REGISTRATION_KEY))["client_id"] == "newer" + async def test_invalid_client_on_code_exchange_drops_registration(self, fake): + storage = InMemoryAgentStorage() + + async def browser(url: str) -> str: + callback = fake.authorize(url) + fake.registrations.clear() # Kinde dropped the registration mid sign-in + return callback + + with pytest.raises(TokenRequestError, match="invalid_client"): + await make_client(fake, storage=storage, authorization_handler=browser).connect() + assert await storage.get_registration(REGISTRATION_KEY) is None + # The next sign-in registers again and works + await make_client(fake, storage=storage).connect() + assert fake.count("/register") == 2 + async def test_too_many_registrations(self, fake): fake.register_status = 429 with pytest.raises(RegistrationError, match="try again later"): diff --git a/testv2/testv2_agents/test_resource_server.py b/testv2/testv2_agents/test_resource_server.py index 5a5a0df4..0bbc21d8 100644 --- a/testv2/testv2_agents/test_resource_server.py +++ b/testv2/testv2_agents/test_resource_server.py @@ -6,6 +6,7 @@ from kinde_sdk.agents import ( AgentAuthConfig, + AgentAuthError, AgentConfigError, AuthenticationRequired, InsufficientScopeError, @@ -338,11 +339,90 @@ def test_revoking_one_token_evicts_its_cached_pair(self, fake): with pytest.raises(InvalidTokenError): auth.verify_token(access_token) + async def test_async_revoke_clears_cache(self, fake): + token = fake.issue() + auth = make_auth(fake, introspection_cache_seconds=30) + await auth.verify_token_async(token) + await auth.revoke_token_async(token, "access_token") + assert fake.revoked == [token] + assert fake.requests[-1].headers["authorization"].startswith("Basic ") + with pytest.raises(InvalidTokenError): + await auth.verify_token_async(token) + + def test_revoke_network_failure_is_unavailable(self, fake): + fake.handle = lambda request: (_ for _ in ()).throw(httpx.ConnectError("offline")) + with pytest.raises(VerificationUnavailableError, match="revoke"): + make_auth(fake).revoke_token("t") + + async def test_async_revoke_network_failure_is_unavailable(self, fake): + fake.handle = lambda request: (_ for _ in ()).throw(httpx.ConnectError("offline")) + with pytest.raises(VerificationUnavailableError, match="revoke"): + await make_auth(fake).revoke_token_async("t") + + def test_revoke_refused_keeps_the_cache(self, fake): + handle = fake.handle + fake.handle = lambda request: httpx.Response(400) if request.url.path.endswith("/revoke") else handle(request) + token = fake.issue() + auth = make_auth(fake, introspection_cache_seconds=30) + auth.verify_token(token) + with pytest.raises(AgentAuthError, match="HTTP 400"): + auth.revoke_token(token) + assert auth._introspection_cache + + def test_full_cache_drops_expired_entries_first(self, fake, monkeypatch): + from kinde_sdk.agents import resource_server + + monkeypatch.setattr(resource_server, "_INTROSPECTION_CACHE_MAX_ENTRIES", 2) + now = [1000.0] + auth = make_auth(fake, introspection_cache_seconds=30) + auth._clock = lambda: now[0] + auth._remember_introspection("old", {"exp": 1010}) + auth._remember_introspection("live", {"exp": 2000}) + now[0] = 1020 # "old" has expired + auth._remember_introspection("new", {"exp": 2000}) + assert auth._cached_introspection("live") is not None + assert auth._cached_introspection("new") is not None + assert len(auth._introspection_cache) == 2 + + def test_full_cache_of_live_entries_is_cleared(self, fake, monkeypatch): + from kinde_sdk.agents import resource_server + + monkeypatch.setattr(resource_server, "_INTROSPECTION_CACHE_MAX_ENTRIES", 2) + auth = make_auth(fake, introspection_cache_seconds=30) + for token in ("a", "b", "c"): + auth._remember_introspection(token, {}) + # Never grows past the limit: when nothing has expired it starts again + assert list(auth._introspection_cache) == [auth._cache_key("c")] + class TestJwtVerification: def jwt_auth(self, fake, strategy="jwt"): return make_auth(fake, verification=strategy) + async def test_async_valid_token(self, fake): + context = await self.jwt_auth(fake).verify_token_async(make_token(permissions=["read:docs"])) + assert context.permissions == ["read:docs"] + assert fake.count("/introspect") == 0 + + async def test_async_wrong_signature(self, fake): + with pytest.raises(InvalidTokenError): + await self.jwt_auth(fake).verify_token_async(make_token(key=other_key())) + + async def test_async_jwt_and_introspection(self, fake): + auth = self.jwt_auth(fake, "jwt+introspection") + token = fake.issue(permissions=["read:docs"]) + context = await auth.verify_token_async(token) + assert context.permissions == ["read:docs"] + assert fake.count("/jwks") == 1 and fake.count("/introspect") == 1 + fake.active_tokens.pop(token) # revoked: the signature is still good, introspection is not + with pytest.raises(InvalidTokenError): + await auth.verify_token_async(token) + + async def test_async_jwt_and_introspection_checks_signature_first(self, fake): + with pytest.raises(InvalidTokenError): + await self.jwt_auth(fake, "jwt+introspection").verify_token_async(make_token(key=other_key())) + assert fake.count("/introspect") == 0 + def test_valid_token(self, fake): context = self.jwt_auth(fake).verify_token(make_token(permissions=["read:docs"])) assert context.user_id == "kp_user" From 72f0f9935f7143612e0899c89aa7bb31a2f99e9c Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Fri, 30 Oct 2026 07:32:13 +0200 Subject: [PATCH 8/8] fix(agents): delete stale registrations atomically and validate secret expiry Cleaning up after invalid_client read the stored registration and then deleted it in a separate call, so another process could save a newer registration in between and lose it. The built-in stores now offer delete_registration_if_client(), which FileAgentStorage runs under its file lock, and the client uses it when a store provides it. Custom stores without it keep working as before. Also reject a registration response whose client_secret_expires_at is not a number, instead of failing later with a TypeError, and test that tokens issued to a replaced registration require a new sign-in. --- kinde_sdk/agents/client.py | 10 ++++- kinde_sdk/agents/storage.py | 20 +++++++++ testv2/testv2_agents/test_agent_client.py | 50 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/kinde_sdk/agents/client.py b/kinde_sdk/agents/client.py index 1d1cb1b0..c926b159 100644 --- a/kinde_sdk/agents/client.py +++ b/kinde_sdk/agents/client.py @@ -490,6 +490,11 @@ async def _drop_registration(self, identity: Dict[str, Any]) -> None: key = identity.get("registration_key") if not key: return + delete_if = getattr(self.storage, "delete_registration_if_client", None) + if delete_if is not None: + # The storage checks and deletes in one step, so a newer registration can't slip in between + await delete_if(key, identity.get("client_id")) + return stored = await self.storage.get_registration(key) if stored and stored.get("client_id") == identity.get("client_id"): await self.storage.delete_registration(key) @@ -537,13 +542,16 @@ async def _register(self, redirect_uri: str, key: str) -> Dict[str, Any]: secret = body.get("client_secret") if secret is not None and (not isinstance(secret, str) or not secret): raise RegistrationError("The registration response has an invalid client_secret") + expires_at = body.get("client_secret_expires_at") or 0 + if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)): + raise RegistrationError("The registration response has an invalid client_secret_expires_at") registration = { "client_id": body["client_id"], "client_secret": secret, # Use what came back, not what was asked for "token_endpoint_auth_method": body.get("token_endpoint_auth_method") or ("client_secret_post" if secret else "none"), - "client_secret_expires_at": body.get("client_secret_expires_at") or 0, + "client_secret_expires_at": expires_at, "redirect_uri": redirect_uri, "issuer": self._as_metadata["issuer"], } diff --git a/kinde_sdk/agents/storage.py b/kinde_sdk/agents/storage.py index d92a64d4..65ce4db7 100644 --- a/kinde_sdk/agents/storage.py +++ b/kinde_sdk/agents/storage.py @@ -21,6 +21,14 @@ class AgentClientStorage(Protocol): + """Where an agent keeps its tokens and DCR registrations. + + A store shared between processes can also define + ``async delete_registration_if_client(key, client_id)``, which deletes the + registration only if it still has that ``client_id``, in one atomic step. + The client uses it when present. + """ + async def get_tokens(self, key: str) -> Optional[Dict[str, Any]]: ... async def set_tokens(self, key: str, tokens: Dict[str, Any]) -> None: ... @@ -63,6 +71,11 @@ async def set_registration(self, key: str, registration: Dict[str, Any]) -> None async def delete_registration(self, key: str) -> None: self._registrations.pop(key, None) + async def delete_registration_if_client(self, key: str, client_id: Optional[str]) -> None: + """Delete the registration only if it still belongs to ``client_id``.""" + if (self._registrations.get(key) or {}).get("client_id") == client_id: + self._registrations.pop(key, None) + class FileAgentStorage: """Keeps everything in one JSON file readable only by the current user (mode 0600). @@ -171,3 +184,10 @@ async def set_registration(self, key: str, registration: Dict[str, Any]) -> None async def delete_registration(self, key: str) -> None: await self._set("registrations", key, None) + + async def delete_registration_if_client(self, key: str, client_id: Optional[str]) -> None: + """Delete the registration only if it still belongs to ``client_id``, under the file lock.""" + async with self._guard(): + with self._file_lock(): + if (self._read()["registrations"].get(key) or {}).get("client_id") == client_id: + self._update("registrations", key, None) diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py index bc10ad0c..f90d5399 100644 --- a/testv2/testv2_agents/test_agent_client.py +++ b/testv2/testv2_agents/test_agent_client.py @@ -281,6 +281,56 @@ async def browser(url: str) -> str: await make_client(fake, storage=storage).connect() assert fake.count("/register") == 2 + async def test_tokens_for_a_replaced_registration_need_a_new_sign_in(self, fake): + storage = InMemoryAgentStorage() + await make_client(fake, storage=storage, redirect_uri="http://127.0.0.1:8001/callback").connect() + old_tokens = await storage.get_tokens(f"{RESOURCE_URL}|default") + # Another sign-in on a new port replaced this account's registration + await make_client(fake, storage=storage, redirect_uri="http://127.0.0.1:8002/callback").authorize() + assert fake.count("/register") == 2 + await storage.set_tokens(f"{RESOURCE_URL}|default", {**old_tokens, "expires_at": time.time() - 10}) + client = make_client(fake, storage=storage, redirect_uri="http://127.0.0.1:8002/callback") + assert await client.refresh(stale_access_token=old_tokens["access_token"]) is None + assert await storage.get_tokens(f"{RESOURCE_URL}|default") is None + + @pytest.mark.parametrize("value", ["2030-01-01", True, [1]]) + async def test_invalid_secret_expiry_in_registration(self, fake, value): + fake._register = lambda request: httpx.Response( + 201, json={"client_id": "c", "client_secret": "s", "client_secret_expires_at": value} + ) + with pytest.raises(RegistrationError, match="client_secret_expires_at"): + await make_client(fake).connect() + + @pytest.mark.parametrize("storage_class", ["memory", "file"]) + async def test_drop_registration_is_compare_and_delete(self, fake, tmp_path, storage_class): + storage = InMemoryAgentStorage() if storage_class == "memory" else FileAgentStorage(str(tmp_path / "t.json")) + client = make_client(fake, storage=storage) + await storage.set_registration(REGISTRATION_KEY, {"client_id": "newer"}) + await client._drop_registration({"client_id": "older", "registration_key": REGISTRATION_KEY}) + assert (await storage.get_registration(REGISTRATION_KEY))["client_id"] == "newer" + await client._drop_registration({"client_id": "newer", "registration_key": REGISTRATION_KEY}) + assert await storage.get_registration(REGISTRATION_KEY) is None + + async def test_drop_registration_without_compare_and_delete(self, fake): + class BasicStorage: + """A custom store with only the required methods.""" + + def __init__(self): + self.inner = InMemoryAgentStorage() + + def __getattr__(self, name): + if name == "delete_registration_if_client": + raise AttributeError(name) + return getattr(self.inner, name) + + storage = BasicStorage() + client = make_client(fake, storage=storage) + await storage.set_registration(REGISTRATION_KEY, {"client_id": "newer"}) + await client._drop_registration({"client_id": "older", "registration_key": REGISTRATION_KEY}) + assert (await storage.get_registration(REGISTRATION_KEY))["client_id"] == "newer" + await client._drop_registration({"client_id": "newer", "registration_key": REGISTRATION_KEY}) + assert await storage.get_registration(REGISTRATION_KEY) is None + async def test_too_many_registrations(self, fake): fake.register_status = 429 with pytest.raises(RegistrationError, match="try again later"):