using Microsoft.AspNetCore.Authentication.JwtBearer; using ModelContextProtocol.AspNetCore.Authentication; using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using System.Net; using System.Reflection; using Xunit.Sdk; namespace ModelContextProtocol.AspNetCore.Tests.OAuth; public class AuthTests : OAuthTestBase { private const string ClientMetadataDocumentUrl = $"{OAuthServerUrl}/client-metadata/cimd-client.json"; public AuthTests(ITestOutputHelper outputHelper) : base(outputHelper) { } [Fact] public async Task CanAuthenticate() { 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"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task CannotAuthenticate_WithoutOAuthConfiguration() { await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), }, HttpClient, LoggerFactory); var httpEx = await Assert.ThrowsAsync(async () => await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal(HttpStatusCode.Unauthorized, httpEx.StatusCode); } [Fact] public async Task CannotAuthenticate_WithUnregisteredClient() { await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new() { ClientId = "unregistered-demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); // The EqualException is thrown by HandleAuthorizationUrlAsync when the /authorize request gets a 400 var equalEx = await Assert.ThrowsAsync(async () => await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] public async Task CanAuthenticate_WithDynamicClientRegistration() { await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, Scopes = ["mcp:tools"], DynamicClientRegistration = new() { ClientName = "Test MCP Client", ClientUri = new Uri("https://example.com"), }, }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task CanAuthenticate_WithClientMetadataDocument() { await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl) }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task UsesDynamicClientRegistration_WhenCimdNotSupported() { // Disable CIMD support on the test OAuth server so the client // falls back to dynamic registration even if a CIMD URL is provided. TestOAuthServer.ClientIdMetadataDocumentSupported = false; await using var app = await StartMcpServerAsync(); // Provide an invalid CIMD URL; if CIMD were used, auth would fail. await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), Scopes = ["mcp:tools"], DynamicClientRegistration = new() { ClientName = "Test MCP Client (No CIMD)", ClientUri = new Uri("https://example.com/no-cimd"), }, }, }, HttpClient, LoggerFactory); // Should succeed via dynamic client registration. await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified() { await using var app = await StartMcpServerAsync(); // Provide an invalid CIMD URL; if CIMD were used, auth would fail. await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new ClientOAuthOptions() { ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Theory] [InlineData("http://localhost:7029/client-metadata/cimd-client.json")] // Non-HTTPS Scheme [InlineData("http://localhost:7029")] // Missing path public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string uri) { await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(uri), }, }, HttpClient, LoggerFactory); var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); Assert.StartsWith("Failed to handle unauthorized response", ex.Message); } [Fact] public async Task CanAuthenticate_WithTokenRefresh() { await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() { Endpoint = new(McpServerUrl), OAuth = new() { ClientId = "test-refresh-client", ClientSecret = "test-refresh-secret", RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); // The test-refresh-client should get an expired token first, // then automatically refresh it to get a working token await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.True(TestOAuthServer.HasRefreshedToken); } [Fact] public async Task CanAuthenticate_WithExtraParams() { await using var app = await StartMcpServerAsync(); Uri? lastAuthorizationUri = null; 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"), AuthorizationRedirectDelegate = (uri, redirect, ct) => { lastAuthorizationUri = uri; return HandleAuthorizationUrlAsync(uri, redirect, ct); }, AdditionalAuthorizationParameters = new Dictionary { ["custom_param"] = "custom_value", } }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(lastAuthorizationUri?.Query); Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query); } [Fact] public async Task CannotOverrideExistingParameters_WithExtraParams() { 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"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, AdditionalAuthorizationParameters = new Dictionary { ["redirect_uri"] = "custom_value", } }, }, HttpClient, LoggerFactory); await Assert.ThrowsAsync(() => McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader() { await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme); 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"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader_WithPathSuffix() { const string serverPath = "/mcp"; await using var app = await StartMcpServerAsync(serverPath, authScheme: JwtBearerDefaults.AuthenticationScheme); await using var transport = new HttpClientTransport(new() { Endpoint = new Uri($"{McpServerUrl}{serverPath}"), OAuth = new() { ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata() { await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme); using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode); var headerFound = false; foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate) { headerFound = true; Assert.Equal("Bearer", header.Scheme); Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase)); } Assert.True(headerFound); using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken); metadataResponse.EnsureSuccessStatusCode(); } [Fact] public void CloneResourceMetadataClonesAllProperties() { var propertyNames = typeof(ProtectedResourceMetadata).GetProperties().Select(property => property.Name).ToList(); // Set metadata properties to non-default values to verify they're copied. var metadata = new ProtectedResourceMetadata { Resource = new Uri("https://example.com/resource"), AuthorizationServers = [new Uri("https://auth1.example.com"), new Uri("https://auth2.example.com")], BearerMethodsSupported = ["header", "body", "query"], ScopesSupported = ["read", "write", "admin"], JwksUri = new Uri("https://example.com/.well-known/jwks.json"), ResourceSigningAlgValuesSupported = ["RS256", "ES256"], ResourceName = "Test Resource", ResourceDocumentation = new Uri("https://docs.example.com"), ResourcePolicyUri = new Uri("https://example.com/policy"), ResourceTosUri = new Uri("https://example.com/terms"), TlsClientCertificateBoundAccessTokens = true, AuthorizationDetailsTypesSupported = ["payment_initiation", "account_information"], DpopSigningAlgValuesSupported = ["RS256", "PS256"], DpopBoundAccessTokensRequired = true }; // Use reflection to call the internal CloneResourceMetadata method var handlerType = typeof(McpAuthenticationHandler); var cloneMethod = handlerType.GetMethod("CloneResourceMetadata", BindingFlags.Static | BindingFlags.NonPublic); Assert.NotNull(cloneMethod); var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata, null]); Assert.NotNull(clonedMetadata); // Ensure the cloned metadata is not the same instance Assert.NotSame(metadata, clonedMetadata); // Verify Resource property Assert.Equal(metadata.Resource, clonedMetadata.Resource); Assert.True(propertyNames.Remove(nameof(metadata.Resource))); // Verify AuthorizationServers list is cloned and contains the same values Assert.NotSame(metadata.AuthorizationServers, clonedMetadata.AuthorizationServers); Assert.Equal(metadata.AuthorizationServers, clonedMetadata.AuthorizationServers); Assert.True(propertyNames.Remove(nameof(metadata.AuthorizationServers))); // Verify BearerMethodsSupported list is cloned and contains the same values Assert.NotSame(metadata.BearerMethodsSupported, clonedMetadata.BearerMethodsSupported); Assert.Equal(metadata.BearerMethodsSupported, clonedMetadata.BearerMethodsSupported); Assert.True(propertyNames.Remove(nameof(metadata.BearerMethodsSupported))); // Verify ScopesSupported list is cloned and contains the same values Assert.NotSame(metadata.ScopesSupported, clonedMetadata.ScopesSupported); Assert.Equal(metadata.ScopesSupported, clonedMetadata.ScopesSupported); Assert.True(propertyNames.Remove(nameof(metadata.ScopesSupported))); // Verify JwksUri property Assert.Equal(metadata.JwksUri, clonedMetadata.JwksUri); Assert.True(propertyNames.Remove(nameof(metadata.JwksUri))); // Verify ResourceSigningAlgValuesSupported list is cloned (nullable list) Assert.NotSame(metadata.ResourceSigningAlgValuesSupported, clonedMetadata.ResourceSigningAlgValuesSupported); Assert.Equal(metadata.ResourceSigningAlgValuesSupported, clonedMetadata.ResourceSigningAlgValuesSupported); Assert.True(propertyNames.Remove(nameof(metadata.ResourceSigningAlgValuesSupported))); // Verify ResourceName property Assert.Equal(metadata.ResourceName, clonedMetadata.ResourceName); Assert.True(propertyNames.Remove(nameof(metadata.ResourceName))); // Verify ResourceDocumentation property Assert.Equal(metadata.ResourceDocumentation, clonedMetadata.ResourceDocumentation); Assert.True(propertyNames.Remove(nameof(metadata.ResourceDocumentation))); // Verify ResourcePolicyUri property Assert.Equal(metadata.ResourcePolicyUri, clonedMetadata.ResourcePolicyUri); Assert.True(propertyNames.Remove(nameof(metadata.ResourcePolicyUri))); // Verify ResourceTosUri property Assert.Equal(metadata.ResourceTosUri, clonedMetadata.ResourceTosUri); Assert.True(propertyNames.Remove(nameof(metadata.ResourceTosUri))); // Verify TlsClientCertificateBoundAccessTokens property Assert.Equal(metadata.TlsClientCertificateBoundAccessTokens, clonedMetadata.TlsClientCertificateBoundAccessTokens); Assert.True(propertyNames.Remove(nameof(metadata.TlsClientCertificateBoundAccessTokens))); // Verify AuthorizationDetailsTypesSupported list is cloned (nullable list) Assert.NotSame(metadata.AuthorizationDetailsTypesSupported, clonedMetadata.AuthorizationDetailsTypesSupported); Assert.Equal(metadata.AuthorizationDetailsTypesSupported, clonedMetadata.AuthorizationDetailsTypesSupported); Assert.True(propertyNames.Remove(nameof(metadata.AuthorizationDetailsTypesSupported))); // Verify DpopSigningAlgValuesSupported list is cloned (nullable list) Assert.NotSame(metadata.DpopSigningAlgValuesSupported, clonedMetadata.DpopSigningAlgValuesSupported); Assert.Equal(metadata.DpopSigningAlgValuesSupported, clonedMetadata.DpopSigningAlgValuesSupported); Assert.True(propertyNames.Remove(nameof(metadata.DpopSigningAlgValuesSupported))); // Verify DpopBoundAccessTokensRequired property Assert.Equal(metadata.DpopBoundAccessTokensRequired, clonedMetadata.DpopBoundAccessTokensRequired); Assert.True(propertyNames.Remove(nameof(metadata.DpopBoundAccessTokensRequired))); // Ensure we've checked every property. When new properties get added, we'll have to update this test along with the CloneResourceMetadata implementation. Assert.Empty(propertyNames); } }