From 0b5c6a9391fc1b67001e2ade4d80cb561e708ab5 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 3 Aug 2026 15:47:39 -0700 Subject: [PATCH 1/7] feat(keycardai-langchain): add LangChain integration package Delegated access at the tool-call boundary for LangChain 1.x agents: KeycardGrantMiddleware implements wrap_tool_call, acquires resource tokens under the identity of the run via keycardai-oauth, and exposes the result to tools as a non-throwing AccessContext. KeycardIdentity selects the access pattern per invocation: - subject_token: on-behalf-of (RFC 8693 token exchange) - as_self=True: the agent acts as itself (client credentials); never interrupts, since no user exists to send to a consent page - user_identifier: impersonation (forbidden by default, zone policy) Missing grants and sign-in pause the run with LangGraph interrupts when authorization_url / sign_in_url are set. keycardai.langchain.testing provides mock_access_context for testing tools without a zone. Includes two runnable examples (user-facing on-behalf-of agent with the interrupt/resume loop; background as-itself agent), extracted from the demo spike verified against a live zone. Registers the package in the justfile test and test-coverage targets so its suite runs in CI (fastmcp is missing from those lists, ECO-172). --- justfile | 2 + packages/langchain/README.md | 263 +++++++++++++ .../examples/background_agent/README.md | 39 ++ .../examples/background_agent/main.py | 127 +++++++ .../examples/background_agent/pyproject.toml | 14 + .../examples/user_facing_agent/README.md | 45 +++ .../examples/user_facing_agent/main.py | 122 ++++++ .../examples/user_facing_agent/pyproject.toml | 14 + packages/langchain/pyproject.toml | 124 +++++++ .../src/keycardai/langchain/__init__.py | 67 ++++ .../src/keycardai/langchain/middleware.py | 351 ++++++++++++++++++ .../keycardai/langchain/testing/__init__.py | 13 + .../keycardai/langchain/testing/test_utils.py | 84 +++++ packages/langchain/tests/test_middleware.py | 266 +++++++++++++ packages/langchain/tests/test_testing_seam.py | 49 +++ pyproject.toml | 1 + uv.lock | 28 ++ 17 files changed, 1609 insertions(+) create mode 100644 packages/langchain/README.md create mode 100644 packages/langchain/examples/background_agent/README.md create mode 100644 packages/langchain/examples/background_agent/main.py create mode 100644 packages/langchain/examples/background_agent/pyproject.toml create mode 100644 packages/langchain/examples/user_facing_agent/README.md create mode 100644 packages/langchain/examples/user_facing_agent/main.py create mode 100644 packages/langchain/examples/user_facing_agent/pyproject.toml create mode 100644 packages/langchain/pyproject.toml create mode 100644 packages/langchain/src/keycardai/langchain/__init__.py create mode 100644 packages/langchain/src/keycardai/langchain/middleware.py create mode 100644 packages/langchain/src/keycardai/langchain/testing/__init__.py create mode 100644 packages/langchain/src/keycardai/langchain/testing/test_utils.py create mode 100644 packages/langchain/tests/test_middleware.py create mode 100644 packages/langchain/tests/test_testing_seam.py diff --git a/justfile b/justfile index c79cc53..429ba98 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,7 @@ test: build just test-package fastmcp just test-package mcp-fastmcp just test-package a2a + just test-package langchain # Run tests for a specific package test-package PACKAGE: @@ -41,6 +42,7 @@ test-coverage: build cd packages/fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=60 cd packages/mcp-fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=70 cd packages/a2a && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=55 + cd packages/langchain && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=85 check: uv run ruff check diff --git a/packages/langchain/README.md b/packages/langchain/README.md new file mode 100644 index 0000000..3ee1f0f --- /dev/null +++ b/packages/langchain/README.md @@ -0,0 +1,263 @@ +# keycardai-langchain + +Keycard integration for LangChain agents. Every tool call gets a short-lived +credential brokered by Keycard, scoped to the identity the agent is acting for, +and recorded in the audit log. + +Your tools never hold an API key, the model never sees a credential, and you do +not write an OAuth flow. + +## Install + +```bash +pip install keycardai-langchain +``` + +## Quick start + +```python +from langchain.agents import create_agent +from langchain.tools import tool + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +CALENDAR = "https://www.googleapis.com/calendar/v3" + +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=[CALENDAR], + client_id="your-agent", + client_secret=..., +) + + +@tool +def list_events(days_ahead: int = 0) -> str: + """List the user's calendar events.""" + token = get_access_context().access(CALENDAR).access_token + ... + + +agent = create_agent( + model, + tools=[list_events], + middleware=[keycard], + context_schema=KeycardIdentity, +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), +) +``` + +That is the whole integration: one middleware in the agent's middleware list, +and one call inside each tool to read the credential for this call. + +## How it works + +`KeycardGrantMiddleware` implements LangChain's `wrap_tool_call` hook, so it +runs at the tool-call boundary. Before each tool executes it acquires tokens +for the declared resources under the identity of the run, then exposes the +result to the tool as an `AccessContext`. + +The middleware is framework-shaped rather than Keycard-shaped: identity travels +on the agent's own `context_schema`, and the pause-for-authorization flow is a +LangGraph interrupt. Nothing is bolted onto the side of the framework. + +The same middleware instance works under `create_agent`, a raw LangGraph graph, +and `create_deep_agent` (deep agents are built on the same middleware system). + +## Access patterns + +`KeycardIdentity` carries the identity for a run, and its fields select the +access pattern: + +| Field | Pattern | Meaning | +|---|---|---| +| `subject_token` | on-behalf-of | Exchange the caller's own token for resource tokens (RFC 8693). | +| `as_self=True` | as itself | Client-credentials grant under the agent's own application identity. No user anywhere. | +| `user_identifier` | impersonation | Substitute-user exchange, authenticated by the agent's credential. Forbidden by default; requires a zone policy. | + +A run with no identity at all is an error (or a sign-in interrupt), never a +silent fallback to the agent's own authority — acting as itself is always an +explicit choice. + +### On-behalf-of: a user-facing agent + +The agent acts for the person in the chat. Their token is exchanged per tool +call, so every resource access is attributed to agent-for-user in the audit +log, and revoking the user's grant cuts the agent off immediately. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://www.googleapis.com/calendar/v3"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # Optional: pause the run in-chat instead of failing. + sign_in_url="https://your-app.example/signin", + authorization_url="https://your-app.example/authorize", +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), +) +``` + +Runnable version: [`examples/user_facing_agent`](examples/user_facing_agent). + +### As itself: a background agent + +No user in the loop — a scheduled digest, a queue worker, a monitor. The agent +authenticates as its own application and Keycard delivers whatever credential +the zone brokers for the resource, including vaulted secrets, so the worker's +environment holds no API keys and revocation lives in one place. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.github.com"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(as_self=True), +) +``` + +As-itself runs never pause on an interrupt, even when `sign_in_url` or +`authorization_url` is set: there is no user to send to a consent page, so a +denied grant stays on the `AccessContext` as an error for the tool and the +operator's logs. + +Runnable version: [`examples/background_agent`](examples/background_agent). + +### Impersonation: acting as a specific user without their token + +The agent asks for tokens *as* a named user, authenticated only by its own +credential. This is the sharpest tool in the box and is forbidden by default; +it requires an explicit impersonation policy in the zone. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://www.googleapis.com/calendar/v3"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(user_identifier="user@example.com"), +) +``` + +### Identity without per-run context + +For a deployed agent whose surface does not thread per-run context, set +`fallback_identity`. Pass a **callable** to resolve it per tool call, so a +sign-in that happens mid-conversation takes effect on resume without a restart: + +```python +keycard = KeycardGrantMiddleware( + ..., + fallback_identity=lambda: KeycardIdentity(subject_token=session_token()), +) +``` + +## Errors are data, not exceptions + +A missing grant is normal operation in a brokered setup, so the `AccessContext` +records failures instead of raising. Only `access(resource)` raises, and only +when you ask for a resource that has no token: + +```python +access = get_access_context() +if access.has_errors(): + return f"Cannot reach the API yet: {access.get_errors()}" +token = access.access(CALENDAR).access_token +``` + +Returning a readable sentence beats raising here: in a chat UI a raised +exception reads as an internal error, when the truthful message is "you have +not granted this yet." + +## Pausing for sign-in and consent + +With `sign_in_url` and `authorization_url` set, the middleware pauses the run +with a LangGraph interrupt instead of failing, so the whole flow can live in +your chat surface: + +```python +keycard = KeycardGrantMiddleware( + zone_url=..., + resources=[CALENDAR], + sign_in_url="https://your-app.example/signin", + authorization_url=lambda resources: f"https://your-app.example/authorize?r={resources[0]}", +) +``` + +| Payload `type` | Fires when | Resume behavior | +|---|---|---| +| `sign_in_required` | The run carries no identity | Identity is re-resolved, then the exchange runs | +| `authorization_required` | Identity present, grant missing | The exchange is retried | + +Both require a checkpointer. Two details worth knowing: + +- **Resume needs no new token.** Consent changes the grant in the zone, not the + token in your session, so the existing subject token exchanges successfully + afterward. +- **Runtime context is not checkpointed.** A resume must re-supply identity, + which a server does on every run anyway. + +Scope granularity falls out of this for free: if a user has granted read but +not write, the read call succeeds and the write call is the one that pauses. + +## Per-tool resources and scopes + +```python +KeycardGrantMiddleware( + zone_url=..., + resources=[CALENDAR], # default for every tool + tool_resources={"post_message": [SLACK]}, # per-tool override + request_scopes={CALENDAR: ["calendar.events"]}, +) +``` + +`request_scopes` is the **outbound** scope requested from Keycard, for both the +exchange and the as-itself grant. It is distinct from any scope enforced on the +caller's inbound token. + +## Testing + +```python +from keycardai.langchain.testing import mock_access_context + + +def test_list_events(): + with mock_access_context(resource_tokens={CALENDAR: "test-token"}): + assert list_events.invoke({"days_ahead": 0}) +``` + +`mock_access_context(access_token=...)` serves one token for any resource, which +is convenient but cannot catch a mistyped resource URL, since every lookup +succeeds. Pass `resource_tokens={...}` when the test should assert which +resource a tool reads. `resource_errors=` and `error_message=` cover the failure +paths, and `override_access_context` takes a hand-built context for full +control. + +## A note on tool arguments + +Give tools arguments that express **intent**, and keep configuration and clocks +out of the model's hands. A tool that accepts a resource URL will eventually be +called with a resource the model invented; a tool that accepts an absolute +timestamp will eventually be called with the wrong date. Prefer +`days_ahead: int` over an ISO string, and read the resource from configuration. diff --git a/packages/langchain/examples/background_agent/README.md b/packages/langchain/examples/background_agent/README.md new file mode 100644 index 0000000..f3463eb --- /dev/null +++ b/packages/langchain/examples/background_agent/README.md @@ -0,0 +1,39 @@ +# Background Agent (as itself) + +A LangChain agent with no user anywhere: a scheduled PR-review digest that +fetches open pull requests from GitHub and summarizes them. It authenticates +as its own Keycard application (`KeycardIdentity(as_self=True)`), and Keycard +delivers whatever credential the zone brokers for the GitHub resource — a +vaulted PAT, a GitHub App token — per tool call. The worker's environment +holds no GitHub credential, and revoking access happens in one place. + +## Keycard setup + +1. Create a **zone** at [keycard.ai](https://keycard.ai) (or use an existing one). +2. Create an **application** for the agent and note its client ID and secret. +3. Create a **resource** for `https://api.github.com` and attach a credential + provider that can serve it (for example a zone vault holding a GitHub + token, or a GitHub App provider). +4. Grant the application access to the resource. + +## Run + +```bash +export KEYCARD_ZONE_URL="https://your-zone.keycard.cloud" +export KEYCARD_CLIENT_ID="your-agent-client-id" +export KEYCARD_CLIENT_SECRET="your-agent-client-secret" +export ANTHROPIC_API_KEY="sk-ant-..." +export DIGEST_REPOS="your-org/repo-a,your-org/repo-b" # optional + +uv run main.py +``` + +## What to look at + +- The middleware has **no** `sign_in_url` / `authorization_url`: there is no + user in this process, so a denied grant is an error on the `AccessContext`, + never a consent pause. +- The tool reads its token with `get_access_context().access(...)`; no + credential appears in the environment, the code, or the model's context. +- Each run of the digest produces audit events in the zone with the + application as the actor. diff --git a/packages/langchain/examples/background_agent/main.py b/packages/langchain/examples/background_agent/main.py new file mode 100644 index 0000000..a76bcd4 --- /dev/null +++ b/packages/langchain/examples/background_agent/main.py @@ -0,0 +1,127 @@ +"""A background agent with no user anywhere: a morning PR-review digest. + +The agent runs as itself (KeycardIdentity(as_self=True)): resource access is +attributed to the application alone, the GitHub credential lives in the zone +(vaulted or brokered), and every fetch is an audit event. Nothing in this +process or its environment holds a GitHub credential. + +Run: uv run main.py +(In real use this is a cron entry; running it by hand is the same thing.) +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone + +import httpx +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +GITHUB = os.environ.get("KEYCARD_GITHUB_RESOURCE", "https://api.github.com") +REPOS = [ + r.strip() + for r in os.environ.get("DIGEST_REPOS", "langchain-ai/langchain").split(",") + if r.strip() +] + +# One client for the process: connection reuse across tool calls, bounded waits. +_http = httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) + + +@tool +def list_open_pull_requests() -> str: + """List open pull requests across the configured repositories.""" + access = get_access_context() + if access.has_error(): + return f"Cannot reach GitHub: {access.get_error()['message']}" + if access.has_resource_error(GITHUB): + return f"GitHub access not granted: {access.get_resource_error(GITHUB)}" + + headers = { + "Authorization": f"Bearer {access.access(GITHUB).access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + now = datetime.now(timezone.utc) + report: dict[str, list[dict] | str] = {} + for repo in REPOS: + response = _http.get( + f"{GITHUB}/repos/{repo}/pulls", + params={"state": "open", "per_page": 20}, + headers=headers, + ) + if response.status_code != 200: + report[repo] = f"error {response.status_code}: {response.text[:200]}" + continue + report[repo] = [ + { + "number": pr["number"], + "title": pr["title"], + "author": pr["user"]["login"], + "draft": pr["draft"], + "age_days": (now - datetime.fromisoformat(pr["created_at"])).days, + } + for pr in response.json() + ] + return json.dumps(report, indent=2) + + +def _text_of(message) -> str: + """Final text from a message, whether content is a string or block list.""" + content = message.content + if isinstance(content, str): + return content + return "\n".join( + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ).strip() + + +SYSTEM_PROMPT = ( + "You compile a morning review digest for a maintainer. Fetch the open " + "pull requests, then write a short digest: what needs review first, " + "what is a draft and can wait, and anything that looks stuck. " + "Plain prose, under 200 words." +) + + +def main() -> None: + keycard = KeycardGrantMiddleware( + zone_url=os.environ["KEYCARD_ZONE_URL"], + resources=[GITHUB], + client_id=os.environ["KEYCARD_CLIENT_ID"], + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # No authorization_url / sign_in_url on purpose: there is no user in + # this process, so access failures are errors, never consent pauses. + ) + agent = create_agent( + model=ChatAnthropic( + model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-5"), + max_tokens=4096, + ), + tools=[list_open_pull_requests], + system_prompt=SYSTEM_PROMPT, + middleware=[keycard], + context_schema=KeycardIdentity, + ) + + result = agent.invoke( + {"messages": [HumanMessage("Compile this morning's review digest.")]}, + context=KeycardIdentity(as_self=True), + ) + print(_text_of(result["messages"][-1])) + + +if __name__ == "__main__": + main() diff --git a/packages/langchain/examples/background_agent/pyproject.toml b/packages/langchain/examples/background_agent/pyproject.toml new file mode 100644 index 0000000..b420575 --- /dev/null +++ b/packages/langchain/examples/background_agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "background-agent-example" +version = "0.1.0" +description = "A background LangChain agent acting as itself, with credentials brokered by Keycard" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-langchain", + "langchain-anthropic>=1.0", + "httpx>=0.27.0,<1.0.0", +] + +[tool.uv.sources] +keycardai-langchain = { path = "../../", editable = true } diff --git a/packages/langchain/examples/user_facing_agent/README.md b/packages/langchain/examples/user_facing_agent/README.md new file mode 100644 index 0000000..388014a --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/README.md @@ -0,0 +1,45 @@ +# User-Facing Agent (on behalf of) + +A LangChain calendar assistant that acts **on behalf of** the person invoking +it. The caller's Keycard token is exchanged per tool call for a Google +Calendar token (RFC 8693 token exchange), so access is attributed to +agent-for-user in the audit log and revoking the user's grant cuts the agent +off immediately. + +When the user has not granted calendar access yet, the middleware pauses the +run with a LangGraph `authorization_required` interrupt. This CLI prints the +consent link, waits, and resumes the same run — in a chat UI the same payload +becomes an in-chat sign-in card. + +## Keycard setup + +1. Create a **zone** at [keycard.ai](https://keycard.ai) (or use an existing one). +2. Create an **application** for the agent and note its client ID and secret. +3. Create a **resource** for `https://www.googleapis.com/calendar/v3` with a + Google OAuth credential provider. +4. Sign in through the agent's application to obtain a subject token for the + user (any OAuth client can drive this; the token must be issued by your + zone with the agent's application as the audience owner). + +## Run + +```bash +export KEYCARD_ZONE_URL="https://your-zone.keycard.cloud" +export KEYCARD_CLIENT_ID="your-agent-client-id" +export KEYCARD_CLIENT_SECRET="your-agent-client-secret" +export KEYCARD_SUBJECT_TOKEN="" +export ANTHROPIC_API_KEY="sk-ant-..." + +uv run main.py "what's on my calendar today?" +``` + +## What to look at + +- The identity for the run is `KeycardIdentity(subject_token=...)`, passed as + LangChain runtime context — not middleware state, so one deployed agent + serves many users. +- The interrupt/resume loop at the bottom of `main.py`: consent changes the + grant in the zone, not the token in your session, so the resume retries the + exchange with the same subject token and succeeds. +- The tool never sees a Google credential until the moment of the call, and + the model never sees one at all. diff --git a/packages/langchain/examples/user_facing_agent/main.py b/packages/langchain/examples/user_facing_agent/main.py new file mode 100644 index 0000000..ff5a231 --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/main.py @@ -0,0 +1,122 @@ +"""A user-facing agent acting on behalf of the caller: a calendar assistant. + +The caller's Keycard token is exchanged per tool call for a Google Calendar +token (RFC 8693), so every calendar read is attributed to agent-for-user in +the zone's audit log. When the user has not granted calendar access yet, the +run pauses with a LangGraph interrupt; this CLI prints the consent link, +waits, then resumes the same run. + +Run: uv run main.py "what's on my calendar today?" +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timedelta, timezone + +import httpx +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +CALENDAR = os.environ.get( + "KEYCARD_CALENDAR_RESOURCE", "https://www.googleapis.com/calendar/v3" +) + +_http = httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) + + +@tool +def list_events(days_ahead: int = 0) -> str: + """List the user's calendar events for one day, days_ahead days from today.""" + access = get_access_context() + if access.has_error(): + return f"Calendar unavailable: {access.get_error()['message']}" + if access.has_resource_error(CALENDAR): + return f"Calendar access not granted: {access.get_resource_error(CALENDAR)}" + + day = datetime.now(timezone.utc) + timedelta(days=days_ahead) + start = day.replace(hour=0, minute=0, second=0, microsecond=0) + response = _http.get( + f"{CALENDAR}/calendars/primary/events", + params={ + "timeMin": start.isoformat(), + "timeMax": (start + timedelta(days=1)).isoformat(), + "singleEvents": "true", + "orderBy": "startTime", + }, + headers={"Authorization": f"Bearer {access.access(CALENDAR).access_token}"}, + ) + if response.status_code != 200: + return f"Calendar API error {response.status_code}: {response.text[:200]}" + events = response.json().get("items", []) + if not events: + return "No events that day." + return "\n".join( + f"- {e.get('start', {}).get('dateTime', e.get('start', {}).get('date'))}: " + f"{e.get('summary', '(no title)')}" + for e in events + ) + + +def main() -> None: + question = " ".join(sys.argv[1:]) or "What's on my calendar today?" + identity = KeycardIdentity(subject_token=os.environ["KEYCARD_SUBJECT_TOKEN"]) + + keycard = KeycardGrantMiddleware( + zone_url=os.environ["KEYCARD_ZONE_URL"], + resources=[CALENDAR], + client_id=os.environ["KEYCARD_CLIENT_ID"], + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # A missing grant pauses the run instead of failing; the loop below + # prints the link and resumes after consent. + authorization_url=os.environ.get( + "KEYCARD_AUTHORIZATION_URL", os.environ["KEYCARD_ZONE_URL"] + ), + ) + agent = create_agent( + model=ChatAnthropic( + model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-5"), + max_tokens=4096, + ), + tools=[list_events], + middleware=[keycard], + context_schema=KeycardIdentity, + checkpointer=InMemorySaver(), # interrupts require a checkpointer + ) + config = {"configurable": {"thread_id": "cli"}} + + result = agent.invoke( + {"messages": [HumanMessage(question)]}, config, context=identity + ) + while result.get("__interrupt__"): + payload = result["__interrupt__"][0].value + print(f"\n{payload['message']}") + print(f" {payload.get('authorization_url') or payload.get('sign_in_url')}") + input("\nPress Enter after granting access to resume... ") + # Runtime context is not checkpointed: a resume re-supplies identity. + result = agent.invoke(Command(resume="granted"), config, context=identity) + + final = result["messages"][-1] + content = final.content + if isinstance(content, list): + content = "\n".join( + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ).strip() + print(content) + + +if __name__ == "__main__": + main() diff --git a/packages/langchain/examples/user_facing_agent/pyproject.toml b/packages/langchain/examples/user_facing_agent/pyproject.toml new file mode 100644 index 0000000..f6d664d --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "user-facing-agent-example" +version = "0.1.0" +description = "A user-facing LangChain agent acting on behalf of the caller, with consent pauses via LangGraph interrupts" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-langchain", + "langchain-anthropic>=1.0", + "httpx>=0.27.0,<1.0.0", +] + +[tool.uv.sources] +keycardai-langchain = { path = "../../", editable = true } diff --git a/packages/langchain/pyproject.toml b/packages/langchain/pyproject.toml new file mode 100644 index 0000000..ad0509c --- /dev/null +++ b/packages/langchain/pyproject.toml @@ -0,0 +1,124 @@ +[project] +name = "keycardai-langchain" +dynamic = ["version"] +description = "LangChain integration for Keycard: delegated, per-tool-call access with brokered credentials" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Keycard", email = "support@keycard.ai" }] +dependencies = [ + "httpx>=0.27.2", + "keycardai-oauth>=0.9.0", + "langchain>=1.0", + "langgraph>=1.0", +] +keywords = ["langchain", "langgraph", "agents", "oauth", "token-exchange", "authentication", "keycard"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Security", + "Topic :: Internet :: WWW/HTTP :: Session", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "License :: OSI Approved :: MIT License", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", +] + +[project.urls] +Homepage = "https://github.com/keycardai/python-sdk" +Repository = "https://github.com/keycardai/python-sdk" +Documentation = "https://docs.keycardai.com" +Issues = "https://github.com/keycardai/python-sdk/issues" + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-langchain" +style = "pep440" + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + +[tool.hatch.build.targets.wheel] +packages = ["src/keycardai"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, we'll handle case by case +] +isort = { combine-as-imports = true, known-first-party = ["keycardai"] } + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["T20"] + +[tool.mypy] +strict = true +disallow_incomplete_defs = false +disallow_untyped_defs = false +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.coverage.run] +source = ["tests", "src/keycardai"] + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@abc.abstractmethod", + "raise NotImplementedError", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q" +asyncio_mode = "auto" + +[tool.commitizen] +name = "cz_customize" +version = "0.1.0" +tag_format = "${version}-keycardai-langchain" +ignored_tag_formats = ["${version}-*"] +update_changelog_on_bump = true +bump_message = "bump: keycardai-langchain $current_version → $new_version" +major_version_zero = true + +[tool.commitizen.customize] +changelog_pattern = "^(feat|fix|refactor|perf|test|build|ci|revert)\\(keycardai-langchain\\)(!)?:" diff --git a/packages/langchain/src/keycardai/langchain/__init__.py b/packages/langchain/src/keycardai/langchain/__init__.py new file mode 100644 index 0000000..f174a1c --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/__init__.py @@ -0,0 +1,67 @@ +"""Keycard integration for LangChain agents. + +Adds delegated access at the tool-call boundary: every tool call gets a +short-lived credential brokered by Keycard, scoped to the identity the agent is +acting for, and audited as a delegation chain. + +Quick start: + + from langchain.agents import create_agent + from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, + ) + + keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.example.com"], + client_id="your-app", + client_secret=..., + ) + + @tool + def call_api(query: str) -> str: + \"\"\"Call the external API.\"\"\" + token = get_access_context().access("https://api.example.com").access_token + ... + + agent = create_agent( + model, + tools=[call_api], + middleware=[keycard], + context_schema=KeycardIdentity, + ) + + agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), + ) + +Re-export guide: + +- Local definitions: ``KeycardGrantMiddleware``, ``KeycardIdentity``, + ``get_access_context``. +- Borrowed from ``keycardai-oauth``: ``AccessContext`` (the per-request token + container) and ``ResourceAccessError`` (raised only by + ``AccessContext.access``), re-exported so callers need one import. +""" + +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.exceptions import ResourceAccessError + +from .middleware import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +__all__ = [ + # === Primary API === + "KeycardGrantMiddleware", + "KeycardIdentity", + "get_access_context", + # === Re-exported from keycardai-oauth === + "AccessContext", + "ResourceAccessError", +] diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py new file mode 100644 index 0000000..7921814 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -0,0 +1,351 @@ +"""Keycard grant middleware for LangChain 1.x agents. + +Grants delegated access at the tool-call boundary: before each tool executes, +the middleware exchanges the caller's identity for short-lived resource tokens +(RFC 8693) via the shared keycardai-oauth orchestration, and exposes the result +to the tool as a non-throwing AccessContext. + +The same middleware instance works under `create_agent` and `create_deep_agent` +(deepagents is built on the create_agent middleware system). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any +from weakref import WeakKeyDictionary + +from langchain_core.messages import ToolMessage +from langgraph.types import Command, interrupt + +from keycardai.oauth import AsyncClient, BasicAuth, ClientConfig, NoneAuth +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.token_exchange import exchange_tokens_for_resources +from langchain.agents.middleware import AgentMiddleware +from langchain.agents.middleware.types import ToolCallRequest + + +@dataclass +class KeycardIdentity: + """Per-invocation identity, passed as the agent's runtime context. + + Exactly one of the three should be set: + - subject_token: on-behalf-of. The caller's Keycard access token, exchanged + per tool call for resource tokens (RFC 8693). + - user_identifier: impersonation. A substitute-user exchange for this user, + authenticated by the agent's own application credential. Forbidden by + default; requires an explicit policy in the zone. + - as_self=True: the agent acts as itself (client credentials). No user + anywhere: resource access is attributed to the application alone. This is + deliberately explicit; a run with no identity at all stays an error (or a + sign-in interrupt), never silently escalates to the agent's own authority. + """ + + subject_token: str | None = None + user_identifier: str | None = None + as_self: bool = False + + def __bool__(self) -> bool: + return bool(self.subject_token or self.user_identifier or self.as_self) + + +_current_access: ContextVar[AccessContext | None] = ContextVar( + "keycard_access_context", default=None +) + + +def get_access_context() -> AccessContext: + """The AccessContext for the tool call currently executing. + + Call from inside a tool. Raises RuntimeError when no KeycardGrantMiddleware + wrapped this call. + """ + access = _current_access.get() + if access is None: + raise RuntimeError( + "No Keycard AccessContext for this tool call. Add KeycardGrantMiddleware " + "to the agent's middleware list and invoke the agent with a " + "KeycardIdentity context." + ) + return access + + +class KeycardGrantMiddleware(AgentMiddleware): + """Exchange the caller's identity for resource tokens on every tool call. + + Args: + zone_url: Keycard zone URL (issuer). Required unless `client` is given. + resources: Resource URLs to grant for every tool call. + client_id / client_secret: The agent's application credential. Used to + authenticate the exchange; required for impersonation. + tool_resources: Optional per-tool override, tool name -> resource URLs. + Tools absent from the map get `resources`. + request_scopes: Optional outbound scopes for the exchange, same shapes + as the core orchestrator (str | list | dict per resource). + authorization_url: When set, a failed exchange pauses the run with a + LangGraph interrupt instead of recording a silent error. The + interrupt payload carries this URL (str, or callable taking the + failed resource URLs) for the user to establish the grant; on + resume the exchange is retried. Requires a checkpointer. + sign_in_url: When set, a run that carries no identity at all pauses + with a `sign_in_required` interrupt linking here, instead of + failing. The whole flow then lives in the chat: sign in, resume. + fallback_identity: Identity used when the runtime context carries + none. Pass a callable to resolve it per tool call, so a sign-in + that happens mid-run is picked up on resume without a restart. + client: Injectable AsyncClient (tests). When set, zone_url is unused + and the client is reused as-is. + """ + + def __init__( + self, + *, + zone_url: str | None = None, + resources: list[str], + client_id: str | None = None, + client_secret: str | None = None, + tool_resources: dict[str, list[str]] | None = None, + request_scopes: str | list[str] | dict[str, str | list[str]] | None = None, + authorization_url: str | Callable[[list[str]], str] | None = None, + sign_in_url: str | None = None, + fallback_identity: KeycardIdentity + | Callable[[], KeycardIdentity | None] + | None = None, + client: AsyncClient | None = None, + ) -> None: + super().__init__() + if client is None and not zone_url: + raise ValueError( + "KeycardGrantMiddleware requires zone_url (or an injected client)" + ) + self._zone_url = zone_url + self._resources = list(resources) + self._client_id = client_id + self._client_secret = client_secret + self._tool_resources = tool_resources or {} + self._request_scopes = request_scopes + self._authorization_url = authorization_url + self._sign_in_url = sign_in_url + self._fallback_identity = fallback_identity + self._injected_client = client + self._loop_clients: WeakKeyDictionary[ + asyncio.AbstractEventLoop, AsyncClient + ] = WeakKeyDictionary() + + def _resolve_fallback(self) -> KeycardIdentity | None: + fallback = self._fallback_identity + return fallback() if callable(fallback) else fallback + + def _new_client(self) -> AsyncClient: + auth = ( + BasicAuth(self._client_id, self._client_secret) + if self._client_id and self._client_secret + else NoneAuth() + ) + return AsyncClient( + issuer=self._zone_url, + auth=auth, + config=ClientConfig( + enable_metadata_discovery=True, + auto_register_client=False, + ), + ) + + def _client(self) -> AsyncClient: + """The client bound to the running event loop. + + Cached per loop rather than per call: an AsyncClient holds connections + owned by its loop and must not outlive it. Under an async server the + loop persists, so this reuses one client (and one metadata discovery) + for the process. Sync callers that reach here through `asyncio.run` + get a fresh loop each time and therefore a fresh client. + """ + if self._injected_client is not None: + return self._injected_client + loop = asyncio.get_running_loop() + client = self._loop_clients.get(loop) + if client is None: + client = self._new_client() + self._loop_clients[loop] = client + return client + + def _resources_for(self, request: ToolCallRequest) -> list[str]: + name = request.tool_call.get("name", "") + return self._tool_resources.get(name, self._resources) + + def _resolve_identity(self, request: ToolCallRequest) -> KeycardIdentity | None: + """The effective identity for this tool call: context first, then fallback. + + Resolved per call: a sign-in that happens mid-run (via the + sign_in_required interrupt) is picked up on resume. + """ + identity = getattr(request.runtime, "context", None) + if identity is not None and ( + getattr(identity, "subject_token", None) + or getattr(identity, "user_identifier", None) + or getattr(identity, "as_self", False) + ): + return KeycardIdentity( + subject_token=getattr(identity, "subject_token", None), + user_identifier=getattr(identity, "user_identifier", None), + as_self=getattr(identity, "as_self", False), + ) + return self._resolve_fallback() + + def _scope_for(self, resource: str) -> str | None: + scopes = self._request_scopes + if scopes is None: + return None + value = scopes.get(resource) if isinstance(scopes, dict) else scopes + if value is None: + return None + return " ".join(value) if isinstance(value, list) else value + + async def _grant_as_self( + self, resources: list[str], access: AccessContext + ) -> AccessContext: + """Client-credentials acquisition: the agent's own authority, no subject. + + Not routed through exchange_tokens_for_resources(), which only models + subject-token flows. + """ + client = self._client() + for resource in resources: + try: + kwargs: dict[str, Any] = {"resource": resource} + scope = self._scope_for(resource) + if scope: + kwargs["scope"] = scope + token = await client.client_credentials_grant(**kwargs) + access.set_token(resource, token) + except Exception as e: + error: dict[str, str] = { + "message": f"Client credentials grant failed for {resource}" + } + if hasattr(e, "error"): + error["code"] = e.error + if getattr(e, "error_description", None): + error["description"] = e.error_description + if "code" not in error: + error["raw_error"] = str(e) + access.set_resource_error(resource, error) + return access + + async def _build_access(self, request: ToolCallRequest) -> AccessContext: + access = AccessContext() + identity = self._resolve_identity(request) + + if identity is None: + access.set_error( + { + "message": ( + "No Keycard identity for this run. Sign in to continue." + if self._sign_in_url + else "No Keycard identity on the runtime context. Invoke the " + "agent with context=KeycardIdentity(subject_token=...), " + "KeycardIdentity(user_identifier=...), or " + "KeycardIdentity(as_self=True)." + ), + "code": "missing_identity", + } + ) + return access + + if identity.as_self: + return await self._grant_as_self(self._resources_for(request), access) + + return await exchange_tokens_for_resources( + client=self._client(), + resources=self._resources_for(request), + subject_token=identity.subject_token or "", + access_context=access, + user_identifier=identity.user_identifier, + request_scopes=self._request_scopes, + ) + + _MAX_AUTHORIZATION_ATTEMPTS = 3 + + def _interrupt_payload( + self, failed: list[str], access: AccessContext + ) -> dict[str, Any]: + url = self._authorization_url + if callable(url): + url = url(failed) + return { + "type": "authorization_required", + "resources": failed, + "authorization_url": url, + "errors": {r: access.get_resource_error(r) for r in failed}, + "message": ( + "Access to the resources above has not been granted yet. " + "Open the authorization URL to grant it, then resume the run." + ), + } + + def _sign_in_payload(self) -> dict[str, Any]: + return { + "type": "sign_in_required", + "sign_in_url": self._sign_in_url, + "message": ( + "Sign in with Keycard to continue. Open the link, sign in, " + "then resume the run." + ), + } + + def _pending_interrupt( + self, access: AccessContext, request: ToolCallRequest + ) -> dict[str, Any] | None: + """The interrupt this AccessContext calls for, if any. + + As-itself runs never interrupt: there is no user to send to a sign-in + or consent page, so failures stay on the AccessContext as errors for + the tool (and the operator's logs) to surface. + """ + identity = self._resolve_identity(request) + if identity is not None and identity.as_self: + return None + if access.has_error() and self._sign_in_url: + return self._sign_in_payload() + failed = access.get_failed_resources() + if failed and self._authorization_url is not None: + return self._interrupt_payload(failed, access) + return None + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + access = await self._build_access(request) + for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): + payload = self._pending_interrupt(access, request) + if payload is None: + break + interrupt(payload) + access = await self._build_access(request) + token = _current_access.set(access) + try: + return await handler(request) + finally: + _current_access.reset(token) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + access = asyncio.run(self._build_access(request)) + for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): + payload = self._pending_interrupt(access, request) + if payload is None: + break + interrupt(payload) + access = asyncio.run(self._build_access(request)) + token = _current_access.set(access) + try: + return handler(request) + finally: + _current_access.reset(token) diff --git a/packages/langchain/src/keycardai/langchain/testing/__init__.py b/packages/langchain/src/keycardai/langchain/testing/__init__.py new file mode 100644 index 0000000..5219688 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/testing/__init__.py @@ -0,0 +1,13 @@ +"""Test seams for agents built with keycardai-langchain. + +Lets tests exercise tools without a zone, a network, or real token exchange. + + from keycardai.langchain.testing import mock_access_context + + with mock_access_context(resource_tokens={"https://api.example.com": "tok"}): + result = my_tool.invoke({"query": "hello"}) +""" + +from .test_utils import mock_access_context, override_access_context + +__all__ = ["mock_access_context", "override_access_context"] diff --git a/packages/langchain/src/keycardai/langchain/testing/test_utils.py b/packages/langchain/src/keycardai/langchain/testing/test_utils.py new file mode 100644 index 0000000..3d0c5f9 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/testing/test_utils.py @@ -0,0 +1,84 @@ +"""Install a preloaded AccessContext for the duration of a test.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.types.models import TokenResponse + +from ..middleware import _current_access + + +@contextmanager +def override_access_context(access_context: AccessContext) -> Iterator[AccessContext]: + """Serve `access_context` to tools for the duration of the block. + + The full-control seam: build the AccessContext yourself (including partial + failures) and hand it over. `mock_access_context` covers the common cases. + """ + token = _current_access.set(access_context) + try: + yield access_context + finally: + _current_access.reset(token) + + +@contextmanager +def mock_access_context( + access_token: str | None = None, + resource_tokens: dict[str, str] | None = None, + resource_errors: dict[str, str] | None = None, + error_message: str | None = None, +) -> Iterator[AccessContext]: + """Serve a synthetic AccessContext to tools, with no exchange performed. + + Args: + access_token: Served for every resource. Convenient, but it cannot + catch a mistyped resource URL in an `access(...)` call, since every + lookup succeeds. Prefer `resource_tokens` when the test should + assert which resource a tool reads. + resource_tokens: Per-resource tokens, keyed by resource URL. + resource_errors: Per-resource failures, keyed by resource URL, as a + grant failure would record them. + error_message: A global failure (no identity, unreachable zone). Takes + precedence: no resource tokens are served. + """ + context = _AnyResourceAccessContext(access_token) + + if error_message is not None: + context.set_error({"message": error_message, "code": "mock_error"}) + else: + for resource, token in (resource_tokens or {}).items(): + context.set_token( + resource, TokenResponse(access_token=token, token_type="Bearer") + ) + for resource, message in (resource_errors or {}).items(): + context.set_resource_error( + resource, {"message": message, "code": "mock_resource_error"} + ) + + with override_access_context(context): + yield context + + +class _AnyResourceAccessContext(AccessContext): + """AccessContext that can serve one token for any resource. + + Only used when `mock_access_context(access_token=...)` is given; with + `resource_tokens` the base class behavior applies unchanged. + """ + + def __init__(self, default_token: str | None = None) -> None: + super().__init__() + self._default_token = default_token + + def access(self, resource: str) -> TokenResponse: + if ( + self._default_token is not None + and not self.has_errors() + and resource not in self.get_successful_resources() + ): + return TokenResponse(access_token=self._default_token, token_type="Bearer") + return super().access(resource) diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py new file mode 100644 index 0000000..143991e --- /dev/null +++ b/packages/langchain/tests/test_middleware.py @@ -0,0 +1,266 @@ +"""Middleware behavior, exercised through a real create_agent loop. + +The exchange client is a stub, so no zone or network is involved; everything +else (the agent graph, the middleware hooks, the tool call) is real. +""" + +from __future__ import annotations + +import pytest +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) +from keycardai.oauth import TokenResponse +from keycardai.oauth.types.models import TokenExchangeRequest + +RESOURCE = "https://api.example.test" +PROMPT = {"messages": [HumanMessage("Read the delegated token.")]} + + +class StubExchangeClient: + """Stands in for keycardai.oauth.AsyncClient on the exchange paths.""" + + def __init__(self) -> None: + self.exchange_calls: list[TokenExchangeRequest] = [] + self.impersonate_calls: list[dict[str, str]] = [] + self.self_calls: list[dict[str, str]] = [] + self.granted = True + self.self_granted = True + + async def exchange_token(self, request: TokenExchangeRequest) -> TokenResponse: + self.exchange_calls.append(request) + if not self.granted: + raise RuntimeError("no grant for this resource yet") + return TokenResponse( + access_token=f"obo-token-for-{request.resource}", + token_type="Bearer", + expires_in=300, + ) + + async def impersonate( + self, *, user_identifier: str, resource: str, scope: str | None = None + ) -> TokenResponse: + self.impersonate_calls.append({"user": user_identifier, "resource": resource}) + return TokenResponse( + access_token=f"impersonated-{user_identifier}-for-{resource}", + token_type="Bearer", + ) + + async def client_credentials_grant(self, request=None, **kwargs) -> TokenResponse: + self.self_calls.append(kwargs) + if not self.self_granted: + raise RuntimeError("policy denies this application self access") + return TokenResponse( + access_token=f"self-token-for-{kwargs.get('resource')}", + token_type="Bearer", + ) + + +@tool +def read_delegated_token(resource: str) -> str: + """Read the delegated Keycard token for a resource.""" + access = get_access_context() + if access.has_error(): + return f"GLOBAL_ERROR: {access.get_error()}" + if access.has_resource_error(resource): + return f"RESOURCE_ERROR: {access.get_resource_error(resource)}" + return f"TOKEN: {access.access(resource).access_token}" + + +class _ToolBindableFakeModel(GenericFakeChatModel): + """GenericFakeChatModel that tolerates bind_tools; the script drives calls.""" + + def bind_tools(self, tools, **kwargs): # noqa: ANN001, ANN003, ANN201 + return self + + +def scripted_model() -> GenericFakeChatModel: + return _ToolBindableFakeModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "read_delegated_token", + "args": {"resource": RESOURCE}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="done"), + ] + ) + ) + + +def build_agent(stub: StubExchangeClient, **middleware_kwargs) -> object: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=stub, **middleware_kwargs + ) + checkpointer = ( + InMemorySaver() + if middleware_kwargs.get("authorization_url") + or middleware_kwargs.get("sign_in_url") + else None + ) + return create_agent( + model=scripted_model(), + tools=[read_delegated_token], + middleware=[middleware], + context_schema=KeycardIdentity, + checkpointer=checkpointer, + ) + + +def last_tool_message(result: dict) -> ToolMessage: + messages = [m for m in result["messages"] if isinstance(m, ToolMessage)] + assert messages, f"no ToolMessage in {result['messages']}" + return messages[-1] + + +def test_on_behalf_of_exchanges_the_callers_token() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token="caller-token") + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + assert stub.exchange_calls[0].subject_token == "caller-token" + + +async def test_on_behalf_of_works_on_the_async_path() -> None: + stub = StubExchangeClient() + result = await build_agent(stub).ainvoke( + PROMPT, context=KeycardIdentity(subject_token="caller-token") + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_impersonation_uses_the_substitute_user_path() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(user_identifier="user@example.com") + ) + assert "TOKEN: impersonated-user@example.com" in last_tool_message(result).content + assert not stub.exchange_calls + assert len(stub.impersonate_calls) == 1 + + +def test_missing_identity_is_recorded_not_raised() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT) + content = last_tool_message(result).content + assert "GLOBAL_ERROR" in content + assert "missing_identity" in content + assert not stub.exchange_calls + + +def test_tool_schema_carries_no_keycard_plumbing() -> None: + """The model must not see (or be able to supply) auth arguments.""" + properties = read_delegated_token.args_schema.model_json_schema()["properties"] + assert set(properties) == {"resource"} + + +def test_authorization_interrupt_pauses_then_resumes() -> None: + stub = StubExchangeClient() + stub.granted = False + agent = build_agent(stub, authorization_url="https://consent.example/authorize") + config = {"configurable": {"thread_id": "auth-interrupt"}} + + result = agent.invoke( + PROMPT, config, context=KeycardIdentity(subject_token="caller-token") + ) + interrupts = result.get("__interrupt__", []) + assert len(interrupts) == 1 + payload = interrupts[0].value + assert payload["type"] == "authorization_required" + assert payload["authorization_url"] == "https://consent.example/authorize" + assert payload["resources"] == [RESOURCE] + + stub.granted = True # the user consented out of band + # Runtime context is not checkpointed, so a resume re-supplies identity, + # exactly as a server does on every run. + result = agent.invoke( + Command(resume="authorized"), + config, + context=KeycardIdentity(subject_token="caller-token"), + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_sign_in_interrupt_picks_up_identity_without_a_restart() -> None: + stub = StubExchangeClient() + signed_in: dict[str, KeycardIdentity | None] = {"identity": None} + agent = build_agent( + stub, + sign_in_url="https://consent.example/", + authorization_url="https://consent.example/authorize", + fallback_identity=lambda: signed_in["identity"], + ) + config = {"configurable": {"thread_id": "sign-in-interrupt"}} + + result = agent.invoke(PROMPT, config) + payload = result["__interrupt__"][0].value + assert payload["type"] == "sign_in_required" + assert payload["sign_in_url"] == "https://consent.example/" + assert not stub.exchange_calls + + signed_in["identity"] = KeycardIdentity(subject_token="caller-token") + result = agent.invoke(Command(resume="signed in"), config) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_request_scopes_reach_the_exchange() -> None: + stub = StubExchangeClient() + agent = build_agent(stub, request_scopes={RESOURCE: ["read", "write"]}) + agent.invoke(PROMPT, context=KeycardIdentity(subject_token="caller-token")) + assert stub.exchange_calls[0].scope == "read write" + + +def test_as_self_uses_client_credentials_not_exchange() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT, context=KeycardIdentity(as_self=True)) + assert f"TOKEN: self-token-for-{RESOURCE}" in last_tool_message(result).content + assert not stub.exchange_calls + assert not stub.impersonate_calls + assert stub.self_calls == [{"resource": RESOURCE}] + + +def test_as_self_request_scopes_reach_the_grant() -> None: + stub = StubExchangeClient() + agent = build_agent(stub, request_scopes={RESOURCE: ["repo:read"]}) + agent.invoke(PROMPT, context=KeycardIdentity(as_self=True)) + assert stub.self_calls == [{"resource": RESOURCE, "scope": "repo:read"}] + + +def test_as_self_denial_is_an_error_never_an_interrupt() -> None: + """No user exists to send to a consent page, so as-itself must not pause.""" + stub = StubExchangeClient() + stub.self_granted = False + agent = build_agent( + stub, + authorization_url="https://consent.example/authorize", + sign_in_url="https://consent.example/", + ) + config = {"configurable": {"thread_id": "as-self-denied"}} + + result = agent.invoke(PROMPT, config, context=KeycardIdentity(as_self=True)) + assert not result.get("__interrupt__") + content = last_tool_message(result).content + assert "RESOURCE_ERROR" in content + assert "Client credentials grant failed" in content + + +def test_zone_url_is_required_without_an_injected_client() -> None: + with pytest.raises(ValueError, match="zone_url"): + KeycardGrantMiddleware(resources=[RESOURCE]) diff --git a/packages/langchain/tests/test_testing_seam.py b/packages/langchain/tests/test_testing_seam.py new file mode 100644 index 0000000..d26911a --- /dev/null +++ b/packages/langchain/tests/test_testing_seam.py @@ -0,0 +1,49 @@ +"""The testing seam: exercise tools with no middleware, zone, or network.""" + +from __future__ import annotations + +import pytest +from langchain.tools import tool + +from keycardai.langchain import ResourceAccessError, get_access_context +from keycardai.langchain.testing import mock_access_context + +RESOURCE = "https://api.example.test" + + +@tool +def call_api() -> str: + """Call the API with the delegated token.""" + access = get_access_context() + if access.has_error(): + return f"unavailable: {access.get_error()['message']}" + return access.access(RESOURCE).access_token + + +def test_resource_tokens_are_served_per_resource() -> None: + with mock_access_context(resource_tokens={RESOURCE: "tok-123"}): + assert call_api.invoke({}) == "tok-123" + + +def test_any_resource_token_is_a_convenience_with_a_tradeoff() -> None: + """The bare form serves any resource, so it cannot catch a wrong URL.""" + with mock_access_context(access_token="tok-any"): + assert call_api.invoke({}) == "tok-any" + + +def test_global_error_is_visible_to_the_tool() -> None: + with mock_access_context(error_message="no identity for this run"): + assert call_api.invoke({}) == "unavailable: no identity for this run" + + +def test_resource_error_raises_only_on_access() -> None: + with mock_access_context(resource_errors={RESOURCE: "not granted"}) as access: + assert access.has_errors() + assert not access.has_error() # per-resource, not global + with pytest.raises(ResourceAccessError): + access.access(RESOURCE) + + +def test_outside_the_seam_the_tool_reports_a_missing_middleware() -> None: + with pytest.raises(RuntimeError, match="KeycardGrantMiddleware"): + call_api.invoke({}) diff --git a/pyproject.toml b/pyproject.toml index f5c94e9..2100b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ members = [ "packages/starlette", "packages/mcp", "packages/a2a", + "packages/langchain", ] # Examples are standalone projects with their own lockfiles: each pins its parent # package via a path source and resolves the rest from the index. diff --git a/uv.lock b/uv.lock index 096c477..81df9e9 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ resolution-markers = [ members = [ "keycardai", "keycardai-a2a", + "keycardai-langchain", "keycardai-mcp", "keycardai-oauth", "keycardai-starlette", @@ -1455,6 +1456,33 @@ requires-dist = [ ] provides-extras = ["dev", "test"] +[[package]] +name = "keycardai-langchain" +source = { editable = "packages/langchain" } +dependencies = [ + { name = "httpx" }, + { name = "keycardai-oauth" }, + { name = "langchain" }, + { name = "langgraph" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "keycardai-oauth", editable = "packages/oauth" }, + { name = "langchain", specifier = ">=1.0" }, + { name = "langgraph", specifier = ">=1.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.1.0" }, +] +provides-extras = ["test"] + [[package]] name = "keycardai-mcp" source = { editable = "packages/mcp" } From 17305328634d1619247d1dfaf1eb106b1a02a579 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 12 Aug 2026 17:32:25 -0700 Subject: [PATCH 2/7] feat(keycardai-langchain): grant() escape hatch, persistent sync loop, expiry-aware sign-in Three changes from testing against the workshop agent: - grant()/agrant() context managers serve get_access_context() for code that runs outside an agent, e.g. seeding a dashboard panel with the same governed tool the agent uses in chat. - The sync tool path dispatches to one persistent background event loop instead of asyncio.run per call, so the per-loop client cache hits and metadata discovery happens once per process instead of per tool call. - An expired subject token now routes to sign_in_required instead of authorization_required: expiry is detected locally (decode-only exp check, the zone stays the authority on validity) and the payload carries a reason field (missing_identity | subject_token_expired). --- packages/langchain/README.md | 29 +++- .../src/keycardai/langchain/middleware.py | 160 ++++++++++++++++-- packages/langchain/tests/test_middleware.py | 126 ++++++++++++++ 3 files changed, 299 insertions(+), 16 deletions(-) diff --git a/packages/langchain/README.md b/packages/langchain/README.md index 3ee1f0f..5015796 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -207,8 +207,14 @@ keycard = KeycardGrantMiddleware( | Payload `type` | Fires when | Resume behavior | |---|---|---| -| `sign_in_required` | The run carries no identity | Identity is re-resolved, then the exchange runs | -| `authorization_required` | Identity present, grant missing | The exchange is retried | +| `sign_in_required` | The run carries no identity, or its subject token has expired | Identity is re-resolved, then the exchange runs | +| `authorization_required` | Identity present and valid, grant missing | The exchange is retried | + +Expiry is detected locally (a decode-only check of the JWT's `exp`; the zone +stays the authority on validity), so an expired session routes to sign-in +rather than to a consent page that cannot fix it. The `sign_in_required` +payload carries a `reason` field (`missing_identity` or +`subject_token_expired`) so a chat surface can word the prompt accordingly. Both require a checkpointer. Two details worth knowing: @@ -221,6 +227,25 @@ Both require a checkpointer. Two details worth knowing: Scope granularity falls out of this for free: if a user has granted read but not write, the read call succeeds and the write call is the one that pauses. +## Using tools outside the agent + +`get_access_context()` normally only works inside an agent run, because the +middleware sets the context at the tool-call boundary. For code that calls a +tool without the agent loop, `grant()` enters the same access context +explicitly. The motivating case is a UI panel served by the same governed +tool the agent uses in chat: + +```python +def dashboard_snapshot(session_token: str) -> str: + with keycard.grant(KeycardIdentity(subject_token=session_token)): + return list_requests.invoke({}) +``` + +`agrant()` is the async variant. Both accept `tool_name=` to apply that +tool's `tool_resources` override, and fall back to `fallback_identity` when +no identity is passed. There is no run to pause, so nothing interrupts here: +failures stay on the yielded `AccessContext`, exactly as tools see them. + ## Per-tool resources and scopes ```python diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py index 7921814..dd3d7e2 100644 --- a/packages/langchain/src/keycardai/langchain/middleware.py +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -12,7 +12,12 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +import base64 +import json +import threading +import time +from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator +from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar from dataclasses import dataclass from typing import Any @@ -57,6 +62,29 @@ def __bool__(self) -> bool: ) +def _subject_token_expired(token: str) -> bool: + """Whether a JWT subject token is already expired. + + Decode-only, no signature verification: the zone remains the authority on + validity. This check exists to route an expiry to sign-in instead of a + consent page, and to skip an exchange round trip that is guaranteed to + fail. Opaque or malformed tokens return False and are left for the zone + to judge. + """ + parts = token.split(".") + if len(parts) != 3: + return False + try: + raw = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)) + payload = json.loads(raw) + except (ValueError, UnicodeDecodeError): + return False + if not isinstance(payload, dict): + return False + exp = payload.get("exp") + return isinstance(exp, (int, float)) and exp <= time.time() + + def get_access_context() -> AccessContext: """The AccessContext for the tool call currently executing. @@ -90,9 +118,11 @@ class KeycardGrantMiddleware(AgentMiddleware): interrupt payload carries this URL (str, or callable taking the failed resource URLs) for the user to establish the grant; on resume the exchange is retried. Requires a checkpointer. - sign_in_url: When set, a run that carries no identity at all pauses - with a `sign_in_required` interrupt linking here, instead of - failing. The whole flow then lives in the chat: sign in, resume. + sign_in_url: When set, a run that carries no identity, or whose + subject token has already expired, pauses with a + `sign_in_required` interrupt linking here, instead of failing. + The payload's `reason` field says which case it was. The whole + flow then lives in the chat: sign in, resume. fallback_identity: Identity used when the runtime context carries none. Pass a callable to resolve it per tool call, so a sign-in that happens mid-run is picked up on resume without a restart. @@ -134,6 +164,8 @@ def __init__( self._loop_clients: WeakKeyDictionary[ asyncio.AbstractEventLoop, AsyncClient ] = WeakKeyDictionary() + self._sync_loop: asyncio.AbstractEventLoop | None = None + self._sync_loop_lock = threading.Lock() def _resolve_fallback(self) -> KeycardIdentity | None: fallback = self._fallback_identity @@ -160,8 +192,8 @@ def _client(self) -> AsyncClient: Cached per loop rather than per call: an AsyncClient holds connections owned by its loop and must not outlive it. Under an async server the loop persists, so this reuses one client (and one metadata discovery) - for the process. Sync callers that reach here through `asyncio.run` - get a fresh loop each time and therefore a fresh client. + for the process. The sync tool path runs on the middleware's own + persistent loop (see _run_sync), so it shares one warm client too. """ if self._injected_client is not None: return self._injected_client @@ -172,6 +204,27 @@ def _client(self) -> AsyncClient: self._loop_clients[loop] = client return client + def _run_sync(self, coro: Coroutine[Any, Any, AccessContext]) -> AccessContext: + """Run grant work from the sync path on one persistent background loop. + + asyncio.run would build a fresh event loop per tool call, so the + per-loop client cache would miss every time and each call would pay + client construction plus metadata discovery again. One long-lived + loop keeps a single warm client (and its connections) for every sync + tool call in the process. + """ + with self._sync_loop_lock: + loop = self._sync_loop + if loop is None: + loop = asyncio.new_event_loop() + threading.Thread( + target=loop.run_forever, + name="keycard-grant-middleware", + daemon=True, + ).start() + self._sync_loop = loop + return asyncio.run_coroutine_threadsafe(coro, loop).result() + def _resources_for(self, request: ToolCallRequest) -> list[str]: name = request.tool_call.get("name", "") return self._tool_resources.get(name, self._resources) @@ -235,8 +288,14 @@ async def _grant_as_self( return access async def _build_access(self, request: ToolCallRequest) -> AccessContext: + return await self._build_access_for( + self._resolve_identity(request), self._resources_for(request) + ) + + async def _build_access_for( + self, identity: KeycardIdentity | None, resources: list[str] + ) -> AccessContext: access = AccessContext() - identity = self._resolve_identity(request) if identity is None: access.set_error( @@ -254,12 +313,24 @@ async def _build_access(self, request: ToolCallRequest) -> AccessContext: ) return access + if identity.subject_token and _subject_token_expired(identity.subject_token): + access.set_error( + { + "message": ( + "The subject token for this run has expired. " + "Sign in again to continue." + ), + "code": "subject_token_expired", + } + ) + return access + if identity.as_self: - return await self._grant_as_self(self._resources_for(request), access) + return await self._grant_as_self(resources, access) return await exchange_tokens_for_resources( client=self._client(), - resources=self._resources_for(request), + resources=resources, subject_token=identity.subject_token or "", access_context=access, user_identifier=identity.user_identifier, @@ -285,12 +356,17 @@ def _interrupt_payload( ), } - def _sign_in_payload(self) -> dict[str, Any]: + def _sign_in_payload(self, access: AccessContext) -> dict[str, Any]: + error = access.get_error() or {} + reason = error.get("code", "missing_identity") return { "type": "sign_in_required", "sign_in_url": self._sign_in_url, + "reason": reason, "message": ( - "Sign in with Keycard to continue. Open the link, sign in, " + "Your session has expired. Sign in again, then resume the run." + if reason == "subject_token_expired" + else "Sign in with Keycard to continue. Open the link, sign in, " "then resume the run." ), } @@ -308,7 +384,7 @@ def _pending_interrupt( if identity is not None and identity.as_self: return None if access.has_error() and self._sign_in_url: - return self._sign_in_payload() + return self._sign_in_payload(access) failed = access.get_failed_resources() if failed and self._authorization_url is not None: return self._interrupt_payload(failed, access) @@ -337,15 +413,71 @@ def wrap_tool_call( request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], ) -> ToolMessage | Command[Any]: - access = asyncio.run(self._build_access(request)) + access = self._run_sync(self._build_access(request)) for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): payload = self._pending_interrupt(access, request) if payload is None: break interrupt(payload) - access = asyncio.run(self._build_access(request)) + access = self._run_sync(self._build_access(request)) token = _current_access.set(access) try: return handler(request) finally: _current_access.reset(token) + + def _grant_target( + self, identity: KeycardIdentity | None, tool_name: str | None + ) -> tuple[KeycardIdentity | None, list[str]]: + resolved = identity if identity is not None else self._resolve_fallback() + resources = ( + self._tool_resources.get(tool_name, self._resources) + if tool_name is not None + else self._resources + ) + return resolved, resources + + @contextmanager + def grant( + self, + identity: KeycardIdentity | None = None, + *, + tool_name: str | None = None, + ) -> Iterator[AccessContext]: + """Serve get_access_context() for code that runs outside an agent. + + Lets the same governed tools back non-agent surfaces, e.g. seeding a + dashboard panel on page load with the tool the agent uses in chat: + + with keycard.grant(KeycardIdentity(subject_token=token)): + rows = list_requests.invoke({}) + + When `identity` is omitted, `fallback_identity` is used. `tool_name` + applies that tool's `tool_resources` override; otherwise the default + resources are granted. There is no run to pause, so nothing + interrupts here: failures stay on the yielded AccessContext, exactly + as tools see them. + """ + resolved, resources = self._grant_target(identity, tool_name) + access = self._run_sync(self._build_access_for(resolved, resources)) + token = _current_access.set(access) + try: + yield access + finally: + _current_access.reset(token) + + @asynccontextmanager + async def agrant( + self, + identity: KeycardIdentity | None = None, + *, + tool_name: str | None = None, + ) -> AsyncIterator[AccessContext]: + """Async grant(): the same contract on the running event loop.""" + resolved, resources = self._grant_target(identity, tool_name) + access = await self._build_access_for(resolved, resources) + token = _current_access.set(access) + try: + yield access + finally: + _current_access.reset(token) diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py index 143991e..3edb5cf 100644 --- a/packages/langchain/tests/test_middleware.py +++ b/packages/langchain/tests/test_middleware.py @@ -6,6 +6,11 @@ from __future__ import annotations +import asyncio +import base64 +import json +import time + import pytest from langchain.agents import create_agent from langchain.tools import tool @@ -26,6 +31,16 @@ PROMPT = {"messages": [HumanMessage("Read the delegated token.")]} +def jwt_with_exp(exp: float) -> str: + """An unsigned JWT carrying only exp; the middleware never verifies.""" + + def b64(part: dict) -> str: + raw = base64.urlsafe_b64encode(json.dumps(part).encode()) + return raw.rstrip(b"=").decode() + + return f"{b64({'alg': 'none'})}.{b64({'exp': exp})}.sig" + + class StubExchangeClient: """Stands in for keycardai.oauth.AsyncClient on the exchange paths.""" @@ -264,3 +279,114 @@ def test_as_self_denial_is_an_error_never_an_interrupt() -> None: def test_zone_url_is_required_without_an_injected_client() -> None: with pytest.raises(ValueError, match="zone_url"): KeycardGrantMiddleware(resources=[RESOURCE]) + + +def test_expired_subject_token_pauses_for_sign_in_not_consent() -> None: + """Consent cannot fix an expired token, so it must not route to consent.""" + stub = StubExchangeClient() + agent = build_agent( + stub, + sign_in_url="https://consent.example/", + authorization_url="https://consent.example/authorize", + ) + config = {"configurable": {"thread_id": "expired-token"}} + + result = agent.invoke( + PROMPT, + config, + context=KeycardIdentity(subject_token=jwt_with_exp(time.time() - 60)), + ) + payload = result["__interrupt__"][0].value + assert payload["type"] == "sign_in_required" + assert payload["reason"] == "subject_token_expired" + assert not stub.exchange_calls, "an expired token must not be sent for exchange" + + +def test_expired_subject_token_without_sign_in_url_is_an_error() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token=jwt_with_exp(time.time() - 60)) + ) + content = last_tool_message(result).content + assert "GLOBAL_ERROR" in content + assert "subject_token_expired" in content + assert not stub.exchange_calls + + +def test_unexpired_jwt_subject_token_exchanges_normally() -> None: + stub = StubExchangeClient() + token = jwt_with_exp(time.time() + 3600) + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token=token) + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + assert stub.exchange_calls[0].subject_token == token + + +def test_sync_path_runs_on_one_persistent_loop() -> None: + """A fresh loop per sync call would defeat the per-loop client cache.""" + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=StubExchangeClient() + ) + + async def running_loop() -> asyncio.AbstractEventLoop: + return asyncio.get_running_loop() + + assert middleware._run_sync(running_loop()) is middleware._run_sync(running_loop()) + + +def test_grant_serves_tools_outside_the_agent() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware(resources=[RESOURCE], client=stub) + + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert not access.has_errors() + result = read_delegated_token.invoke({"resource": RESOURCE}) + assert f"TOKEN: obo-token-for-{RESOURCE}" in result + + with pytest.raises(RuntimeError, match="KeycardGrantMiddleware"): + read_delegated_token.invoke({"resource": RESOURCE}) + + +async def test_agrant_serves_tools_on_the_async_path() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware(resources=[RESOURCE], client=stub) + + async with middleware.agrant(KeycardIdentity(as_self=True)) as access: + assert access.access(RESOURCE).access_token == f"self-token-for-{RESOURCE}" + result = await read_delegated_token.ainvoke({"resource": RESOURCE}) + assert f"TOKEN: self-token-for-{RESOURCE}" in result + + +def test_grant_uses_the_fallback_identity_when_omitted() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + fallback_identity=KeycardIdentity(as_self=True), + ) + with middleware.grant() as access: + assert access.access(RESOURCE).access_token == f"self-token-for-{RESOURCE}" + + +def test_grant_applies_the_tool_resources_override() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=["https://other.example.test"], + tool_resources={"read_delegated_token": [RESOURCE]}, + client=stub, + ) + with middleware.grant( + KeycardIdentity(subject_token="caller-token"), tool_name="read_delegated_token" + ) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + assert [c.resource for c in stub.exchange_calls] == [RESOURCE] + + +def test_grant_records_missing_identity_instead_of_raising() -> None: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=StubExchangeClient() + ) + with middleware.grant() as access: + assert access.has_error() + assert access.get_error()["code"] == "missing_identity" From a805ef5fdb79316956f49cf5661a2c1ec9065ee4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 14 Aug 2026 08:28:13 -0700 Subject: [PATCH 3/7] feat(keycardai-langchain): grant(resources=...) for resources with no tool grant()/agrant() accept an explicit resources list, mutually exclusive with tool_name, for grants that have no tool attached, e.g. fetching a vaulted LLM key under the agent's own identity. Registering a fake tool name in tool_resources to reach such a resource is no longer needed. --- packages/langchain/README.md | 16 +++++-- .../src/keycardai/langchain/middleware.py | 45 ++++++++++++------- packages/langchain/tests/test_middleware.py | 28 ++++++++++++ 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/packages/langchain/README.md b/packages/langchain/README.md index 5015796..9daf99e 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -241,10 +241,20 @@ def dashboard_snapshot(session_token: str) -> str: return list_requests.invoke({}) ``` +It also serves resources that have no tool at all. Fetching a vaulted LLM +key under the agent's own identity, for example: + +```python +with keycard.grant(KeycardIdentity(as_self=True), resources=[LLM_KEY]) as access: + key = access.access(LLM_KEY).access_token +``` + `agrant()` is the async variant. Both accept `tool_name=` to apply that -tool's `tool_resources` override, and fall back to `fallback_identity` when -no identity is passed. There is no run to pause, so nothing interrupts here: -failures stay on the yielded `AccessContext`, exactly as tools see them. +tool's `tool_resources` override, or `resources=` to grant exactly the +listed resources (one or the other, not both), and fall back to +`fallback_identity` when no identity is passed. There is no run to pause, +so nothing interrupts here: failures stay on the yielded `AccessContext`, +exactly as tools see them. ## Per-tool resources and scopes diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py index dd3d7e2..cef3b08 100644 --- a/packages/langchain/src/keycardai/langchain/middleware.py +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -427,15 +427,19 @@ def wrap_tool_call( _current_access.reset(token) def _grant_target( - self, identity: KeycardIdentity | None, tool_name: str | None + self, + identity: KeycardIdentity | None, + tool_name: str | None, + resources: list[str] | None, ) -> tuple[KeycardIdentity | None, list[str]]: + if tool_name is not None and resources is not None: + raise ValueError("Pass tool_name or resources, not both") resolved = identity if identity is not None else self._resolve_fallback() - resources = ( - self._tool_resources.get(tool_name, self._resources) - if tool_name is not None - else self._resources - ) - return resolved, resources + if resources is not None: + return resolved, list(resources) + if tool_name is not None: + return resolved, self._tool_resources.get(tool_name, self._resources) + return resolved, self._resources @contextmanager def grant( @@ -443,6 +447,7 @@ def grant( identity: KeycardIdentity | None = None, *, tool_name: str | None = None, + resources: list[str] | None = None, ) -> Iterator[AccessContext]: """Serve get_access_context() for code that runs outside an agent. @@ -452,14 +457,23 @@ def grant( with keycard.grant(KeycardIdentity(subject_token=token)): rows = list_requests.invoke({}) + Also serves resources that have no tool at all, e.g. fetching a + vaulted LLM key under the agent's own identity: + + with keycard.grant( + KeycardIdentity(as_self=True), resources=[LLM_KEY] + ) as access: + key = access.access(LLM_KEY).access_token + When `identity` is omitted, `fallback_identity` is used. `tool_name` - applies that tool's `tool_resources` override; otherwise the default - resources are granted. There is no run to pause, so nothing - interrupts here: failures stay on the yielded AccessContext, exactly - as tools see them. + applies that tool's `tool_resources` override, `resources` grants + exactly the listed resources (the two are mutually exclusive), and + with neither the default resources are granted. There is no run to + pause, so nothing interrupts here: failures stay on the yielded + AccessContext, exactly as tools see them. """ - resolved, resources = self._grant_target(identity, tool_name) - access = self._run_sync(self._build_access_for(resolved, resources)) + resolved, targets = self._grant_target(identity, tool_name, resources) + access = self._run_sync(self._build_access_for(resolved, targets)) token = _current_access.set(access) try: yield access @@ -472,10 +486,11 @@ async def agrant( identity: KeycardIdentity | None = None, *, tool_name: str | None = None, + resources: list[str] | None = None, ) -> AsyncIterator[AccessContext]: """Async grant(): the same contract on the running event loop.""" - resolved, resources = self._grant_target(identity, tool_name) - access = await self._build_access_for(resolved, resources) + resolved, targets = self._grant_target(identity, tool_name, resources) + access = await self._build_access_for(resolved, targets) token = _current_access.set(access) try: yield access diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py index 3edb5cf..7758344 100644 --- a/packages/langchain/tests/test_middleware.py +++ b/packages/langchain/tests/test_middleware.py @@ -390,3 +390,31 @@ def test_grant_records_missing_identity_instead_of_raising() -> None: with middleware.grant() as access: assert access.has_error() assert access.get_error()["code"] == "missing_identity" + + +def test_grant_accepts_explicit_resources_without_a_tool() -> None: + """A resource with no tool attached, e.g. a vaulted LLM key.""" + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware(resources=[RESOURCE], client=stub) + key_resource = "https://llm-key.example.test" + + with middleware.grant( + KeycardIdentity(as_self=True), resources=[key_resource] + ) as access: + assert access.access(key_resource).access_token == ( + f"self-token-for-{key_resource}" + ) + assert stub.self_calls == [{"resource": key_resource}] + + +def test_grant_rejects_tool_name_and_resources_together() -> None: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=StubExchangeClient() + ) + with pytest.raises(ValueError, match="not both"): + with middleware.grant( + KeycardIdentity(as_self=True), + tool_name="read_delegated_token", + resources=[RESOURCE], + ): + pass From 44a2d07c2a692e549be5da6de1a55019a8a43fdc Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 18 Aug 2026 13:00:44 -0700 Subject: [PATCH 4/7] test(keycardai-langchain): document the test matrix, lock partial success and pre-interrupt purity TESTING.md maps every ECO-224 matrix row to a named test or a deferral with an issue reference (ECO-286 multi-zone, ECO-287 version matrix, ECO-288 live smoke, ECO-219 mcp extra, ECO-220 fallback tool, ECO-223 e2e). Two rows gain tests: a denied resource next to a granted one yields a token plus a per-resource error (partial success is contract), and an interrupted run contains no ToolMessage (nothing side-effectful runs before consent resolves). --- packages/langchain/README.md | 3 ++ packages/langchain/TESTING.md | 60 +++++++++++++++++++++ packages/langchain/tests/test_middleware.py | 33 +++++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/langchain/TESTING.md diff --git a/packages/langchain/README.md b/packages/langchain/README.md index 9daf99e..9208e4f 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -289,6 +289,9 @@ resource a tool reads. `resource_errors=` and `error_message=` cover the failure paths, and `override_access_context` takes a hand-built context for full control. +The package's own test strategy, row by row with coverage status, lives in +[TESTING.md](TESTING.md). + ## A note on tool arguments Give tools arguments that express **intent**, and keep configuration and clocks diff --git a/packages/langchain/TESTING.md b/packages/langchain/TESTING.md new file mode 100644 index 0000000..905df90 --- /dev/null +++ b/packages/langchain/TESTING.md @@ -0,0 +1,60 @@ +# Test matrix + +The test strategy for the LangChain integration, tracked in +[ECO-224](https://linear.app/keycardlabs/issue/ECO-224). Each row is either +covered by a named test or explicitly deferred with an issue reference. +The TypeScript package (`@keycardai/langchain`, +[ECO-221](https://linear.app/keycardlabs/issue/ECO-221)) mirrors this matrix +when it exists; parity rows apply to it from day one. + +Run the suite with `just test-package langchain`, or directly: + +```bash +cd packages/langchain && uv run --extra test pytest tests/ -v +``` + +## Unit + +| Row | Status | Where | +|---|---|---| +| Grant runs before the tool; AccessContext reachable from inside it | covered | `test_on_behalf_of_exchanges_the_callers_token`, `test_grant_serves_tools_outside_the_agent` | +| Keycard params never leak into the model-facing tool schema | covered | `test_tool_schema_carries_no_keycard_plumbing` | +| Non-throwing error model: failures recorded, `access()` raises `ResourceAccessError` | covered | `test_missing_identity_is_recorded_not_raised`, `test_resource_error_raises_only_on_access` (seam suite) | +| Partial behavior locked: one denied resource does not poison a granted one | covered | `test_partial_grant_yields_token_and_resource_error_side_by_side` | +| Impersonation mode routes to the substitute-user path | covered | `test_impersonation_uses_the_substitute_user_path` (wire format of the substitute-user token is owned by keycardai-oauth's suite) | +| As-itself mode: client credentials, no exchange, scopes honored, denial never interrupts | covered | `test_as_self_uses_client_credentials_not_exchange`, `test_as_self_request_scopes_reach_the_grant`, `test_as_self_denial_is_an_error_never_an_interrupt` | +| Expired subject token routes to sign-in, not consent; opaque tokens pass through | covered | `test_expired_subject_token_pauses_for_sign_in_not_consent`, `test_expired_subject_token_without_sign_in_url_is_an_error`, `test_unexpired_jwt_subject_token_exchanges_normally` | +| Sync path runs on one persistent loop (client cache effective) | covered | `test_sync_path_runs_on_one_persistent_loop` | +| Multi-zone credential selection: issuer-keyed, fail-closed | deferred | [ECO-286](https://linear.app/keycardlabs/issue/ECO-286) (not implemented; middleware is single-zone today) | +| Testing seams themselves, including the resource-pinned form | covered | `tests/test_testing_seam.py` (all five) | + +## Integration + +| Row | Status | Where | +|---|---|---| +| Keycard-protected MCP server via `langchain-mcp-adapters` interceptors | deferred | [ECO-219](https://linear.app/keycardlabs/issue/ECO-219) (the `[mcp]` extra does not exist yet) | +| Live-zone smoke for the exchange paths (gated, not per-PR) | deferred | [ECO-288](https://linear.app/keycardlabs/issue/ECO-288) | +| Version matrix: langchain/langgraph floors, mcp 1.x vs 2.x resolution | deferred | [ECO-287](https://linear.app/keycardlabs/issue/ECO-287) (CI runs latest resolutions only today) | + +## Interrupt flow + +| Row | Status | Where | +|---|---|---| +| Interrupt payload shape (`authorization_required`, `sign_in_required` + `reason`) | covered | `test_authorization_interrupt_pauses_then_resumes`, `test_sign_in_interrupt_picks_up_identity_without_a_restart`, `test_expired_subject_token_pauses_for_sign_in_not_consent` | +| Resume retries the grant and the tool proceeds | covered | same two resume tests | +| Nothing side-effectful runs before the interrupt resolves | covered | `test_no_tool_executes_before_an_interrupt_resolves` | +| Checkpointer-less fallback auth tool | deferred | remaining scope of [ECO-220](https://linear.app/keycardlabs/issue/ECO-220) (interrupts shipped; the fallback tool did not) | + +## E2E + +| Row | Status | Where | +|---|---|---| +| Templates `eval/` run: ephemeral zone, provision from SPEC.md, consent, authenticated call | deferred | [ECO-223](https://linear.app/keycardlabs/issue/ECO-223) (template branch exists, gated on the package release) | + +## Process guards + +| Row | Status | Where | +|---|---|---| +| Suite wired into CI targets from day one | covered | justfile `test` / `test-coverage` targets (the fastmcp omission was [ECO-172](https://linear.app/keycardlabs/issue/ECO-172)) | +| Coverage gate | covered | `--cov-fail-under=85` in the justfile target | +| Cross-language parity claims tested on both halves | deferred | applies when [ECO-221](https://linear.app/keycardlabs/issue/ECO-221) lands; no cross-language claims in this README until then | diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py index 7758344..22eb433 100644 --- a/packages/langchain/tests/test_middleware.py +++ b/packages/langchain/tests/test_middleware.py @@ -50,10 +50,11 @@ def __init__(self) -> None: self.self_calls: list[dict[str, str]] = [] self.granted = True self.self_granted = True + self.denied_resources: set[str] = set() async def exchange_token(self, request: TokenExchangeRequest) -> TokenResponse: self.exchange_calls.append(request) - if not self.granted: + if not self.granted or request.resource in self.denied_resources: raise RuntimeError("no grant for this resource yet") return TokenResponse( access_token=f"obo-token-for-{request.resource}", @@ -392,6 +393,36 @@ def test_grant_records_missing_identity_instead_of_raising() -> None: assert access.get_error()["code"] == "missing_identity" +def test_partial_grant_yields_token_and_resource_error_side_by_side() -> None: + """Partial success is the contract: one denied resource must not poison + the granted one, and the failure stays per-resource, not global.""" + stub = StubExchangeClient() + denied = "https://denied.example.test" + stub.denied_resources.add(denied) + middleware = KeycardGrantMiddleware(resources=[RESOURCE, denied], client=stub) + + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + assert access.has_resource_error(denied) + assert not access.has_error() + + +def test_no_tool_executes_before_an_interrupt_resolves() -> None: + """The pause happens before the handler: an interrupted run must contain + no ToolMessage, so nothing side-effectful ran pre-consent.""" + stub = StubExchangeClient() + stub.granted = False + agent = build_agent(stub, authorization_url="https://consent.example/authorize") + config = {"configurable": {"thread_id": "no-tool-before-interrupt"}} + + result = agent.invoke( + PROMPT, config, context=KeycardIdentity(subject_token="caller-token") + ) + assert result.get("__interrupt__") + tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)] + assert not tool_messages, "tool ran before authorization resolved" + + def test_grant_accepts_explicit_resources_without_a_tool() -> None: """A resource with no tool attached, e.g. a vaulted LLM key.""" stub = StubExchangeClient() From cd172d65080d3f9d5680db62db1b35828f4db8e3 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 20 Aug 2026 09:42:39 -0700 Subject: [PATCH 5/7] feat(keycardai-langchain): accept application_credential for zone auth Review feedback (kamil): the middleware only took client_id/client_secret, which rules out workload identity. It now accepts any ApplicationCredential (ClientSecret, WorkloadIdentity); client_id/client_secret stays as shorthand for ClientSecret and the two forms are mutually exclusive. Wiring: the client is built with the credential's auth strategy and client config; the exchange path hands the credential to exchange_tokens_for_resources; the as-itself path lifts jwt-bearer client assertion fields from the credential's prepared request, since the credential protocol only exposes preparation for token exchange (moving that loop into shared orchestration is flagged in the as-itself spec). Also from review: the README how-it-works paragraph drops its justification sentences, and the no-identity behavior now states when it errors vs when it pauses for sign-in. --- packages/langchain/README.md | 33 +++++++-- .../src/keycardai/langchain/middleware.py | 71 +++++++++++++++---- packages/langchain/tests/test_middleware.py | 64 +++++++++++++++++ 3 files changed, 147 insertions(+), 21 deletions(-) diff --git a/packages/langchain/README.md b/packages/langchain/README.md index 9208e4f..5f34916 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -65,9 +65,8 @@ runs at the tool-call boundary. Before each tool executes it acquires tokens for the declared resources under the identity of the run, then exposes the result to the tool as an `AccessContext`. -The middleware is framework-shaped rather than Keycard-shaped: identity travels -on the agent's own `context_schema`, and the pause-for-authorization flow is a -LangGraph interrupt. Nothing is bolted onto the side of the framework. +Identity travels on the agent's own `context_schema`, and the +pause-for-authorization flow is a LangGraph interrupt. The same middleware instance works under `create_agent`, a raw LangGraph graph, and `create_deep_agent` (deep agents are built on the same middleware system). @@ -83,9 +82,9 @@ access pattern: | `as_self=True` | as itself | Client-credentials grant under the agent's own application identity. No user anywhere. | | `user_identifier` | impersonation | Substitute-user exchange, authenticated by the agent's credential. Forbidden by default; requires a zone policy. | -A run with no identity at all is an error (or a sign-in interrupt), never a -silent fallback to the agent's own authority — acting as itself is always an -explicit choice. +A run with no identity fails with a `missing_identity` error, or pauses with a +`sign_in_required` interrupt when `sign_in_url` is set. It never falls back to +the agent's own authority: acting as itself is always an explicit choice. ### On-behalf-of: a user-facing agent @@ -114,7 +113,7 @@ Runnable version: [`examples/user_facing_agent`](examples/user_facing_agent). ### As itself: a background agent -No user in the loop — a scheduled digest, a queue worker, a monitor. The agent +No user in the loop: a scheduled digest, a queue worker, a monitor. The agent authenticates as its own application and Keycard delivers whatever credential the zone brokers for the resource, including vaulted secrets, so the worker's environment holds no API keys and revocation lives in one place. @@ -160,6 +159,26 @@ agent.invoke( ) ``` +### Authenticating without a static secret + +`client_id` / `client_secret` is shorthand for a `ClientSecret` credential. +Every pattern also accepts an `application_credential`, so a deployed agent +can authenticate with a platform-signed OIDC token instead of holding a +secret: + +```python +from keycardai.oauth.server import FileTokenSource, WorkloadIdentity + +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.github.com"], + application_credential=WorkloadIdentity(FileTokenSource()), +) +``` + +`WorkloadIdentity` fetches the platform token per call and sends it as a +jwt-bearer client assertion; nothing long-lived sits in the environment. + ### Identity without per-run context For a deployed agent whose surface does not thread per-run context, set diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py index cef3b08..fe0d61a 100644 --- a/packages/langchain/src/keycardai/langchain/middleware.py +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -26,8 +26,9 @@ from langchain_core.messages import ToolMessage from langgraph.types import Command, interrupt -from keycardai.oauth import AsyncClient, BasicAuth, ClientConfig, NoneAuth +from keycardai.oauth import AsyncClient, ClientConfig, NoneAuth from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.credentials import ApplicationCredential, ClientSecret from keycardai.oauth.server.token_exchange import exchange_tokens_for_resources from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.types import ToolCallRequest @@ -107,8 +108,13 @@ class KeycardGrantMiddleware(AgentMiddleware): Args: zone_url: Keycard zone URL (issuer). Required unless `client` is given. resources: Resource URLs to grant for every tool call. - client_id / client_secret: The agent's application credential. Used to - authenticate the exchange; required for impersonation. + application_credential: How the agent authenticates to the zone. + `ClientSecret` for Keycard-issued client credentials, + `WorkloadIdentity` for a platform-signed OIDC token (no static + secret on the box). Mutually exclusive with client_id / + client_secret. + client_id / client_secret: Shorthand for + `application_credential=ClientSecret((client_id, client_secret))`. tool_resources: Optional per-tool override, tool name -> resource URLs. Tools absent from the map get `resources`. request_scopes: Optional outbound scopes for the exchange, same shapes @@ -135,6 +141,7 @@ def __init__( *, zone_url: str | None = None, resources: list[str], + application_credential: ApplicationCredential | None = None, client_id: str | None = None, client_secret: str | None = None, tool_resources: dict[str, list[str]] | None = None, @@ -151,10 +158,18 @@ def __init__( raise ValueError( "KeycardGrantMiddleware requires zone_url (or an injected client)" ) + if application_credential is not None and (client_id or client_secret): + raise ValueError( + "Pass application_credential or client_id/client_secret, not both" + ) self._zone_url = zone_url self._resources = list(resources) - self._client_id = client_id - self._client_secret = client_secret + if application_credential is not None: + self._credential: ApplicationCredential | None = application_credential + elif client_id and client_secret: + self._credential = ClientSecret((client_id, client_secret)) + else: + self._credential = None self._tool_resources = tool_resources or {} self._request_scopes = request_scopes self._authorization_url = authorization_url @@ -173,18 +188,17 @@ def _resolve_fallback(self) -> KeycardIdentity | None: def _new_client(self) -> AsyncClient: auth = ( - BasicAuth(self._client_id, self._client_secret) - if self._client_id and self._client_secret + self._credential.get_http_client_auth() + if self._credential is not None else NoneAuth() ) - return AsyncClient( - issuer=self._zone_url, - auth=auth, - config=ClientConfig( - enable_metadata_discovery=True, - auto_register_client=False, - ), + config = ClientConfig( + enable_metadata_discovery=True, + auto_register_client=False, ) + if self._credential is not None: + config = self._credential.set_client_config(config, {}) + return AsyncClient(issuer=self._zone_url, auth=auth, config=config) def _client(self) -> AsyncClient: """The client bound to the running event loop. @@ -257,6 +271,33 @@ def _scope_for(self, resource: str) -> str | None: return None return " ".join(value) if isinstance(value, list) else value + async def _client_auth_fields( + self, client: AsyncClient, resource: str + ) -> dict[str, str]: + """Client-authentication fields the credential puts in the request body. + + Assertion-based credentials (WorkloadIdentity, WebIdentity) carry no + HTTP-level auth; their proof rides in the request as a jwt-bearer + client assertion. The protocol only exposes request preparation for + token exchange, so this prepares one and lifts the auth fields for + the client-credentials call. ClientSecret authenticates at the HTTP + layer and contributes nothing here. + + The subject token below is a placeholder: client credentials has no + subject, the request model requires a non-empty one, and only the + client-auth fields of the prepared request are read. + """ + if self._credential is None: + return {} + prepared = await self._credential.prepare_token_exchange_request( + client=client, subject_token="client-credentials", resource=resource + ) + fields: dict[str, str] = {} + if getattr(prepared, "client_assertion", None): + fields["client_assertion"] = prepared.client_assertion + fields["client_assertion_type"] = prepared.client_assertion_type + return fields + async def _grant_as_self( self, resources: list[str], access: AccessContext ) -> AccessContext: @@ -272,6 +313,7 @@ async def _grant_as_self( scope = self._scope_for(resource) if scope: kwargs["scope"] = scope + kwargs.update(await self._client_auth_fields(client, resource)) token = await client.client_credentials_grant(**kwargs) access.set_token(resource, token) except Exception as e: @@ -333,6 +375,7 @@ async def _build_access_for( resources=resources, subject_token=identity.subject_token or "", access_context=access, + application_credential=self._credential, user_identifier=identity.user_identifier, request_scopes=self._request_scopes, ) diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py index 22eb433..0c4116c 100644 --- a/packages/langchain/tests/test_middleware.py +++ b/packages/langchain/tests/test_middleware.py @@ -393,6 +393,70 @@ def test_grant_records_missing_identity_instead_of_raising() -> None: assert access.get_error()["code"] == "missing_identity" +class StubAssertionCredential: + """ApplicationCredential whose proof rides in the request body, + the shape WorkloadIdentity and WebIdentity use.""" + + def get_http_client_auth(self): # noqa: ANN201 + from keycardai.oauth import NoneAuth + + return NoneAuth() + + def set_client_config(self, config, auth_info): # noqa: ANN001, ANN201 + return config + + async def prepare_token_exchange_request( + self, client, subject_token: str, resource: str, auth_info=None + ): # noqa: ANN001, ANN201 + return TokenExchangeRequest( + subject_token=subject_token, + resource=resource, + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + client_assertion="stub-assertion", + client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ) + + +def test_credential_and_client_id_are_mutually_exclusive() -> None: + with pytest.raises(ValueError, match="not both"): + KeycardGrantMiddleware( + zone_url="https://zone.example", + resources=[RESOURCE], + application_credential=StubAssertionCredential(), + client_id="agent", + client_secret="secret", + ) + + +def test_credential_prepares_the_exchange_request() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + application_credential=StubAssertionCredential(), + ) + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + request = stub.exchange_calls[0] + assert request.subject_token == "caller-token" + assert request.client_assertion == "stub-assertion" + + +def test_credential_assertion_reaches_the_as_self_grant() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + application_credential=StubAssertionCredential(), + ) + with middleware.grant(KeycardIdentity(as_self=True)) as access: + assert access.access(RESOURCE).access_token == f"self-token-for-{RESOURCE}" + call = stub.self_calls[0] + assert call["resource"] == RESOURCE + assert call["client_assertion"] == "stub-assertion" + assert call["client_assertion_type"].endswith("jwt-bearer") + + def test_partial_grant_yields_token_and_resource_error_side_by_side() -> None: """Partial success is the contract: one denied resource must not poison the granted one, and the failure stays per-resource, not global.""" From d8621938d0fa3d911de52f000dd2f8f6e99e752c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 20 Aug 2026 10:04:18 -0700 Subject: [PATCH 6/7] ci: release workflow fires on keycardai-langchain tags The release workflow triggers on a per-package tag list; without this entry the bump tag is created and no publish runs. --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a46049..2052a7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: - '*-keycardai-mcp-fastmcp' - '*-keycardai-fastmcp' - '*-keycardai-a2a' + - '*-keycardai-langchain' jobs: detect-package: From 2d9de6465754a802c5e1628ed02a67d813f768b0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 20 Aug 2026 10:20:51 -0700 Subject: [PATCH 7/7] test(keycardai-langchain): pin client_id/client_secret as ClientSecret shorthand The params and application_credential=ClientSecret((id, secret)) must be one object with one code path; this locks the equivalence so the two spellings cannot drift. --- packages/langchain/tests/test_middleware.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py index 0c4116c..c9575c3 100644 --- a/packages/langchain/tests/test_middleware.py +++ b/packages/langchain/tests/test_middleware.py @@ -417,6 +417,27 @@ async def prepare_token_exchange_request( ) +def test_client_id_and_secret_are_client_secret_shorthand() -> None: + """The two spellings must be one object: the params build the same + ClientSecret a caller would pass as application_credential.""" + from keycardai.oauth.server.credentials import ClientSecret + + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + client_id="agent", + client_secret="s3cret", + ) + assert isinstance(middleware._credential, ClientSecret) + + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + request = stub.exchange_calls[0] + assert request.subject_token == "caller-token" + assert request.client_assertion is None + + def test_credential_and_client_id_are_mutually_exclusive() -> None: with pytest.raises(ValueError, match="not both"): KeycardGrantMiddleware(