Skip to content

Add docs, tested examples, and a story for SEP-990 identity assertion#3004

Merged
maxisbey merged 1 commit into
mainfrom
docs-identity-assertion
Jun 26, 2026
Merged

Add docs, tested examples, and a story for SEP-990 identity assertion#3004
maxisbey merged 1 commit into
mainfrom
docs-identity-assertion

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Documentation for the Identity Assertion Authorization Grant (SEP-990) that #2988 added. That PR shipped the implementation with two snippets under examples/snippets/ and a migration-guide entry; this adds the book page, the tested docs_src/ examples behind it, and a self-verifying story, in the architecture #2978 established.

Motivation and Context

IdentityAssertionOAuthProvider, exchange_identity_assertion, and AuthSettings(identity_assertion_enabled=...) are public API with no presence in the docs book, and nothing renders or executes the examples/snippets/ files. Concretely:

  • docs/advanced/identity-assertion.md, a new page after OAuth clients in the nav. It covers both halves: the client provider (and its inverted trust model: the authorization server is configuration, never discovered from the MCP server) and the authorization-server hook. It is careful to keep the two parties apart that the rest of the book is allowed to blur: the enterprise IdP that issues the ID-JAG, and the MCP authorization server that accepts it. The two existing auth pages each gain a one-paragraph cross-reference.
  • docs_src/identity_assertion/: the page's two included examples. The client uses the modern Client API with an in-process stand-in IdP. The authorization server really validates the ID-JAG with pyjwt (signature, typ, iss/aud/exp, the client_id match, jti replay, and an ID-JAG for a resource it does not serve is refused with invalid_target) instead of stubbing the validation out.
  • tests/docs_src/test_identity_assertion.py proves the page's claims, including the whole grant end to end across two in-memory ASGI apps. The recorded wire is one 401, the issuer's well-known fetch, one POST /token, and the retried request: no /authorize, no /register, no protected-resource-metadata fetch.
  • examples/stories/identity_assertion/: a self-verifying story (stand-in IdP, authorization server, bearer-gated MCP server, client), registered in the stories manifest and run by the tests/examples/ matrix over both server variants and both protocol eras.

Two things worth a reviewer's attention:

  1. The shipped client snippet cannot complete the flow it demonstrates. examples/snippets/clients/identity_assertion_client.py sets issuer="http://localhost:8001", but an authorization server built with create_auth_routes serves its metadata issuer from an AnyHttpUrl, which renders a path-less origin as http://localhost:8001/. The provider's RFC 8414 §3.3 comparison is an exact string match, so discovery stops at issuer mismatch. It shipped that way because nothing executes the snippets, which is the gap this PR closes; the new page documents the exact-match rule and the end-to-end test pins it. I left the examples/snippets/ files untouched here and can fix or fold them into the story as a follow-up.
  2. tests/docs_src/test_shape.py loses the plain-ASCII typography check. It enforced a style preference (no em dashes, curly quotes, arrows, section signs) as a hard CI failure across every book page and example, which also blocks legitimate typography such as the section sign in an RFC citation (RFC 7523 §3) that the SDK's own docstrings already use. The plain-ASCII style stays as the convention the book follows; the gate goes.

How Has This Been Tested?

  • ./scripts/test (the full suite, 100% branch coverage, strict-no-cover), pre-commit run --all-files, and uv run mkdocs build --strict are green.
  • The new docs test wires the page's two example files together and runs the whole grant in memory, asserting the request sequence, the exact token-request form, and that the sub the IdP signed is what get_access_token().subject reports inside a tool.
  • The story passes the tests/examples/ matrix (both server variants, both protocol eras), and its README commands run for real: uv run python -m stories.identity_assertion.client --http self-hosts a uvicorn server and exits OK.

Breaking Changes

None. Docs, examples, and tests only; nothing under src/ changes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The page presents this as an optional extension (Enterprise-Managed Authorization), not as part of a protocol release, and is deliberate about the two RFCs: obtaining the ID-JAG from the IdP is an RFC 8693 token exchange and out of the SDK's scope; what the SDK implements on both sides is the RFC 7523 jwt-bearer presentation of it. (#2988's title says RFC 8693; the merged implementation is RFC 7523, per the extension spec.)

Also moves the stories README's mrtr row out of the "deferred" table, where it was stale: it has been runnable since #2998.

AI Disclaimer

#2988 added the Identity Assertion Authorization Grant (SEP-990) with two
snippets under examples/snippets/ and a migration-guide entry, but no book
page, nothing under docs_src/, and no story, so nothing renders or executes
the examples. Document it the way the rest of the book is documented:

- docs/advanced/identity-assertion.md: a new page after "OAuth clients"
  covering both halves: the IdentityAssertionOAuthProvider client (and its
  inverted trust model: the authorization server is configuration, never
  discovered from the MCP server) and the exchange_identity_assertion hook
  behind identity_assertion_enabled. The two existing auth pages each gain a
  one-paragraph cross-reference.
- docs_src/identity_assertion/: the page's two included examples. The client
  uses the modern Client API with an in-process stand-in IdP; the
  authorization server really validates the ID-JAG with pyjwt (signature,
  typ, iss/aud/exp, client_id match, jti replay, and an unknown resource is
  refused with invalid_target) instead of stubbing the validation out.
- tests/docs_src/test_identity_assertion.py: proves the page's claims,
  including the whole grant end to end across two in-memory ASGI apps; the
  recorded wire is one 401, the issuer's well-known fetch, one POST /token,
  and the retried request.
- examples/stories/identity_assertion/: a self-verifying story (stand-in IdP,
  authorization server, bearer-gated MCP server, client) registered in the
  stories manifest. `python -m stories.identity_assertion.client --http`
  runs the real thing and asserts the user the IdP named is the user the
  tool sees.

The page calls out the one configuration trap: the client's `issuer` must
match the authorization server's metadata `issuer` character for character,
and an issuer built from a pydantic URL object renders a path-less origin
with a trailing slash. The end-to-end test pins it.

The plain-ASCII typography check (test_page_uses_plain_ascii_punctuation and
its helpers in tests/docs_src/test_shape.py) is removed rather than extended.
It enforced a style preference as a hard CI failure, which also blocks
legitimate typography such as the section sign in an RFC citation
("RFC 7523 §3") that the SDK's own docstrings already use, and a character
class cannot judge which is which. The plain-ASCII style stays as the
convention the book follows.

Also moves the stories README's mrtr row out of the "deferred" table; it has
been runnable since #2998.
@maxisbey maxisbey marked this pull request as ready for review June 26, 2026 18:31
@maxisbey maxisbey enabled auto-merge (squash) June 26, 2026 18:33

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 18 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs_src/identity_assertion/tutorial002.py">

<violation number="1" location="docs_src/identity_assertion/tutorial002.py:72">
P2: `scope` claim type is not validated before `.split()`, so malformed assertions can trigger server 500s. Reject non-string scope with `TokenError` before parsing.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

if claims["jti"] in self.seen_jtis:
raise TokenError("invalid_grant", "the assertion has already been used")
self.seen_jtis.add(claims["jti"])
scopes = claims["scope"].split()

@cubic-dev-ai cubic-dev-ai Bot Jun 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: scope claim type is not validated before .split(), so malformed assertions can trigger server 500s. Reject non-string scope with TokenError before parsing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs_src/identity_assertion/tutorial002.py, line 72:

<comment>`scope` claim type is not validated before `.split()`, so malformed assertions can trigger server 500s. Reject non-string scope with `TokenError` before parsing.</comment>

<file context>
@@ -0,0 +1,107 @@
+        if claims["jti"] in self.seen_jtis:
+            raise TokenError("invalid_grant", "the assertion has already been used")
+        self.seen_jtis.add(claims["jti"])
+        scopes = claims["scope"].split()
+        access_token = f"mcp_{secrets.token_hex(16)}"
+        self.access_tokens[access_token] = AccessToken(
</file context>
Fix with cubic

Comment on lines 151 to +154
| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current |
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current |
| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | current |
| [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | current |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new identity_assertion story self-hosts on the fixed :8000 port (fixed_port = 8000 in manifest.toml), but the earlier "Running a story" section's enumeration of auth stories that need :8000 free still lists only bearer_auth/, oauth/, and oauth_client_credentials/. Add identity_assertion/ to that parenthetical so the list of stories requiring :8000 stays accurate.

Extended reasoning...

What the issue is. The "Running a story" section of examples/stories/README.md (around lines 85–89) explains which stories self-host on a fixed port: "The auth stories (bearer_auth/, oauth/, oauth_client_credentials/) self-host on their fixed :8000 instead of a free port because their issuer/PRM metadata bake it in — :8000 must be free, and the run refuses to start ... if it is not." This PR adds a fourth auth story, identity_assertion, which has exactly the same property — examples/stories/manifest.toml gains [story.identity_assertion] with fixed_port = 8000 and the comment "issuer/PRM metadata bake in :8000", and the story's own README repeats the constraint ("Self-hosting uses this story's fixed :8000 ... so :8000 must be free"). The enumerating sentence in the stories README was not updated.\n\nHow it manifests. A reader skimming the top-level stories README to learn which stories require :8000 to be free will get an incomplete answer: three stories are named, but as of this PR there are four. The mitigation is that the per-story README does state the constraint, so the practical guidance isn't lost — which is why this is a nit rather than a blocking issue.\n\nWhy it slipped through. The stale sentence is outside the diff hunks for examples/stories/README.md (the PR's edits touch the stories table at lines ~128–158), so it doesn't show up in the diff view. Nothing mechanical ties the manifest's fixed_port entries to that prose enumeration, so no test catches the drift.\n\nStep-by-step proof.\n1. examples/stories/manifest.toml in this PR adds [story.identity_assertion] with fixed_port = 8000 — the same configuration as bearer_auth, oauth, and oauth_client_credentials.\n2. examples/stories/identity_assertion/README.md says "Self-hosting uses this story's fixed :8000 (the issuer/PRM metadata bake it in), so :8000 must be free."\n3. examples/stories/README.md, "Running a story" section, still reads "The auth stories (bearer_auth/, oauth/, oauth_client_credentials/) self-host on their fixed :8000 ..." — identity_assertion/ is missing from the parenthetical.\n4. Therefore the enumeration of stories requiring :8000 is incomplete after this PR merges.\n\nHow to fix. Add identity_assertion/ to the parenthetical list, e.g. "The auth stories (bearer_auth/, oauth/, oauth_client_credentials/, identity_assertion/) self-host on their fixed :8000 ...". A one-word documentation change, no functional impact.

@maxisbey maxisbey merged commit 3b78f86 into main Jun 26, 2026
37 checks passed
@maxisbey maxisbey deleted the docs-identity-assertion branch June 26, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants