Skip to content

Commit 1ce785c

Browse files
committed
refactor: remove IVT, make HttpClient required, drop DiscoverAndRequest overload, move docs to transports.md
1 parent db67570 commit 1ce785c

9 files changed

Lines changed: 114 additions & 808 deletions

File tree

README.md

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -31,37 +31,10 @@ For more information about MCP:
3131
- [Protocol Specification](https://modelcontextprotocol.io/specification/)
3232
- [GitHub Organization](https://github.com/modelcontextprotocol)
3333

34-
## Enterprise Auth / Enterprise Managed Authorization
35-
36-
The SDK provides Cross-Application Access support for the [Identity Assertion Authorization Grant flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx),
37-
enabling enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider and
38-
access MCP servers without per-server authorization prompts.
39-
40-
The flow consists of two token operations:
41-
1. **RFC 8693 Token Exchange** at the IdP: ID Token → JWT Authorization Grant (JAG)
42-
2. **RFC 7523 JWT Bearer Grant** at the MCP Server: JAG → Access Token
43-
44-
### Using CrossApplicationAccessProvider
45-
46-
```csharp
47-
using ModelContextProtocol.Authentication;
48-
49-
var provider = new CrossApplicationAccessProvider(new CrossApplicationAccessProviderOptions
50-
{
51-
ClientId = "mcp-client-id",
52-
IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
53-
IdpClientId = "idp-client-id",
54-
IdTokenCallback = async (context, ct) =>
55-
{
56-
// Return the OIDC ID token from your SSO session
57-
return myIdToken;
58-
}
59-
});
60-
61-
var tokens = await provider.GetAccessTokenAsync(
62-
resourceUrl: new Uri("https://mcp-server.example.com"),
63-
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"));
64-
```
34+
## Cross-Application Access (Identity Assertion Authorization Grant flow)
35+
36+
The SDK provides support for the [Identity Assertion Authorization Grant flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx)
37+
via `CrossApplicationAccessProvider`. See the [Cross-Application Access](docs/concepts/transports/transports.md#cross-application-access) section in the transport docs for full usage details.
6538

6639
## License
6740

docs/concepts/transports/transports.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,42 @@ Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" }));
378378
```
379379

380380
Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:stateless) for how session behavior varies across transports.
381+
382+
## Cross-Application Access
383+
384+
The SDK provides built-in support for the [Identity Assertion Authorization Grant (IDAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) via `CrossApplicationAccessProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts.
385+
386+
The flow consists of two steps:
387+
1. **RFC 8693 Token Exchange** at the enterprise IdP: OIDC ID token → JWT Authorization Grant (JAG)
388+
2. **RFC 7523 JWT Bearer Grant** at the MCP authorization server: JAG → access token
389+
390+
### Usage
391+
392+
```csharp
393+
using ModelContextProtocol.Authentication;
394+
395+
// The caller owns the HttpClient lifetime.
396+
var httpClient = new HttpClient();
397+
398+
var provider = new CrossApplicationAccessProvider(
399+
new CrossApplicationAccessProviderOptions
400+
{
401+
ClientId = "mcp-client-id",
402+
IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
403+
IdpClientId = "idp-client-id",
404+
IdTokenCallback = (context, cancellationToken) =>
405+
// Fetch a fresh ID token from your SSO session.
406+
mySsoClient.GetIdTokenAsync(cancellationToken)
407+
},
408+
httpClient);
409+
410+
var tokens = await provider.GetAccessTokenAsync(
411+
resourceUrl: new Uri("https://mcp-server.example.com"),
412+
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
413+
cancellationToken: ct);
414+
415+
// Use tokens.AccessToken to authenticate against the MCP server.
416+
// Call provider.InvalidateCache() to force a fresh token exchange on the next call.
417+
```
418+
419+
The provider caches the resulting access token and reuses it until it expires. To force re-authentication (e.g. after a 401 response), call `provider.InvalidateCache()` before retrying.

src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs

Lines changed: 21 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,16 @@ internal static class CrossApplicationAccess
1414
{
1515
#region Constants
1616

17-
/// <summary>
18-
/// Grant type URN for RFC 8693 token exchange.
19-
/// </summary>
17+
/// <summary>Grant type URN for RFC 8693 token exchange.</summary>
2018
public const string GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange";
2119

22-
/// <summary>
23-
/// Grant type URN for RFC 7523 JWT Bearer authorization grant.
24-
/// </summary>
20+
/// <summary>Grant type URN for RFC 7523 JWT Bearer authorization grant.</summary>
2521
public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer";
2622

27-
/// <summary>
28-
/// Token type URN for OpenID Connect ID Tokens (RFC 8693).
29-
/// </summary>
23+
/// <summary>Token type URN for OpenID Connect ID Tokens (RFC 8693).</summary>
3024
public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token";
3125

32-
/// <summary>
33-
/// Token type URN for SAML 2.0 assertions (RFC 8693).
34-
/// </summary>
26+
/// <summary>Token type URN for SAML 2.0 assertions (RFC 8693).</summary>
3527
public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2";
3628

3729
/// <summary>
@@ -57,16 +49,15 @@ internal static class CrossApplicationAccess
5749
/// </summary>
5850
public static async Task<string> RequestJwtAuthorizationGrantAsync(
5951
RequestJwtAuthGrantOptions options,
52+
HttpClient httpClient,
6053
CancellationToken cancellationToken = default)
6154
{
6255
Throw.IfNull(options);
63-
Throw.IfNullOrEmpty(options.TokenEndpoint, "TokenEndpoint is required.");
64-
Throw.IfNullOrEmpty(options.Audience, "Audience is required.");
65-
Throw.IfNullOrEmpty(options.Resource, "Resource is required.");
66-
Throw.IfNullOrEmpty(options.IdToken, "IdToken is required.");
67-
Throw.IfNullOrEmpty(options.ClientId, "ClientId is required.");
68-
69-
var httpClient = options.HttpClient ?? new HttpClient();
56+
Throw.IfNullOrWhiteSpace(options.TokenEndpoint);
57+
Throw.IfNullOrWhiteSpace(options.Audience);
58+
Throw.IfNullOrWhiteSpace(options.Resource);
59+
Throw.IfNullOrWhiteSpace(options.IdToken);
60+
Throw.IfNullOrWhiteSpace(options.ClientId);
7061

7162
var formData = new Dictionary<string, string>
7263
{
@@ -123,7 +114,9 @@ public static async Task<string> RequestJwtAuthorizationGrantAsync(
123114

124115
if (response is null)
125116
{
126-
throw new CrossApplicationAccessException($"Failed to parse token exchange response: {responseBody}");
117+
var ex = new CrossApplicationAccessException("Failed to parse token exchange response.");
118+
ex.Data["ResponseBody"] = responseBody;
119+
throw ex;
127120
}
128121

129122
if (string.IsNullOrEmpty(response.AccessToken))
@@ -137,7 +130,7 @@ public static async Task<string> RequestJwtAuthorizationGrantAsync(
137130
$"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'.");
138131
}
139132

140-
if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.OrdinalIgnoreCase))
133+
if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal))
141134
{
142135
throw new CrossApplicationAccessException(
143136
$"Token exchange response token_type must be '{TokenTypeNotApplicable}' per RFC 8693 §2.2.1, got '{response.TokenType}'.");
@@ -146,43 +139,6 @@ public static async Task<string> RequestJwtAuthorizationGrantAsync(
146139
return response.AccessToken;
147140
}
148141

149-
/// <summary>
150-
/// Discovers the IDP's token endpoint via OAuth/OIDC metadata, then requests a JWT Authorization Grant.
151-
/// Convenience wrapper over <see cref="RequestJwtAuthorizationGrantAsync"/>.
152-
/// </summary>
153-
public static async Task<string> DiscoverAndRequestJwtAuthorizationGrantAsync(
154-
DiscoverAndRequestJwtAuthGrantOptions options,
155-
CancellationToken cancellationToken = default)
156-
{
157-
Throw.IfNull(options);
158-
159-
var tokenEndpoint = options.IdpTokenEndpoint;
160-
161-
if (string.IsNullOrEmpty(tokenEndpoint))
162-
{
163-
Throw.IfNullOrEmpty(options.IdpUrl, "Either IdpUrl or IdpTokenEndpoint is required.");
164-
165-
var httpClient = options.HttpClient ?? new HttpClient();
166-
var idpMetadata = await DiscoverAuthServerMetadataAsync(
167-
new Uri(options.IdpUrl!), httpClient, cancellationToken).ConfigureAwait(false);
168-
169-
tokenEndpoint = idpMetadata.TokenEndpoint?.ToString()
170-
?? throw new CrossApplicationAccessException($"IDP metadata discovery for {options.IdpUrl} did not return a token_endpoint.");
171-
}
172-
173-
return await RequestJwtAuthorizationGrantAsync(new RequestJwtAuthGrantOptions
174-
{
175-
TokenEndpoint = tokenEndpoint!,
176-
Audience = options.Audience,
177-
Resource = options.Resource,
178-
IdToken = options.IdToken,
179-
ClientId = options.ClientId,
180-
ClientSecret = options.ClientSecret,
181-
Scope = options.Scope,
182-
HttpClient = options.HttpClient,
183-
}, cancellationToken).ConfigureAwait(false);
184-
}
185-
186142
#endregion
187143

188144
#region JWT Bearer Grant (RFC 7523)
@@ -193,14 +149,13 @@ public static async Task<string> DiscoverAndRequestJwtAuthorizationGrantAsync(
193149
/// </summary>
194150
public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
195151
ExchangeJwtBearerGrantOptions options,
152+
HttpClient httpClient,
196153
CancellationToken cancellationToken = default)
197154
{
198155
Throw.IfNull(options);
199-
Throw.IfNullOrEmpty(options.TokenEndpoint, "TokenEndpoint is required.");
200-
Throw.IfNullOrEmpty(options.Assertion, "Assertion (JAG) is required.");
201-
Throw.IfNullOrEmpty(options.ClientId, "ClientId is required.");
202-
203-
var httpClient = options.HttpClient ?? new HttpClient();
156+
Throw.IfNullOrWhiteSpace(options.TokenEndpoint);
157+
Throw.IfNullOrWhiteSpace(options.Assertion);
158+
Throw.IfNullOrWhiteSpace(options.ClientId);
204159

205160
var formData = new Dictionary<string, string>
206161
{
@@ -253,7 +208,9 @@ public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
253208

254209
if (response is null)
255210
{
256-
throw new CrossApplicationAccessException($"Failed to parse JWT bearer grant response: {responseBody}");
211+
var ex = new CrossApplicationAccessException("Failed to parse JWT bearer grant response.");
212+
ex.Data["ResponseBody"] = responseBody;
213+
throw ex;
257214
}
258215

259216
if (string.IsNullOrEmpty(response.AccessToken))
@@ -336,27 +293,4 @@ internal static async Task<AuthorizationServerMetadata> DiscoverAuthServerMetada
336293
}
337294

338295
#endregion
339-
340-
#region Helpers
341-
342-
private static class Throw
343-
{
344-
public static void IfNull<T>(T value, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(value))] string? name = null) where T : class
345-
{
346-
if (value is null)
347-
{
348-
throw new ArgumentNullException(name);
349-
}
350-
}
351-
352-
public static void IfNullOrEmpty(string? value, string message)
353-
{
354-
if (string.IsNullOrEmpty(value))
355-
{
356-
throw new ArgumentException(message);
357-
}
358-
}
359-
}
360-
361-
#endregion
362296
}

src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProvider.cs

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,16 @@ namespace ModelContextProtocol.Authentication;
3131
/// </remarks>
3232
/// <example>
3333
/// <code>
34-
/// var provider = new CrossApplicationAccessProvider(new CrossApplicationAccessProviderOptions
35-
/// {
36-
/// ClientId = "mcp-client-id",
37-
/// IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
38-
/// IdpClientId = "idp-client-id",
39-
/// IdTokenCallback = async (context, ct) =>
34+
/// var provider = new CrossApplicationAccessProvider(
35+
/// new CrossApplicationAccessProviderOptions
4036
/// {
41-
/// // Return the ID token from your SSO session
42-
/// return myIdToken;
43-
/// }
44-
/// });
37+
/// ClientId = "mcp-client-id",
38+
/// IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
39+
/// IdpClientId = "idp-client-id",
40+
/// IdTokenCallback = (context, ct) =>
41+
/// mySsoClient.GetIdTokenAsync(ct)
42+
/// },
43+
/// httpClient: myHttpClient);
4544
///
4645
/// var tokens = await provider.GetAccessTokenAsync(
4746
/// resourceUrl: new Uri("https://mcp-server.example.com"),
@@ -61,38 +60,35 @@ public sealed class CrossApplicationAccessProvider
6160
/// Initializes a new instance of the <see cref="CrossApplicationAccessProvider"/> class.
6261
/// </summary>
6362
/// <param name="options">Configuration for the Cross-Application Access provider.</param>
64-
/// <param name="httpClient">Optional HTTP client. A default will be created if not provided.</param>
63+
/// <param name="httpClient">
64+
/// The HTTP client to use for token exchange requests. The caller is responsible for the lifetime of this instance.
65+
/// </param>
6566
/// <param name="loggerFactory">Optional logger factory.</param>
66-
/// <exception cref="ArgumentNullException"><paramref name="options"/> is null.</exception>
67+
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="httpClient"/> is null.</exception>
6768
/// <exception cref="ArgumentException">Required option values are missing.</exception>
6869
public CrossApplicationAccessProvider(
6970
CrossApplicationAccessProviderOptions options,
70-
HttpClient? httpClient = null,
71+
HttpClient httpClient,
7172
ILoggerFactory? loggerFactory = null)
7273
{
73-
_options = options ?? throw new ArgumentNullException(nameof(options));
74+
Throw.IfNull(options);
75+
Throw.IfNull(httpClient);
7476

75-
if (string.IsNullOrEmpty(options.ClientId))
76-
{
77-
throw new ArgumentException("ClientId is required.", nameof(options));
78-
}
79-
80-
if (string.IsNullOrEmpty(options.IdpClientId))
81-
{
82-
throw new ArgumentException("IdpClientId is required.", nameof(options));
83-
}
77+
Throw.IfNullOrWhiteSpace(options.ClientId);
78+
Throw.IfNullOrWhiteSpace(options.IdpClientId);
8479

8580
if (string.IsNullOrEmpty(options.IdpUrl) && string.IsNullOrEmpty(options.IdpTokenEndpoint))
8681
{
87-
throw new ArgumentException("Either IdpUrl or IdpTokenEndpoint is required.", nameof(options));
82+
throw new ArgumentException("Either IdpUrl or IdpTokenEndpoint is required.", $"{nameof(options)}.{nameof(options.IdpUrl)}");
8883
}
8984

9085
if (options.IdTokenCallback is null)
9186
{
92-
throw new ArgumentException("IdTokenCallback is required.", nameof(options));
87+
throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}");
9388
}
9489

95-
_httpClient = httpClient ?? new HttpClient();
90+
_options = options;
91+
_httpClient = httpClient;
9692
_logger = (ILogger?)loggerFactory?.CreateLogger<CrossApplicationAccessProvider>() ?? NullLogger.Instance;
9793
}
9894

@@ -154,8 +150,7 @@ public async Task<TokenContainer> GetAccessTokenAsync(
154150
ClientId = _options.IdpClientId,
155151
ClientSecret = _options.IdpClientSecret,
156152
Scope = _options.IdpScope,
157-
HttpClient = _httpClient,
158-
}, cancellationToken).ConfigureAwait(false);
153+
}, _httpClient, cancellationToken).ConfigureAwait(false);
159154

160155
// Step 4: RFC 7523 JWT bearer grant — JAG → access token at the MCP authorization server
161156
_logger.LogDebug("Exchanging JAG for access token at {McpTokenEndpoint}", mcpTokenEndpoint);
@@ -167,8 +162,7 @@ public async Task<TokenContainer> GetAccessTokenAsync(
167162
ClientId = _options.ClientId,
168163
ClientSecret = _options.ClientSecret,
169164
Scope = _options.Scope,
170-
HttpClient = _httpClient,
171-
}, cancellationToken).ConfigureAwait(false);
165+
}, _httpClient, cancellationToken).ConfigureAwait(false);
172166

173167
_cachedTokens = tokens;
174168
_logger.LogDebug("Cross-Application Access flow completed successfully");

0 commit comments

Comments
 (0)