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
18 changes: 12 additions & 6 deletions samples/ProtectedMcpClient/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Diagnostics;
Expand Down Expand Up @@ -32,7 +33,7 @@
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "ProtectedMcpClient",
Expand Down Expand Up @@ -68,12 +69,16 @@
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
/// </summary>
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
/// <param name="authorizationContext">The context containing the authorization and redirect URIs.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
/// <returns>The authorization result extracted from the callback, or null if the operation failed.</returns>
static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
AuthorizationCallbackContext authorizationContext,
CancellationToken cancellationToken)
{
var authorizationUrl = authorizationContext.AuthorizationUri;
var redirectUri = authorizationContext.RedirectUri;

Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Opening browser to: {authorizationUrl}");

Expand All @@ -93,6 +98,7 @@
var context = await listener.GetContextAsync();
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
var code = query["code"];
var iss = query["iss"];
var error = query["error"];

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

Console.WriteLine("Authorization code received successfully.");
return code;
return new AuthorizationResult { Code = code, Iss = iss };
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Provides the information needed to complete an OAuth authorization request.
/// </summary>
public sealed class AuthorizationCallbackContext
{
/// <summary>
/// Gets the authorization URI that the user needs to visit.
/// </summary>
public required Uri AuthorizationUri { get; init; }

/// <summary>
/// Gets the redirect URI where the authorization response will be sent.
/// </summary>
public required Uri RedirectUri { get; init; }
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Represents the result of an OAuth authorization redirect, containing the authorization code
/// and optionally the issuer identifier from the authorization response.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="Iss"/> property should be populated from the <c>iss</c> query parameter in the
/// redirect URI when present, as specified by
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
/// This enables the SDK to validate that the authorization response originated from the expected
/// authorization server, mitigating mix-up attacks.
/// </para>
/// </remarks>
public sealed class AuthorizationResult
{
/// <summary>
/// Gets the authorization code returned by the authorization server.
/// </summary>
public string? Code { get; init; }

/// <summary>
/// Gets the issuer identifier returned in the authorization response per
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
/// </summary>
/// <remarks>
/// <para>
/// This value should be extracted from the <c>iss</c> query parameter of the redirect URI.
/// When present, the SDK validates it against the expected authorization server issuer to
/// prevent mix-up attacks.
/// </para>
/// <para>
/// Implementations of <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> should populate this
/// property whenever the <c>iss</c> parameter is present in the redirect URI callback.
/// </para>
/// </remarks>
public string? Iss { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,11 @@ internal sealed class AuthorizationServerMetadata
/// </summary>
[JsonPropertyName("client_id_metadata_document_supported")]
public bool ClientIdMetadataDocumentSupported { get; set; }

/// <summary>
/// Indicates whether the authorization server includes the <c>iss</c> parameter in authorization responses
/// as defined in <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
/// </summary>
[JsonPropertyName("authorization_response_iss_parameter_supported")]
public bool AuthorizationResponseIssParameterSupported { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,23 @@ public sealed class ClientOAuthOptions
public ScopeSelectorDelegate? ScopeSelector { get; set; }

/// <summary>
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
/// Gets or sets the callback that handles the OAuth authorization flow.
/// </summary>
/// <remarks>
/// <para>
/// This delegate is responsible for handling the OAuth authorization URL and obtaining the authorization code.
/// If not specified, a default implementation will be used that prompts the user to enter the code manually.
/// This callback receives the authorization and redirect URIs in an
/// <see cref="AuthorizationCallbackContext"/> and returns the authorization response.
/// If not specified, a default implementation prompts the user to enter the authorization code manually.
/// </para>
/// <para>
/// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture
/// the authorization code from the OAuth redirect.
/// the authorization response. They should return both the <c>code</c> and <c>iss</c> query parameters
/// from the redirect URI callback. This enables the SDK to validate the <c>iss</c> parameter per
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>, which mitigates
/// mix-up attacks.
/// </para>
/// </remarks>
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
public Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>>? AuthorizationCallbackHandler { get; set; }

/// <summary>
/// Gets or sets the authorization server selector function.
Expand Down
100 changes: 87 additions & 13 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly ScopeSelectorDelegate? _scopeSelector;
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
private readonly Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>> _authorizationCallbackHandler;
private readonly Uri? _clientMetadataDocumentUri;

// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
Expand Down Expand Up @@ -92,8 +92,8 @@ public ClientOAuthProvider(
// Set up authorization server selection strategy
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;

// Set up authorization URL handler (use default if not provided)
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
// Set up authorization callback handler (use default if not provided)
_authorizationCallbackHandler = options.AuthorizationCallbackHandler ?? DefaultAuthorizationUrlHandler;

_dcrClientName = options.DynamicClientRegistration?.ClientName;
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
Expand All @@ -112,18 +112,19 @@ public ClientOAuthProvider(
/// <summary>
/// Default authorization URL handler that displays the URL to the user for manual input.
/// </summary>
/// <param name="authorizationUrl">The authorization URL to handle.</param>
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
/// <param name="context">The context containing the authorization and redirect URIs.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The authorization code entered by the user, or null if none was provided.</returns>
private static Task<string?> DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
/// <returns>The authorization result entered by the user, or null if none was provided.</returns>
private static Task<AuthorizationResult?> DefaultAuthorizationUrlHandler(
AuthorizationCallbackContext context,
CancellationToken cancellationToken)
{
Console.WriteLine($"Please open the following URL in your browser to authorize the application:");
Console.WriteLine($"{authorizationUrl}");
Console.WriteLine($"{context.AuthorizationUri}");
Console.WriteLine();
Console.Write("Enter the authorization code from the redirect URL: ");
var authorizationCode = Console.ReadLine();
return Task.FromResult<string?>(authorizationCode);
return Task.FromResult<AuthorizationResult?>(new() { Code = authorizationCode });
}

internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
Expand Down Expand Up @@ -400,6 +401,24 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"];
metadata.CodeChallengeMethodsSupported ??= ["S256"];

// Validate the issuer in the metadata document per RFC 8414 Section 3.3:
// the issuer value MUST be identical to the issuer identifier used to construct
// the well-known URL.
// Skip validation in legacy backcompat mode (resourceUri is null) because the
// authServerUri was derived from the server origin rather than from Protected
// Resource Metadata, so it may not match the server's canonical issuer.
// Note: resourceUri is null exclusively in the 2025-03-26 legacy path. For newer
// protocol versions, ExtractProtectedResourceMetadata throws if the PRM document
// omits the resource field (VerifyResourceMatch returns false for null Resource),
// so we never reach this point with resourceUri == null in non-legacy flows.
if (resourceUri is not null &&
metadata.Issuer is not null &&
!string.Equals(metadata.Issuer.OriginalString, authServerUri.OriginalString, StringComparison.Ordinal))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: OriginalString + Ordinal is spec-correct for RFC 8414's exact match, but comparing raw original strings can reject otherwise-compliant servers over trailing-slash, case, or percent-encoding differences. Worth considering light normalization, or at least a note about the strictness.

{
ThrowFailedToHandleUnauthorizedResponse(
$"Authorization server metadata issuer '{metadata.Issuer}' does not match the expected issuer '{authServerUri}' (RFC 8414 Section 3.3).");
}

return metadata;
}
catch (Exception ex)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These validations (the issuer mismatch here, plus the authorization_endpoint checks above) throw McpException via ThrowFailedToHandleUnauthorizedResponse, but they run inside the try, so this catch swallows them and moves on to the next well-known endpoint. A real RFC 8414 issuer mismatch is a security signal and should surface to the caller rather than being logged and retried against other endpoints.

Suggest letting it propagate, e.g. add a typed catch (McpException) { throw; } ahead of this catch, or move the metadata validation out after the loop.

Expand Down Expand Up @@ -492,14 +511,28 @@ private async Task<string> InitiateAuthorizationCodeFlowAsync(
var codeChallenge = GenerateCodeChallenge(codeVerifier);

var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (pre-existing, out of scope): the authorization URL built here carries no state parameter, which is the usual CSRF/binding defense for the redirect. Not introduced by this PR, but flagging since it is adjacent to this auth work.

var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);

if (string.IsNullOrEmpty(authCode))
var authResult = await _authorizationCallbackHandler(
new AuthorizationCallbackContext
{
AuthorizationUri = authUrl,
RedirectUri = _redirectUri,
},
cancellationToken).ConfigureAwait(false);

if (authResult is null || string.IsNullOrEmpty(authResult.Code))
{
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code.");
}

return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
ValidateIssuerResponse(authResult!.Iss, authServerMetadata);

return await ExchangeCodeForTokenAsync(
protectedResourceMetadata,
authServerMetadata,
authResult.Code!,
codeVerifier,
cancellationToken).ConfigureAwait(false);
}

private Uri BuildAuthorizationUrl(
Expand Down Expand Up @@ -879,6 +912,47 @@ private bool ChallengeIntroducesNewScopes(ProtectedResourceMetadata protectedRes
return scope + " " + OfflineAccess;
}

/// <summary>
/// Validates the <c>iss</c> parameter from an authorization response per
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
/// </summary>
/// <param name="iss">The issuer identifier received in the authorization response, or null if absent.</param>
/// <param name="authServerMetadata">The authorization server metadata containing the expected issuer.</param>
private void ValidateIssuerResponse(string? iss, AuthorizationServerMetadata authServerMetadata)
{
var expectedIssuer = authServerMetadata.Issuer?.OriginalString;

@tarekgh tarekgh Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: expectedIssuer can be null when the server metadata omits issuer. If the server advertises iss support (or returns an iss without advertising it), the comparison below runs against null and always fails with a confusing expected issuer '' message. Worth handling the null-metadata-issuer case explicitly.


if (authServerMetadata.AuthorizationResponseIssParameterSupported)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: iss validation is gated entirely on the server advertising AuthorizationResponseIssParameterSupported. A mix-up authorization server can skip validation by omitting both the advertisement and the iss value. This is inherent to RFC 9207, but a short comment documenting the limitation would help.

{
// Server advertises iss support: iss MUST be present and match.
if (string.IsNullOrEmpty(iss))
{
ThrowFailedToHandleUnauthorizedResponse(
"Authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response.");
}

// Use exact string comparison per RFC 9207 / RFC 3986 §6.2.1.
if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal))
{
ThrowFailedToHandleUnauthorizedResponse(
$"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'.");
}
}
else
{
// Server does not advertise iss support: if iss is present, still validate it.
if (!string.IsNullOrEmpty(iss))
{
if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal))
{
ThrowFailedToHandleUnauthorizedResponse(
$"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'.");
}
}
// If iss is absent and not advertised, proceed normally.
}
}

/// <summary>
/// Verifies that the resource URI in the metadata matches the original request URL.
/// Accepts either an exact match with the full request URL, or a match with the base URL
Expand Down
Loading
Loading