Skip to content

Commit e8b87db

Browse files
committed
test: add OAuthTestBase integration tests
1 parent ed87d4b commit e8b87db

File tree

4 files changed

+444
-2
lines changed

4 files changed

+444
-2
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModelContextProtocol.Authentication;
3+
using ModelContextProtocol.Client;
4+
using System.Net.Http.Headers;
5+
6+
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
7+
8+
/// <summary>
9+
/// Integration tests for Enterprise Managed Authorization (SEP-990) using the in-memory
10+
/// test OAuth server as a stand-in for both the enterprise Identity Provider (IdP) and
11+
/// the MCP Authorization Server (AS).
12+
///
13+
/// Flow exercised:
14+
/// 1. <see cref="EnterpriseAuthProvider.GetAccessTokenAsync"/> discovers the MCP AS
15+
/// metadata and calls the assertion callback.
16+
/// 2. The assertion callback calls <c>/idp/token</c> on the test OAuth server
17+
/// (RFC 8693 token exchange: ID token → JAG).
18+
/// 3. The provider exchanges the JAG for an access token at <c>/token</c>
19+
/// (RFC 7523 JWT-bearer grant: JAG → access token).
20+
/// 4. The access token is passed to the MCP client transport and used to authenticate
21+
/// against the protected MCP server.
22+
/// </summary>
23+
public class EnterpriseAuthIntegrationTests : OAuthTestBase
24+
{
25+
public EnterpriseAuthIntegrationTests(ITestOutputHelper outputHelper)
26+
: base(outputHelper)
27+
{
28+
}
29+
30+
[Fact]
31+
public async Task CanAuthenticate_WithEnterpriseAuthProvider()
32+
{
33+
// Enable SEP-990 endpoints on the test OAuth server.
34+
TestOAuthServer.EnterpriseSupportEnabled = true;
35+
36+
await using var app = await StartMcpServerAsync();
37+
38+
// Simulate the enterprise ID token that would normally come from the SSO login step.
39+
const string simulatedIdToken = "test-enterprise-sso-id-token";
40+
41+
// Create the provider. The assertion callback calls the IdP's token-exchange
42+
// endpoint (/idp/token on the test OAuth server) to obtain a JAG, which is then
43+
// exchanged automatically for an access token at the MCP AS token endpoint (/token).
44+
var provider = new EnterpriseAuthProvider(
45+
new EnterpriseAuthProviderOptions
46+
{
47+
ClientId = "enterprise-mcp-client",
48+
ClientSecret = "enterprise-mcp-secret",
49+
AssertionCallback = (context, ct) =>
50+
EnterpriseAuth.RequestJwtAuthorizationGrantAsync(
51+
new RequestJwtAuthGrantOptions
52+
{
53+
// /idp/token acts as the enterprise IdP token endpoint.
54+
TokenEndpoint = $"{OAuthServerUrl}/idp/token",
55+
// The JAG audience is the MCP AS, and the resource is the MCP server.
56+
Audience = context.AuthorizationServerUrl.ToString(),
57+
Resource = context.ResourceUrl.ToString(),
58+
IdToken = simulatedIdToken,
59+
ClientId = "enterprise-idp-client",
60+
ClientSecret = "enterprise-idp-secret",
61+
HttpClient = HttpClient,
62+
}, ct),
63+
},
64+
httpClient: HttpClient);
65+
66+
// Run the full SEP-990 flow: discover AS → get JAG → exchange for access token.
67+
var tokens = await provider.GetAccessTokenAsync(
68+
resourceUrl: new Uri(McpServerUrl),
69+
authorizationServerUrl: new Uri(OAuthServerUrl),
70+
cancellationToken: TestContext.Current.CancellationToken);
71+
72+
Assert.NotNull(tokens.AccessToken);
73+
Assert.False(string.IsNullOrEmpty(tokens.AccessToken));
74+
Assert.Equal("bearer", tokens.TokenType, ignoreCase: true);
75+
76+
// Wire the obtained access token into an HTTP client that shares the same
77+
// in-memory Kestrel transport as the rest of the test fixture.
78+
var mcpHttpClient = new HttpClient(SocketsHttpHandler, disposeHandler: false);
79+
ConfigureHttpClient(mcpHttpClient);
80+
mcpHttpClient.DefaultRequestHeaders.Authorization =
81+
new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
82+
83+
// Connect the MCP client using the enterprise access token — no interactive OAuth flow.
84+
await using var transport = new HttpClientTransport(
85+
new HttpClientTransportOptions { Endpoint = new Uri(McpServerUrl) },
86+
mcpHttpClient,
87+
LoggerFactory);
88+
89+
await using var client = await McpClient.CreateAsync(
90+
transport,
91+
loggerFactory: LoggerFactory,
92+
cancellationToken: TestContext.Current.CancellationToken);
93+
94+
// If we get here the MCP server accepted the enterprise access token.
95+
Assert.NotNull(client);
96+
}
97+
98+
[Fact]
99+
public async Task EnterpriseAuthProvider_ReturnsCachedToken_OnSecondCall()
100+
{
101+
TestOAuthServer.EnterpriseSupportEnabled = true;
102+
103+
await using var _ = await StartMcpServerAsync();
104+
105+
var assertionCallCount = 0;
106+
107+
var provider = new EnterpriseAuthProvider(
108+
new EnterpriseAuthProviderOptions
109+
{
110+
ClientId = "enterprise-mcp-client",
111+
ClientSecret = "enterprise-mcp-secret",
112+
AssertionCallback = async (context, ct) =>
113+
{
114+
assertionCallCount++;
115+
return await EnterpriseAuth.RequestJwtAuthorizationGrantAsync(
116+
new RequestJwtAuthGrantOptions
117+
{
118+
TokenEndpoint = $"{OAuthServerUrl}/idp/token",
119+
Audience = context.AuthorizationServerUrl.ToString(),
120+
Resource = context.ResourceUrl.ToString(),
121+
IdToken = "test-sso-token",
122+
ClientId = "enterprise-idp-client",
123+
ClientSecret = "enterprise-idp-secret",
124+
HttpClient = HttpClient,
125+
}, ct);
126+
},
127+
},
128+
httpClient: HttpClient);
129+
130+
var tokens1 = await provider.GetAccessTokenAsync(
131+
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
132+
TestContext.Current.CancellationToken);
133+
134+
var tokens2 = await provider.GetAccessTokenAsync(
135+
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
136+
TestContext.Current.CancellationToken);
137+
138+
// The assertion callback (and therefore the IdP round-trip) should only fire once.
139+
Assert.Equal(1, assertionCallCount);
140+
Assert.Equal(tokens1.AccessToken, tokens2.AccessToken);
141+
}
142+
143+
[Fact]
144+
public async Task EnterpriseAuthProvider_FetchesFreshToken_AfterInvalidateCache()
145+
{
146+
TestOAuthServer.EnterpriseSupportEnabled = true;
147+
148+
await using var _ = await StartMcpServerAsync();
149+
150+
var assertionCallCount = 0;
151+
152+
var provider = new EnterpriseAuthProvider(
153+
new EnterpriseAuthProviderOptions
154+
{
155+
ClientId = "enterprise-mcp-client",
156+
ClientSecret = "enterprise-mcp-secret",
157+
AssertionCallback = async (context, ct) =>
158+
{
159+
assertionCallCount++;
160+
return await EnterpriseAuth.RequestJwtAuthorizationGrantAsync(
161+
new RequestJwtAuthGrantOptions
162+
{
163+
TokenEndpoint = $"{OAuthServerUrl}/idp/token",
164+
Audience = context.AuthorizationServerUrl.ToString(),
165+
Resource = context.ResourceUrl.ToString(),
166+
IdToken = "test-sso-token",
167+
ClientId = "enterprise-idp-client",
168+
ClientSecret = "enterprise-idp-secret",
169+
HttpClient = HttpClient,
170+
}, ct);
171+
},
172+
},
173+
httpClient: HttpClient);
174+
175+
var tokens1 = await provider.GetAccessTokenAsync(
176+
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
177+
TestContext.Current.CancellationToken);
178+
179+
// Invalidate the cache to force a full re-exchange.
180+
provider.InvalidateCache();
181+
182+
var tokens2 = await provider.GetAccessTokenAsync(
183+
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
184+
TestContext.Current.CancellationToken);
185+
186+
// The IdP should have been called twice — once for each GetAccessTokenAsync after invalidation.
187+
Assert.Equal(2, assertionCallCount);
188+
// The tokens may or may not be identical depending on timing, but the flow ran again.
189+
Assert.NotNull(tokens2.AccessToken);
190+
}
191+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.TestOAuthServer;
4+
5+
/// <summary>
6+
/// Represents the token exchange response for the Identity Assertion JWT Authorization Grant (ID-JAG)
7+
/// per RFC 8693 / SEP-990.
8+
/// </summary>
9+
internal sealed class JagTokenExchangeResponse
10+
{
11+
/// <summary>
12+
/// Gets or sets the issued JWT Authorization Grant (JAG).
13+
/// Despite the field name "access_token" (required by RFC 8693), this contains a JAG JWT,
14+
/// not an OAuth access token.
15+
/// </summary>
16+
[JsonPropertyName("access_token")]
17+
public required string AccessToken { get; init; }
18+
19+
/// <summary>
20+
/// Gets or sets the type of security token issued.
21+
/// For SEP-990, this MUST be "urn:ietf:params:oauth:token-type:id-jag".
22+
/// </summary>
23+
[JsonPropertyName("issued_token_type")]
24+
public required string IssuedTokenType { get; init; }
25+
26+
/// <summary>
27+
/// Gets or sets the token type.
28+
/// For SEP-990, this MUST be "N_A" per RFC 8693 §2.2.1 because the JAG is not an access token.
29+
/// </summary>
30+
[JsonPropertyName("token_type")]
31+
public required string TokenType { get; init; }
32+
33+
/// <summary>
34+
/// Gets or sets the lifetime in seconds of the issued JAG.
35+
/// </summary>
36+
[JsonPropertyName("expires_in")]
37+
public int? ExpiresIn { get; init; }
38+
}

tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace ModelContextProtocol.TestOAuthServer;
55
[JsonSerializable(typeof(OAuthServerMetadata))]
66
[JsonSerializable(typeof(AuthorizationServerMetadata))]
77
[JsonSerializable(typeof(TokenResponse))]
8+
[JsonSerializable(typeof(JagTokenExchangeResponse))]
89
[JsonSerializable(typeof(JsonWebKeySet))]
910
[JsonSerializable(typeof(JsonWebKey))]
1011
[JsonSerializable(typeof(TokenIntrospectionResponse))]

0 commit comments

Comments
 (0)