Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)

This commit is contained in:
Den Delimarsky 🌺 2025-07-03 14:39:28 -07:00 • committed by GitHub
commit cdf88e7f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 4207 additions and 126 deletions

View file

@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents authorization code information for OAuth flow.
/// </summary>
internal sealed class AuthorizationCodeInfo
{
/// <summary>
/// Gets or sets the client ID associated with this authorization code.
/// </summary>
public required string ClientId { get; init; }
/// <summary>
/// Gets or sets the redirect URI associated with this authorization code.
/// </summary>
public required string RedirectUri { get; init; }
/// <summary>
/// Gets or sets the code challenge associated with this authorization code (for PKCE).
/// </summary>
public required string CodeChallenge { get; init; }
/// <summary>
/// Gets or sets the list of scopes approved for this authorization code.
/// </summary>
public List<string> Scope { get; init; } = [];
/// <summary>
/// Gets or sets the optional resource URI this authorization code is for.
/// </summary>
public Uri? Resource { get; init; }
}

View file

@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents the authorization server metadata for OAuth discovery.
/// </summary>
internal sealed class AuthorizationServerMetadata
{
/// <summary>
/// Gets or sets the issuer URL.
/// </summary>
[JsonPropertyName("issuer")]
public required Uri Issuer { get; init; }
/// <summary>
/// Gets or sets the authorization endpoint URL.
/// </summary>
[JsonPropertyName("authorization_endpoint")]
public required Uri AuthorizationEndpoint { get; init; }
/// <summary>
/// Gets or sets the token endpoint URL.
/// </summary>
[JsonPropertyName("token_endpoint")]
public required Uri TokenEndpoint { get; init; }
/// <summary>
/// Gets the introspection endpoint URL.
/// </summary>
[JsonPropertyName("introspection_endpoint")]
public Uri? IntrospectionEndpoint => new Uri($"{Issuer}/introspect");
/// <summary>
/// Gets or sets the response types supported by this server.
/// </summary>
[JsonPropertyName("response_types_supported")]
public required List<string> ResponseTypesSupported { get; init; }
/// <summary>
/// Gets or sets the grant types supported by this server.
/// </summary>
[JsonPropertyName("grant_types_supported")]
public required List<string> GrantTypesSupported { get; init; }
/// <summary>
/// Gets or sets the token endpoint authentication methods supported by this server.
/// </summary>
[JsonPropertyName("token_endpoint_auth_methods_supported")]
public required List<string> TokenEndpointAuthMethodsSupported { get; init; }
/// <summary>
/// Gets or sets the code challenge methods supported by this server.
/// </summary>
[JsonPropertyName("code_challenge_methods_supported")]
public required List<string> CodeChallengeMethodsSupported { get; init; }
/// <summary>
/// Gets or sets the scopes supported by this server.
/// </summary>
[JsonPropertyName("scopes_supported")]
public List<string>? ScopesSupported { get; init; }
}

View file

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents client information for OAuth flow.
/// </summary>
internal sealed class ClientInfo
{
/// <summary>
/// Gets or sets the client ID.
/// </summary>
public required string ClientId { get; init; }
/// <summary>
/// Gets or sets the client secret.
/// </summary>
public required string ClientSecret { get; init; }
/// <summary>
/// Gets or sets the list of redirect URIs allowed for this client.
/// </summary>
public List<string> RedirectUris { get; init; } = [];
}

View file

@ -0,0 +1,93 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents a client registration request as defined in RFC 7591.
/// </summary>
internal sealed class ClientRegistrationRequest
{
/// <summary>
/// Gets or sets the redirect URIs for the client.
/// </summary>
[JsonPropertyName("redirect_uris")]
public required List<string> RedirectUris { get; init; }
/// <summary>
/// Gets or sets the token endpoint authentication method.
/// </summary>
[JsonPropertyName("token_endpoint_auth_method")]
public string? TokenEndpointAuthMethod { get; init; }
/// <summary>
/// Gets or sets the grant types that the client will use.
/// </summary>
[JsonPropertyName("grant_types")]
public List<string>? GrantTypes { get; init; }
/// <summary>
/// Gets or sets the response types that the client will use.
/// </summary>
[JsonPropertyName("response_types")]
public List<string>? ResponseTypes { get; init; }
/// <summary>
/// Gets or sets the human-readable name of the client.
/// </summary>
[JsonPropertyName("client_name")]
public string? ClientName { get; init; }
/// <summary>
/// Gets or sets the URL of the client's home page.
/// </summary>
[JsonPropertyName("client_uri")]
public string? ClientUri { get; init; }
/// <summary>
/// Gets or sets the URL for the client's logo.
/// </summary>
[JsonPropertyName("logo_uri")]
public string? LogoUri { get; init; }
/// <summary>
/// Gets or sets the scope values that the client will use.
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; init; }
/// <summary>
/// Gets or sets the contacts for the client.
/// </summary>
[JsonPropertyName("contacts")]
public List<string>? Contacts { get; init; }
/// <summary>
/// Gets or sets the URL for the client's terms of service.
/// </summary>
[JsonPropertyName("tos_uri")]
public string? TosUri { get; init; }
/// <summary>
/// Gets or sets the URL for the client's privacy policy.
/// </summary>
[JsonPropertyName("policy_uri")]
public string? PolicyUri { get; init; }
/// <summary>
/// Gets or sets the JWK Set URL for the client.
/// </summary>
[JsonPropertyName("jwks_uri")]
public string? JwksUri { get; init; }
/// <summary>
/// Gets or sets the software identifier for the client.
/// </summary>
[JsonPropertyName("software_id")]
public string? SoftwareId { get; init; }
/// <summary>
/// Gets or sets the software version for the client.
/// </summary>
[JsonPropertyName("software_version")]
public string? SoftwareVersion { get; init; }
}

View file

@ -0,0 +1,147 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents a client registration response as defined in RFC 7591.
/// </summary>
internal sealed class ClientRegistrationResponse
{
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
[JsonPropertyName("client_id")]
public required string ClientId { get; init; }
/// <summary>
/// Gets or sets the client secret.
/// </summary>
[JsonPropertyName("client_secret")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientSecret { get; init; }
/// <summary>
/// Gets or sets the redirect URIs for the client.
/// </summary>
[JsonPropertyName("redirect_uris")]
public required List<string> RedirectUris { get; init; }
/// <summary>
/// Gets or sets the registration access token.
/// </summary>
[JsonPropertyName("registration_access_token")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RegistrationAccessToken { get; init; }
/// <summary>
/// Gets or sets the registration client URI.
/// </summary>
[JsonPropertyName("registration_client_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RegistrationClientUri { get; init; }
/// <summary>
/// Gets or sets the client ID issued timestamp.
/// </summary>
[JsonPropertyName("client_id_issued_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? ClientIdIssuedAt { get; init; }
/// <summary>
/// Gets or sets the client secret expiration time.
/// </summary>
[JsonPropertyName("client_secret_expires_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? ClientSecretExpiresAt { get; init; }
/// <summary>
/// Gets or sets the token endpoint authentication method.
/// </summary>
[JsonPropertyName("token_endpoint_auth_method")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TokenEndpointAuthMethod { get; init; }
/// <summary>
/// Gets or sets the grant types that the client will use.
/// </summary>
[JsonPropertyName("grant_types")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? GrantTypes { get; init; }
/// <summary>
/// Gets or sets the response types that the client will use.
/// </summary>
[JsonPropertyName("response_types")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? ResponseTypes { get; init; }
/// <summary>
/// Gets or sets the human-readable name of the client.
/// </summary>
[JsonPropertyName("client_name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientName { get; init; }
/// <summary>
/// Gets or sets the URL of the client's home page.
/// </summary>
[JsonPropertyName("client_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientUri { get; init; }
/// <summary>
/// Gets or sets the URL for the client's logo.
/// </summary>
[JsonPropertyName("logo_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LogoUri { get; init; }
/// <summary>
/// Gets or sets the scope values that the client will use.
/// </summary>
[JsonPropertyName("scope")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Scope { get; init; }
/// <summary>
/// Gets or sets the contacts for the client.
/// </summary>
[JsonPropertyName("contacts")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? Contacts { get; init; }
/// <summary>
/// Gets or sets the URL for the client's terms of service.
/// </summary>
[JsonPropertyName("tos_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TosUri { get; init; }
/// <summary>
/// Gets or sets the URL for the client's privacy policy.
/// </summary>
[JsonPropertyName("policy_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyUri { get; init; }
/// <summary>
/// Gets or sets the JWK Set URL for the client.
/// </summary>
[JsonPropertyName("jwks_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? JwksUri { get; init; }
/// <summary>
/// Gets or sets the software identifier for the client.
/// </summary>
[JsonPropertyName("software_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SoftwareId { get; init; }
/// <summary>
/// Gets or sets the software version for the client.
/// </summary>
[JsonPropertyName("software_version")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SoftwareVersion { get; init; }
}

View file

@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents a JSON Web Key.
/// </summary>
internal sealed class JsonWebKey
{
/// <summary>
/// Gets or sets the key type (e.g., "RSA").
/// </summary>
[JsonPropertyName("kty")]
public required string KeyType { get; init; }
/// <summary>
/// Gets or sets the intended use of the key (e.g., "sig" for signature).
/// </summary>
[JsonPropertyName("use")]
public required string Use { get; init; }
/// <summary>
/// Gets or sets the key ID.
/// </summary>
[JsonPropertyName("kid")]
public required string KeyId { get; init; }
/// <summary>
/// Gets or sets the algorithm intended for use with the key (e.g., "RS256").
/// </summary>
[JsonPropertyName("alg")]
public required string Algorithm { get; init; }
/// <summary>
/// Gets or sets the RSA exponent (base64url-encoded).
/// </summary>
[JsonPropertyName("e")]
public required string Exponent { get; init; }
/// <summary>
/// Gets or sets the RSA modulus (base64url-encoded).
/// </summary>
[JsonPropertyName("n")]
public required string Modulus { get; init; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents a JSON Web Key Set (JWKS) response.
/// </summary>
internal sealed class JsonWebKeySet
{
/// <summary>
/// Gets or sets the array of JSON Web Keys.
/// </summary>
[JsonPropertyName("keys")]
public required JsonWebKey[] Keys { get; init; }
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net9.0;net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents an OAuth error response.
/// </summary>
internal sealed class OAuthErrorResponse
{
/// <summary>
/// Gets or sets the error code.
/// </summary>
[JsonPropertyName("error")]
public required string Error { get; init; }
/// <summary>
/// Gets or sets the error description.
/// </summary>
[JsonPropertyName("error_description")]
public required string ErrorDescription { get; init; }
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
[JsonSerializable(typeof(OAuthServerMetadata))]
[JsonSerializable(typeof(AuthorizationServerMetadata))]
[JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(JsonWebKeySet))]
[JsonSerializable(typeof(JsonWebKey))]
[JsonSerializable(typeof(TokenIntrospectionResponse))]
[JsonSerializable(typeof(OAuthErrorResponse))]
[JsonSerializable(typeof(ClientRegistrationRequest))]
[JsonSerializable(typeof(ClientRegistrationResponse))]
[JsonSerializable(typeof(Dictionary<string, string>))]
internal sealed partial class OAuthJsonContext : JsonSerializerContext;

View file

@ -0,0 +1,174 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents the OAuth 2.0 Authorization Server Metadata as defined in RFC 8414.
/// </summary>
internal sealed class OAuthServerMetadata
{
/// <summary>
/// Gets or sets the issuer URL.
/// REQUIRED. The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components.
/// </summary>
[JsonPropertyName("issuer")]
public required string Issuer { get; init; }
/// <summary>
/// Gets or sets the authorization endpoint URL.
/// URL of the authorization server's authorization endpoint. This is REQUIRED unless no grant types are supported that use the authorization endpoint.
/// </summary>
[JsonPropertyName("authorization_endpoint")]
public required string AuthorizationEndpoint { get; init; }
/// <summary>
/// Gets or sets the token endpoint URL.
/// URL of the authorization server's token endpoint. This is REQUIRED unless only the implicit grant type is supported.
/// </summary>
[JsonPropertyName("token_endpoint")]
public required string TokenEndpoint { get; init; }
/// <summary>
/// Gets or sets the JWKS URI.
/// OPTIONAL. URL of the authorization server's JWK Set document.
/// </summary>
[JsonPropertyName("jwks_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? JwksUri { get; init; }
/// <summary>
/// Gets or sets the registration endpoint URL for dynamic client registration.
/// OPTIONAL. URL of the authorization server's OAuth 2.0 Dynamic Client Registration endpoint.
/// </summary>
[JsonPropertyName("registration_endpoint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RegistrationEndpoint { get; init; }
/// <summary>
/// Gets or sets the scopes supported by this server.
/// RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that this server supports.
/// </summary>
[JsonPropertyName("scopes_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? ScopesSupported { get; init; }
/// <summary>
/// Gets or sets the response types supported by this server.
/// RECOMMENDED. JSON array containing a list of the OAuth 2.0 "response_type" values that this server supports.
/// </summary>
[JsonPropertyName("response_types_supported")]
public required List<string> ResponseTypesSupported { get; init; }
/// <summary>
/// Gets or sets the response modes supported by this server.
/// OPTIONAL. JSON array containing a list of the OAuth 2.0 "response_mode" values that this server supports.
/// </summary>
[JsonPropertyName("response_modes_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? ResponseModesSupported { get; init; }
/// <summary>
/// Gets or sets the grant types supported by this server.
/// OPTIONAL. JSON array containing a list of the OAuth 2.0 grant type values that this server supports.
/// </summary>
[JsonPropertyName("grant_types_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? GrantTypesSupported { get; init; }
/// <summary>
/// Gets or sets the token endpoint authentication methods supported by this server.
/// OPTIONAL. JSON array containing a list of client authentication methods supported by this token endpoint.
/// </summary>
[JsonPropertyName("token_endpoint_auth_methods_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? TokenEndpointAuthMethodsSupported { get; init; }
/// <summary>
/// Gets or sets the token endpoint authentication signing algorithms supported by this server.
/// OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the token endpoint.
/// </summary>
[JsonPropertyName("token_endpoint_auth_signing_alg_values_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? TokenEndpointAuthSigningAlgValuesSupported { get; init; }
/// <summary>
/// Gets or sets the introspection endpoint URL.
/// OPTIONAL. URL of the authorization server's OAuth 2.0 introspection endpoint.
/// </summary>
[JsonPropertyName("introspection_endpoint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? IntrospectionEndpoint { get; init; }
/// <summary>
/// Gets or sets the introspection endpoint authentication methods supported by this server.
/// OPTIONAL. JSON array containing a list of client authentication methods supported by this introspection endpoint.
/// </summary>
[JsonPropertyName("introspection_endpoint_auth_methods_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? IntrospectionEndpointAuthMethodsSupported { get; init; }
/// <summary>
/// Gets or sets the introspection endpoint authentication signing algorithms supported by this server.
/// OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the introspection endpoint.
/// </summary>
[JsonPropertyName("introspection_endpoint_auth_signing_alg_values_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? IntrospectionEndpointAuthSigningAlgValuesSupported { get; init; }
/// <summary>
/// Gets or sets the revocation endpoint URL.
/// OPTIONAL. URL of the authorization server's OAuth 2.0 revocation endpoint.
/// </summary>
[JsonPropertyName("revocation_endpoint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RevocationEndpoint { get; init; }
/// <summary>
/// Gets or sets the revocation endpoint authentication methods supported by this server.
/// OPTIONAL. JSON array containing a list of client authentication methods supported by this revocation endpoint.
/// </summary>
[JsonPropertyName("revocation_endpoint_auth_methods_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? RevocationEndpointAuthMethodsSupported { get; init; }
/// <summary>
/// Gets or sets the revocation endpoint authentication signing algorithms supported by this server.
/// OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the revocation endpoint.
/// </summary>
[JsonPropertyName("revocation_endpoint_auth_signing_alg_values_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? RevocationEndpointAuthSigningAlgValuesSupported { get; init; }
/// <summary>
/// Gets or sets the code challenge methods supported by this server.
/// OPTIONAL. JSON array containing a list of Proof Key for Code Exchange (PKCE) code challenge methods supported by this server.
/// </summary>
[JsonPropertyName("code_challenge_methods_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? CodeChallengeMethodsSupported { get; init; }
// OpenID Connect specific fields that are commonly included in OAuth metadata
/// <summary>
/// Gets or sets the subject types supported by this server.
/// REQUIRED for OpenID Connect. JSON array containing a list of the Subject Identifier types that this OP supports.
/// </summary>
[JsonPropertyName("subject_types_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? SubjectTypesSupported { get; init; }
/// <summary>
/// Gets or sets the ID token signing algorithms supported by this server.
/// REQUIRED for OpenID Connect. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for the ID Token.
/// </summary>
[JsonPropertyName("id_token_signing_alg_values_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? IdTokenSigningAlgValuesSupported { get; init; }
/// <summary>
/// Gets or sets the claims supported by this server.
/// RECOMMENDED for OpenID Connect. JSON array containing a list of the Claim Names of the Claims that the OpenID Provider MAY be able to supply values for.
/// </summary>
[JsonPropertyName("claims_supported")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? ClaimsSupported { get; init; }
}

View file

@ -0,0 +1,634 @@
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace ModelContextProtocol.TestOAuthServer;
public sealed class Program
{
private const int _port = 7029;
private static readonly string _url = $"https://localhost:{_port}";
// 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/"];
private readonly ConcurrentDictionary<string, AuthorizationCodeInfo> _authCodes = new();
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
private readonly ConcurrentDictionary<string, ClientInfo> _clients = new();
private readonly RSA _rsa;
private readonly string _keyId;
private readonly ILoggerProvider? _loggerProvider;
private readonly IConnectionListenerFactory? _kestrelTransport;
/// <summary>
/// Initializes a new instance of the <see cref="Program"/> class with logging and transport parameters.
/// </summary>
/// <param name="loggerProvider">Optional logger provider for logging.</param>
/// <param name="kestrelTransport">Optional Kestrel transport for in-memory connections.</param>
public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactory? kestrelTransport = null)
{
_rsa = RSA.Create(2048);
_keyId = Guid.NewGuid().ToString();
_loggerProvider = loggerProvider;
_kestrelTransport = kestrelTransport;
}
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
public bool HasIssuedExpiredToken { get; set; }
public bool HasIssuedRefreshToken { get; set; }
/// <summary>
/// Entry point for the application.
/// </summary>
/// <param name="args">Command line arguments.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static Task Main(string[] args) => new Program().RunServerAsync(args);
/// <summary>
/// Runs the OAuth server with the specified parameters.
/// </summary>
/// <param name="args">Command line arguments.</param>
/// <param name="cancellationToken">Cancellation token to stop the server.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task RunServerAsync(string[]? args = null, CancellationToken cancellationToken = default)
{
Console.WriteLine("Starting in-memory test-only OAuth Server...");
var builder = WebApplication.CreateEmptyBuilder(new()
{
Args = args,
});
if (_kestrelTransport is not null)
{
// Add passed-in transport before calling UseKestrel() to avoid the SocketsHttpHandler getting added.
builder.Services.AddSingleton(_kestrelTransport);
}
builder.WebHost.UseKestrel(kestrelOptions =>
{
kestrelOptions.ListenLocalhost(_port, listenOptions =>
{
listenOptions.UseHttps();
});
});
builder.Services.AddRoutingCore();
builder.Services.AddLogging();
builder.Services.ConfigureHttpJsonOptions(jsonOptions =>
{
jsonOptions.SerializerOptions.TypeInfoResolverChain.Add(OAuthJsonContext.Default);
});
builder.Logging.AddConsole();
if (_loggerProvider is not null)
{
builder.Logging.AddProvider(_loggerProvider);
}
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(_ => { });
// Set up the demo client
var clientId = "demo-client";
var clientSecret = "demo-secret";
_clients[clientId] = new ClientInfo
{
ClientId = clientId,
ClientSecret = clientSecret,
RedirectUris = ["http://localhost:1179/callback"],
};
// When this client ID is used, the first token issued will already be expired to make
// testing the refresh flow easier.
_clients["test-refresh-client"] = new ClientInfo
{
ClientId = "test-refresh-client",
ClientSecret = "test-refresh-secret",
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
//
// The requirements for these endpoints are at https://www.rfc-editor.org/rfc/rfc8414 and
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata respectively.
// They do differ, but it's close enough at least for our current testing to use the same response for both.
// See https://gist.github.com/localden/26d8bcf641703c08a5d8741aa9c3336c
string[] metadataEndpoints = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
foreach (var metadataEndpoint in metadataEndpoints)
{
// OAuth 2.0 Authorization Server Metadata (RFC 8414)
app.MapGet(metadataEndpoint, () =>
{
var metadata = new OAuthServerMetadata
{
Issuer = _url,
AuthorizationEndpoint = $"{_url}/authorize",
TokenEndpoint = $"{_url}/token",
JwksUri = $"{_url}/.well-known/jwks.json",
ResponseTypesSupported = ["code"],
SubjectTypesSupported = ["public"],
IdTokenSigningAlgValuesSupported = ["RS256"],
ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
CodeChallengeMethodsSupported = ["S256"],
GrantTypesSupported = ["authorization_code", "refresh_token"],
IntrospectionEndpoint = $"{_url}/introspect",
RegistrationEndpoint = $"{_url}/register"
};
return Results.Ok(metadata);
});
}
// JWKS endpoint to expose the public key
app.MapGet("/.well-known/jwks.json", () =>
{
var parameters = _rsa.ExportParameters(false);
// Convert parameters to base64url encoding
var e = WebEncoders.Base64UrlEncode(parameters.Exponent ?? Array.Empty<byte>());
var n = WebEncoders.Base64UrlEncode(parameters.Modulus ?? Array.Empty<byte>());
var jwks = new JsonWebKeySet
{
Keys = [
new JsonWebKey
{
KeyType = "RSA",
Use = "sig",
KeyId = _keyId,
Algorithm = "RS256",
Exponent = e,
Modulus = n
}
]
};
return Results.Ok(jwks);
});
// Authorize endpoint
app.MapGet("/authorize", (
[FromQuery] string client_id,
[FromQuery] string? redirect_uri,
[FromQuery] string response_type,
[FromQuery] string code_challenge,
[FromQuery] string code_challenge_method,
[FromQuery] string? scope,
[FromQuery] string? state,
[FromQuery] string? resource) =>
{
// Validate client
if (!_clients.TryGetValue(client_id, out var client))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Validate redirect_uri
if (string.IsNullOrEmpty(redirect_uri))
{
if (client.RedirectUris.Count == 1)
{
redirect_uri = client.RedirectUris[0];
}
else
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "redirect_uri is required when client has multiple registered URIs"
});
}
}
else if (!client.RedirectUris.Contains(redirect_uri))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Unregistered redirect_uri"
});
}
// Validate response_type
if (response_type != "code")
{
return Results.Redirect($"{redirect_uri}?error=unsupported_response_type&error_description=Only+code+response_type+is+supported&state={state}");
}
// Validate code challenge method
if (code_challenge_method != "S256")
{
return Results.Redirect($"{redirect_uri}?error=invalid_request&error_description=Only+S256+code_challenge_method+is+supported&state={state}");
}
// Validate resource in accordance with RFC 8707
if (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource))
{
return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}");
}
// Generate a new authorization code
var code = GenerateRandomToken();
var requestedScopes = scope?.Split(' ').ToList() ?? [];
// Store code information for later verification
_authCodes[code] = new AuthorizationCodeInfo
{
ClientId = client_id,
RedirectUri = redirect_uri,
CodeChallenge = code_challenge,
Scope = requestedScopes,
Resource = !string.IsNullOrEmpty(resource) ? new Uri(resource) : null
};
// Redirect back to client with the code
var redirectUrl = $"{redirect_uri}?code={code}";
if (!string.IsNullOrEmpty(state))
{
redirectUrl += $"&state={Uri.EscapeDataString(state)}";
}
return Results.Redirect(redirectUrl);
});
// Token endpoint
app.MapPost("/token", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
// Authenticate client
var client = AuthenticateClient(context, form);
if (client == null)
{
context.Response.StatusCode = 401;
return Results.Problem(
statusCode: 401,
title: "Unauthorized",
detail: "Invalid client credentials",
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
}
// Validate resource in accordance with RFC 8707
var resource = form["resource"].ToString();
if (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_target",
ErrorDescription = "The specified resource is not valid."
});
}
var grant_type = form["grant_type"].ToString();
if (grant_type == "authorization_code")
{
var code = form["code"].ToString();
var code_verifier = form["code_verifier"].ToString();
var redirect_uri = form["redirect_uri"].ToString();
// Validate code
if (string.IsNullOrEmpty(code) || !_authCodes.TryRemove(code, out var codeInfo))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid authorization code"
});
}
// Validate client_id
if (codeInfo.ClientId != client.ClientId)
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Authorization code was not issued to this client"
});
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirect_uri) && redirect_uri != codeInfo.RedirectUri)
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Redirect URI mismatch"
});
}
// Validate code verifier
if (string.IsNullOrEmpty(code_verifier) || !VerifyCodeChallenge(code_verifier, codeInfo.CodeChallenge))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Code verifier does not match the challenge"
});
}
// Generate JWT token response
var response = GenerateJwtTokenResponse(client.ClientId, codeInfo.Scope, codeInfo.Resource);
return Results.Ok(response);
}
else if (grant_type == "refresh_token")
{
var refresh_token = form["refresh_token"].ToString();
// Validate refresh token
if (string.IsNullOrEmpty(refresh_token) || !_tokens.TryGetValue(refresh_token, out var tokenInfo) || tokenInfo.ClientId != client.ClientId)
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token"
});
}
// Generate new token response, keeping the same scopes
var response = GenerateJwtTokenResponse(client.ClientId, tokenInfo.Scopes, tokenInfo.Resource);
// Remove the old refresh token
if (!string.IsNullOrEmpty(refresh_token))
{
_tokens.TryRemove(refresh_token, out _);
}
HasIssuedRefreshToken = true;
return Results.Ok(response);
}
else
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "unsupported_grant_type",
ErrorDescription = "Unsupported grant type"
});
}
});
// Introspection endpoint
app.MapPost("/introspect", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var token = form["token"].ToString();
if (string.IsNullOrEmpty(token))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Token is required"
});
}
// Check opaque access tokens
if (_tokens.TryGetValue(token, out var tokenInfo))
{
if (tokenInfo.ExpiresAt < DateTimeOffset.UtcNow)
{
return Results.Ok(new TokenIntrospectionResponse { Active = false });
}
return Results.Ok(new TokenIntrospectionResponse
{
Active = true,
ClientId = tokenInfo.ClientId,
Scope = string.Join(" ", tokenInfo.Scopes),
ExpirationTime = tokenInfo.ExpiresAt.ToUnixTimeSeconds(),
Audience = tokenInfo.Resource?.ToString()
});
}
return Results.Ok(new TokenIntrospectionResponse { Active = false });
});
// Dynamic Client Registration endpoint (RFC 7591)
app.MapPost("/register", async (HttpContext context) =>
{
using var stream = context.Request.Body;
var registrationRequest = await JsonSerializer.DeserializeAsync(
stream,
OAuthJsonContext.Default.ClientRegistrationRequest,
context.RequestAborted);
if (registrationRequest is null)
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid registration request"
});
}
// Validate redirect URIs are provided
if (registrationRequest.RedirectUris.Count == 0)
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_redirect_uri",
ErrorDescription = "At least one redirect URI must be provided"
});
}
// Validate redirect URIs
foreach (var redirectUri in registrationRequest.RedirectUris)
{
if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
return Results.BadRequest(new OAuthErrorResponse
{
Error = "invalid_redirect_uri",
ErrorDescription = $"Invalid redirect URI: {redirectUri}"
});
}
}
// Generate client credentials
var clientId = $"dyn-{Guid.NewGuid():N}";
var clientSecret = GenerateRandomToken();
var issuedAt = DateTimeOffset.UtcNow;
// Store the registered client
_clients[clientId] = new ClientInfo
{
ClientId = clientId,
ClientSecret = clientSecret,
RedirectUris = registrationRequest.RedirectUris,
};
var registrationResponse = new ClientRegistrationResponse
{
ClientId = clientId,
ClientSecret = clientSecret,
ClientIdIssuedAt = issuedAt.ToUnixTimeSeconds(),
RedirectUris = registrationRequest.RedirectUris,
GrantTypes = ["authorization_code", "refresh_token"],
ResponseTypes = ["code"],
TokenEndpointAuthMethod = "client_secret_post",
};
return Results.Ok(registrationResponse);
});
app.MapGet("/", () => "Demo In-Memory OAuth 2.0 Server with JWT Support");
Console.WriteLine($"OAuth Authorization Server running at {_url}");
Console.WriteLine($"OAuth Server Metadata at {_url}/.well-known/oauth-authorization-server");
Console.WriteLine($"JWT keys available at {_url}/.well-known/jwks.json");
Console.WriteLine($"Demo Client ID: {clientId}");
Console.WriteLine($"Demo Client Secret: {clientSecret}");
await app.RunAsync(cancellationToken);
}
/// <summary>
/// Authenticates a client based on client credentials in the request.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <param name="form">The form collection containing client credentials.</param>
/// <returns>The client info if authentication succeeds, null otherwise.</returns>
private ClientInfo? AuthenticateClient(HttpContext context, IFormCollection form)
{
var clientId = form["client_id"].ToString();
var clientSecret = form["client_secret"].ToString();
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
{
return null;
}
if (_clients.TryGetValue(clientId, out var client) && client.ClientSecret == clientSecret)
{
return client;
}
return null;
}
/// <summary>
/// Generates a JWT token response.
/// </summary>
/// <param name="clientId">The client ID.</param>
/// <param name="scopes">The approved scopes.</param>
/// <param name="resource">The resource URI.</param>
/// <returns>A token response.</returns>
private TokenResponse GenerateJwtTokenResponse(string clientId, List<string> scopes, Uri? resource)
{
var expiresIn = TimeSpan.FromHours(1);
var issuedAt = DateTimeOffset.UtcNow;
// For test-refresh-client, make the first token expired to test refresh functionality.
if (clientId == "test-refresh-client" && !HasIssuedExpiredToken)
{
HasIssuedExpiredToken = true;
expiresIn = TimeSpan.FromHours(-1);
}
var expiresAt = issuedAt.Add(expiresIn);
var jwtId = Guid.NewGuid().ToString();
// Create JWT header and payload
var header = new Dictionary<string, string>
{
{ "alg", "RS256" },
{ "typ", "JWT" },
{ "kid", _keyId }
};
var payload = new Dictionary<string, string>
{
{ "iss", _url },
{ "sub", $"user-{clientId}" },
{ "name", $"user-{clientId}" },
{ "aud", resource?.ToString() ?? clientId },
{ "client_id", clientId },
{ "jti", jwtId },
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
{ "scope", string.Join(" ", scopes) }
};
// Create JWT token
var headerJson = JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString);
var payloadJson = JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DictionaryStringString);
var headerBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
var dataToSign = $"{headerBase64}.{payloadBase64}";
var signature = _rsa.SignData(Encoding.UTF8.GetBytes(dataToSign), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var signatureBase64 = WebEncoders.Base64UrlEncode(signature);
var jwtToken = $"{headerBase64}.{payloadBase64}.{signatureBase64}";
// Generate opaque refresh token
var refreshToken = GenerateRandomToken();
// Store token info (for refresh token and introspection)
var tokenInfo = new TokenInfo
{
ClientId = clientId,
Scopes = scopes,
IssuedAt = issuedAt,
ExpiresAt = expiresAt,
Resource = resource,
JwtId = jwtId
};
_tokens[refreshToken] = tokenInfo;
return new TokenResponse
{
AccessToken = jwtToken,
RefreshToken = refreshToken,
TokenType = "Bearer",
ExpiresIn = (int)expiresIn.TotalSeconds,
Scope = string.Join(" ", scopes)
};
}
/// <summary>
/// Generates a random token for authorization code or refresh token.
/// </summary>
/// <returns>A Base64Url encoded random token.</returns>
public static string GenerateRandomToken()
{
var bytes = new byte[32];
Random.Shared.NextBytes(bytes);
return WebEncoders.Base64UrlEncode(bytes);
}
/// <summary>
/// Verifies a PKCE code challenge against a code verifier.
/// </summary>
/// <param name="codeVerifier">The code verifier to verify.</param>
/// <param name="codeChallenge">The code challenge to verify against.</param>
/// <returns>True if the code challenge is valid, false otherwise.</returns>
public static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge)
{
using var sha256 = SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
var computedChallenge = WebEncoders.Base64UrlEncode(challengeBytes);
return computedChallenge == codeChallenge;
}
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7029",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents token information for OAuth flow.
/// </summary>
internal sealed class TokenInfo
{
/// <summary>
/// Gets or sets the client ID associated with this token.
/// </summary>
public required string ClientId { get; init; }
/// <summary>
/// Gets or sets the list of scopes approved for this token.
/// </summary>
public List<string> Scopes { get; init; } = [];
/// <summary>
/// Gets or sets the issued time of this token.
/// </summary>
public required DateTimeOffset IssuedAt { get; init; }
/// <summary>
/// Gets or sets the expiration time of this token.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Gets or sets the optional resource URI this token is for.
/// </summary>
public Uri? Resource { get; init; }
/// <summary>
/// Gets or sets the JWT ID for this token.
/// </summary>
public string? JwtId { get; init; }
}

View file

@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents the response from the token introspection endpoint.
/// </summary>
internal sealed class TokenIntrospectionResponse
{
/// <summary>
/// Gets or sets a value indicating whether the token is active.
/// </summary>
[JsonPropertyName("active")]
public required bool Active { get; init; }
/// <summary>
/// Gets or sets the client ID associated with the token.
/// </summary>
[JsonPropertyName("client_id")]
public string? ClientId { get; init; }
/// <summary>
/// Gets or sets the scope of the token.
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; init; }
/// <summary>
/// Gets or sets the expiration timestamp of the token (Unix timestamp).
/// </summary>
[JsonPropertyName("exp")]
public long? ExpirationTime { get; init; }
/// <summary>
/// Gets or sets the audience of the token.
/// </summary>
[JsonPropertyName("aud")]
public string? Audience { get; init; }
}

View file

@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.TestOAuthServer;
/// <summary>
/// Represents the token response for OAuth flow.
/// </summary>
internal sealed class TokenResponse
{
/// <summary>
/// Gets or sets the access token.
/// </summary>
[JsonPropertyName("access_token")]
public required string AccessToken { get; init; }
/// <summary>
/// Gets or sets the token type.
/// </summary>
[JsonPropertyName("token_type")]
public required string TokenType { get; init; }
/// <summary>
/// Gets or sets the token expiration time in seconds.
/// </summary>
[JsonPropertyName("expires_in")]
public required int ExpiresIn { get; init; }
/// <summary>
/// Gets or sets the refresh token.
/// </summary>
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; init; }
/// <summary>
/// Gets or sets the scope approved for this token.
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; init; }
}