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
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 the rename and the file split. The public surface (CrossApplicationAccess*) reads much better now.

That said, the PR has grown rather than shrunk in this round, and most of the new weight is testing internals rather than behavior. Before another pass I'd like to see the surface area pulled back and a few correctness/lifecycle issues addressed. Grouping the asks below so it's easier to triage:

Please remove

  • InternalsVisibleTo for both test assemblies in ModelContextProtocol.Core.csproj. We intentionally do not use IVT in this repo (see #6 — it was one of the first things we removed after migrating to the modelcontextprotocol org). If a test can't be expressed against the public API, just delete it.
  • The nested Throw helper class in CrossApplicationAccess.cs. src/Common/Throw.cs is already linked into Core (<Compile Include="..\Common\Throw.cs" Link="Throw.cs" />) and provides Throw.IfNull (with [NotNull] + CallerArgumentExpression) and Throw.IfNullOrWhiteSpace. Use those everywhere instead — including replacing the manual string.IsNullOrEmpty(...) → throw new ArgumentException(...) blocks in the CrossApplicationAccessProvider constructor.
  • The unused DiscoverAndRequestJwtAuthorizationGrantAsync overload (and its DiscoverAndRequestJwtAuthGrantOptions DTO, and the 4 unit tests targeting it). With CrossApplicationAccess now internal, that method has no callers — the production flow goes through CrossApplicationAccessProvider.ResolveIdpTokenEndpointAsync instead. Once IVT is gone, the corresponding tests must go too.
  • The leftover "Enterprise" wording in README.md (heading on line 34) and the async lambda on line 57 that doesn't await.

Please change

  • HttpClient should be a required, non-nullable constructor parameter on CrossApplicationAccessProvider, not HttpClient? httpClient = null with a ?? new HttpClient() fallback. The owning caller (e.g. HttpClientTransport) is responsible for the HttpClient lifetime — this is exactly the pattern ClientOAuthProvider uses (see ClientOAuthProvider.cs:62). Apply the same fix to the internal CrossApplicationAccess helpers that also do httpClient ?? new HttpClient().
  • Don't echo raw HTTP response bodies into CrossApplicationAccessException.Message. Keep the exception message to a short, sanitized summary (status code + endpoint).
  • The new docs roughly double the README size. Please move the bulk of it under docs/ (see the inline suggestion on line 65 for where).

Out of scope for this PR

The cache fields on CrossApplicationAccessProvider aren't thread-safe — but neither are ClientOAuthProvider._tokenCache / _authServerMetadata, nor the equivalents in the TS (CrossAppAccessProvider._tokens) and Go (EnterpriseHandler.tokenSource) SDKs. Since this is a pre-existing gap shared across providers (and SDKs) rather than something this PR introduces, I'd rather address it as a single follow-up that covers both C# providers and aligns with whatever TS/Go land on. Tracked in #1595 — no action needed in this PR.

Once the surface is trimmed and the HttpClient lifecycle matches ClientOAuthProvider, this should be in good shape.


private static class Throw
{
public static void IfNull<T>(T value, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(value))] string? name = null) where T : class
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.

Do you know why you were unable to rely on the Throw.IfNull/IfNullOrWhiteSpace that ships with latest .NET versions and the polyfill in src/Common/Throw.cs?

public static void IfNull([NotNull] object? arg, [CallerArgumentExpression(nameof(arg))] string? parameterName = null)

That polyfill is already linked into ModelContextProtocol.Core (see the entry in ModelContextProtocol.Core.csproj) and is used in dozens of files here. I'd rather use it than duplicate, and IfNullOrWhiteSpace is also a stricter check than the new IfNullOrEmpty for these OAuth parameters anyway.

</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
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.

We very intentionally do not use InternalsVisibleTo in our tests. Removing that was one of the first things we did after migrating the SDK to the modelcontextprotocol org. See #6

If there's no way to write an equivalent test using just the public API, just delete it.

Comment thread README.md
- [Protocol Specification](https://modelcontextprotocol.io/specification/)
- [GitHub Organization](https://github.com/modelcontextprotocol)

## Enterprise Auth / Enterprise Managed Authorization
Copy link
Copy Markdown
Contributor

@halter73 halter73 May 21, 2026

Choose a reason for hiding this comment

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

Suggested change
## Enterprise Auth / Enterprise Managed Authorization
## Cross-Application Access (Identity Assertion Authorization Grant flow)

I think we should de-emphasize "Enterprise" now that it's no longer part of the type names. This appears to align better with the TS SDK's conceptual docs.

https://github.com/modelcontextprotocol/typescript-sdk/blob/48251fe2cb24fb9b2a12ef3cffb5f5bd196a7ea4/docs/client.md#L169

/// Discovers the IDP's token endpoint via OAuth/OIDC metadata, then requests a JWT Authorization Grant.
/// Convenience wrapper over <see cref="RequestJwtAuthorizationGrantAsync"/>.
/// </summary>
public static async Task<string> DiscoverAndRequestJwtAuthorizationGrantAsync(
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.

This method has no production callers. It's only callares are the 4 unit tests in CrossApplicationAccessTests.cs (lines 451, 500, 534, 553) reference it. CrossApplicationAccessProvider.ResolveIdpTokenEndpointAsync (lines 187–211 of CrossApplicationAccessProvider.cs) already does the same IdP discovery inline. Since CrossApplicationAccess is now internal, no external caller is possible either.

Please delete:

  • This DiscoverAndRequestJwtAuthorizationGrantAsync method (lines 149–184)
  • The whole DiscoverAndRequestJwtAuthGrantOptions.cs file
  • The #region DiscoverAndRequestJwtAuthorizationGrantAsync Tests block in CrossApplicationAccessTests.cs (lines 448–566)

/// <exception cref="ArgumentException">Required option values are missing.</exception>
public CrossApplicationAccessProvider(
CrossApplicationAccessProviderOptions options,
HttpClient? httpClient = null,
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.

Please match the ClientOAuthProvider pattern instead of httpClient ?? new HttpClient(). See src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:62HttpClient is a required, non-nullable constructor parameter; the caller owns its lifetime, and the provider never news one up. That avoids the classic socket-exhaustion anti-pattern and makes the IHttpClientFactory / DI story work without any changes here.

Concrete changes:

  1. Change this parameter to HttpClient httpClient (required, non-nullable, no default). Validate with Throw.IfNull(httpClient).
  2. Drop the ?? new HttpClient() on line 95.
  3. Remove the HttpClient? HttpClient property from RequestJwtAuthGrantOptions.cs and ExchangeJwtBearerGrantOptions.cs, and drop the ?? new HttpClient() fallbacks at CrossApplicationAccess.cs:86 and :203. The provider should pass its own _httpClient into those internal helpers explicitly.
  4. Update the integration test and any unit tests to pass an HttpClient (the integration test in CrossApplicationAccessIntegrationTests.cs already does — httpClient: HttpClient — so this is mostly removing the fallback path).

If you want to make this easier for the caller, we should find a way to use the HttpClient passed to the HttpClientTransport constructor, similar to what ClientOAuthProvider does. However, since unlike ClientOAuthProvider, this is a lower level API where public callers are meant to construct the provider themselves, I think just making it a required constructor parameter is fine.

Comment thread README.md
IdTokenCallback = async (context, ct) =>
{
// Return the OIDC ID token from your SSO session
return myIdToken;
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.

This callback is async but has no await, which will compile-warn (CS1998). Either drop async and return Task.FromResult(myIdToken), or make the example realistically asynchronous by showing the ID-token fetch:

Suggested change
return myIdToken;
// Fetch a fresh ID token from your SSO session.
return await mySsoClient.GetIdTokenAsync(cancellationToken);


if (response is null)
{
throw new CrossApplicationAccessException($"Failed to parse token exchange response: {responseBody}");
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.

Embedding the raw response body in the exception message risks leaking token material or PII into caller logs if the IdP returns something unexpected. Same on line 256 of this file.

Please drop the body from the message and instead set it on Exception.Data, e.g.:

var ex = new CrossApplicationAccessException("Failed to parse token exchange response.");
ex.Data["ResponseBody"] = responseBody;
throw ex;

Or add a special parameter to CrossApplicationAccessException to get the body if you think it's valuable, but it should not be serialized directly into Exception.Message. That keeps the diagnostic information available to callers who explicitly opt in to inspecting it, without pushing it into the default ToString() output.

$"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'.");
}

if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.OrdinalIgnoreCase))
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.

RFC 8693 §2.2.1 specifies the literal string "N_A". Please use StringComparison.Ordinal here to match — and to be consistent with the Ordinal comparison used for issued_token_type 5 lines above. (The bearer comparison on line 269 is correctly case-insensitive per RFC 7523, leave that one alone.)

Suggested change
if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal))

Comment on lines +75 to +78
if (string.IsNullOrEmpty(options.ClientId))
{
throw new ArgumentException("ClientId is required.", nameof(options));
}
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.

Throwing ArgumentException with paramName: nameof(options) for each individual missing property (lines 77, 82, 87, 92) hides the actual missing field in stack traces — every error just says the options arg is invalid. Please use Throw.IfNullOrWhiteSpace(options.ClientId), so the property name shows up:

Suggested change
if (string.IsNullOrEmpty(options.ClientId))
{
throw new ArgumentException("ClientId is required.", nameof(options));
}
Throw.IfNullOrWhiteSpace(options.ClientId)

Or if you really want the message to be "ClientId" is required:

Suggested change
if (string.IsNullOrEmpty(options.ClientId))
{
throw new ArgumentException("ClientId is required.", nameof(options));
}
if (string.IsNullOrEmpty(options.ClientId))
{
throw new ArgumentException("ClientId is required.", $"{nameof(options)}.{nameof(options.ClientId)}");
}

I think the one-liner is probably nicer though.

@halter73
Copy link
Copy Markdown
Contributor

halter73 commented May 21, 2026

And I hate to circle back on the naming, but I don't love CrossApplication* either despite having been the person to suggest it. It still feels super generic — OAuth is designed to provide access across applications in general, whether you're using an authorization code flow or ID-JAG client assertions. I only suggested it because the TS SDK used crossApp as a prefix and it seemed like the most direct transliteration into C# naming conventions.

But now I notice that Go went with EnterpriseHandler and the Python PR at modelcontextprotocol/python-sdk#1721 is currently using EnterpriseAuthOAuthClientProvider. To be clear, I'm not suggesting we go back to something as generic as EnterpriseAuthProvider. But if we're going to be inconsistent with the other SDKs anyway, can we pick something more specific?

A couple of options to consider, in rough order of my preference:

  • IdJagAccessProvider — names the actual mechanism, matches the spec terminology
  • IdentityAssertionGrantProvider — spells out the acronym (matching our "App → Application" convention) and is exactly the section heading in SEP-990.
  • IdJagProvider — concise, but undersells the whole three-step flow.

I do see that CrossApplicationAccess matches what the SEP-990 conformance test framework calls the flow externally, so devs hunting for "SEP-990 in C#" might find it more easily. With an ID-JAG name we lean more on the spec internals, but I think that's an acceptable tradeoff for a less generic name.

Edit: To be clear, I'm not going to hold up the PR on this. I'll merge with the current naming if we can't agree on something better, but I do think we can come up with something better.

Comment thread README.md
resourceUrl: new Uri("https://mcp-server.example.com"),
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"));
```

Copy link
Copy Markdown
Contributor

@halter73 halter73 May 22, 2026

Choose a reason for hiding this comment

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

I thought this was in a conceptual doc. This doubles the size of our README. We try to keep this pretty slim. I would move this to somewhere under the docs/ directory. We don't have client OAuth documented yet, and I'm not going to ask you to do that. Feel free to add it as a section to transports.md, and we'll move it to a dedicated client OAuth doc once we create that.

If you want to go the extra mile, you could add this flow to ProtectedMcpServer and ProtectedMcpClient. These both run against the TestOAuthServer that you've already updated, so I think you're already most of the way there. It could be helpful for referencing from the /docs too. We already do that in identity.md for the server side at least.

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