Skip to content

feat: Add Enterprise Managed Authorization (SEP-990) support#1305

Open
aniket-okta wants to merge 4 commits into
modelcontextprotocol:mainfrom
aniket-okta:feature/enterprise-managed-authorization
Open

feat: Add Enterprise Managed Authorization (SEP-990) support#1305
aniket-okta wants to merge 4 commits into
modelcontextprotocol:mainfrom
aniket-okta:feature/enterprise-managed-authorization

Conversation

@aniket-okta
Copy link
Copy Markdown

@aniket-okta aniket-okta commented Feb 18, 2026

Implements Enterprise Managed Authorization (SEP-990) for the C# MCP SDK, enabling MCP Clients to leverage enterprise Identity Providers for seamless authorization without per-server user authentication.

Closes #949

Flow

  1. SSO: User authenticates to the MCP Client via enterprise IdP (Okta, Auth0, Azure AD, etc.)
  2. Token Exchange (RFC 8693): Client exchanges ID Token for Identity Assertion JWT Authorization Grant (ID-JAG) at the IdP
  3. JWT Bearer Grant (RFC 7523): Client exchanges ID-JAG for Access Token at the MCP Server

Design

Layer 2: EnterpriseAuth static class : standalone utilities (~680 lines)

  • RequestJwtAuthorizationGrantAsync() : RFC 8693 token exchange (ID Token → ID-JAG)
  • DiscoverAndRequestJwtAuthorizationGrantAsync() : convenience wrapper with IdP discovery
  • ExchangeJwtBearerGrantAsync() : RFC 7523 JWT bearer grant (ID-JAG → Access Token)
  • DiscoverAuthServerMetadataAsync() : OAuth authorization server metadata discovery
  • Option types: RequestJwtAuthGrantOptions, DiscoverAndRequestJwtAuthGrantOptions, ExchangeJwtBearerGrantOptions
  • Response types: JagTokenExchangeResponse, JwtBearerAccessTokenResponse, OAuthErrorResponse

Layer 3: EnterpriseAuthProvider : high-level provider with caching (~230 lines)

  • Assertion callback pattern decouples IdP interaction from the provider
  • Automatic token caching with InvalidateCache()
  • EnterpriseAuthProviderOptions for configuration (ClientId, ClientSecret, Scope, AssertionCallback)

Tests

36 unit tests covering both layers, passing on net8.0, net9.0, and net10.0.

Related

Comment thread README.md

var tokens = await provider.GetAccessTokenAsync(
resourceUrl: new Uri("https://mcp-server.example.com"),
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"));
Copy link
Copy Markdown
Contributor

@halter73 halter73 Feb 18, 2026

Choose a reason for hiding this comment

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

We experimented with adding some of these OAuth flows in #1178, but we haven't added it yet since it's an Auth Extension rather than a primary part of the spec. See https://github.com/modelcontextprotocol/ext-auth/blob/5c50488e96e242b4d09c2b97ae47fa152d96a836/specification/draft/enterprise-managed-authorization.mdx and https://github.com/modelcontextprotocol/conformance/blob/066b2d70800a8bd6bca82e000007c9877b443e5b/src/scenarios/client/auth/index.ts#L50-L55.

I do like that this doesn't pollute the ClientOAuthOptions like #1178 did, but I wonder if this wouldn't be better as a separate package. It looks like you could use these APIs to get the access token for any HttpClient needing to support these OAuth flows. It seems like something with more features like Duende.AccessTokenManagement.OpenIdConnect might be a better general solution for this than something tied specifically to the MCP C# SDK.

Copy link
Copy Markdown
Author

@aniket-okta aniket-okta Feb 19, 2026

Choose a reason for hiding this comment

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

Thanks for the review @halter73 ! Good points - I want to address the packaging question.

Why I placed this in ModelContextProtocol.Core:

  • This was requested in Implement SEP-990: Enterprise Managed Authorization (Extension) #949, filed by the MCP spec team and tracked on the project board. Both the Go SDK (go-sdk#770) and TS SDK (typescript-sdk#1531) include this in their main packages - not as separate packages.
  • While the underlying RFCs (8693, 7523) are generic, the implementation is MCP-specific - it uses the urn:ietf:params:oauth:token-type:id-jag token type, MCP resource URIs, and the EnterpriseAuthProvider is built around the MCP orchestrator context (ResourceUrl, AuthorizationServerUrl).
  • Re: Duende - it handles OIDC token lifecycle for ASP.NET Core apps, but doesn't implement the ID-JAG token exchange or the MCP-specific JWT bearer grant pattern. Users would still need to orchestrate the full flow manually.
  • There are conformance tests (CrossAppAccessCompleteFlowScenario) for this flow already.

That said, I'm happy to move this to a separate package (e.g. ModelContextProtocol.Auth.Enterprise) if the team prefers that pattern for auth extensions. The code is self-contained with no dependencies on SDK internals, so it would be a straightforward move. What do you think?

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.

Makes sense. Are APIs that just provide the access token that we can then use to create a new client sufficient? I know for the normal OAuth flows we need a bit deeper integration to support scope step up and token refresh. I suppose scope step up would be hard without an interactive user and a browser, but refresh seems like it could still be useful. I suppose you could always refresh manually and call ResumeSessionAsync with the new access token. What do the other SDKs do for token refresh in this case?

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.

As for the separate package, I don't think that's necessary. My primary concern would be not making ClientOAuthOptions overly confusing, but this PR avoids that by providing completely independent APIs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the great questions, @halter73!

On scope step-up:

Correct - scope step-up is fundamentally incompatible with non-interactive enterprise flows. The whole point of SEP-990 is that the user has already authenticated once via their enterprise IdP (SSO), so there is no browser/user available at MCP-request time. Scope step-up would require re-prompting the user, which this flow is designed to eliminate. So no step-up support is by design, and that matches the behavior in both the Go and TS SDKs.

On token refresh:

Neither the Go SDK (go-sdk#770) nor the TS SDK (typescript-sdk#1531) implement explicit refresh token handling for this flow. The reason is structural: the ID-JAG itself is short-lived by design (it's a one-time assertion, not a long-lived credential). When the access token expires, the correct action is to re-run the full flow — re-request a fresh ID-JAG from the IdP, then exchange it for a new access token. This is effectively a "refresh" but through the full two-step exchange rather than a refresh_token grant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

In this PR, that is already supported via:

  1. Automatic re-execution: EnterpriseAuthProvider.GetAccessTokenAsync() checks _cachedTokens.IsExpired and re-runs the full flow when the token expires.
  2. Manual invalidation: Callers can call InvalidateCache() to force a fresh exchange on the next call.

Your suggestion of calling ResumeSessionAsync with a new access token after InvalidateCache() would work well for the HttpClient-based integration. However, a more seamless pattern might be to expose a CreateAuthorizationHeaderAsync() / GetAccessTokenAsync() method that can be wired directly into an HttpMessageHandler (similar to how the TS SDK's CrossAppAccessProvider plugs into withOAuth). This would give transparent token refresh on 401 without the caller needing to manage ResumeSessionAsync manually. I can add an HttpMessageHandler-based integration helper to EnterpriseAuthProvider if that would be the preferred approach, happy to follow whatever pattern the team is converging on for auth handlers.

Let me know if you'd like me to add refresh-on-401 integration or any deeper transport hookup in this PR, or if the current GetAccessTokenAsync + manual ResumeSessionAsync pattern is sufficient for the initial merge.

@aniket-okta aniket-okta requested a review from halter73 February 24, 2026 04:53
@aniket-okta
Copy link
Copy Markdown
Author

aniket-okta commented Mar 13, 2026

Hey @halter73, just a friendly ping on this one! It's been a few weeks since our last exchange and I wanted to check if you had any further thoughts on the token refresh / HttpMessageHandler integration question, or if the current GetAccessTokenAsync + manual ResumeSessionAsync pattern is good enough to merge as-is.

For context, both the Go (go-sdk#770) and TS (typescript-sdk#1531) PRs are still in review too, so I'm happy to align on whatever approach the team lands on across SDKs. I can also keep this PR scoped to the current API surface and follow up with a separate PR for the HttpMessageHandler helper if that's cleaner.

Happy to make any adjustments - just let me know!

@aniket-okta
Copy link
Copy Markdown
Author

@halter73 Quick update: The TypeScript SDK's SEP-990 PR (typescript-sdk#1531) just merged! 🎉

This confirms the pattern we've implemented here is solid - token refresh via full flow re-execution, no scope step-up support, and caching with expiry checks. All aligned with how the TS SDK handles it.

@aniket-okta aniket-okta force-pushed the feature/enterprise-managed-authorization branch from c945019 to ed87d4b Compare March 31, 2026 04:48
@halter73
Copy link
Copy Markdown
Contributor

halter73 commented Apr 2, 2026

Would it be possible to add OAuthTestBase integration test for this? You might need to add features to the TestOAuthServetlr for it, but I don't think it should be too hard. It's not like it needs to be hardened or anything

@aniket-okta
Copy link
Copy Markdown
Author

Hi @halter73, done! Added 3 OAuthTestBase integration tests covering the full E2E flow, token caching, and cache invalidation. Also extended TestOAuthServer with the /idp/token (RFC 8693 token exchange) and jwt-bearer grant (RFC 7523) endpoints to support the tests.

One question: do we need a conformance test for this as well, or are the integration tests sufficient?

Copy link
Copy Markdown
Contributor

@halter73 halter73 left a comment

Choose a reason for hiding this comment

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

I wonder if we need top-level documentation for this in https://csharp.sdk.modelcontextprotocol.io/concepts/index.html. Or do we assume that each authorization provider (Entra, Okta, etc.) will provide their own docs for each SDK that's specific to their platform?

@mikekistler @jeffhandley Do you have any thoughts on this PR?

Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs Outdated
Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs Outdated
Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs Outdated
@aniket-okta
Copy link
Copy Markdown
Author

aniket-okta commented Apr 12, 2026

On top-level docs:
Waiting on @mikekistler and @jeffhandley's thoughts. Happy to add a docs/concepts/ page if the team wants it, or keep it to XML API docs only.

On CI (no checks running):
I think the reason for no checks are running because the PR is from a fork and GitHub requires a maintainer to approve the first workflow run.

@aniket-okta
Copy link
Copy Markdown
Author

aniket-okta commented Apr 15, 2026

The Build and Test / build (windows-latest, Debug) failure seems like a transient CI infrastructure issue.

The log shows:

vstest.console process failed to connect to testhost process after 90 seconds.
This may occur due to machine slowness, please set environment variable VSTEST_CONNECTION_TIMEOUT to increase timeout.

All tests still ran and passed despite the timeout - 0 failures across all assemblies (1922 passed in ModelContextProtocol.Tests, 336 passed in ModelContextProtocol.AspNetCore.Tests, 57 passed in Analyzers.Tests). The dotnet test process exited with code 1 due to the VSTest connection timeout event, which caused make to report Error 1. The macOS and Ubuntu Debug/Release jobs all passed cleanly.

@halter73 Could you please re-run the failed Windows Debug job? Nothing in our code changes would cause this.

@halter73
Copy link
Copy Markdown
Contributor

halter73 commented May 19, 2026

This PR adds 8 public types here (this static class with 4 URN constants + 3 methods, the provider, its options, the assertion context, the exception, and three layer-2 option DTOs). For comparison:

  • TS SDK (merged): ~4 public types.
  • Go SDK (merged): just EnterpriseHandler + EnterpriseHandlerConfig — 2 public types.
  • C# (this PR): 8.

I think we should try to reduce, so we can get close to Go. Concrete cuts:

  1. Have the callback return an ID token (like Go's IDTokenFetcher), not a pre-fetched JAG. The provider then drives both the RFC 8693 token exchange and the RFC 7523 JWT bearer grant internally.
  2. Demote this static class, its URN constants, and the three layer-2 option DTOs (RequestJwtAuthGrantOptions, DiscoverAndRequestJwtAuthGrantOptions, ExchangeJwtBearerGrantOptions) to internal.
  3. Fold the IdP discovery config (IdpUrl/IdpTokenEndpoint, IdP client id/secret, scopes) into the single CrossApplicationAccessProviderOptions.

Lands us at 3 required public types (CrossApplicationAccessProvider, CrossApplicationAccessProviderOptions, CrossApplicationAccessException), plus optionally CrossApplicationAccessContext and the named delegate I called out on the provider file — 3–5 instead of 8.

Trade-off: the current "bring your own JAG" extension point goes away unless we re-expose helpers later. Given no one is asking for that today and we can promote back to public non-breakingly, the smaller surface is the right starting point.

Copy link
Copy Markdown
Contributor

@halter73 halter73 left a comment

Choose a reason for hiding this comment

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

Thanks for sticking with this! Two things before merge:

  1. A couple of earlier review threads were marked "Done" but weren't actually finished:
  • One top-level class per file - EnterpriseAuthProvider.cs still defines three top-level public types (EnterpriseAuthAssertionContext, EnterpriseAuthProvider, EnterpriseAuthProviderOptions), and EnterpriseAuth.cs still defines three internal response classes alongside the static helper. See inline comments for the splits.
  • Remove SEP-990 references — still present in EnterpriseAuth.cs:51, McpJsonUtilities.cs:190, and README.md lines 34/36. Inline comments below.
  1. Naming + public surface area.. Picking up the thread I left at #1305 (comment), I'd like to land on CrossApplicationAccess* (TS-aligned, no abbreviations) and significantly shrink the public surface to match the Go SDK's 2-type shape. Details inline on EnterpriseAuth.cs.

Also withdrawing my earlier "consider this authentication, not authorization" aside — see the inline comment for why.

Requesting changes on the naming + visibility decision so we don't ship a public API we can't easily walk back.

Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuthProvider.cs Outdated
Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuthProvider.cs Outdated
Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs Outdated
Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs Outdated
Comment thread src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs Outdated
Comment thread src/ModelContextProtocol.Core/McpJsonUtilities.cs Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
@aniket-okta aniket-okta force-pushed the feature/enterprise-managed-authorization branch from dc12435 to db67570 Compare May 20, 2026 18:41
@aniket-okta
Copy link
Copy Markdown
Author

Hey @halter73, I've addressed all of your review feedback. Here's a summary of what changed:

Naming / file structure

  • Renamed everything from EnterpriseAuth*CrossApplicationAccess* (no abbreviations, matching your guidance)
  • Split every type into its own file (one-type-per-file convention)
  • Renamed test files accordingly: EnterpriseAuthTests.csCrossApplicationAccessTests.cs, EnterpriseAuthIntegrationTests.csCrossApplicationAccessIntegrationTests.cs

Public surface reduction

  • RequestJwtAuthGrantOptions, DiscoverAndRequestJwtAuthGrantOptions, ExchangeJwtBearerGrantOptions → all made internal sealed
  • The CrossApplicationAccess static class (was EnterpriseAuth) → internal
  • JagTokenExchangeResponse, JwtBearerAccessTokenResponse, OAuthErrorResponse → split out and made internal sealed
  • Added InternalsVisibleTo in ModelContextProtocol.Core.csproj for both test assemblies

Callback simplification

  • IdTokenCallback now returns Task<string> (just the ID token string): the provider drives RFC 8693 and RFC 7523 internally, so callers don't need to know about those flows
  • Introduced a named delegate CrossApplicationAccessIdTokenCallback in its own file (modeled on AuthorizationRedirectDelegate)
  • Folded IdP config (IdpUrl/IdpTokenEndpoint, IdpClientId, IdpClientSecret, IdpScope) directly into CrossApplicationAccessProviderOptions: no separate IdP options class

SEP-990 / spec reference cleanup

  • Removed all SEP-990 references from public-facing files (XML docs, McpJsonUtilities.cs, README.md, test comments)
  • Replaced with a link to the official ext-auth spec: https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx

Context type

  • EnterpriseAuthAssertionContextCrossApplicationAccessContext with required Uri ResourceUrl and required Uri AuthorizationServerUrl

Let me know if anything else needs adjusting!

@aniket-okta aniket-okta requested a review from halter73 May 20, 2026 18:50
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.

Implement SEP-990: Enterprise Managed Authorization (Extension)

2 participants