Implement RFC 9207 issuer validation in ClientOAuthProvider (#1605)

This commit is contained in:
Mike Kistler 2026-07-23 08:01:22 -07:00 • committed by GitHub
commit 4aade88558
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 707 additions and 149 deletions

View file

@ -48,7 +48,7 @@ public class AuthEventTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
},
HttpClient,
@ -76,7 +76,7 @@ public class AuthEventTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{

View file

@ -42,7 +42,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -50,6 +50,132 @@ public class AuthTests : OAuthTestBase
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public async Task AuthorizationCallbackHandler_ReceivesConfiguredRedirectUri()
{
await using var app = await StartMcpServerAsync();
var redirectUri = new Uri("http://localhost:1179/callback");
AuthorizationCallbackContext? callbackContext = null;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = redirectUri,
AuthorizationCallbackHandler = (context, cancellationToken) =>
{
callbackContext = context;
return HandleAuthorizationUrlAsync(context, cancellationToken);
},
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(callbackContext);
Assert.Equal(redirectUri, callbackContext.RedirectUri);
}
[Theory]
[InlineData(false, null)]
[InlineData(true, "https://localhost:7029")]
[InlineData(true, "https://attacker.example")]
public async Task AuthorizationRedirectDelegate_ReceivesConfiguredUrisAndSkipsResponseIssuerValidation(
bool authorizationResponseIssParameterSupported,
string? authorizationResponseIssuer)
{
TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported;
TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer;
await using var app = await StartMcpServerAsync();
var redirectUri = new Uri("http://localhost:1179/callback");
Uri? receivedAuthorizationUri = null;
Uri? receivedRedirectUri = null;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = redirectUri,
#pragma warning disable MCP9007 // Verify the obsolete callback remains functional during its compatibility window.
AuthorizationRedirectDelegate = (authorizationUri, callbackRedirectUri, cancellationToken) =>
{
receivedAuthorizationUri = authorizationUri;
receivedRedirectUri = callbackRedirectUri;
return HandleAuthorizationUrlAsync(authorizationUri, callbackRedirectUri, cancellationToken);
},
#pragma warning restore MCP9007
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(receivedAuthorizationUri);
Assert.Equal(redirectUri, receivedRedirectUri);
}
[Fact]
public async Task AuthorizationRedirectDelegate_DoesNotSkipMetadataIssuerValidation()
{
TestOAuthServer.MetadataIssuerOverride = "https://attacker.example";
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
#pragma warning disable MCP9007 // Verify the obsolete callback retains metadata issuer validation.
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
#pragma warning restore MCP9007
},
}, HttpClient, LoggerFactory);
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("does not match the expected issuer", ex.Message);
}
[Fact]
public void HttpClientTransport_RejectsBothAuthorizationCallbacks()
{
#pragma warning disable MCP9007 // Verify ambiguous legacy and current callback configuration is rejected.
var options = new ClientOAuthOptions
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (_, _) => Task.FromResult<ModelContextProtocol.Authentication.AuthorizationResult?>(new()),
AuthorizationRedirectDelegate = (_, _, _) => Task.FromResult<string?>("code"),
};
#pragma warning restore MCP9007
var ex = Assert.Throws<ArgumentException>(() => new HttpClientTransport(
new()
{
Endpoint = new(McpServerUrl),
OAuth = options,
},
HttpClient,
LoggerFactory));
Assert.Contains(nameof(ClientOAuthOptions.AuthorizationCallbackHandler), ex.Message);
#pragma warning disable MCP9007 // The obsolete property name should be included in the diagnostic.
Assert.Contains(nameof(ClientOAuthOptions.AuthorizationRedirectDelegate), ex.Message);
#pragma warning restore MCP9007
}
[Fact]
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
{
@ -79,7 +205,7 @@ public class AuthTests : OAuthTestBase
ClientId = "unregistered-demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -99,7 +225,7 @@ public class AuthTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
@ -125,7 +251,7 @@ public class AuthTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ApplicationType = "web",
@ -150,7 +276,7 @@ public class AuthTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
DynamicClientRegistration = new()
{
@ -177,7 +303,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -205,7 +331,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -234,7 +360,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -258,7 +384,7 @@ public class AuthTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
DynamicClientRegistration = new()
{
@ -287,7 +413,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
DynamicClientRegistration = new()
{
@ -313,7 +439,7 @@ public class AuthTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(uri),
},
}, HttpClient, LoggerFactory);
@ -378,7 +504,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -405,10 +531,10 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
lastAuthorizationUri = uri;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
lastAuthorizationUri = context.AuthorizationUri;
return HandleAuthorizationUrlAsync(context, ct);
},
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
@ -437,7 +563,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
["redirect_uri"] = "custom_value",
@ -462,7 +588,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -484,7 +610,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -512,11 +638,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -565,11 +691,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -660,11 +786,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -765,11 +891,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScopes.Add(query["scope"].ToString());
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -806,6 +932,8 @@ public class AuthTests : OAuthTestBase
// before either has stepped up. They serialize on the provider's token acquisition lock: the
// first runs the step-up and caches the broader token, and the second must reuse that token
// instead of failing as a "repeated" challenge. Only one interactive step-up should run.
TestOAuthServer.AuthorizationResponseIssParameterSupported = true;
TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl;
Builder.Services.AddMcpServer()
.WithTools([
@ -879,14 +1007,14 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, cancellationToken) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
lock (scopeLock)
{
requestedScopes.Add(query["scope"].ToString());
}
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, cancellationToken);
},
},
}, HttpClient, LoggerFactory);
@ -977,11 +1105,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScopes.Add(query["scope"].ToString());
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -1072,11 +1200,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScopes.Add(query["scope"].ToString());
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -1120,7 +1248,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1159,7 +1287,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1187,7 +1315,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1220,7 +1348,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1237,6 +1365,69 @@ public class AuthTests : OAuthTestBase
TestOAuthServer.MetadataRequests);
}
[Fact]
public async Task CannotAuthenticate_WhenAuthorizationServerMetadataIssuerMismatches()
{
TestOAuthServer.MetadataIssuerOverride = "https://attacker.example";
await using var app = await StartMcpServerAsync();
await using var transport = CreateOAuthTransport();
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("does not match the expected issuer", ex.Message);
Assert.Single(TestOAuthServer.MetadataRequests);
}
[Fact]
public async Task CannotAuthenticate_WhenAuthorizationServerMetadataOmitsIssuer()
{
TestOAuthServer.IncludeIssuerInMetadata = false;
await using var app = await StartMcpServerAsync();
await using var transport = CreateOAuthTransport();
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("did not provide the required issuer", ex.Message);
Assert.Single(TestOAuthServer.MetadataRequests);
}
[Fact]
public async Task CanAuthenticate_WhenAuthorizationResponseIssuerMatches()
{
TestOAuthServer.AuthorizationResponseIssParameterSupported = true;
TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl;
await using var app = await StartMcpServerAsync();
await using var transport = CreateOAuthTransport();
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
[Theory]
[InlineData(true, "https://attacker.example", "does not match expected issuer")]
[InlineData(true, null, "advertises RFC 9207 iss parameter support but none was received")]
[InlineData(false, "https://attacker.example", "does not match expected issuer")]
public async Task CannotAuthenticate_WhenAuthorizationResponseIssuerIsInvalid(
bool authorizationResponseIssParameterSupported,
string? authorizationResponseIssuer,
string expectedMessage)
{
TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported;
TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer;
await using var app = await StartMcpServerAsync();
await using var transport = CreateOAuthTransport();
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains(expectedMessage, ex.Message);
}
[Fact]
public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
{
@ -1284,7 +1475,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1339,7 +1530,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1386,7 +1577,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1428,7 +1619,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1474,7 +1665,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1521,7 +1712,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1673,7 +1864,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1790,7 +1981,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1892,7 +2083,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1963,7 +2154,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@ -1991,11 +2182,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -2023,11 +2214,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -2062,11 +2253,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
},
}, HttpClient, LoggerFactory);
@ -2099,11 +2290,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
},
@ -2130,11 +2321,11 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
requestedScope = query["scope"].ToString();
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"],
},
@ -2168,7 +2359,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ScopeSelector = scopes =>
{
capturedInput = scopes;
@ -2198,10 +2389,10 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(uri, redirect, ct);
scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(context, ct);
},
ScopeSelector = _ => null,
},
@ -2228,10 +2419,10 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(uri, redirect, ct);
scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(context, ct);
},
ScopeSelector = _ => [],
},
@ -2243,6 +2434,19 @@ public class AuthTests : OAuthTestBase
Assert.False(scopePresent);
}
private HttpClientTransport CreateOAuthTransport() =>
new(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
[Fact]
public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope()
{
@ -2259,7 +2463,7 @@ public class AuthTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
},

View file

@ -26,7 +26,7 @@ public class DcrFailureTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("myapp://callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
},
}, HttpClient, LoggerFactory);
@ -54,7 +54,7 @@ public class DcrFailureTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("myapp://callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
@ -75,7 +75,7 @@ public class DcrFailureTests : OAuthTestBase
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",

View file

@ -0,0 +1,46 @@
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
[CollectionDefinition(nameof(DisableConsoleParallelization), DisableParallelization = true)]
public sealed class DisableConsoleParallelization;
[Collection(nameof(DisableConsoleParallelization))]
public class DefaultAuthorizationUrlHandlerTests(ITestOutputHelper outputHelper) : OAuthTestBase(outputHelper)
{
[Fact]
public async Task RejectsAuthorizationCodeWithoutAbsoluteRedirectUrl()
{
await using var app = await StartMcpServerAsync();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
},
}, HttpClient, LoggerFactory);
var originalInput = Console.In;
using var consoleInput = new StringReader("authorization-code");
Console.SetIn(consoleInput);
try
{
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("not a valid absolute URL", ex.Message);
}
finally
{
Console.SetIn(originalInput);
}
}
}

View file

@ -106,18 +106,40 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
return app;
}
protected async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken)
protected async Task<ModelContextProtocol.Authentication.AuthorizationResult?> HandleAuthorizationUrlAsync(
ModelContextProtocol.Authentication.AuthorizationCallbackContext authorizationContext,
CancellationToken cancellationToken)
{
using var redirectResponse = await HttpClient.GetAsync(authorizationUri, cancellationToken);
using var redirectResponse = await HttpClient.GetAsync(authorizationContext.AuthorizationUri, cancellationToken);
Assert.Equal(HttpStatusCode.Redirect, redirectResponse.StatusCode);
var location = redirectResponse.Headers.Location;
if (location is not null && !string.IsNullOrEmpty(location.Query))
{
var queryParams = QueryHelpers.ParseQuery(location.Query);
return queryParams["code"];
return new ModelContextProtocol.Authentication.AuthorizationResult
{
Code = queryParams["code"],
Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null,
};
}
return null;
}
protected async Task<string?> HandleAuthorizationUrlAsync(
Uri authorizationUri,
Uri redirectUri,
CancellationToken cancellationToken)
{
var result = await HandleAuthorizationUrlAsync(
new ModelContextProtocol.Authentication.AuthorizationCallbackContext
{
AuthorizationUri = authorizationUri,
RedirectUri = redirectUri,
},
cancellationToken);
return result?.Code;
}
}

View file

@ -26,10 +26,10 @@ public class TokenCacheTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
@ -40,7 +40,7 @@ public class TokenCacheTests : OAuthTestBase
// Just connecting should trigger auth and storage.
}
Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token");
Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token");
Assert.NotNull(tokenCache.LastStoredToken);
var authDelegateCalledAgain = false;
@ -53,10 +53,10 @@ public class TokenCacheTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache
},
@ -64,7 +64,7 @@ public class TokenCacheTests : OAuthTestBase
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when token is valid");
Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when token is valid");
}
[Fact]
@ -82,7 +82,7 @@ public class TokenCacheTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache
},
}, HttpClient, LoggerFactory);
@ -109,10 +109,10 @@ public class TokenCacheTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalled = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
@ -120,7 +120,7 @@ public class TokenCacheTests : OAuthTestBase
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authDelegateCalled, "AuthorizationRedirectDelegate should be called when cached token is invalid");
Assert.True(authDelegateCalled, "AuthorizationCallbackHandler should be called when cached token is invalid");
Assert.NotNull(tokenCache.LastStoredToken);
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}
@ -141,10 +141,10 @@ public class TokenCacheTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
@ -155,7 +155,7 @@ public class TokenCacheTests : OAuthTestBase
// Just connecting should trigger auth and storage.
}
Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token");
Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token");
Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet");
Assert.NotNull(tokenCache.LastStoredToken);
@ -171,10 +171,10 @@ public class TokenCacheTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache
},
@ -182,7 +182,7 @@ public class TokenCacheTests : OAuthTestBase
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when refresh token is valid");
Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when refresh token is valid");
Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed");
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}

View file

@ -3,6 +3,7 @@ using System.Net.Sockets;
using System.Text.Json;
using System.Web;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@ -92,7 +93,7 @@ var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions
RedirectUri = clientRedirectUri,
// Configure the metadata document URI for CIMD.
ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"),
AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
};
if (preRegisteredClientId is not null)
@ -340,8 +341,12 @@ catch (Exception ex)
// Copied from ProtectedMcpClient sample
// Simulate a user opening the browser and logging in
// Copied from OAuthTestBase
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
AuthorizationCallbackContext authorizationContext,
CancellationToken cancellationToken)
{
var authorizationUrl = authorizationContext.AuthorizationUri;
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Simulating opening browser to: {authorizationUrl}");
@ -355,16 +360,30 @@ static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri
if (location is not null && !string.IsNullOrEmpty(location.Query))
{
// Parse query string to extract "code" parameter
// Parse query string to extract "code" and "iss" parameters
var query = location.Query.TrimStart('?');
string? code = null;
string? iss = null;
foreach (var pair in query.Split('&'))
{
var parts = pair.Split('=', 2);
if (parts.Length == 2 && parts[0] == "code")
if (parts.Length == 2)
{
return HttpUtility.UrlDecode(parts[1]);
if (parts[0] == "code")
{
code = HttpUtility.UrlDecode(parts[1]);
}
else if (parts[0] == "iss")
{
iss = HttpUtility.UrlDecode(parts[1]);
}
}
}
if (code is not null)
{
return new AuthorizationResult { Code = code, Iss = iss };
}
}
return null;

View file

@ -12,7 +12,8 @@ internal sealed class OAuthServerMetadata
/// REQUIRED. The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components.
/// </summary>
[JsonPropertyName("issuer")]
public required string Issuer { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Issuer { get; init; }
/// <summary>
/// Gets or sets the authorization endpoint URL.
@ -178,4 +179,11 @@ internal sealed class OAuthServerMetadata
[JsonPropertyName("client_id_metadata_document_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ClientIdMetadataDocumentSupported { get; init; }
/// <summary>
/// Gets or sets a value indicating whether authorization responses contain an issuer parameter.
/// </summary>
[JsonPropertyName("authorization_response_iss_parameter_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? AuthorizationResponseIssParameterSupported { get; init; }
}

View file

@ -100,6 +100,26 @@ public sealed class Program
/// </remarks>
public bool IncludeOfflineAccessInMetadata { get; set; }
/// <summary>
/// Gets or sets a value indicating whether authorization server metadata includes an issuer.
/// </summary>
public bool IncludeIssuerInMetadata { get; set; } = true;
/// <summary>
/// Gets or sets an issuer value that overrides the authorization server's metadata issuer.
/// </summary>
public string? MetadataIssuerOverride { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the authorization server advertises RFC 9207 support.
/// </summary>
public bool AuthorizationResponseIssParameterSupported { get; set; }
/// <summary>
/// Gets or sets the issuer included in authorization responses, or <see langword="null"/> to omit it.
/// </summary>
public string? AuthorizationResponseIssuer { get; set; }
/// <summary>
/// Gets or sets the code challenge methods advertised by metadata endpoints.
/// </summary>
@ -242,7 +262,7 @@ public sealed class Program
var metadata = new OAuthServerMetadata
{
Issuer = $"{_url}{issuerPath}",
Issuer = IncludeIssuerInMetadata ? MetadataIssuerOverride ?? $"{_url}{issuerPath}" : null,
AuthorizationEndpoint = $"{_url}/authorize",
TokenEndpoint = $"{_url}/token",
JwksUri = $"{_url}/.well-known/jwks.json",
@ -261,6 +281,7 @@ public sealed class Program
IntrospectionEndpoint = $"{_url}/introspect",
RegistrationEndpoint = $"{_url}/register",
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null,
};
return Results.Ok(metadata);
@ -392,6 +413,10 @@ public sealed class Program
{
redirectUrl += $"&state={Uri.EscapeDataString(state)}";
}
if (!string.IsNullOrEmpty(AuthorizationResponseIssuer))
{
redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}";
}
return Results.Redirect(redirectUrl);
});