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
10 changes: 6 additions & 4 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 @@ -71,8 +72,8 @@
/// <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="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(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
{
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Opening browser to: {authorizationUrl}");
Expand All @@ -93,6 +94,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 +117,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
Expand Up @@ -22,7 +22,11 @@ namespace ModelContextProtocol.Authentication;
/// <para>
/// The implementation should handle user interaction to visit the authorization URL and extract
/// the authorization code from the callback. The authorization code is typically provided as
/// a query parameter in the redirect URI callback.
/// a <c>code</c> query parameter in the redirect URI callback.
/// </para>
/// <para>
/// For RFC 9207 issuer validation support, use <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/>
/// instead, which allows returning both the authorization code and the <c>iss</c> parameter.
/// </para>
/// </remarks>
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
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; }
}
18 changes: 18 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ public sealed class ClientOAuthOptions
/// </remarks>
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }

/// <summary>
/// Gets or sets a callback that handles the full OAuth authorization flow, returning both the
/// authorization code and the issuer identifier for RFC 9207 validation.
/// </summary>
/// <remarks>
/// <para>
/// When set, this handler takes precedence over <see cref="AuthorizationRedirectDelegate"/>.
/// It enables the SDK to validate the <c>iss</c> parameter in the authorization response per
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>, which mitigates
/// mix-up attacks.
/// </para>
/// <para>
/// Implementations should extract both the <c>code</c> and <c>iss</c> query parameters from
/// the redirect URI callback and return them in an <see cref="AuthorizationResult"/>.
/// </para>
/// </remarks>
public Func<Uri, Uri, CancellationToken, Task<AuthorizationResult?>>? AuthorizationCallbackHandler { get; set; }

/// <summary>
/// Gets or sets the authorization server selector function.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
private readonly Func<Uri, Uri, 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 @@ -84,6 +85,9 @@ public ClientOAuthProvider(
// Set up authorization server selection strategy
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;

// Set up authorization callback handler (new RFC 9207-aware handler takes precedence)
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;

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

Expand Down Expand Up @@ -370,6 +374,16 @@ 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.
if (metadata.Issuer 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).");
}

return metadata;
}
catch (Exception ex)
Expand Down Expand Up @@ -462,14 +476,33 @@ private async Task<string> InitiateAuthorizationCodeFlowAsync(
var codeChallenge = GenerateCodeChallenge(codeVerifier);

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

if (string.IsNullOrEmpty(authCode))
string? authorizationCode;
string? iss = null;

if (_authorizationCallbackHandler is not null)
{
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
var authResult = await _authorizationCallbackHandler(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
if (authResult is null || string.IsNullOrEmpty(authResult.Code))
{
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code.");
}

authorizationCode = authResult!.Code!;
iss = authResult.Iss;
}
else
{
authorizationCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(authorizationCode))
{
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
}
}

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

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

private Uri BuildAuthorizationUrl(
Expand Down Expand Up @@ -773,6 +806,47 @@ private async Task PerformDynamicClientRegistrationAsync(
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;

if (authServerMetadata.AuthorizationResponseIssParameterSupported)
{
// 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 exactly matches the original request URL as required by the RFC.
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task CanAuthenticate_WithResourceMetadataFromEvent()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
},
HttpClient,
Expand Down Expand Up @@ -76,7 +76,7 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
Expand Down
Loading
Loading