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
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:
@@ -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)
|
||||
Reference in New Issue
Block a user