chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
@@ -0,0 +1,103 @@
# identity-assertion
SEP-990 (Enterprise-Managed Authorization): the enterprise identity provider,
not the end user, decides which MCP servers a client may reach. The IdP signs
that decision into an Identity Assertion JWT Authorization Grant (an ID-JAG);
the client presents it to the MCP authorization server under the RFC 7523
`jwt-bearer` grant and gets an ordinary, audience-restricted access token back.
No browser, no consent screen, no dynamic client registration, no refresh
token. This story co-hosts the authorization server and the bearer-gated MCP
server on one app, stands in for the IdP with an in-process signer, and proves
the user the IdP named is the user the tool sees.
## Run it
```bash
# HTTP, self-hosted: the client spawns the co-hosted AS + MCP app, presents an
# ID-JAG, and asserts `whoami` reports the IdP's subject. Self-hosting uses
# this story's fixed :8000 (the issuer/PRM metadata bake it in), so :8000 must
# be free.
uv run python -m stories.identity_assertion.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.identity_assertion.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000). The next section's
# curl probes use it too and `kill` it when done.
uv run python -m stories.identity_assertion.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/mcp
```
`Client(url)` has no `auth=` passthrough, so both runners thread the module's
`build_auth` export (an `IdentityAssertionOAuthProvider`) onto the
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
already routed through it.
## Try it without the SDK client
```bash
# the AS metadata advertises the jwt-bearer grant AND the ID-JAG grant profile
curl -s http://127.0.0.1:8000/.well-known/oauth-authorization-server \
| jq '{grant_types_supported, authorization_grant_profiles_supported}'
# dynamic client registration refuses the jwt-bearer grant: an ID-JAG client
# must be pre-registered out of band
curl -si http://127.0.0.1:8000/register -H 'content-type: application/json' \
-d '{"redirect_uris":["http://localhost:3030/cb"],"grant_types":["authorization_code","urn:ietf:params:oauth:grant-type:jwt-bearer"]}' \
| head -1
# done with the server you started in "Run it"
kill "$SERVER_PID"
```
## What to look at
- `client.py` `fetch_id_jag` — the one seam the SDK leaves you: given the
authorization server's issuer and the MCP server's resource identifier,
return a fresh ID-JAG. In production this is an RFC 8693 token exchange
against your IdP; here it calls the stand-in signer in `idp.py`.
- `client.py` `build_auth``IdentityAssertionOAuthProvider` is the same
`httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the
trailing slash: the provider compares it to the metadata document's `issuer`
by simple string comparison and refuses a mismatch before sending anything.
- `server.py` `exchange_identity_assertion` — the whole authorization-server
hook. The SDK authenticates the client and gates the grant; the signature,
`typ`, `aud`, `client_id`-match, `jti`-replay, and audience-restriction
checks inside the hook are the implementation's job.
- `server.py` `build_app``auth_settings(identity_assertion_enabled=True)`
is the one flag. Off (the default), `/token` answers the grant with
`unsupported_grant_type` even when the hook is implemented.
- `idp.py` — the claims an ID-JAG carries (`iss`, `sub`, `aud`, `client_id`,
`resource`, `scope`, `jti`, `iat`, `exp`) and its `typ: oauth-id-jag+jwt`
header.
## Caveats
- The IdP here is a module, not a service, and it signs with a shared HMAC
secret so the client process and a separately launched server process agree
on it. A real IdP signs with its private key, the authorization server
verifies against the IdP's published JWKS, and the client obtains the ID-JAG
over the network with an RFC 8693 token exchange.
- Co-hosting the authorization server and the MCP server on one app
(`auth_server_provider=`) is a demo convenience. SEP-990's model keeps them
separate, and either way the client only ever learns about the authorization
server from its own configuration, never from the MCP server.
- The provider's state is in-memory demo state: `seen_jtis` and the issued
tokens only ever grow. A real server evicts a `jti` once the assertion's
`exp` has passed and expires tokens out of its own store.
- `transport_security=NO_DNS_REBIND` is harness-only; drop it for a real
deployment.
- Auth is HTTP-only; over stdio or the in-memory transport there is no gate.
## Spec
[Enterprise-Managed Authorization (SEP-990)](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)
· RFC 7523 (JWT bearer grant: the leg the SDK implements)
· RFC 8693 (token exchange: the IdP leg the SDK leaves to you)
· `draft-ietf-oauth-identity-assertion-authz-grant` (the ID-JAG profile)
## See also
`oauth/` (the interactive `authorization_code` grant) ·
`oauth_client_credentials/` (machine to machine, no user at all) ·
`bearer_auth/` (the resource-server half on its own).
@@ -0,0 +1,69 @@
"""HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject."""
import httpx
from mcp.client import Client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from stories._harness import Target, run_client
from stories._shared.auth import MCP_URL, InMemoryTokenStorage
from .idp import issue_id_jag
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE, ISSUER
# The end user the stand-in IdP says is signed in.
DEMO_SUBJECT = "alice@example.com"
async def fetch_id_jag(audience: str, resource: str) -> str:
"""Step one, the part the SDK does not do: obtain a fresh ID-JAG from the enterprise IdP.
A real implementation makes an RFC 8693 token-exchange request to the IdP, presenting the
signed-in user's ID token; `audience` (the authorization server's issuer) and `resource` (the
MCP server's identifier) pass straight through into the ID-JAG's `aud` and `resource` claims.
Here the stand-in IdP signs one in-process instead.
"""
return issue_id_jag(
subject=DEMO_SUBJECT, client_id=DEMO_CLIENT_ID, audience=audience, resource=resource, scope=DEMO_SCOPE
)
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
"""An `IdentityAssertionOAuthProvider` for the pre-registered confidential client.
`issuer` is configuration, not discovery: the provider fetches metadata from this issuer's
well-known and never asks the MCP server which authorization server to use. The string must
equal the `issuer` its metadata serves byte for byte (note the trailing slash).
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
`httpx.AsyncClient` and hands `main` a target that is already routed through it.
"""
return IdentityAssertionOAuthProvider(
server_url=MCP_URL,
storage=InMemoryTokenStorage(),
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
issuer=ISSUER,
assertion_provider=fetch_id_jag,
scope=DEMO_SCOPE,
)
async def main(target: Target, *, mode: str = "auto") -> None:
# The target is already routed through `build_auth`'s provider. The first request 401s; the
# provider fetches the authorization server's metadata from the configured issuer (never from
# the MCP server), mints a fresh ID-JAG through `fetch_id_jag`, exchanges it at `/token` under
# the jwt-bearer grant, and retries with the bearer. No `/authorize`, no `/register`, no browser.
async with Client(target, mode=mode) as client:
listed = await client.list_tools()
assert [t.name for t in listed.tools] == ["whoami"]
result = await client.call_tool("whoami", {})
assert not result.is_error, result
assert result.structured_content == {
"subject": DEMO_SUBJECT,
"client_id": DEMO_CLIENT_ID,
"scopes": [DEMO_SCOPE],
}, result.structured_content
if __name__ == "__main__":
run_client(main)
@@ -0,0 +1,43 @@
"""A stand-in enterprise identity provider: it signs the ID-JAGs the demo authorization server trusts.
In production the IdP is a separate service (Okta, Microsoft Entra ID, ...) and the client obtains
the ID-JAG from it with an RFC 8693 token-exchange request, presenting the signed-in user's ID
token. `issue_id_jag` collapses that whole step into one in-process signing call so the story runs
unattended; the README's caveats spell out what a real deployment changes.
"""
import time
import uuid
import jwt
IDP_ISSUER = "https://idp.example.com"
# Demo only: a real IdP signs with its private key and the authorization server verifies the
# signature against the IdP's published JWKS. A shared HMAC secret keeps this story self-contained.
IDP_SIGNING_KEY = "demo-idp-signing-key"
def issue_id_jag(*, subject: str, client_id: str, audience: str, resource: str, scope: str) -> str:
"""The IdP's short-lived, signed statement that `subject`, via `client_id`, may reach `resource`.
This is where the enterprise enforces policy: an IdP that does not authorize the combination
simply never issues the ID-JAG, and there is nothing for the client to present. The `typ`
header and the claim set are fixed by the Identity Assertion JWT Authorization Grant profile.
"""
now = int(time.time())
return jwt.encode(
{
"iss": IDP_ISSUER,
"sub": subject,
"aud": audience,
"client_id": client_id,
"resource": resource,
"scope": scope,
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + 300,
},
IDP_SIGNING_KEY,
algorithm="HS256",
headers={"typ": "oauth-id-jag+jwt"},
)
@@ -0,0 +1,110 @@
"""SEP-990 authorization server + bearer-gated MCP server on one app; exports `build_app()`.
`identity_assertion_enabled=True` turns the RFC 7523 jwt-bearer grant on, and the provider's
`exchange_identity_assertion` validates the IdP-signed ID-JAG and mints an access token bound to
the user and resource the assertion names. The MCP server half is ordinary bearer auth.
"""
import jwt
from pydantic import BaseModel
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import IdentityAssertionParams, TokenError
from mcp.server.mcpserver import MCPServer
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import MCP_URL, InMemoryAuthorizationServerProvider, auth_settings
from .idp import IDP_ISSUER, IDP_SIGNING_KEY
# DEMO ONLY: never hard-code real credentials.
DEMO_CLIENT_ID = "finance-agent"
DEMO_CLIENT_SECRET = "demo-finance-agent-secret"
DEMO_SCOPE = "mcp"
# The exact `issuer` string this authorization server's metadata serves. The client must configure
# the byte-identical string: RFC 8414 issuer comparison is character for character, and the
# settings' `AnyHttpUrl` renders the path-less loopback origin with a trailing slash.
ISSUER = str(auth_settings().issuer_url)
class Whoami(BaseModel):
subject: str
client_id: str
scopes: list[str]
class IdentityAssertionAuthorizationServer(InMemoryAuthorizationServerProvider):
"""The demo in-process AS plus the SEP-990 hook: validate an ID-JAG, mint a bound token."""
def __init__(self) -> None:
super().__init__()
self.seen_jtis: set[str] = set()
# Pre-registered out of band. Dynamic client registration refuses the jwt-bearer grant,
# so an ID-JAG client always arrives already known and already confidential.
self.clients[DEMO_CLIENT_ID] = OAuthClientInformationFull(
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="client_secret_post",
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG per RFC 7523 §3 and the SEP-990 processing rules, then issue the token."""
try:
header = jwt.get_unverified_header(params.assertion)
claims = jwt.decode(
params.assertion,
IDP_SIGNING_KEY,
algorithms=["HS256"],
issuer=IDP_ISSUER,
audience=ISSUER,
options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]},
)
except jwt.InvalidTokenError as error:
raise TokenError("invalid_grant", "the assertion did not verify") from error
if header.get("typ") != "oauth-id-jag+jwt":
raise TokenError("invalid_grant", "the assertion is not an ID-JAG")
if claims["client_id"] != client.client_id:
raise TokenError("invalid_grant", "the assertion was issued to a different client")
if claims["resource"] != MCP_URL:
raise TokenError("invalid_target", "the assertion is for a resource this server does not serve")
if claims["jti"] in self.seen_jtis:
raise TokenError("invalid_grant", "the assertion has already been used")
self.seen_jtis.add(claims["jti"])
# Everything on the issued token comes from the validated assertion, the audience
# restriction above all: it binds the token to the ID-JAG's `resource` claim, never to
# the client-controlled `params.resource`. No refresh token is returned either; the IdP
# owns session lifetime by deciding whether to issue the next ID-JAG.
scopes = claims["scope"].split()
access = self.mint_access_token(
client_id=claims["client_id"], scopes=scopes, resource=claims["resource"], subject=claims["sub"]
)
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
def build_app() -> Starlette:
provider = IdentityAssertionAuthorizationServer()
# `auth_server_provider=` alone is enough: MCPServer derives a token verifier from it
# (passing both trips the mutex guard).
mcp = MCPServer(
"identity-assertion-example",
auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True),
auth_server_provider=provider,
)
@mcp.tool(description="Return the end user the ID-JAG named, plus the authenticated client and scopes.")
def whoami() -> Whoami:
token = get_access_token()
assert token is not None
assert token.subject is not None
return Whoami(subject=token.subject, client_id=token.client_id, scopes=token.scopes)
return mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
if __name__ == "__main__":
run_app_from_args(build_app)
@@ -0,0 +1,65 @@
"""SEP-990 authorization server + bearer-gated MCP server (lowlevel API); same app shape."""
import json
from typing import Any
import mcp_types as types
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import auth_settings
from .server import DEMO_SCOPE, IdentityAssertionAuthorizationServer
WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"subject": {"type": "string"},
"client_id": {"type": "string"},
"scopes": {"type": "array", "items": {"type": "string"}},
},
"required": ["subject", "client_id", "scopes"],
}
def build_app() -> Starlette:
provider = IdentityAssertionAuthorizationServer()
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="whoami",
description="Return the end user the ID-JAG named, plus the authenticated client and scopes.",
input_schema={"type": "object"},
output_schema=WHOAMI_OUTPUT_SCHEMA,
),
]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "whoami"
token = get_access_token()
assert token is not None
assert token.subject is not None
payload = {"subject": token.subject, "client_id": token.client_id, "scopes": token.scopes}
return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload)
server = Server("identity-assertion-example", on_list_tools=list_tools, on_call_tool=call_tool)
# Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth at app-build time.
return server.streamable_http_app(
auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True),
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
transport_security=NO_DNS_REBIND,
)
if __name__ == "__main__":
run_app_from_args(build_app)