Skip to content
Merged
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
94 changes: 83 additions & 11 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly bool _validateAuthorizationResponseState;
private readonly bool _validateAuthorizationResponseIssuer;
private readonly Uri? _clientMetadataDocumentUri;
private readonly string? _configuredClientId;

// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
private readonly string? _dcrClientName;
Expand All @@ -49,6 +50,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private string? _clientId;
private string? _clientSecret;
private string? _tokenEndpointAuthMethod;
private string? _clientCredentialsAuthorizationServer;
private ITokenCache _tokenCache;
private AuthorizationServerMetadata? _authServerMetadata;

Expand Down Expand Up @@ -96,6 +98,7 @@ public ClientOAuthProvider(
}

_clientId = options.ClientId;
_configuredClientId = options.ClientId;
_clientSecret = options.ClientSecret;
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
Expand Down Expand Up @@ -244,7 +247,9 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
return (current.AccessToken, true);
}

if (_authServerMetadata is not null && current?.RefreshToken is { Length: > 0 } refreshToken)
if (_authServerMetadata is not null &&
current?.RefreshToken is { Length: > 0 } refreshToken &&
CachedTokensMatchClientCredentials(current, _clientCredentialsAuthorizationServer))
{
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
return (accessToken, true);
Expand Down Expand Up @@ -427,7 +432,10 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
// provider has not assigned a client ID yet. Restoring it here makes the refresh below possible
// and avoids a redundant dynamic client registration in the assignment block.
var cachedTokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false);
RestoreCachedClientCredentials(cachedTokens);
RestoreCachedClientCredentials(cachedTokens, selectedAuthServer);
BindClientCredentialsToAuthorizationServer(selectedAuthServer);
var cachedTokensMatchClientCredentials =
CachedTokensMatchClientCredentials(cachedTokens, selectedAuthServer.OriginalString);

// Only attempt a token refresh if we haven't attempted to already for this request.
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
Expand All @@ -439,6 +447,7 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
if (!attemptedRefresh &&
response.StatusCode == System.Net.HttpStatusCode.Unauthorized &&
!string.IsNullOrEmpty(_clientId) &&
cachedTokensMatchClientCredentials &&
cachedTokens is { RefreshToken: { Length: > 0 } refreshToken })
{
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
Expand All @@ -463,6 +472,8 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
}
}

_clientCredentialsAuthorizationServer = selectedAuthServer.OriginalString;

// Determine the token endpoint auth method from server metadata if not already set by DCR.
_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();

Expand Down Expand Up @@ -875,6 +886,7 @@ private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpRespon
ClientId = _clientId,
ClientSecret = _clientSecret,
TokenEndpointAuthMethod = _tokenEndpointAuthMethod,
AuthorizationServer = _clientCredentialsAuthorizationServer,
};

await _tokenCache.StoreTokensAsync(tokens, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -1458,33 +1470,93 @@ private static string ToBase64UrlString(byte[] bytes)
/// <summary>
/// Restores the client registration persisted alongside cached tokens when this provider has not been
/// assigned a client ID yet. This allows a durable <see cref="ITokenCache"/> to use a refresh token that
/// survived a process restart without re-running dynamic client registration. An explicitly configured
/// survived a process restart without re-running dynamic client registration. Credentials are restored
/// only when the cache binds them to the currently selected authorization server. An explicitly configured
/// client ID always takes precedence, so nothing is restored when one is already available.
/// </summary>
/// <remarks>
/// Callers must hold <c>_tokenAcquisitionLock</c>: this writes the shared <c>_clientId</c>,
/// <c>_clientSecret</c>, and <c>_tokenEndpointAuthMethod</c> fields, which the lock serializes.
/// Like the persisted refresh token, the restored client ID assumes the durable cache belongs to the
/// current authorization server; a single cache shared across authorization servers is not supported
/// (an inherent property of the single-container <see cref="ITokenCache"/> design).
/// A single cache still stores only one registration, but the persisted authorization-server issuer
/// prevents that registration from being reused with a different server.
/// </remarks>
private void RestoreCachedClientCredentials(TokenContainer? tokens)
private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selectedAuthServer)
{
if (!string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(tokens?.ClientId))
if (tokens is null)
{
return;
}

// The guard above guarantees a non-null container, but the older nullable flow analysis on
// netstandard2.0/net472 doesn't infer that from the null-conditional check, so capture a
// non-null local and use it for every assignment.
// The guard above guarantees a non-null container, but older nullable flow analysis on
// netstandard2.0/net472 may not preserve that narrowing, so capture a non-null local.
var cached = tokens!;

if (_configuredClientId is not null)
{
if (!string.Equals(cached.ClientId, _configuredClientId, StringComparison.Ordinal))
{
return;
}

// Restore the issuer binding independently from the secret. A rotated configured secret
// must not make credentials previously bound to another issuer appear portable.
_clientCredentialsAuthorizationServer = cached.AuthorizationServer;

if (string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal) &&
Comment thread
halter73 marked this conversation as resolved.
string.Equals(cached.ClientSecret, _clientSecret, StringComparison.Ordinal))
{
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
}

return;
}

if (!string.IsNullOrEmpty(_clientId))
{
return;
}

if (string.IsNullOrEmpty(cached.ClientId) ||
!string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal))
{
return;
}

// Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the
// secret and auth method must already be in place before _clientId becomes observable.
_clientSecret ??= cached.ClientSecret;
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
_clientId = cached.ClientId;
_clientCredentialsAuthorizationServer = cached.AuthorizationServer;
}

private bool CachedTokensMatchClientCredentials(TokenContainer? tokens, string? authorizationServer) =>
tokens is not null &&
string.Equals(tokens.AuthorizationServer, authorizationServer, StringComparison.Ordinal) &&
string.Equals(tokens.ClientId, _clientId, StringComparison.Ordinal) &&
string.Equals(tokens.ClientSecret, _clientSecret, StringComparison.Ordinal) &&
string.Equals(tokens.TokenEndpointAuthMethod, _tokenEndpointAuthMethod, StringComparison.Ordinal);

private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer)
{
if (_clientCredentialsAuthorizationServer is null ||
string.Equals(_clientCredentialsAuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal))
{
return;
}

if (_configuredClientId is not null)
{
ThrowFailedToHandleUnauthorizedResponse(
$"The authorization server changed from '{_clientCredentialsAuthorizationServer}' to '{selectedAuthServer.OriginalString}', " +
"but explicitly configured client credentials cannot be assumed to be valid for the new authorization server.");
}

_clientId = null;
_clientSecret = null;
_tokenEndpointAuthMethod = null;
_authServerMetadata = null;
_clientCredentialsAuthorizationServer = null;
}

[DoesNotReturn]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ internal sealed class ExchangeJwtBearerGrantOptions
/// </summary>
public string? ClientSecret { get; set; }

/// <summary>
/// Gets or sets the token endpoint authentication method.
/// </summary>
public string? TokenEndpointAuthMethod { get; set; }

/// <summary>
/// Gets or sets the scopes to request (space-separated). Optional.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,18 @@ public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
["client_id"] = options.ClientId,
};

if (!string.IsNullOrEmpty(options.ClientSecret))
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint);

if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
{
formData.Remove("client_id");
var credentials = $"{Uri.EscapeDataString(options.ClientId)}:{Uri.EscapeDataString(options.ClientSecret ?? string.Empty)}";
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials)));
}
else if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_post", StringComparison.Ordinal) &&
!string.IsNullOrEmpty(options.ClientSecret))
{
formData["client_secret"] = options.ClientSecret!;
}
Expand All @@ -174,12 +185,7 @@ public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
formData["scope"] = options.Scope!;
}

using var requestContent = new FormUrlEncodedContent(formData);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
{
Content = requestContent
};

httpRequest.Content = new FormUrlEncodedContent(formData);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ public IdentityAssertionGrantProvider(
throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}");
}

if (options.TokenEndpointAuthMethod is not null &&
options.TokenEndpointAuthMethod is not ("client_secret_basic" or "client_secret_post" or "none"))
{
throw new ArgumentException(
$"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.",
$"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}");
}

if (options.TokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" &&
string.IsNullOrEmpty(options.ClientSecret))
{
throw new ArgumentException(
$"{nameof(options.ClientSecret)} is required when {nameof(options.TokenEndpointAuthMethod)} is '{options.TokenEndpointAuthMethod}'.",
$"{nameof(options)}.{nameof(options.ClientSecret)}");
}
Comment thread
halter73 marked this conversation as resolved.

_options = options;
_httpClient = httpClient;
_logger = (ILogger?)loggerFactory?.CreateLogger<IdentityAssertionGrantProvider>() ?? NullLogger.Instance;
Expand Down Expand Up @@ -197,6 +213,7 @@ private async Task<TokenContainer> AcquireAccessTokenAsync(
Assertion = jag,
ClientId = _options.ClientId,
ClientSecret = _options.ClientSecret,
TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata),
Scope = _options.Scope,
}, _httpClient, cancellationToken).ConfigureAwait(false);

Expand All @@ -206,6 +223,50 @@ private async Task<TokenContainer> AcquireAccessTokenAsync(
return tokens;
}

private string SelectTokenEndpointAuthMethod(AuthorizationServerMetadata metadata)
{
var supportedMethods = metadata.TokenEndpointAuthMethodsSupported;
if (_options.TokenEndpointAuthMethod is { } configuredMethod)
{
if (supportedMethods is { Count: > 0 } &&
!supportedMethods.Contains(configuredMethod, StringComparer.Ordinal))
{
throw new IdentityAssertionGrantException(
$"The configured token endpoint authentication method '{configuredMethod}' is not advertised by the MCP authorization server.");
}

return configuredMethod;
}

if (string.IsNullOrEmpty(_options.ClientSecret))
{
if (supportedMethods is null or { Count: 0 } ||
supportedMethods.Contains("none", StringComparer.Ordinal))
{
return "none";
}

throw new IdentityAssertionGrantException(
"The MCP authorization server does not advertise a token endpoint authentication method usable without a client secret.");
}

// Preserve the provider's existing client_secret_post behavior when it is available.
// RFC 8414 defines this metadata as a list of supported methods, not a preference order.
if (supportedMethods is null or { Count: 0 } ||
supportedMethods.Contains("client_secret_post", StringComparer.Ordinal))
{
return "client_secret_post";
}

if (supportedMethods.Contains("client_secret_basic", StringComparer.Ordinal))
{
return "client_secret_basic";
}

throw new IdentityAssertionGrantException(
"The MCP authorization server does not advertise a supported token endpoint authentication method.");
}

/// <summary>
/// Clears any cached tokens, forcing a fresh token exchange on the next call to <see cref="GetAccessTokenAsync"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ public sealed class IdentityAssertionGrantProviderOptions
/// </summary>
public string? ClientSecret { get; set; }

/// <summary>
/// Gets or sets the authentication method used at the MCP authorization server's token endpoint.
/// </summary>
/// <remarks>
/// Supported values are <c>client_secret_basic</c>, <c>client_secret_post</c>, and <c>none</c>.
/// Set this to the method assigned to the pre-registered MCP client. When omitted, the provider
/// preserves its existing <c>client_secret_post</c> behavior when supported, then falls back to
/// another compatible method advertised by the authorization server.
/// </remarks>
public string? TokenEndpointAuthMethod { get; set; }

/// <summary>
/// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/TokenContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,15 @@ public sealed class TokenContainer
/// </remarks>
public string? TokenEndpointAuthMethod { get; set; }

/// <summary>
/// Gets or sets the authorization server issuer that issued the client credentials and tokens.
/// </summary>
/// <remarks>
/// OAuth client credentials are bound to an authorization server and must not be reused with a
/// different issuer. Durable cache implementations should persist this value alongside the token
/// and client registration so restored credentials can be validated before use.
/// </remarks>
public string? AuthorizationServer { get; set; }

internal bool IsExpired => ExpiresIn is not null && DateTimeOffset.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn.Value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ public ClientConformanceTests(ITestOutputHelper output)
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]

// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
// [InlineData("auth/client-credentials-jwt")]
// [InlineData("auth/client-credentials-basic")]
[InlineData("auth/authorization-server-migration")]
[InlineData("auth/client-credentials-jwt")]
[InlineData("auth/client-credentials-basic")]
[InlineData("auth/enterprise-managed-authorization")]
[InlineData("sep-2322-client-request-state")]
[InlineData("json-schema-ref-no-deref")]

public async Task RunConformanceTest(string scenario)
{
Expand Down
Loading