Skip to content

Commit 8202bcc

Browse files
authored
feat: Add Enterprise Managed Authorization (SEP-990) support (#1305)
1 parent bc372f1 commit 8202bcc

19 files changed

Lines changed: 1695 additions & 2 deletions

README.md

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

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 `IdentityAssertionGrantProvider`. See the [Cross-Application Access](docs/concepts/transports/transports.md#cross-application-access) section in the transport docs for full usage details.
38+
3439
## License
3540

3641
This project is licensed under the [Apache License 2.0](LICENSE).

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 (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. 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 IdentityAssertionGrantProvider(
399+
new IdentityAssertionGrantProviderOptions
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.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
namespace ModelContextProtocol.Authentication;
2+
3+
/// <summary>
4+
/// Options for exchanging a JWT Authorization Grant for an access token via RFC 7523.
5+
/// </summary>
6+
internal sealed class ExchangeJwtBearerGrantOptions
7+
{
8+
/// <summary>
9+
/// Gets or sets the MCP Server's authorization server token endpoint URL.
10+
/// </summary>
11+
public required string TokenEndpoint { get; set; }
12+
13+
/// <summary>
14+
/// Gets or sets the JWT Authorization Grant (JAG) assertion obtained from token exchange.
15+
/// </summary>
16+
public required string Assertion { get; set; }
17+
18+
/// <summary>
19+
/// Gets or sets the client ID for authentication with the MCP authorization server.
20+
/// </summary>
21+
public required string ClientId { get; set; }
22+
23+
/// <summary>
24+
/// Gets or sets the client secret for authentication with the MCP authorization server. Optional.
25+
/// </summary>
26+
public string? ClientSecret { get; set; }
27+
28+
/// <summary>
29+
/// Gets or sets the scopes to request (space-separated). Optional.
30+
/// </summary>
31+
public string? Scope { get; set; }
32+
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
using System.Net.Http.Headers;
2+
using System.Text.Json;
3+
4+
namespace ModelContextProtocol.Authentication;
5+
6+
/// <summary>
7+
/// Provides internal utilities for the Cross-Application Access authorization flow.
8+
/// </summary>
9+
/// <remarks>
10+
/// Implements the Enterprise Managed Authorization flow as specified at
11+
/// <see href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx"/>.
12+
/// </remarks>
13+
internal static class IdentityAssertionGrant
14+
{
15+
#region Constants
16+
17+
/// <summary>Grant type URN for RFC 8693 token exchange.</summary>
18+
public const string GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange";
19+
20+
/// <summary>Grant type URN for RFC 7523 JWT Bearer authorization grant.</summary>
21+
public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer";
22+
23+
/// <summary>Token type URN for OpenID Connect ID Tokens (RFC 8693).</summary>
24+
public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token";
25+
26+
/// <summary>Token type URN for SAML 2.0 assertions (RFC 8693).</summary>
27+
public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2";
28+
29+
/// <summary>
30+
/// Token type URN for Identity Assertion JWT Authorization Grants.
31+
/// As specified at
32+
/// <see href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx"/>.
33+
/// </summary>
34+
public const string TokenTypeIdJag = "urn:ietf:params:oauth:token-type:id-jag";
35+
36+
/// <summary>
37+
/// The expected value for <c>token_type</c> in a JAG token exchange response per RFC 8693 §2.2.1.
38+
/// The issued token is not an OAuth access token, so its type is "N_A".
39+
/// </summary>
40+
public const string TokenTypeNotApplicable = "N_A";
41+
42+
#endregion
43+
44+
#region Token Exchange (RFC 8693)
45+
46+
/// <summary>
47+
/// Requests a JWT Authorization Grant (JAG) from an Identity Provider via RFC 8693 Token Exchange.
48+
/// Returns the JAG string to be used as a JWT Bearer assertion (RFC 7523) against the MCP authorization server.
49+
/// </summary>
50+
public static async Task<string> RequestJwtAuthorizationGrantAsync(
51+
RequestJwtAuthGrantOptions options,
52+
HttpClient httpClient,
53+
CancellationToken cancellationToken = default)
54+
{
55+
Throw.IfNull(options);
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);
61+
62+
var formData = new Dictionary<string, string>
63+
{
64+
["grant_type"] = GrantTypeTokenExchange,
65+
["requested_token_type"] = TokenTypeIdJag,
66+
["subject_token"] = options.IdToken,
67+
["subject_token_type"] = TokenTypeIdToken,
68+
["audience"] = options.Audience,
69+
["resource"] = options.Resource,
70+
["client_id"] = options.ClientId,
71+
};
72+
73+
if (!string.IsNullOrEmpty(options.ClientSecret))
74+
{
75+
formData["client_secret"] = options.ClientSecret!;
76+
}
77+
78+
if (!string.IsNullOrEmpty(options.Scope))
79+
{
80+
formData["scope"] = options.Scope!;
81+
}
82+
83+
using var requestContent = new FormUrlEncodedContent(formData);
84+
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
85+
{
86+
Content = requestContent
87+
};
88+
89+
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
90+
91+
using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
92+
var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
93+
94+
if (!httpResponse.IsSuccessStatusCode)
95+
{
96+
OAuthErrorResponse? errorResponse = null;
97+
try
98+
{
99+
errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse);
100+
}
101+
catch
102+
{
103+
// Could not parse error response
104+
}
105+
106+
throw new IdentityAssertionGrantException(
107+
$"Token exchange failed with status {(int)httpResponse.StatusCode}.",
108+
errorResponse?.Error,
109+
errorResponse?.ErrorDescription,
110+
errorResponse?.ErrorUri);
111+
}
112+
113+
var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JagTokenExchangeResponse);
114+
115+
if (response is null)
116+
{
117+
var ex = new IdentityAssertionGrantException("Failed to parse token exchange response.");
118+
ex.Data["ResponseBody"] = responseBody;
119+
throw ex;
120+
}
121+
122+
if (string.IsNullOrEmpty(response.AccessToken))
123+
{
124+
throw new IdentityAssertionGrantException("Token exchange response missing required field: access_token");
125+
}
126+
127+
if (!string.Equals(response.IssuedTokenType, TokenTypeIdJag, StringComparison.Ordinal))
128+
{
129+
throw new IdentityAssertionGrantException(
130+
$"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'.");
131+
}
132+
133+
if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal))
134+
{
135+
throw new IdentityAssertionGrantException(
136+
$"Token exchange response token_type must be '{TokenTypeNotApplicable}' per RFC 8693 §2.2.1, got '{response.TokenType}'.");
137+
}
138+
139+
return response.AccessToken;
140+
}
141+
142+
#endregion
143+
144+
#region JWT Bearer Grant (RFC 7523)
145+
146+
/// <summary>
147+
/// Exchanges a JWT Authorization Grant (JAG) for an access token at an MCP Server's authorization server
148+
/// using the JWT Bearer grant (RFC 7523).
149+
/// </summary>
150+
public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
151+
ExchangeJwtBearerGrantOptions options,
152+
HttpClient httpClient,
153+
CancellationToken cancellationToken = default)
154+
{
155+
Throw.IfNull(options);
156+
Throw.IfNullOrWhiteSpace(options.TokenEndpoint);
157+
Throw.IfNullOrWhiteSpace(options.Assertion);
158+
Throw.IfNullOrWhiteSpace(options.ClientId);
159+
160+
var formData = new Dictionary<string, string>
161+
{
162+
["grant_type"] = GrantTypeJwtBearer,
163+
["assertion"] = options.Assertion,
164+
["client_id"] = options.ClientId,
165+
};
166+
167+
if (!string.IsNullOrEmpty(options.ClientSecret))
168+
{
169+
formData["client_secret"] = options.ClientSecret!;
170+
}
171+
172+
if (!string.IsNullOrEmpty(options.Scope))
173+
{
174+
formData["scope"] = options.Scope!;
175+
}
176+
177+
using var requestContent = new FormUrlEncodedContent(formData);
178+
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
179+
{
180+
Content = requestContent
181+
};
182+
183+
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
184+
185+
using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
186+
var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
187+
188+
if (!httpResponse.IsSuccessStatusCode)
189+
{
190+
OAuthErrorResponse? errorResponse = null;
191+
try
192+
{
193+
errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse);
194+
}
195+
catch
196+
{
197+
// Could not parse error response
198+
}
199+
200+
throw new IdentityAssertionGrantException(
201+
$"JWT bearer grant failed with status {(int)httpResponse.StatusCode}.",
202+
errorResponse?.Error,
203+
errorResponse?.ErrorDescription,
204+
errorResponse?.ErrorUri);
205+
}
206+
207+
var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JwtBearerAccessTokenResponse);
208+
209+
if (response is null)
210+
{
211+
var ex = new IdentityAssertionGrantException("Failed to parse JWT bearer grant response.");
212+
ex.Data["ResponseBody"] = responseBody;
213+
throw ex;
214+
}
215+
216+
if (string.IsNullOrEmpty(response.AccessToken))
217+
{
218+
throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: access_token");
219+
}
220+
221+
if (string.IsNullOrEmpty(response.TokenType))
222+
{
223+
throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: token_type");
224+
}
225+
226+
if (!string.Equals(response.TokenType, "bearer", StringComparison.OrdinalIgnoreCase))
227+
{
228+
throw new IdentityAssertionGrantException(
229+
$"JWT bearer grant response token_type must be 'bearer' per RFC 7523, got '{response.TokenType}'.");
230+
}
231+
232+
return new TokenContainer
233+
{
234+
AccessToken = response.AccessToken,
235+
TokenType = response.TokenType,
236+
RefreshToken = response.RefreshToken,
237+
ExpiresIn = response.ExpiresIn,
238+
Scope = response.Scope,
239+
ObtainedAt = DateTimeOffset.UtcNow,
240+
};
241+
}
242+
243+
#endregion
244+
245+
#region Helper: Auth Server Metadata Discovery
246+
247+
private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"];
248+
249+
/// <summary>
250+
/// Discovers authorization server metadata from the well-known endpoints.
251+
/// </summary>
252+
internal static async Task<AuthorizationServerMetadata> DiscoverAuthServerMetadataAsync(
253+
Uri issuerUrl,
254+
HttpClient httpClient,
255+
CancellationToken cancellationToken)
256+
{
257+
var baseUrl = issuerUrl.ToString();
258+
if (!baseUrl.EndsWith("/", StringComparison.Ordinal))
259+
{
260+
issuerUrl = new Uri($"{baseUrl}/");
261+
}
262+
263+
foreach (var path in s_wellKnownPaths)
264+
{
265+
try
266+
{
267+
var wellKnownEndpoint = new Uri(issuerUrl, path);
268+
var response = await httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
269+
270+
if (!response.IsSuccessStatusCode)
271+
{
272+
continue;
273+
}
274+
275+
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
276+
var metadata = await JsonSerializer.DeserializeAsync(
277+
stream,
278+
McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata,
279+
cancellationToken).ConfigureAwait(false);
280+
281+
if (metadata is not null)
282+
{
283+
return metadata;
284+
}
285+
}
286+
catch
287+
{
288+
continue;
289+
}
290+
}
291+
292+
throw new IdentityAssertionGrantException($"Failed to discover authorization server metadata for: {issuerUrl}");
293+
}
294+
295+
#endregion
296+
}

0 commit comments

Comments
 (0)