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
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,10 @@ internal sealed class AuthorizationServerMetadata
/// </summary>
[JsonPropertyName("scopes_supported")]
public List<string>? ScopesSupported { get; set; }

/// <summary>
/// Indicates if the server supports OAuth Client ID Metadata Documents.
/// </summary>
[JsonPropertyName("client_id_metadata_document_supported")]
public bool ClientIdMetadataDocumentSupported { get; set; }
}
11 changes: 11 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ public sealed class ClientOAuthOptions
/// </remarks>
public string? ClientSecret { get; set; }

/// <summary>
/// Gets or sets the HTTPS URL pointing to this client's metadata document.
/// </summary>
/// <remarks>
/// When specified, and when the authorization server metadata reports
/// <c>client_id_metadata_document_supported = true</c>, the OAuth client will respond to
/// challenges by sending this URL as the client identifier instead of performing dynamic
/// client registration.
/// </remarks>
public Uri? ClientMetadataDocumentUri { get; set; }

/// <summary>
/// Gets or sets the OAuth scopes to request.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal sealed partial class ClientOAuthProvider
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
private readonly Uri? _clientMetadataDocumentUri;

// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
private readonly string? _dcrClientName;
Expand Down Expand Up @@ -74,6 +75,7 @@ public ClientOAuthProvider(
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_scopes = options.Scopes?.ToArray();
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;

// Set up authorization server selection strategy
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
Expand Down Expand Up @@ -221,7 +223,7 @@ private async Task PerformOAuthAuthorizationAsync(
_authServerMetadata = authServerMetadata;

// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: {} refreshToken })
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken })
{
var refreshedTokens = await RefreshTokenAsync(refreshToken, protectedResourceMetadata.Resource, authServerMetadata, cancellationToken).ConfigureAwait(false);
if (refreshedTokens is not null)
Expand All @@ -231,10 +233,18 @@ private async Task PerformOAuthAuthorizationAsync(
}
}

// Perform dynamic client registration if needed
// Assign a client ID if necessary
if (string.IsNullOrEmpty(_clientId))
{
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
// Try using a client metadata document before falling back to dynamic client registration
if (authServerMetadata.ClientIdMetadataDocumentSupported && _clientMetadataDocumentUri is not null)
{
ApplyClientIdMetadataDocument(_clientMetadataDocumentUri);
}
else
{
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
}
}

// Perform the OAuth flow
Expand All @@ -243,6 +253,23 @@ private async Task PerformOAuthAuthorizationAsync(
LogOAuthAuthorizationCompleted();
}

private void ApplyClientIdMetadataDocument(Uri metadataUri)
{
if (!IsValidClientMetadataDocumentUri(metadataUri))
{
ThrowFailedToHandleUnauthorizedResponse(
$"{nameof(ClientOAuthOptions.ClientMetadataDocumentUri)} must be an HTTPS URL with a non-root absolute path. Value: '{metadataUri}'.");
}

_clientId = metadataUri.AbsoluteUri;

// See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3
static bool IsValidClientMetadataDocumentUri(Uri uri)
=> uri.IsAbsoluteUri
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
}

private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
{
if (authServerUri.OriginalString.Length == 0 ||
Expand Down
105 changes: 103 additions & 2 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ namespace ModelContextProtocol.AspNetCore.Tests.OAuth;

public class AuthTests : OAuthTestBase
{
public AuthTests(ITestOutputHelper outputHelper)
: base(outputHelper)
private const string ClientMetadataDocumentUrl = $"{OAuthServerUrl}/client-metadata/cimd-client.json";

public AuthTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}

Expand Down Expand Up @@ -98,6 +100,105 @@ public async Task CanAuthenticate_WithDynamicClientRegistration()
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}

[Fact]
public async Task CanAuthenticate_WithClientMetadataDocument()
{
await using var app = await StartMcpServerAsync();

await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl)
},
}, HttpClient, LoggerFactory);

await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}

[Fact]
public async Task UsesDynamicClientRegistration_WhenCimdNotSupported()
{
// Disable CIMD support on the test OAuth server so the client
// falls back to dynamic registration even if a CIMD URL is provided.
TestOAuthServer.ClientIdMetadataDocumentSupported = false;

await using var app = await StartMcpServerAsync();

// Provide an invalid CIMD URL; if CIMD were used, auth would fail.
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client (No CIMD)",
ClientUri = new Uri("https://example.com/no-cimd"),
},
},
}, HttpClient, LoggerFactory);

// Should succeed via dynamic client registration.
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}

[Fact]
public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified()
{
await using var app = await StartMcpServerAsync();

// Provide an invalid CIMD URL; if CIMD were used, auth would fail.
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
},
}, HttpClient, LoggerFactory);

await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}

[Theory]
[InlineData("http://localhost:7029/client-metadata/cimd-client.json")] // Non-HTTPS Scheme
[InlineData("http://localhost:7029")] // Missing path
public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string uri)
{
await using var app = await StartMcpServerAsync();

await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(uri),
},
}, HttpClient, LoggerFactory);

var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

Assert.StartsWith("Failed to handle unauthorized response", ex.Message);
}

[Fact]
public async Task CanAuthenticate_WithTokenRefresh()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@ internal sealed class AuthorizationServerMetadata
/// </summary>
[JsonPropertyName("scopes_supported")]
public List<string>? ScopesSupported { get; init; }

/// <summary>
/// Gets or sets a value indicating whether CIMD client IDs are supported.
/// </summary>
[JsonPropertyName("client_id_metadata_document_supported")]
public bool ClientIdMetadataDocumentSupported { get; init; }
}
7 changes: 6 additions & 1 deletion tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ internal sealed class ClientInfo
/// </summary>
public required string ClientId { get; init; }

/// <summary>
/// Gets or sets whether a client secret is required.
/// </summary>
public required bool RequiresClientSecret { get; init; }

/// <summary>
/// Gets or sets the client secret.
/// </summary>
public required string ClientSecret { get; init; }
public string? ClientSecret { get; init; }

/// <summary>
/// Gets or sets the list of redirect URIs allowed for this client.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,11 @@ internal sealed class OAuthServerMetadata
[JsonPropertyName("claims_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? ClaimsSupported { get; init; }

/// <summary>
/// Gets or sets a value indicating whether CIMD client IDs are supported.
/// </summary>
[JsonPropertyName("client_id_metadata_document_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ClientIdMetadataDocumentSupported { get; init; }
}
38 changes: 33 additions & 5 deletions tests/ModelContextProtocol.TestOAuthServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed class Program
{
private const int _port = 7029;
private static readonly string _url = $"https://localhost:{_port}";
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";

// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
private static readonly string[] ValidResources = ["http://localhost:5000/", "http://localhost:7071/"];
Expand Down Expand Up @@ -44,6 +45,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
public bool HasIssuedExpiredToken { get; set; }
public bool HasRefreshedToken { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the authorization server
/// advertises support for client ID metadata documents in its discovery
/// document. This is used by tests to toggle CIMD support.
/// </summary>
/// <remarks>
/// The default value is <c>true</c>.
/// </remarks>
public bool ClientIdMetadataDocumentSupported { get; set; } = true;

/// <summary>
/// Entry point for the application.
/// </summary>
Expand Down Expand Up @@ -102,6 +113,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
_clients[clientId] = new ClientInfo
{
ClientId = clientId,
RequiresClientSecret = true,
ClientSecret = clientSecret,
RedirectUris = ["http://localhost:1179/callback"],
};
Expand All @@ -111,10 +123,24 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
_clients["test-refresh-client"] = new ClientInfo
{
ClientId = "test-refresh-client",
RequiresClientSecret = true,
ClientSecret = "test-refresh-secret",
RedirectUris = ["http://localhost:1179/callback"],
};

// This client is pre-registered to support testing Client ID Metadata Documents (CIMD).
// A non-test OAuth server implementation would fetch the metadata document from the client-specified
// URL during authorization, but we just register the client here to keep the test implementation simple.
// We also set 'RequiresClientSecret' to 'false' here because client secrets are disallowed when using CIMD.
// See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-4.1
_clients[_clientMetadataDocumentUrl] = new ClientInfo
{
ClientId = _clientMetadataDocumentUrl,

RequiresClientSecret = false,
RedirectUris = ["http://localhost:1179/callback"],
};

// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
// /.well-known/openid-configuration by default. To make things easier, we support both with the same response
// which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628
Expand Down Expand Up @@ -144,7 +170,8 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
CodeChallengeMethodsSupported = ["S256"],
GrantTypesSupported = ["authorization_code", "refresh_token"],
IntrospectionEndpoint = $"{_url}/introspect",
RegistrationEndpoint = $"{_url}/register"
RegistrationEndpoint = $"{_url}/register",
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
};

return Results.Ok(metadata);
Expand Down Expand Up @@ -468,6 +495,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
_clients[clientId] = new ClientInfo
{
ClientId = clientId,
RequiresClientSecret = true,
ClientSecret = clientSecret,
RedirectUris = registrationRequest.RedirectUris,
};
Expand Down Expand Up @@ -508,17 +536,17 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
var clientId = form["client_id"].ToString();
var clientSecret = form["client_secret"].ToString();

if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
if (string.IsNullOrEmpty(clientId) || !_clients.TryGetValue(clientId, out var client))
{
return null;
}

if (_clients.TryGetValue(clientId, out var client) && client.ClientSecret == clientSecret)
if (client.RequiresClientSecret && client.ClientSecret != clientSecret)
{
return client;
return null;
}

return null;
return client;
}

/// <summary>
Expand Down