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/.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 diff --git a/README.md b/README.md index cc680c50..7b697bb2 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. 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. + +## 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. 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: + +```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..00fce311 --- /dev/null +++ b/examples/agents/smoke_check.py @@ -0,0 +1,232 @@ +""" +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 base64 +import json +import sys +from urllib.parse import quote_plus + +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, 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 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: + response = client.get(url) + except httpx.HTTPError as exc: + report("FAIL", f"GET {url}: {type(exc).__name__}") + return None, None + try: + 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: + """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: + 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 + 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" + ) + 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: + """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: + report("FAIL", "Is mcp_server.py running?") + 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") + + +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 ( + ("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: + """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) + 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: + 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") + 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: + """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}) + 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)") + + try: + response = client.post( + config.introspection_url, + data={"token": token}, + # 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__}") + return + 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 + 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")] + 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: + """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)") + 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..38851211 --- /dev/null +++ b/kinde_fastapi/agents.py @@ -0,0 +1,183 @@ +""" +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"))): + ... +""" + +import re +from typing import Any, Callable, Dict, Optional +from urllib.parse import unquote, 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, 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": "*", + "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, + ): + """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) + + 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: + """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( + self.auth.protected_resource_metadata(), + headers={**_CORS_HEADERS, "Cache-Control": "max-age=3600"}, + ) + + # Routes match the decoded request path; the challenge still advertises the encoded URL + route = unquote(self.auth.config.protected_resource_metadata_path) + if _PATH_PARAM.search(route): + # Starlette would read this as a path parameter, not literal text + raise AgentConfigError(f"The resource URL path must not contain a {{name}} segment: {route!r}") + paths = [route] + 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: + """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() + 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: + """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: + 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: + """Verify the bearer token, then require the Kinde permissions.""" + 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") + + # Routes match the decoded request path, so decode any %-escapes in the client_id + route = path or unquote(urlsplit(document["client_id"]).path) + 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_flask/agents.py b/kinde_flask/agents.py new file mode 100644 index 00000000..7ebc4b14 --- /dev/null +++ b/kinde_flask/agents.py @@ -0,0 +1,197 @@ +""" +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 unquote, 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, AgentConfigError +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, + ): + """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: + self.init_app(app) + + 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()) + response.headers.update({**_CORS_HEADERS, "Cache-Control": "max-age=3600"}) + return response + + # Routes match the decoded request path; the challenge still advertises the encoded URL + route = unquote(self.auth.config.protected_resource_metadata_path) + if "<" in route or ">" in route: + # Werkzeug would read these as a route variable + raise AgentConfigError(f"The resource URL path must not contain '<' or '>': {route!r}") + paths = [route] + 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(): + """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()) + 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: + """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) + 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]: + """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: + 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]: + """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: + 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) + + # 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/__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..0ae35693 --- /dev/null +++ b/kinde_sdk/agents/client.py @@ -0,0 +1,1003 @@ +""" +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]]: + """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 + if parts.scheme == "http" and is_loopback_host(parts.hostname): + return + raise DiscoveryError(f"Refusing a non-https {what}: {url}") + + +def _refuse_loopback_from_remote(url: str, resource_url: str, what: str) -> None: + """Raise :class:`DiscoveryError` when a remote resource's metadata points at this machine.""" + # Loopback is fine for local development, where the resource itself is on loopback + if is_loopback_host(urlsplit(url).hostname) and not is_loopback_host(urlsplit(resource_url).hostname): + raise DiscoveryError(f"Refusing a loopback {what} from a remote resource: {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]]: + """Return the OAuth ``error`` and ``error_description`` from a response, if it has them.""" + 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): + """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}") + 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._query = parts.query + 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: + """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) + self._bound_port = self._server.sockets[0].getsockname()[1] + # Keep any query: the authorization server compares the whole registered URI + query = f"?{self._query}" if self._query else "" + self.redirect_uri = f"http://{self._netloc_host}:{self._bound_port}{self._path}{query}" + + 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""): + pass + method, _, rest = request_line.partition(" ") + target = rest.rsplit(" ", 1)[0] + 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 + 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: + """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() + 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, + ): + """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) + 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: + """Close the HTTP client if this client created it.""" + 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: + """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() + return lock + + @property + def _token_key(self) -> str: + return f"{self.resource_url}|{self.account}" + + @property + def _resource_indicator(self) -> str: + """The RFC 8707 ``resource`` to ask for: the one the metadata names, which may be a parent of the URL called.""" + resource = (self._prm or {}).get("resource") + return resource if isinstance(resource, str) else self.resource_url + + @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 ------------------------------------------------------------ + + 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") + _refuse_loopback_from_remote(issuer, self.resource_url, "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]]: + """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: + 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]: + """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")) + 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) + 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")) + 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: + """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 + try: + _refuse_loopback_from_remote(metadata_url, self.resource_url, "resource_metadata URL") + except DiscoveryError: + 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]: + """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: + 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("_", " ")) + _refuse_loopback_from_remote(metadata[name], self.resource_url, 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: + """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: + # 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]: + """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"} + 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 = self._registration_key + registration = await self.storage.get_registration(key) + 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) + return _identity_from_registration(registration, key) + + @property + 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 + 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) + + 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], + "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, + ) + 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 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") + 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": expires_at, + "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: + """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: + 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_indicator, + } + 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() + 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: + 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_indicator, + } + 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": + await self._drop_registration(identity) + 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, requested_scope=pending.scope + ) + + 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) + listener.expected_state = pending.state + 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: + """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: + 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]], + requested_scope: Optional[str] = None, + ) -> Dict[str, Any]: + """Store a token response, keeping the previous refresh token when none is returned.""" + 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, + # Kept so the refresh margin can scale with short-lived tokens + "expires_in": float(expires_in) if isinstance(expires_in, (int, float)) else None, + # RFC 6749 §5.1: no scope in the response means the requested scope was granted + "scope": body.get("scope") or requested_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: + """Whether the access token expires within the refresh margin.""" + expires_at = tokens.get("expires_at") + if expires_at is None: + return False + margin = _REFRESH_MARGIN_SECONDS + lifetime = tokens.get("expires_in") + if isinstance(lifetime, (int, float)) and lifetime > 0: + # At most half the lifetime, or a token living 60 seconds or less would refresh on every call + margin = min(margin, lifetime / 2) + return expires_at - time.time() < margin + + 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() + # 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) + return None + data = { + "grant_type": "refresh_token", + "refresh_token": tokens["refresh_token"], + "resource": self._resource_indicator, + } + 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. Or another client + # sharing this storage rotated the refresh token first: then keep its newer tokens. + return await self._drop_tokens_unless_replaced(tokens["refresh_token"]) + if error == "invalid_client": + # The registration expired: register again and sign in again + await self._drop_registration(identity) + 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 _drop_tokens_unless_replaced(self, refresh_token: str) -> Optional[Dict[str, Any]]: + """Delete the stored tokens if they still hold ``refresh_token``; return any newer tokens left.""" + delete_if = getattr(self.storage, "delete_tokens_if_refresh_token", None) + if delete_if is not None: + # The storage checks and deletes in one step, so a newer pair can't slip in between + await delete_if(self._token_key, refresh_token) + else: + stored = await self.storage.get_tokens(self._token_key) + if stored and stored.get("refresh_token") == refresh_token: + await self.storage.delete_tokens(self._token_key) + return await self.storage.get_tokens(self._token_key) + + 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"]) + 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: + """Resolve ``url`` against the resource URL, refusing other origins.""" + 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: + # So a second 401 tells _sign_in which token was just refused + tokens = refreshed + response = await send(tokens["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 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: + identity = await self._identity_for_tokens(tokens) + try: + metadata = await self.discover() + except AgentClientError: + metadata = {} + endpoint = metadata.get("revocation_endpoint") + # 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") + 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: + # 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]: + """Turn a stored DCR registration into a client identity.""" + 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: + """Raise :class:`AgentConfigError` unless the resource URL is https or http loopback.""" + 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..af7318a0 --- /dev/null +++ b/kinde_sdk/agents/client_metadata.py @@ -0,0 +1,126 @@ +""" +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" + 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: + 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..24f0145d --- /dev/null +++ b/kinde_sdk/agents/config.py @@ -0,0 +1,182 @@ +""" +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: + """Whether ``host`` is localhost, 127.0.0.1 or ::1.""" + 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.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: + 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: + """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)") + 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]: + """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: + 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): + """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)") + 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..ac10d48d --- /dev/null +++ b/kinde_sdk/agents/context.py @@ -0,0 +1,127 @@ +""" +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]: + """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] + scope = claims.get("scope") or scp + if isinstance(scope, str): + return scope.split() + return [] + + +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 [] + 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: + """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 + + +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..bf64cf63 --- /dev/null +++ b/kinde_sdk/agents/errors.py @@ -0,0 +1,108 @@ +""" +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): + """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) + + +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): + """Record the missing permissions.""" + 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..42918174 --- /dev/null +++ b/kinde_sdk/agents/mcp/client.py @@ -0,0 +1,114 @@ +""" +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"): + """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 + + 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 + + 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, + ): + """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"] + 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..6bae2616 --- /dev/null +++ b/kinde_sdk/agents/mcp/server.py @@ -0,0 +1,122 @@ +""" +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): + """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: + 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: + """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() + 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..25ee16c3 --- /dev/null +++ b/kinde_sdk/agents/resource_server.py @@ -0,0 +1,673 @@ +""" +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 asyncio +import base64 +import hashlib +import logging +import re +import threading +import time +import weakref +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._refresh_failed = False + self._lock = threading.Lock() + # One asyncio lock per event loop: a lock can't be shared across loops + self._async_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = ( + weakref.WeakKeyDictionary() + ) + + 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 + 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: + """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: + 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 jwks: + 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 + self._refresh_failed = False + + def _fetch_failed(self, exc: Exception) -> 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): + 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: + """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: + # 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 + + 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): + 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) + + def _fresh_key(self, kid: str) -> Any: + """The cached key for ``kid`` while the key set is fresh, else ``None``.""" + if self._fetched_at is None or time.monotonic() - self._fetched_at >= _JWKS_CACHE_SECONDS: + return None + return self._keys.get(kid) + + async def get_key_async(self, kid: str, client: httpx.AsyncClient) -> Any: + """Async version of :meth:`get_key`.""" + key = self._fresh_key(kid) + if key is not None: + return key + loop = asyncio.get_running_loop() + lock = self._async_locks.get(loop) + if lock is None: + lock = self._async_locks[loop] = asyncio.Lock() + # Requests arriving during a fetch wait for it, then find the new keys instead of failing + async with lock: + 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, + ): + """Use ``config``, or build one from ``options`` or the environment.""" + 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]]] = {} + # Bumped on every revocation, so an introspection answer from before it is never cached + self._revocation_generation = 0 + self._as_metadata_cache: Optional[Tuple[float, Dict[str, Any]]] = None + + # -- 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 + 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]]: + """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: + 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. An explicit + # false from Kinde is kept: agents must not send a CIMD URL to a server that refuses it. + document.setdefault("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]: + """Async version of :meth:`fetch_authorization_server_metadata`.""" + 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: + generation = self._revocation_generation + data = self._handle_introspection(self._post_introspection(token), token, generation) + 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): + 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: + generation = self._revocation_generation + data = self._handle_introspection(await self._post_introspection_async(token), token, generation) + 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, + 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]: + """Async version of :meth:`_post_introspection`.""" + 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, generation: int + ) -> 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") + 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") + # Not cached if a revocation landed while this request was in flight: the answer may be stale + if generation == self._revocation_generation: + 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: + """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. + # 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]]: + """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)) + 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: + """Cache an active introspection result, no longer than the token lives.""" + 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: + """Return the token's key ID, rejecting anything but RS256.""" + 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]: + """Verify the signature, audience, expiry and issuer, and return the claims.""" + 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: + """Async version of :meth:`revoke_token`.""" + 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: + """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 + self._revocation_generation += 1 + self._introspection_cache.clear() + + +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: + 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: + """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 new file mode 100644 index 00000000..f12302d7 --- /dev/null +++ b/kinde_sdk/agents/storage.py @@ -0,0 +1,209 @@ +""" +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 contextlib +import json +import os +import tempfile +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): + """Where an agent keeps its tokens and DCR registrations. + + A store shared between processes can also define two compare-and-delete + methods, each one atomic step. The client uses them when present: + + - ``async delete_registration_if_client(key, client_id)`` deletes the + registration only if it still has that ``client_id``. + - ``async delete_tokens_if_refresh_token(key, refresh_token)`` deletes the + tokens only if they still hold that ``refresh_token``, so a failed refresh + never removes a newer pair another process just saved. + """ + + 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]]: + """Return a copy of the stored tokens, or ``None``.""" + 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]]: + """Return a copy of the stored registration, or ``None``.""" + 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) + + 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) + + async def delete_tokens_if_refresh_token(self, key: str, refresh_token: Optional[str]) -> None: + """Delete the tokens only if they still hold ``refresh_token``.""" + if (self._tokens.get(key) or {}).get("refresh_token") == refresh_token: + self._tokens.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. + + 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): + self.path = os.path.expanduser(path) + 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) + except FileNotFoundError: + return {"tokens": {}, "registrations": {}} + data.setdefault("tokens", {}) + data.setdefault("registrations", {}) + 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 + 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 + + @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) + 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: + """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 + + 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(): + with self._file_lock(): + 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) + 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) + + 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) + + async def delete_tokens_if_refresh_token(self, key: str, refresh_token: Optional[str]) -> None: + """Delete the tokens only if they still hold ``refresh_token``, under the file lock.""" + async with self._guard(): + with self._file_lock(): + if (self._read()["tokens"].get(key) or {}).get("refresh_token") == refresh_token: + self._update("tokens", key, None) diff --git a/kinde_sdk/agents/www_authenticate.py b/kinde_sdk/agents/www_authenticate.py new file mode 100644 index 00000000..0d507185 --- /dev/null +++ b/kinde_sdk/agents/www_authenticate.py @@ -0,0 +1,144 @@ +""" +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 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 = [] + 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]: + """Parse one header value into its challenges.""" + 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..bfac81a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ dependencies = [ "urllib3 >=2.2.0, <3.0", "python-dateutil >=2.9.0, <3.0", - "pyjwt >=2.10.0, <3.0", + "pyjwt[crypto] >=2.10.0, <3.0", "requests ~=2.32.5; python_version < '3.10'", "requests ~=2.34.2; python_version >= '3.10'", "typing-extensions >=4.11.0, <5.0", @@ -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/requirements.txt b/requirements.txt index 1d4f81fd..b851b5ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ requests~=2.34.2; python_version >= "3.10" django>=4.2.0,<5.0.0; python_version < "3.12" django>=6.1,<6.2.0; python_version >= "3.12" python-dotenv>=1.0.0 -pyjwt>=2.10.0 +pyjwt[crypto]>=2.10.0 fastapi>=0.110.0 flask>=3.0.0 flask-session>=0.6.0,!=0.7.0,<1.0.0 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..fa14d345 --- /dev/null +++ b/testv2/testv2_agents/kinde_fake.py @@ -0,0 +1,325 @@ +""" +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: + """Sign a Kinde-shaped access token with the test key.""" + 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.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]] = { + "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]: + """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: + 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: + """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 + 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 self.jwks_body is None else self.jwks_body) + 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: + """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: + 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: + """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) + 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: + """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 + 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: + """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"}) + 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: + """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) + 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]: + """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) + } diff --git a/testv2/testv2_agents/test_agent_client.py b/testv2/testv2_agents/test_agent_client.py new file mode 100644 index 00000000..4609425f --- /dev/null +++ b/testv2/testv2_agents/test_agent_client.py @@ -0,0 +1,847 @@ +import asyncio +import os +import stat +import sys +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, + TokenRequestError, +) +from kinde_sdk.agents.client import ( + _LoopbackListener, + 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" +REGISTRATION_KEY = f"dcr|{RESOURCE_URL}|default" + + +@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 client_with_handler(handler, **options) -> KindeAgentClient: + """A CIMD client whose HTTP calls go through ``handler`` (sync or async) instead of the fake directly.""" + fake = options.pop("fake") + client = make_client(fake, client_metadata_url=CIMD_URL, **options) + client._http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return client + + +def is_grant(request: httpx.Request, grant: str) -> bool: + return request.url.path.endswith("/token") and f"grant_type={grant}".encode() in request.content + + +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(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" + + 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_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_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_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_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_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"): + 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_signs_in_again_when_the_refreshed_token_is_refused(self, fake): + refused = set() + + def handler(request): + response = fake.handle(request) + if is_grant(request, "refresh_token"): + refused.add(response.json()["access_token"]) # the resource refuses refreshed tokens too + bearer = request.headers.get("authorization", "")[7:] + if request.url.host == "api.example.com" and bearer in refused: + return httpx.Response(401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}) + return response + + client = client_with_handler(handler, fake=fake) + await client.connect() + refused.add(await client.get_access_token()) + response = await client.request("GET") + assert response.status_code == 200 + assert len([r for r in fake.requests if is_grant(r, "authorization_code")]) == 2 + + async def test_scope_missing_from_the_token_response_is_the_requested_scope(self, fake): + def handler(request): + response = fake.handle(request) + if request.url.path.endswith("/token"): + body = {k: v for k, v in response.json().items() if k != "scope"} + return httpx.Response(response.status_code, json=body) + return response + + client = client_with_handler(handler, fake=fake, scopes=["offline", "email"]) + await client.connect() + assert (await client.get_token_info())["scopes"] == ["offline", "email"] + + async def test_short_lived_tokens_are_not_refreshed_on_every_call(self, fake): + def handler(request): + response = fake.handle(request) + if request.url.path.endswith("/token") and response.status_code == 200: + return httpx.Response(200, json={**response.json(), "expires_in": 60}) + return response + + client = client_with_handler(handler, fake=fake) + await client.connect() + for _ in range(3): + assert (await client.request("GET")).status_code == 200 + assert not [r for r in fake.requests if is_grant(r, "refresh_token")] + + def test_refresh_margin_is_at_most_half_the_lifetime(self): + now = time.time() + assert KindeAgentClient._expiring({"expires_at": now + 40, "expires_in": 60}) is False + assert KindeAgentClient._expiring({"expires_at": now + 20, "expires_in": 60}) is True + # Long-lived, and tokens stored before the lifetime was kept: the usual 60 seconds + assert KindeAgentClient._expiring({"expires_at": now + 50, "expires_in": 86400}) is True + assert KindeAgentClient._expiring({"expires_at": now + 50}) is True + assert KindeAgentClient._expiring({"expires_at": now + 120}) is False + + @pytest.mark.parametrize("storage_class", ["memory", "file", "basic"]) + async def test_invalid_grant_keeps_tokens_another_client_just_rotated(self, fake, tmp_path, storage_class): + class BasicStorage: + """A custom store with only the required methods.""" + + def __init__(self): + self.inner = InMemoryAgentStorage() + + def __getattr__(self, name): + if name == "delete_tokens_if_refresh_token": + raise AttributeError(name) + return getattr(self.inner, name) + + storage = { + "memory": InMemoryAgentStorage, + "file": lambda: FileAgentStorage(str(tmp_path / "t.json")), + "basic": BasicStorage, + }[storage_class]() + fake.rotate_refresh_tokens = True + other = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) + await other.connect() + + async def handler(request): + if is_grant(request, "refresh_token"): + # The other client (another process) rotates the same refresh token first + await other.refresh() + return fake.handle(request) + + client = client_with_handler(handler, fake=fake, storage=storage) + tokens = await client.refresh() + stored = await storage.get_tokens(f"{RESOURCE_URL}|default") + assert stored is not None and tokens == stored + assert stored["refresh_token"] in fake.refresh_tokens + + 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(REGISTRATION_KEY) 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(REGISTRATION_KEY) 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(REGISTRATION_KEY) 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 (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 + + 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) + 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) + 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_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_remote_resource_cannot_name_a_loopback_issuer(self, fake): + fake.prm["authorization_servers"] = ["http://127.0.0.1:9/oauth2"] + with pytest.raises(DiscoveryError, match="loopback authorization server"): + await make_client(fake, client_metadata_url=CIMD_URL).discover() + assert not [r for r in fake.requests if r.url.host == "127.0.0.1"] + + @pytest.mark.parametrize("endpoint", ["token_endpoint", "registration_endpoint", "revocation_endpoint"]) + async def test_remote_authorization_server_cannot_name_loopback_endpoints(self, fake, endpoint): + fake.as_metadata[endpoint] = "http://localhost:9/admin" + with pytest.raises(DiscoveryError, match="loopback"): + await make_client(fake).discover() + + def test_loopback_resource_may_use_a_loopback_issuer(self): + from kinde_sdk.agents.client import _refuse_loopback_from_remote + + _refuse_loopback_from_remote("http://127.0.0.1:8000/oauth2", "http://localhost:3001/mcp", "issuer") + with pytest.raises(DiscoveryError): + _refuse_loopback_from_remote("http://127.0.0.1:8000/oauth2", RESOURCE_URL, "issuer") + + async def test_sends_the_resource_the_metadata_names(self, fake): + fake.prm["resource"] = "https://api.example.com/" # a parent of the URL the agent calls + storage = InMemoryAgentStorage() + client = make_client(fake, client_metadata_url=CIMD_URL, storage=storage) + authorizations = [] + + async def browser(url): + authorizations.append(url) + return fake.authorize(url) + + client.authorization_handler = browser + assert (await client.request("GET")).status_code == 200 + await client.refresh() + assert query_of(authorizations[0])["resource"] == "https://api.example.com/" + grants = [form_of(r) for r in fake.requests if r.url.path.endswith("/token")] + assert [grant["resource"] for grant in grants] == ["https://api.example.com/"] * 2 + # Requests still go to the URL the developer gave + assert any(str(r.url) == RESOURCE_URL for r in fake.requests) + + 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_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() + 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 = [] + + 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()) + + 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 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 + + +async def test_loopback_listener_keeps_the_redirect_query(): + listener = _LoopbackListener("http://127.0.0.1/callback?tenant=1") + await listener.start() + try: + assert listener.redirect_uri.startswith("http://127.0.0.1:") + assert listener.redirect_uri.endswith("/callback?tenant=1") + listener.expected_state = "s" + async with httpx.AsyncClient() as browser: + response = await browser.get(listener.redirect_uri + "&code=c&state=s") + assert response.status_code == 200 + assert query_of(await listener.wait(1)) == {"tenant": "1", "code": "c", "state": "s"} + finally: + await listener.close() + + +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 + + 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): + 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..eea58e6a --- /dev/null +++ b/testv2/testv2_agents/test_framework_integrations.py @@ -0,0 +1,261 @@ +import pytest +from fastapi import Depends, FastAPI +from flask import Flask +from starlette.testclient import TestClient + +from kinde_sdk.agents import ( + AgentAuthConfig, + AgentConfigError, + 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_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()}"}) + 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" + + +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 + + +@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) + + +@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) + + +ENCODED_RESOURCE = "https://api.example.com/mcp/a%20b" + + +def encoded_resource_auth(): + config = AgentAuthConfig( + domain="test.kinde.com", client_id=APP_ID, verification="jwt", resource_url=ENCODED_RESOURCE + ) + return KindeAgentAuth(config) + + +@pytest.mark.parametrize("framework", ["fastapi", "flask"]) +def test_metadata_served_at_the_advertised_url_for_an_encoded_resource_path(framework): + auth = encoded_resource_auth() + if framework == "fastapi": + app = FastAPI() + FastAPIAgentAuth(auth).install(app) + client = TestClient(app) + else: + app = Flask(__name__) + FlaskAgentAuth(auth, app=app) + client = app.test_client() + # The challenge advertises the encoded URL; the route must answer it + advertised = auth.config.protected_resource_metadata_url + assert advertised == "https://api.example.com/.well-known/oauth-protected-resource/mcp/a%20b" + response = client.get("/.well-known/oauth-protected-resource/mcp/a%20b") + assert response.status_code == 200 + assert body(response)["resource"] == ENCODED_RESOURCE + + +@pytest.mark.parametrize("framework, encoded", [("fastapi", "%7Bname%7D"), ("flask", "%3Cname%3E")]) +def test_encoded_resource_path_that_would_become_a_route_variable(framework, encoded): + config = AgentAuthConfig( + domain="test.kinde.com", client_id=APP_ID, verification="jwt", resource_url=f"https://api.example.com/{encoded}" + ) + with pytest.raises(AgentConfigError): + if framework == "fastapi": + FastAPIAgentAuth(KindeAgentAuth(config)).install(FastAPI()) + else: + FlaskAgentAuth(KindeAgentAuth(config), app=Flask(__name__)) 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..0ff55acf --- /dev/null +++ b/testv2/testv2_agents/test_resource_server.py @@ -0,0 +1,605 @@ +import asyncio +import base64 +import json +import time + +import httpx +import jwt +import pytest + +from kinde_sdk.agents import ( + AgentAuthConfig, + AgentAuthError, + 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, +) +from testv2.testv2_agents.kinde_fake import jwks as fake_jwks + + +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"}, + {"domain": "https://test.kinde.com@evil.example"}, + {"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_adds_missing_cimd_flag(self, fake): + metadata = {**fake.as_metadata, "extra": 1} + del metadata["client_id_metadata_document_supported"] + fake.as_metadata = metadata + 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 + + def test_as_metadata_proxy_keeps_an_explicit_false_cimd_flag(self, fake): + fake.as_metadata = {**fake.as_metadata, "client_id_metadata_document_supported": False} + document = make_auth(fake).fetch_authorization_server_metadata() + # Agents must register instead of sending a CIMD URL the server refuses + assert document["client_id_metadata_document_supported"] is False + + 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) + + 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) + + 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) + + async def test_revocation_during_introspection_is_not_cached(self, fake): + token = fake.issue() + auth = make_auth(fake, introspection_cache_seconds=30) + real = auth._post_introspection_async + + async def revoked_while_in_flight(value): + response = await real(value) # Kinde says active... + await auth.revoke_token_async(token) # ...then the token is revoked before the answer is handled + return response + + auth._post_introspection_async = revoked_while_in_flight + await auth.verify_token_async(token) + auth._post_introspection_async = real + with pytest.raises(InvalidTokenError): + await auth.verify_token_async(token) + assert fake.count("/introspect") == 2 + + 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" + 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 + + @staticmethod + def slow_jwks_auth(fake): + """JWT verification whose JWKS responses take a moment, so concurrent requests overlap a fetch.""" + + async def handler(request): + if request.url.path.endswith("/jwks"): + await asyncio.sleep(0.05) + return fake.handle(request) + + config = AgentAuthConfig(domain=DOMAIN, client_id=APP_ID, resource_url=RESOURCE_URL, verification="jwt") + return KindeAgentAuth(config, async_http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + async def test_concurrent_requests_on_a_cold_cache_wait_for_one_fetch(self, fake): + auth = self.slow_jwks_auth(fake) + contexts = await asyncio.gather(*(auth.verify_token_async(make_token()) for _ in range(5))) + assert len(contexts) == 5 + assert fake.count("/jwks") == 1 + + async def test_concurrent_requests_during_key_rotation_wait_for_one_fetch(self, fake): + auth = self.slow_jwks_auth(fake) + await auth.verify_token_async(make_token()) + rotated = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(other_key().public_key())) + rotated.update({"kid": "rotated", "alg": "RS256", "use": "sig"}) + fake.jwks_body = {"keys": [*fake_jwks()["keys"], rotated]} + tokens = [make_token(kid="rotated", key=other_key()) for _ in range(5)] + contexts = await asyncio.gather(*(auth.verify_token_async(token) for token in tokens)) + assert len(contexts) == 5 + 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 + + @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): + 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..9690987b --- /dev/null +++ b/testv2/testv2_agents/test_www_authenticate_and_cimd.py @@ -0,0 +1,137 @@ +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", + "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) + + 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)