Skip to content

Commit 4e68a4e

Browse files
jeffhandleyCopilot
andauthored
Add 2025-03-26 OAuth backward compatibility for client conformance (#1374)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c57f917 commit 4e68a4e

File tree

4 files changed

+291
-30
lines changed

4 files changed

+291
-30
lines changed

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,10 @@ 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+
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, protectedResourceMetadata.Resource, cancellationToken).ConfigureAwait(false);
274274

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

278278
// Only attempt a token refresh if we haven't attempted to already for this request.
279279
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
@@ -332,7 +332,7 @@ static bool IsValidClientMetadataDocumentUri(Uri uri)
332332
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
333333
}
334334

335-
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
335+
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, string? resourceUri, CancellationToken cancellationToken)
336336
{
337337
foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri))
338338
{
@@ -376,9 +376,35 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
376376
}
377377
}
378378

379+
if (resourceUri is null)
380+
{
381+
// 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery
382+
// also fails, fall back to default endpoint paths per the 2025-03-26 spec.
383+
return BuildDefaultAuthServerMetadata(authServerUri);
384+
}
385+
379386
throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'");
380387
}
381388

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

401-
private async Task<string?> RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
427+
private async Task<string?> RefreshTokensAsync(string refreshToken, string? resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
402428
{
403429
Dictionary<string, string> formFields = new()
404430
{
405431
["grant_type"] = "refresh_token",
406432
["refresh_token"] = refreshToken,
407-
["resource"] = resourceUri,
408433
};
409434

435+
if (resourceUri is not null)
436+
{
437+
formFields["resource"] = resourceUri;
438+
}
439+
410440
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
411441

412442
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -445,7 +475,7 @@ private Uri BuildAuthorizationUrl(
445475
AuthorizationServerMetadata authServerMetadata,
446476
string codeChallenge)
447477
{
448-
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
478+
var resourceUri = GetResourceUri(protectedResourceMetadata);
449479

450480
var queryParamsDictionary = new Dictionary<string, string>
451481
{
@@ -454,9 +484,13 @@ private Uri BuildAuthorizationUrl(
454484
["response_type"] = "code",
455485
["code_challenge"] = codeChallenge,
456486
["code_challenge_method"] = "S256",
457-
["resource"] = resourceUri,
458487
};
459488

489+
if (resourceUri is not null)
490+
{
491+
queryParamsDictionary["resource"] = resourceUri;
492+
}
493+
460494
var scope = GetScopeParameter(protectedResourceMetadata);
461495
if (!string.IsNullOrEmpty(scope))
462496
{
@@ -490,17 +524,21 @@ private async Task<string> ExchangeCodeForTokenAsync(
490524
string codeVerifier,
491525
CancellationToken cancellationToken)
492526
{
493-
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
527+
var resourceUri = GetResourceUri(protectedResourceMetadata);
494528

495529
Dictionary<string, string> formFields = new()
496530
{
497531
["grant_type"] = "authorization_code",
498532
["code"] = authorizationCode,
499533
["redirect_uri"] = _redirectUri.ToString(),
500534
["code_verifier"] = codeVerifier,
501-
["resource"] = resourceUri,
502535
};
503536

537+
if (resourceUri is not null)
538+
{
539+
formFields["resource"] = resourceUri;
540+
}
541+
504542
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
505543

506544
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -671,15 +709,8 @@ private async Task PerformDynamicClientRegistrationAsync(
671709
}
672710
}
673711

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-
}
712+
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
713+
=> protectedResourceMetadata.Resource;
683714

684715
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
685716
{
@@ -801,6 +832,7 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
801832
}
802833

803834
ProtectedResourceMetadata? metadata = null;
835+
bool isLegacyFallback = false;
804836

805837
if (resourceMetadataUrl is not null)
806838
{
@@ -822,7 +854,14 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
822854

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

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

836-
if (!VerifyResourceMatch(metadata, resourceUri))
875+
if (!isLegacyFallback && !VerifyResourceMatch(metadata, resourceUri))
837876
{
838877
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})");
839878
}

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)