Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ var transport = new HttpClientTransport(new HttpClientTransportOptions
});
```

#### OAuth PKCE metadata validation

When using <xref:ModelContextProtocol.Authentication.ClientOAuthOptions> with an HTTP transport, authorization server metadata must advertise `S256` in `code_challenge_methods_supported`. A document that omits that field or advertises only other methods is skipped while the client tries alternative well-known endpoints. If none supplies valid metadata with `S256`, authentication fails before requesting an authorization code or exchanging tokens.

**Compatibility tightening: metadata-free legacy servers.** By default, the client also rejects authentication when authorization server metadata cannot be discovered; it no longer assumes `S256` support. For a trusted legacy deployment whose PKCE support you have independently established, you can explicitly restore the MCP 2025-03-26 default endpoint fallback at application startup:

```csharp
AppContext.SetSwitch(
"ModelContextProtocol.Authentication.AllowLegacyOAuthMetadataFallback",
true);
```

Alternatively, configure the same switch in the application's project file:

```xml
<ItemGroup>
<RuntimeHostConfigurationOption
Include="ModelContextProtocol.Authentication.AllowLegacyOAuthMetadataFallback"
Value="true" />
</ItemGroup>
```

The switch is **disabled when absent or `false`**. When enabled, it restores default `/authorize`, `/token`, and `/register` paths on the MCP server's origin only when protected resource metadata is unavailable and no usable authorization server metadata document was discovered. It never bypasses PKCE or issuer validation of discovered metadata. The client still sends an `S256` challenge and a code verifier, and logs a warning whenever it uses the unverified fallback.

[RFC 9700, Sections 2.1.1](https://www.rfc-editor.org/rfc/rfc9700.html#section-2.1.1) and [4.7.1](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.7.1) permit deployment-specific assurance of PKCE support, but synthesizing metadata is not assurance. Response `state` validation protects response correlation and CSRF; it does not prevent an intercepted authorization code from being redeemed directly. PKCE remains necessary.

> [!WARNING]
> Enabling this switch does not verify PKCE support or change state validation. Use it only after independently establishing that your trusted authorization server correctly enforces PKCE, including the [downgrade defenses in RFC 9700, Section 4.8.2](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.8.2). The setting is **process-wide**, not a per-server allowlist: it affects every OAuth client in the process. Do not enable it in a process that also connects to untrusted authorization servers, or toggle it around individual requests. Prefer fixing metadata discovery and advertising `S256` so the switch can remain disabled.

#### Controlling client timeout timers in tests

The short <xref:ModelContextProtocol.Client.McpClientOptions.DiscoverProbeTimeout> budget excludes time spent acquiring an OAuth token. After authorization, the discovery probe receives a fresh budget. The overall <xref:ModelContextProtocol.Client.McpClientOptions.InitializationTimeout> and caller cancellation still apply.
Expand Down
2 changes: 2 additions & 0 deletions samples/ProtectedMcpClient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ The client is configured with:
- **OAuth Server**: `https://localhost:7029`
- **Protected Resource**: `http://localhost:7071`

The authorization server must advertise `S256` PKCE support in its metadata. The test server does this by default. The SDK rejects missing PKCE advertisement and, by default, unavailable authorization server metadata; see [OAuth PKCE metadata validation](../../docs/concepts/transports/transports.md#oauth-pkce-metadata-validation) for the narrowly scoped, process-wide legacy compatibility switch and its security requirements.

## Available Tools

Once authenticated, the client can access weather tools including:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ namespace ModelContextProtocol.Authentication;
/// <summary>
/// Provides configuration options for the <see cref="ClientOAuthProvider"/>.
/// </summary>
/// <remarks>
/// Authorization server metadata must advertise the <c>S256</c> PKCE method. If metadata discovery fails,
/// authorization fails before requesting an authorization code or exchanging tokens. For trusted legacy
/// deployments without protected resource metadata and without usable authorization server metadata, the process-wide
/// <c>ModelContextProtocol.Authentication.AllowLegacyOAuthMetadataFallback</c> <see cref="AppContext"/> switch
/// can explicitly enable the 2025-03-26 default endpoint fallback. It is disabled by default and does not
/// bypass PKCE validation of discovered metadata. Hosts enabling it must independently establish that
/// their authorization servers correctly enforce PKCE, including downgrade defenses.
/// </remarks>
public sealed class ClientOAuthOptions
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ static bool IsValidClientMetadataDocumentUri(Uri uri)
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, string? resourceUri, CancellationToken cancellationToken)
{
// Tracks whether at least one well-known endpoint returned a structurally valid metadata document.
// This distinguishes "no metadata document was found" (eligible for the 2025-03-26 legacy fallback)
// This distinguishes "no metadata document was found" (eligible for the opt-in 2025-03-26 legacy fallback)
// from "a metadata document exists but failed PKCE validation" (must not fall back to synthesized defaults).
var metadataDocumentFound = false;
McpException? pkceValidationFailure = null;
Expand Down Expand Up @@ -634,8 +634,14 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a

if (resourceUri is null)
{
if (!AppContext.TryGetSwitch("ModelContextProtocol.Authentication.AllowLegacyOAuthMetadataFallback", out var enabled) || !enabled)
{
ThrowFailedToHandleUnauthorizedResponse("Authorization server metadata could not be discovered, so PKCE support could not be verified.");
}

// 2025-03-26 backcompat: when PRM is unavailable and no auth server metadata document was
// discovered, fall back to default endpoint paths per the 2025-03-26 spec.
// discovered, allow explicitly opted-in hosts to use the default endpoint paths.
LogUsingLegacyOAuthMetadataFallback(authServerUri);
return BuildDefaultAuthServerMetadata(authServerUri);
}

Expand Down Expand Up @@ -1587,6 +1593,9 @@ private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
[LoggerMessage(Level = LogLevel.Warning, Message = "Authorization server metadata from {Endpoint} does not satisfy PKCE requirements; skipping it and trying the next discovery endpoint.")]
partial void LogAuthServerMetadataMissingPkceSupport(Exception ex, Uri endpoint);

[LoggerMessage(Level = LogLevel.Warning, Message = "Using legacy OAuth metadata fallback for {AuthorizationServer} because ModelContextProtocol.Authentication.AllowLegacyOAuthMetadataFallback is enabled. PKCE support could not be verified. Use this switch only for trusted deployments whose PKCE support has been independently established.")]
partial void LogUsingLegacyOAuthMetadataFallback(Uri authorizationServer);

[LoggerMessage(Level = LogLevel.Information, Message = "Performing dynamic client registration with {RegistrationEndpoint}")]
partial void LogPerformingDynamicClientRegistration(Uri registrationEndpoint);

Expand Down
130 changes: 74 additions & 56 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ public async Task CannotAuthenticate_WhenMetadataOmitsPkceMethods()
await Assert.ThrowsAsync<McpException>(() => TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal(0, TestOAuthServer.AuthorizationRequestCount);
Assert.Equal(0, TestOAuthServer.TokenRequestCount);
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.Message.Contains("Using legacy OAuth metadata fallback"));

// No discovery endpoint advertises PKCE, so metadata discovery is exhausted. The precise PKCE reason
// is logged as each endpoint is skipped.
Assert.Contains(
Expand Down Expand Up @@ -411,6 +415,10 @@ public async Task CannotAuthenticate_WhenMetadataLacksS256PkceMethod()
await Assert.ThrowsAsync<McpException>(() => TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal(0, TestOAuthServer.AuthorizationRequestCount);
Assert.Equal(0, TestOAuthServer.TokenRequestCount);
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.Message.Contains("Using legacy OAuth metadata fallback"));

Assert.Contains(
MockLoggerProvider.LogMessages,
m => m.Exception?.Message.Contains("required PKCE method 'S256'") == true);
Expand Down Expand Up @@ -439,6 +447,35 @@ public async Task CanAuthenticate_WhenFirstMetadataEndpointOmitsPkce_ButAnotherA

await using var client = await TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);

Assert.Contains("/.well-known/oauth-authorization-server", TestOAuthServer.MetadataRequests);
Assert.Contains("/.well-known/openid-configuration", TestOAuthServer.MetadataRequests);
Assert.Equal(1, TestOAuthServer.AuthorizationRequestCount);
Assert.Equal(1, TestOAuthServer.TokenRequestCount);
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.Message.Contains("Using legacy OAuth metadata fallback"));
}

[Fact]
public async Task CannotAuthenticate_WhenAuthorizationServerMetadataIsUnavailable_WithProtectedResourceMetadata()
{
TestOAuthServer.DisabledMetadataPaths.Add("/.well-known/oauth-authorization-server");
TestOAuthServer.DisabledMetadataPaths.Add("/.well-known/openid-configuration");
await using var app = await StartMcpServerAsync();
var authorizationCallbackCalled = false;

await using var transport = CreateOAuthTransport((context, cancellationToken) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, cancellationToken);
});

await Assert.ThrowsAsync<McpException>(() => TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

Assert.False(authorizationCallbackCalled);
Assert.Equal(0, TestOAuthServer.AuthorizationRequestCount);
Assert.Equal(0, TestOAuthServer.TokenRequestCount);
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.Message.Contains("Using legacy OAuth metadata fallback"));
}

[Fact]
Expand Down Expand Up @@ -2077,11 +2114,10 @@ await context.Response.WriteAsync($$"""
}

[Fact]
public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
public async Task CannotAuthenticate_WithLegacyServerWithoutMetadata_ByDefault()
{
// 2025-03-26 backcompat: server does NOT serve PRM AND does NOT serve auth server metadata.
// The client should fall back to default endpoint paths (/authorize, /token, /register)
// on the MCP server's origin.
// Default clients must reject authentication before using the conventional OAuth endpoints.
TestOAuthServer.ExpectResource = false;

// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
Expand All @@ -2095,8 +2131,8 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()

await using var app = Builder.Build();

// Capture HttpClient for use in the proxy middleware.
var httpClient = HttpClient;
var oauthRequests = 0;
var authorizationCallbackCalled = false;

app.Use(async (context, next) =>
{
Expand All @@ -2107,50 +2143,19 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
return;
}

// Return 404 for auth server metadata to force fallback to default endpoints.
// Return 404 for auth server metadata to exercise the metadata-free legacy path.
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") ||
context.Request.Path.StartsWithSegments("/.well-known/openid-configuration"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}

// Proxy default OAuth endpoints to the real OAuth server.
// In a real 2025-03-26 deployment, the MCP server itself would host these endpoints.
var path = context.Request.Path.Value;
if (path is "/authorize" or "/token" or "/register")
{
var targetUrl = $"{OAuthServerUrl}{path}{context.Request.QueryString}";
using var proxyRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl);

if (context.Request.ContentLength > 0 || context.Request.ContentType is not null)
{
proxyRequest.Content = new StreamContent(context.Request.Body);
if (context.Request.ContentType is not null)
{
proxyRequest.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
}
}

if (context.Request.Headers.Authorization.Count > 0)
{
proxyRequest.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString());
}

using var response = await httpClient.SendAsync(proxyRequest);
context.Response.StatusCode = (int)response.StatusCode;

if (response.Headers.Location is not null)
{
context.Response.Headers.Location = response.Headers.Location.ToString();
}

if (response.Content.Headers.ContentType is not null)
{
context.Response.ContentType = response.Content.Headers.ContentType.ToString();
}

await response.Content.CopyToAsync(context.Response.Body);
Interlocked.Increment(ref oauthRequests);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
return;
}

Expand All @@ -2170,19 +2175,32 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = (context, cancellationToken) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, cancellationToken);
},
},
}, HttpClient, LoggerFactory);

await using var client = await TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var ex = await Assert.ThrowsAsync<McpException>(() => TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

Assert.Contains("PKCE support could not be verified", ex.Message);
Assert.False(authorizationCallbackCalled);
Assert.Equal(0, oauthRequests);
Assert.Equal(0, TestOAuthServer.AuthorizationRequestCount);
Assert.Equal(0, TestOAuthServer.TokenRequestCount);
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.Message.Contains("Using legacy OAuth metadata fallback"));
}

[Fact]
public async Task CannotAuthenticate_WithLegacyServerWhoseMetadataOmitsPkceMethods()
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CannotAuthenticate_WithLegacyServerWhoseMetadataLacksPkceSupport(bool advertisePlain)
{
// 2025-03-26 backcompat regression guard: PRM is unavailable (resourceUri is null), but the server
// DOES serve an auth server metadata document that omits 'code_challenge_methods_supported'.
// DOES serve an auth server metadata document that omits PKCE support or advertises only plain.
// The client must refuse to proceed rather than falling back to synthesized S256 defaults, since a
// discovered metadata document that fails PKCE validation disqualifies the legacy default fallback.
TestOAuthServer.ExpectResource = false;
Expand All @@ -2206,14 +2224,15 @@ public async Task CannotAuthenticate_WithLegacyServerWhoseMetadataOmitsPkceMetho
return;
}

// Serve auth server metadata that omits 'code_challenge_methods_supported' entirely.
// Serve metadata that omits PKCE support or advertises only plain.
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") ||
context.Request.Path.StartsWithSegments("/.well-known/openid-configuration"))
{
context.Response.ContentType = "application/json";
await context.Response.WriteAsync($$"""
{
"issuer": "{{OAuthServerUrl}}",
{{(advertisePlain ? "\"code_challenge_methods_supported\": [\"plain\"]," : "")}}
"authorization_endpoint": "{{McpServerUrl}}/authorize",
"token_endpoint": "{{McpServerUrl}}/token",
"registration_endpoint": "{{McpServerUrl}}/register",
Expand All @@ -2233,24 +2252,23 @@ await context.Response.WriteAsync($$"""
app.MapMcp().RequireAuthorization();
await app.StartAsync(TestContext.Current.CancellationToken);

await using var transport = new HttpClientTransport(new()
var authorizationCallbackCalled = false;
await using var transport = CreateOAuthTransport((context, cancellationToken) =>
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, cancellationToken);
});

var ex = await Assert.ThrowsAsync<McpException>(() => TestMcpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

// The specific PKCE failure reason is surfaced rather than a generic discovery failure or a
// silently-synthesized S256 fallback.
Assert.Contains("code_challenge_methods_supported", ex.Message);
Assert.False(authorizationCallbackCalled);
Assert.Equal(0, TestOAuthServer.AuthorizationRequestCount);
Assert.Equal(0, TestOAuthServer.TokenRequestCount);
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.Message.Contains("Using legacy OAuth metadata fallback"));
}

[Fact]
Expand Down
Loading
Loading