Skip to content

Commit 0bb5fb7

Browse files
committed
Implement RFC 9207 issuer validation in ClientOAuthProvider
1 parent fd1ac08 commit 0bb5fb7

11 files changed

Lines changed: 222 additions & 59 deletions

File tree

samples/ProtectedMcpClient/Program.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.Logging;
2+
using ModelContextProtocol.Authentication;
23
using ModelContextProtocol.Client;
34
using ModelContextProtocol.Protocol;
45
using System.Diagnostics;
@@ -32,7 +33,7 @@
3233
OAuth = new()
3334
{
3435
RedirectUri = new Uri("http://localhost:1179/callback"),
35-
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
36+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
3637
DynamicClientRegistration = new()
3738
{
3839
ClientName = "ProtectedMcpClient",
@@ -71,8 +72,8 @@
7172
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
7273
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
7374
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
74-
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
75-
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
75+
/// <returns>The authorization result extracted from the callback, or null if the operation failed.</returns>
76+
static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
7677
{
7778
Console.WriteLine("Starting OAuth authorization flow...");
7879
Console.WriteLine($"Opening browser to: {authorizationUrl}");
@@ -93,6 +94,7 @@
9394
var context = await listener.GetContextAsync();
9495
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
9596
var code = query["code"];
97+
var iss = query["iss"];
9698
var error = query["error"];
9799

98100
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
@@ -115,7 +117,7 @@
115117
}
116118

117119
Console.WriteLine("Authorization code received successfully.");
118-
return code;
120+
return new AuthorizationResult { Code = code, Iss = iss };
119121
}
120122
catch (Exception ex)
121123
{

src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ namespace ModelContextProtocol.Authentication;
2222
/// <para>
2323
/// The implementation should handle user interaction to visit the authorization URL and extract
2424
/// the authorization code from the callback. The authorization code is typically provided as
25-
/// a query parameter in the redirect URI callback.
25+
/// a <c>code</c> query parameter in the redirect URI callback.
26+
/// </para>
27+
/// <para>
28+
/// For RFC 9207 issuer validation support, use <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/>
29+
/// instead, which allows returning both the authorization code and the <c>iss</c> parameter.
2630
/// </para>
2731
/// </remarks>
2832
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace ModelContextProtocol.Authentication;
2+
3+
/// <summary>
4+
/// Represents the result of an OAuth authorization redirect, containing the authorization code
5+
/// and optionally the issuer identifier from the authorization response.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// The <see cref="Iss"/> property should be populated from the <c>iss</c> query parameter in the
10+
/// redirect URI when present, as specified by
11+
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
12+
/// This enables the SDK to validate that the authorization response originated from the expected
13+
/// authorization server, mitigating mix-up attacks.
14+
/// </para>
15+
/// </remarks>
16+
public sealed class AuthorizationResult
17+
{
18+
/// <summary>
19+
/// Gets the authorization code returned by the authorization server.
20+
/// </summary>
21+
public string? Code { get; init; }
22+
23+
/// <summary>
24+
/// Gets the issuer identifier returned in the authorization response per
25+
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
26+
/// </summary>
27+
/// <remarks>
28+
/// <para>
29+
/// This value should be extracted from the <c>iss</c> query parameter of the redirect URI.
30+
/// When present, the SDK validates it against the expected authorization server issuer to
31+
/// prevent mix-up attacks.
32+
/// </para>
33+
/// <para>
34+
/// Implementations of <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> should populate this property
35+
/// whenever the <c>iss</c> parameter is present in the redirect URI callback.
36+
/// </para>
37+
/// </remarks>
38+
public string? Iss { get; init; }
39+
}

src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,11 @@ internal sealed class AuthorizationServerMetadata
7272
/// </summary>
7373
[JsonPropertyName("client_id_metadata_document_supported")]
7474
public bool ClientIdMetadataDocumentSupported { get; set; }
75+
76+
/// <summary>
77+
/// Indicates whether the authorization server includes the <c>iss</c> parameter in authorization responses
78+
/// as defined in <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
79+
/// </summary>
80+
[JsonPropertyName("authorization_response_iss_parameter_supported")]
81+
public bool AuthorizationResponseIssParameterSupported { get; set; }
7582
}

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@ public sealed class ClientOAuthOptions
8484
/// </remarks>
8585
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
8686

87+
/// <summary>
88+
/// Gets or sets a callback that handles the full OAuth authorization flow, returning both the
89+
/// authorization code and the issuer identifier for RFC 9207 validation.
90+
/// </summary>
91+
/// <remarks>
92+
/// <para>
93+
/// When set, this handler takes precedence over <see cref="AuthorizationRedirectDelegate"/>.
94+
/// It enables the SDK to validate the <c>iss</c> parameter in the authorization response per
95+
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>, which mitigates
96+
/// mix-up attacks.
97+
/// </para>
98+
/// <para>
99+
/// Implementations should extract both the <c>code</c> and <c>iss</c> query parameters from
100+
/// the redirect URI callback and return them in an <see cref="AuthorizationResult"/>.
101+
/// </para>
102+
/// </remarks>
103+
public Func<Uri, Uri, CancellationToken, Task<AuthorizationResult?>>? AuthorizationCallbackHandler { get; set; }
104+
87105
/// <summary>
88106
/// Gets or sets the authorization server selector function.
89107
/// </summary>

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
3232
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
3333
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
3434
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
35+
private readonly Func<Uri, Uri, CancellationToken, Task<AuthorizationResult?>>? _authorizationCallbackHandler;
3536
private readonly Uri? _clientMetadataDocumentUri;
3637

3738
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
@@ -84,6 +85,9 @@ public ClientOAuthProvider(
8485
// Set up authorization server selection strategy
8586
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
8687

88+
// Set up authorization callback handler (new RFC 9207-aware handler takes precedence)
89+
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;
90+
8791
// Set up authorization URL handler (use default if not provided)
8892
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
8993

@@ -370,6 +374,16 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
370374
metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"];
371375
metadata.CodeChallengeMethodsSupported ??= ["S256"];
372376

377+
// Validate the issuer in the metadata document per RFC 8414 Section 3.3:
378+
// the issuer value MUST be identical to the issuer identifier used to construct
379+
// the well-known URL.
380+
if (metadata.Issuer is not null &&
381+
!string.Equals(metadata.Issuer.OriginalString, authServerUri.OriginalString, StringComparison.Ordinal))
382+
{
383+
ThrowFailedToHandleUnauthorizedResponse(
384+
$"Authorization server metadata issuer '{metadata.Issuer}' does not match the expected issuer '{authServerUri}' (RFC 8414 Section 3.3).");
385+
}
386+
373387
return metadata;
374388
}
375389
catch (Exception ex)
@@ -462,14 +476,33 @@ private async Task<string> InitiateAuthorizationCodeFlowAsync(
462476
var codeChallenge = GenerateCodeChallenge(codeVerifier);
463477

464478
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
465-
var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
466479

467-
if (string.IsNullOrEmpty(authCode))
480+
string? authorizationCode;
481+
string? iss = null;
482+
483+
if (_authorizationCallbackHandler is not null)
468484
{
469-
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
485+
var authResult = await _authorizationCallbackHandler(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
486+
if (authResult is null || string.IsNullOrEmpty(authResult.Code))
487+
{
488+
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code.");
489+
}
490+
491+
authorizationCode = authResult!.Code!;
492+
iss = authResult.Iss;
493+
}
494+
else
495+
{
496+
authorizationCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
497+
if (string.IsNullOrEmpty(authorizationCode))
498+
{
499+
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
500+
}
470501
}
471502

472-
return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
503+
ValidateIssuerResponse(iss, authServerMetadata);
504+
505+
return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authorizationCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
473506
}
474507

475508
private Uri BuildAuthorizationUrl(
@@ -773,6 +806,47 @@ private async Task PerformDynamicClientRegistrationAsync(
773806
return scope + " " + OfflineAccess;
774807
}
775808

809+
/// <summary>
810+
/// Validates the <c>iss</c> parameter from an authorization response per
811+
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
812+
/// </summary>
813+
/// <param name="iss">The issuer identifier received in the authorization response, or null if absent.</param>
814+
/// <param name="authServerMetadata">The authorization server metadata containing the expected issuer.</param>
815+
private void ValidateIssuerResponse(string? iss, AuthorizationServerMetadata authServerMetadata)
816+
{
817+
var expectedIssuer = authServerMetadata.Issuer?.OriginalString;
818+
819+
if (authServerMetadata.AuthorizationResponseIssParameterSupported)
820+
{
821+
// Server advertises iss support: iss MUST be present and match.
822+
if (string.IsNullOrEmpty(iss))
823+
{
824+
ThrowFailedToHandleUnauthorizedResponse(
825+
"Authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response.");
826+
}
827+
828+
// Use exact string comparison per RFC 9207 / RFC 3986 §6.2.1.
829+
if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal))
830+
{
831+
ThrowFailedToHandleUnauthorizedResponse(
832+
$"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'.");
833+
}
834+
}
835+
else
836+
{
837+
// Server does not advertise iss support: if iss is present, still validate it.
838+
if (!string.IsNullOrEmpty(iss))
839+
{
840+
if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal))
841+
{
842+
ThrowFailedToHandleUnauthorizedResponse(
843+
$"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'.");
844+
}
845+
}
846+
// If iss is absent and not advertised, proceed normally.
847+
}
848+
}
849+
776850
/// <summary>
777851
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
778852
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public async Task CanAuthenticate_WithResourceMetadataFromEvent()
4848
ClientId = "demo-client",
4949
ClientSecret = "demo-secret",
5050
RedirectUri = new Uri("http://localhost:1179/callback"),
51-
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
51+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
5252
},
5353
},
5454
HttpClient,
@@ -76,7 +76,7 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
7676
OAuth = new ClientOAuthOptions()
7777
{
7878
RedirectUri = new Uri("http://localhost:1179/callback"),
79-
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
79+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
8080
Scopes = ["mcp:tools"],
8181
DynamicClientRegistration = new()
8282
{

0 commit comments

Comments
 (0)