Skip to content
Closed
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 @@ -65,15 +66,19 @@
Console.WriteLine();
}

/// <summary>
/// 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 authorization and redirect URIs for this request.</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 code and state 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 state = query["state"];
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, State = state };
}
catch (Exception ex)
{
Expand Down
4 changes: 3 additions & 1 deletion samples/ProtectedMcpClient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ dotnet run
3. The client initiates OAuth 2.0 authorization code flow:
- Opens a browser to the authorization URL at the OAuth server
- Starts a local HTTP listener on `http://localhost:1179/callback` to receive the authorization code
- Exchanges the authorization code for an access token
- Validates that the returned `state` matches the authorization request before exchanging the code for an access token
4. The client uses the access token to authenticate with the MCP server
5. The client lists available tools and calls the `GetAlerts` tool for Washington state

## OAuth Configuration

The sample uses `AuthorizationCallbackHandler` to return both the `code` and `state` parameters from the redirect to the SDK. State is generated uniquely for each authorization transaction and must match exactly before the SDK exchanges the code. The legacy `AuthorizationRedirectDelegate` remains supported, but returns only a code and does not enable SDK state validation. For all callback paths, `state` is SDK-generated and cannot be overridden through `AdditionalAuthorizationParameters`. When neither callback is configured, the built-in handler prompts for the full redirect URL rather than just the code.

The client is configured with:
- **Client ID**: `demo-client`
- **Client Secret**: `demo-secret`
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; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,10 @@ namespace ModelContextProtocol.Authentication;
/// the authorization code from the callback. The authorization code is typically provided as
/// a query parameter in the redirect URI callback.
/// </para>
/// <para>
/// This delegate returns only the authorization code, so the SDK cannot validate the response's
/// <c>state</c> parameter. Use <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> to return
/// both the code and state and enable SDK state validation.
/// </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,28 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Represents the result of an OAuth authorization redirect, containing the authorization code
/// and state from the authorization response.
/// </summary>
/// <remarks>
/// The <see cref="State"/> property must be populated from the <c>state</c> query parameter in the
/// redirect URI. The SDK validates it against the value sent in the authorization request to bind
/// the response to the initiating transaction and mitigate cross-site request forgery attacks.
/// </remarks>
public sealed class AuthorizationResult
{
/// <summary>
/// Gets the authorization code returned by the authorization server.
/// </summary>
public string? Code { get; init; }

/// <summary>
/// Gets the state value returned in the authorization response.
/// </summary>
/// <remarks>
/// Implementations of <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> must populate this
/// property from the <c>state</c> query parameter of the redirect URI callback. The SDK requires an
/// exact match with the state sent in the authorization request before exchanging the authorization code.
/// </remarks>
public string? State { get; init; }
}
34 changes: 28 additions & 6 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,37 @@ public sealed class ClientOAuthOptions
public IEnumerable<string>? Scopes { 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 neither this callback nor <see cref="AuthorizationRedirectDelegate"/> is 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 must return the <c>code</c> and <c>state</c> query parameters
/// from the redirect URI callback. The SDK requires an exact state match before exchanging the code.
/// </para>
/// <para>
/// This property cannot be configured together with <see cref="AuthorizationRedirectDelegate"/>.
/// </para>
/// </remarks>
public Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>>? AuthorizationCallbackHandler { get; set; }

/// <summary>
/// Gets or sets the legacy authorization redirect delegate for handling the OAuth authorization flow.
/// </summary>
/// <remarks>
/// <para>
/// This delegate returns only the authorization code and cannot provide the <c>state</c> parameter
/// from the authorization response. Consequently, SDK state validation is skipped when this delegate
/// is used. Use <see cref="AuthorizationCallbackHandler"/> to enable SDK state validation.
/// </para>
/// <para>
/// This property cannot be configured together with <see cref="AuthorizationCallbackHandler"/>.
/// </para>
/// </remarks>
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
Expand Down Expand Up @@ -92,8 +113,9 @@ public sealed class ClientOAuthOptions
/// </summary>
/// <remarks>
/// <para>
/// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri",
/// which should instead be configured via <see cref="RedirectUri"/>.
/// Parameters specified cannot override or append to any automatically set parameters like
/// <c>redirect_uri</c> or <c>state</c>. The redirect URI should instead be configured via
/// <see cref="RedirectUri"/>, while state is generated uniquely for each authorization transaction.
/// </para>
/// </remarks>
public IDictionary<string, string> AdditionalAuthorizationParameters { get; set; } = new Dictionary<string, string>();
Expand Down
115 changes: 95 additions & 20 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly string? _configuredScopes;
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 bool _validateAuthorizationResponseState;
private readonly Uri? _clientMetadataDocumentUri;

// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
Expand Down Expand Up @@ -82,8 +83,36 @@ 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;
var authorizationRedirectDelegate = options.AuthorizationRedirectDelegate;
if (options.AuthorizationCallbackHandler is not null && authorizationRedirectDelegate is not null)
{
throw new ArgumentException(
$"{nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} and {nameof(ClientOAuthOptions.AuthorizationRedirectDelegate)} cannot both be configured.",
nameof(options));
}

if (options.AuthorizationCallbackHandler is not null)
{
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;
_validateAuthorizationResponseState = true;
}
else if (authorizationRedirectDelegate is not null)
{
_authorizationCallbackHandler = async (context, cancellationToken) => new AuthorizationResult
{
Code = await authorizationRedirectDelegate(
context.AuthorizationUri,
context.RedirectUri,
cancellationToken).ConfigureAwait(false),
};
// Code-only callbacks cannot provide response state. Preserve their existing behavior.
_validateAuthorizationResponseState = false;
}
else
{
_authorizationCallbackHandler = DefaultAuthorizationUrlHandler;
_validateAuthorizationResponseState = true;
}

_dcrClientName = options.DynamicClientRegistration?.ClientName;
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
Expand All @@ -100,20 +129,32 @@ public ClientOAuthProvider(
private static Uri? DefaultAuthServerSelector(IReadOnlyList<Uri> availableServers) => availableServers.FirstOrDefault();

/// <summary>
/// Default authorization URL handler that displays the URL to the user for manual input.
/// Default authorization URL handler that displays the URL to the user and parses the resulting redirect URL.
/// </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 parsed from the redirect URL.</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))
{
ThrowFailedToHandleUnauthorizedResponse(
"The entered redirect URL is not a valid absolute URL. Paste the full redirect URL from the browser address bar.");
}

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

internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
Expand Down Expand Up @@ -456,24 +497,42 @@ private async Task<string> InitiateAuthorizationCodeFlowAsync(
AuthorizationServerMetadata authServerMetadata,
CancellationToken cancellationToken)
{
var codeVerifier = GenerateCodeVerifier();
var codeVerifier = GenerateRandomBase64UrlValue();
var codeChallenge = GenerateCodeChallenge(codeVerifier);
var state = GenerateRandomBase64UrlValue();

var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state);
var authResult = await _authorizationCallbackHandler(
new AuthorizationCallbackContext
{
AuthorizationUri = authUrl,
RedirectUri = _redirectUri,
},
cancellationToken).ConfigureAwait(false);

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

return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
if (_validateAuthorizationResponseState)
{
ValidateStateResponse(authResult!.State, state);
}

if (string.IsNullOrEmpty(authResult.Code))
{
ThrowFailedToHandleUnauthorizedResponse("The authorization callback returned a null or empty authorization code.");
}

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

private Uri BuildAuthorizationUrl(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata,
string codeChallenge)
string codeChallenge,
string state)
{
var resourceUri = GetResourceUri(protectedResourceMetadata);

Expand All @@ -484,6 +543,7 @@ private Uri BuildAuthorizationUrl(
["response_type"] = "code",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
["state"] = state,
};

if (resourceUri is not null)
Expand Down Expand Up @@ -934,7 +994,22 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase));
}

private static string GenerateCodeVerifier()
private static void ValidateStateResponse(string? state, string expectedState)
{
if (string.IsNullOrEmpty(state))
{
ThrowFailedToHandleUnauthorizedResponse(
"The authorization response did not include the required state parameter.");
}

if (!string.Equals(state, expectedState, StringComparison.Ordinal))
{
ThrowFailedToHandleUnauthorizedResponse(
"The authorization response state did not match the state sent in the authorization request.");
}
}

private static string GenerateRandomBase64UrlValue()
{
#if NET9_0_OR_GREATER
Span<byte> bytes = stackalloc byte[32];
Expand Down
Loading
Loading