Skip to content

Commit e517f14

Browse files
jeffhandleyCopilot
andcommitted
Add 2025-03-26 OAuth backward compatibility for client conformance
Implement legacy OAuth fallback so the client can authenticate against MCP servers that predate Protected Resource Metadata (RFC 9728): - When PRM discovery fails, synthesize minimal metadata using the MCP server's origin as the authorization server - When auth server metadata discovery also fails, fall back to the default endpoint paths (/authorize, /token, /register) specified by the MCP 2025-03-26 spec - Conditionally omit the 'resource' parameter from authorization and token requests when operating in legacy mode - Skip resource-match verification only for synthesized (not fetched) PRM Enable the two previously-commented-out client conformance test scenarios: auth/2025-03-26-oauth-metadata-backcompat auth/2025-03-26-oauth-endpoint-fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3877560 commit e517f14

2 files changed

Lines changed: 64 additions & 22 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,20 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
270270
LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count);
271271

272272
// Get auth server metadata
273-
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
273+
AuthorizationServerMetadata authServerMetadata;
274+
try
275+
{
276+
authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
277+
}
278+
catch (McpException) when (protectedResourceMetadata.Resource is null)
279+
{
280+
// 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery
281+
// also fails, fall back to default endpoint paths per the 2025-03-26 spec.
282+
authServerMetadata = BuildDefaultAuthServerMetadata(selectedAuthServer);
283+
}
274284

275285
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
276-
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
286+
var resourceUri = GetResourceUri(protectedResourceMetadata);
277287

278288
// Only attempt a token refresh if we haven't attempted to already for this request.
279289
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
@@ -379,6 +389,25 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
379389
throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'");
380390
}
381391

392+
/// <summary>
393+
/// Constructs default authorization server metadata using conventional endpoint paths
394+
/// as specified by the MCP 2025-03-26 specification for servers without metadata discovery.
395+
/// </summary>
396+
private static AuthorizationServerMetadata BuildDefaultAuthServerMetadata(Uri authServerUri)
397+
{
398+
var baseUrl = authServerUri.GetLeftPart(UriPartial.Authority);
399+
return new AuthorizationServerMetadata
400+
{
401+
AuthorizationEndpoint = new Uri($"{baseUrl}/authorize"),
402+
TokenEndpoint = new Uri($"{baseUrl}/token"),
403+
RegistrationEndpoint = new Uri($"{baseUrl}/register"),
404+
ResponseTypesSupported = ["code"],
405+
GrantTypesSupported = ["authorization_code", "refresh_token"],
406+
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
407+
CodeChallengeMethodsSupported = ["S256"],
408+
};
409+
}
410+
382411
private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri issuer)
383412
{
384413
var builder = new UriBuilder(issuer);
@@ -398,15 +427,19 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri
398427
}
399428
}
400429

401-
private async Task<string?> RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
430+
private async Task<string?> RefreshTokensAsync(string refreshToken, string? resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
402431
{
403432
Dictionary<string, string> formFields = new()
404433
{
405434
["grant_type"] = "refresh_token",
406435
["refresh_token"] = refreshToken,
407-
["resource"] = resourceUri,
408436
};
409437

438+
if (resourceUri is not null)
439+
{
440+
formFields["resource"] = resourceUri;
441+
}
442+
410443
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
411444

412445
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -445,7 +478,7 @@ private Uri BuildAuthorizationUrl(
445478
AuthorizationServerMetadata authServerMetadata,
446479
string codeChallenge)
447480
{
448-
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
481+
var resourceUri = GetResourceUri(protectedResourceMetadata);
449482

450483
var queryParamsDictionary = new Dictionary<string, string>
451484
{
@@ -454,9 +487,13 @@ private Uri BuildAuthorizationUrl(
454487
["response_type"] = "code",
455488
["code_challenge"] = codeChallenge,
456489
["code_challenge_method"] = "S256",
457-
["resource"] = resourceUri,
458490
};
459491

492+
if (resourceUri is not null)
493+
{
494+
queryParamsDictionary["resource"] = resourceUri;
495+
}
496+
460497
var scope = GetScopeParameter(protectedResourceMetadata);
461498
if (!string.IsNullOrEmpty(scope))
462499
{
@@ -490,17 +527,21 @@ private async Task<string> ExchangeCodeForTokenAsync(
490527
string codeVerifier,
491528
CancellationToken cancellationToken)
492529
{
493-
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
530+
var resourceUri = GetResourceUri(protectedResourceMetadata);
494531

495532
Dictionary<string, string> formFields = new()
496533
{
497534
["grant_type"] = "authorization_code",
498535
["code"] = authorizationCode,
499536
["redirect_uri"] = _redirectUri.ToString(),
500537
["code_verifier"] = codeVerifier,
501-
["resource"] = resourceUri,
502538
};
503539

540+
if (resourceUri is not null)
541+
{
542+
formFields["resource"] = resourceUri;
543+
}
544+
504545
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
505546

506547
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -671,15 +712,8 @@ private async Task PerformDynamicClientRegistrationAsync(
671712
}
672713
}
673714

674-
private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
675-
{
676-
if (protectedResourceMetadata.Resource is null)
677-
{
678-
ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value.");
679-
}
680-
681-
return protectedResourceMetadata.Resource;
682-
}
715+
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
716+
=> protectedResourceMetadata.Resource;
683717

684718
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
685719
{
@@ -801,6 +835,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
801835
}
802836

803837
ProtectedResourceMetadata? metadata = null;
838+
bool isLegacyFallback = false;
804839

805840
if (resourceMetadataUrl is not null)
806841
{
@@ -822,7 +857,14 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
822857

823858
if (metadata is null)
824859
{
825-
throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}");
860+
// 2025-03-26 backcompat: server doesn't support PRM (RFC 9728).
861+
// Fall back to treating the MCP server's origin as the authorization server.
862+
var serverOrigin = _serverUrl.GetLeftPart(UriPartial.Authority);
863+
metadata = new ProtectedResourceMetadata
864+
{
865+
AuthorizationServers = [serverOrigin],
866+
};
867+
isLegacyFallback = true;
826868
}
827869
}
828870

@@ -833,7 +875,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
833875
// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server
834876
LogValidatingResourceMetadata(resourceUri);
835877

836-
if (!VerifyResourceMatch(metadata, resourceUri))
878+
if (!isLegacyFallback && !VerifyResourceMatch(metadata, resourceUri))
837879
{
838880
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})");
839881
}

tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ public ClientConformanceTests(ITestOutputHelper output)
4343
[InlineData("auth/resource-mismatch")]
4444
[InlineData("auth/pre-registration")]
4545

46-
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
47-
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
48-
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
46+
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata).
47+
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
48+
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
4949

5050
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
5151
// [InlineData("auth/client-credentials-jwt")]

0 commit comments

Comments
 (0)