Files
wehub-resource-sync 49b9bb6724
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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

104 lines
5.1 KiB
Markdown

# 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).