Skip to content
Open
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 full redirect URL 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
136 changes: 121 additions & 15 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,29 @@ 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);
Console.Write("Enter the full redirect URL: ");
var redirectUrl = Console.ReadLine();
if (!Uri.TryCreate(redirectUrl, UriKind.Absolute, out var responseUri))
{
return Task.FromResult<AuthorizationResult?>(null);
}

var queryParams = HttpUtility.ParseQueryString(responseUri.Query);
return Task.FromResult<AuthorizationResult?>(new()
{
Code = queryParams["code"],
Iss = queryParams["iss"],
});
}

internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
Expand Down Expand Up @@ -402,10 +413,41 @@ private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri a
ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement.");
}

// 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
Comment thread
halter73 marked this conversation as resolved.
// 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 null)
{
ThrowFailedToHandleUnauthorizedResponse(
$"Authorization server metadata from '{wellKnownEndpoint}' did not provide the required issuer (RFC 8414 Section 2).");
}

// RFC 8414 requires an identical issuer value, so do not normalize URI case,
// trailing slashes, or percent-encoding before comparison.
if (resourceUri is not null &&
!string.Equals(metadata.Issuer!.OriginalString, authServerUri.OriginalString, StringComparison.Ordinal))
{
ThrowFailedToHandleUnauthorizedResponse(
$"Authorization server metadata issuer '{metadata.Issuer}' does not match the expected issuer '{authServerUri}' (RFC 8414 Section 3.3).");
}

// A structurally valid metadata document was discovered. Even if it fails PKCE validation
// below, its existence disqualifies the legacy fallback that would otherwise synthesize S256.
metadataDocumentFound = true;
}
catch (McpException)
{
// Metadata validation failures are security signals and must not fall back to
// another well-known endpoint.
throw;
}
catch (Exception ex)
Comment thread
halter73 marked this conversation as resolved.
{
LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint);
Expand Down Expand Up @@ -539,14 +581,28 @@ private async Task<string> InitiateAuthorizationCodeFlowAsync(
var codeChallenge = GenerateCodeChallenge(codeVerifier);

var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
Comment thread
halter73 marked this conversation as resolved.
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 @@ -926,6 +982,56 @@ 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;
Comment thread
halter73 marked this conversation as resolved.

if ((authServerMetadata.AuthorizationResponseIssParameterSupported || !string.IsNullOrEmpty(iss)) &&
expectedIssuer is null)
{
ThrowFailedToHandleUnauthorizedResponse(
"Authorization server metadata did not provide an issuer required to validate the authorization response.");
}

if (authServerMetadata.AuthorizationResponseIssParameterSupported)
Comment thread
halter73 marked this conversation as resolved.
{
// Server advertises iss support: iss MUST be present and match.
Comment thread
halter73 marked this conversation as resolved.
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.
// RFC 9207 cannot protect against a server that neither advertises support nor
// returns an iss parameter, so an absent iss is accepted in that case.
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