From 3356b00d71dd80f15ff4a4bd6369c2c21742c66f Mon Sep 17 00:00:00 2001 From: Manuel Naujoks Date: Thu, 10 Apr 2025 01:06:05 +0200 Subject: [PATCH] Prototypical sample project for authorization --- Directory.Packages.props | 4 + ModelContextProtocol.sln | 7 + .../.gitignore | 1 + .../AspNetCoreSseServerAuthPrototype.csproj | 21 + .../AspNetCoreSseServerAuthPrototype/OAuth.cs | 373 ++++++++++++++++++ .../Program.cs | 191 +++++++++ .../Properties/launchSettings.json | 23 ++ .../README.md | 18 + .../VibeTool.cs | 23 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + 11 files changed, 678 insertions(+) create mode 100644 samples/AspNetCoreSseServerAuthPrototype/.gitignore create mode 100644 samples/AspNetCoreSseServerAuthPrototype/AspNetCoreSseServerAuthPrototype.csproj create mode 100644 samples/AspNetCoreSseServerAuthPrototype/OAuth.cs create mode 100644 samples/AspNetCoreSseServerAuthPrototype/Program.cs create mode 100644 samples/AspNetCoreSseServerAuthPrototype/Properties/launchSettings.json create mode 100644 samples/AspNetCoreSseServerAuthPrototype/README.md create mode 100644 samples/AspNetCoreSseServerAuthPrototype/VibeTool.cs create mode 100644 samples/AspNetCoreSseServerAuthPrototype/appsettings.Development.json create mode 100644 samples/AspNetCoreSseServerAuthPrototype/appsettings.json diff --git a/Directory.Packages.props b/Directory.Packages.props index acdc0ee8..2244ada3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,5 +67,9 @@ + + + + \ No newline at end of file diff --git a/ModelContextProtocol.sln b/ModelContextProtocol.sln index 0e4fd721..7ca668e7 100644 --- a/ModelContextProtocol.sln +++ b/ModelContextProtocol.sln @@ -56,6 +56,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNet EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelContextProtocol.AspNetCore.Tests", "tests\ModelContextProtocol.AspNetCore.Tests\ModelContextProtocol.AspNetCore.Tests.csproj", "{85557BA6-3D29-4C95-A646-2A972B1C2F25}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreSseServerAuthPrototype", "samples\AspNetCoreSseServerAuthPrototype\AspNetCoreSseServerAuthPrototype.csproj", "{DBBBB4F8-3513-4FA3-95EA-AF885C30F152}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -110,6 +112,10 @@ Global {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Debug|Any CPU.Build.0 = Debug|Any CPU {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.ActiveCfg = Release|Any CPU {85557BA6-3D29-4C95-A646-2A972B1C2F25}.Release|Any CPU.Build.0 = Release|Any CPU + {DBBBB4F8-3513-4FA3-95EA-AF885C30F152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBBBB4F8-3513-4FA3-95EA-AF885C30F152}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBBBB4F8-3513-4FA3-95EA-AF885C30F152}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBBBB4F8-3513-4FA3-95EA-AF885C30F152}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -128,6 +134,7 @@ Global {17B8453F-AB72-99C5-E5EA-D0B065A6AE65} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {37B6A5E0-9995-497D-8B43-3BC6870CC716} = {A2F1F52A-9107-4BF8-8C3F-2F6670E7D0AD} {85557BA6-3D29-4C95-A646-2A972B1C2F25} = {2A77AF5C-138A-4EBB-9A13-9205DCD67928} + {DBBBB4F8-3513-4FA3-95EA-AF885C30F152} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {384A3888-751F-4D75-9AE5-587330582D89} diff --git a/samples/AspNetCoreSseServerAuthPrototype/.gitignore b/samples/AspNetCoreSseServerAuthPrototype/.gitignore new file mode 100644 index 00000000..b58b9e17 --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/.gitignore @@ -0,0 +1 @@ +/devkey.key diff --git a/samples/AspNetCoreSseServerAuthPrototype/AspNetCoreSseServerAuthPrototype.csproj b/samples/AspNetCoreSseServerAuthPrototype/AspNetCoreSseServerAuthPrototype.csproj new file mode 100644 index 00000000..03ebc365 --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/AspNetCoreSseServerAuthPrototype.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/samples/AspNetCoreSseServerAuthPrototype/OAuth.cs b/samples/AspNetCoreSseServerAuthPrototype/OAuth.cs new file mode 100644 index 00000000..d308e3a9 --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/OAuth.cs @@ -0,0 +1,373 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; + +namespace AspNetCoreSseServerAuthPrototype +{ + /// + /// Experimental implementation of OAuth endpoints for MCP servers. + /// + public static class OAuth + { + public class Options + { + /// + /// The audience for the issued JWT token. + /// + public string? Audience { get; set; } + } + + public interface IKeyProvider + { + /// + /// Get the signing key ti sign the issued JWT token. + /// + Task GetSigningKey(); + } + + public interface IClientRepository + { + /// + /// Remember a newly registered client. + /// + Task Register(string clientId, ClientRegistration clientRegistration); + + /// + /// Recalla registered client. + /// + Task Get(string clientId); + } + + public class ClientRegistration + { + [JsonPropertyName("redirect_uris")] + public string[] RedirectUris { get; set; } = null!; + [JsonPropertyName("grant_types")] + public string[] GrantTypes { get; set; } = null!; + [JsonPropertyName("response_types")] + public string[] ResponseTypes { get; set; } = null!; + [JsonPropertyName("client_name")] + public string ClientName { get; set; } = null!; + [JsonPropertyName("client_uri")] + public string ClientUri { get; set; } = null!; + [JsonPropertyName("token_endpoint_auth_method")] + public string TokenEndpointAuthMethod { get; set; } = null!; + [JsonPropertyName("client_secret")] + public string? ClientSecret { get; set; } + [JsonPropertyName("scopes")] + public string[]? Scopes { get; set; } + } + + class AuthCode + { + public string UserId { get; set; } = null!; + public string? UserName { get; set; } + public string ClientId { get; set; } = null!; + public string[]? Scopes { get; set; } + public string RedirectUri { get; set; } = null!; + public string CodeChallenge { get; set; } = null!; + public string CodeChallengeMethod { get; set; } = null!; + public DateTime Expiry { get; set; } + } + + /// + /// Register reguired services for OAuth endpoints. + /// + public static IServiceCollection AddOAuth(this IServiceCollection services, Action optionsConfiguration) + where TSigningKeyProvider : class, IKeyProvider + where TClientRepository : class, IClientRepository + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.Configure(optionsConfiguration); + + return services; + } + + /// + /// Map OAuth endpoints. + /// + public static IEndpointConventionBuilder MapOAuth(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") + { + var routePathPrefix = !string.IsNullOrEmpty(pattern) ? $"/{pattern}" : string.Empty; + var supported = new + { + ResponseTypesSupported = new[] { "code" }, + ResponseModesSupported = new[] { "query" }, + GrantTypesSupported = new[] { "authorization_code", "refresh_token" }, + TokenEndpointAuthMethodsSupported = new[] { "client_secret_post", "none" }, + CodeChallengeMethodsSupported = new[] { "S256" }, + }; + + endpoints.MapGet("/.well-known/oauth-authorization-server", (HttpRequest request) => + { + var iss = new Uri($"{request.Scheme}://{request.Host}").AbsoluteUri.TrimEnd('/'); + return Results.Ok(new + { + issuer = iss, + authorization_endpoint = $"{iss}{routePathPrefix}/authorize", + token_endpoint = $"{iss}{routePathPrefix}/token", + registration_endpoint = $"{iss}{routePathPrefix}/register", + //revocation_endpoint = $"{iss}{pathPrefix}/token", + response_types_supported = supported.ResponseTypesSupported, + response_modes_supported = supported.ResponseModesSupported, + grant_types_supported = supported.GrantTypesSupported, + token_endpoint_auth_methods_supported = supported.TokenEndpointAuthMethodsSupported, + code_challenge_methods_supported = supported.CodeChallengeMethodsSupported, + }); + }); + + var routeGroup = endpoints.MapGroup(pattern); + + routeGroup.MapPost("/register", async ([FromBody] ClientRegistration clientRegistration, IClientRepository clientRepository) => + { + var client_id = Guid.NewGuid().ToString(); + + if (string.IsNullOrEmpty(clientRegistration.ClientName)) + { + return Results.BadRequest(new { error = "invalid_request", error_description = "Client name is required" }); + } + + if (clientRegistration.ResponseTypes.Intersect(supported.ResponseTypesSupported).Count() != clientRegistration.ResponseTypes.Length) + { + return Results.BadRequest(new { error = "invalid_request", error_description = "Invalid response types" }); + } + + if (clientRegistration.GrantTypes.Intersect(supported.GrantTypesSupported).Count() != clientRegistration.GrantTypes.Length) + { + return Results.BadRequest(new { error = "invalid_request", error_description = "Invalid grant types" }); + } + + if (!supported.TokenEndpointAuthMethodsSupported.Contains(clientRegistration.TokenEndpointAuthMethod)) + { + return Results.BadRequest(new { error = "invalid_request", error_description = "Invalid token endpoint auth" }); + } + + await clientRepository.Register(client_id, clientRegistration); + + var createdAt = $"{routePathPrefix}/register/{client_id}"; + return Results.Created(createdAt, new + { + client_id, + redirect_uris = clientRegistration.RedirectUris, + client_name = clientRegistration.ClientName, + client_uri = clientRegistration.ClientUri, + grant_types = clientRegistration.GrantTypes, + response_types = clientRegistration.ResponseTypes, + token_endpoint_auth_method = clientRegistration.TokenEndpointAuthMethod, + registration_client_uri = createdAt, + client_id_issued_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + }); + }); + + routeGroup.MapGet("/authorize", async ( + HttpRequest request, + IDataProtectionProvider dataProtectionProvider, + IOptions options, + IClientRepository clientRepository) => + { + var iss = new Uri($"{request.Scheme}://{request.Host}").AbsoluteUri.TrimEnd('/'); + request.Query.TryGetValue("state", out var state); + + if (!request.Query.TryGetValue("client_id", out var clientId)) + { + return Results.BadRequest(new { error = "unauthorized_client", state, iss, }); + } + + var client = await clientRepository.Get(clientId.ToString()); + if (client is null) + { + return Results.BadRequest(new { error = "unauthorized_client", state, iss, }); + } + + if (!request.Query.TryGetValue("response_type", out var responseType) || !client.ResponseTypes.Contains(responseType.ToString())) + { + return Results.BadRequest(new { error = "invalid_request", state, iss, }); + } + + request.Query.TryGetValue("code_challenge", out var codeChallenge); + + if (!request.Query.TryGetValue("code_challenge_method", out var codeChallengeMethod) || !supported.CodeChallengeMethodsSupported.Contains(codeChallengeMethod.ToString())) + { + return Results.BadRequest(new { error = "invalid_request", state, iss, }); + } + + request.Query.TryGetValue("redirect_uri", out var redirectUri); + + var requestScopes = default(string[]?); + if (request.Query.TryGetValue("scope", out var scope)) + { + var scopes = scope.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (scopes.Intersect(client.Scopes ?? []).Count() != scopes.Length) + { + return Results.BadRequest(new { error = "invalid_scope", state, iss, }); + } + + var userScopes = request.HttpContext.User.Claims + .Where(c => c.Type == "scope") + .Select(c => c.Value) + .ToList(); + + requestScopes = [.. scopes.Where(userScopes.Contains)]; + } + + var protector = dataProtectionProvider.CreateProtector("oauth"); + var authCode = new AuthCode + { + UserId = request.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)!, + UserName = request.HttpContext.User.FindFirstValue("name"), + ClientId = clientId!, + Scopes = requestScopes, + RedirectUri = redirectUri!, + CodeChallenge = codeChallenge!, + CodeChallengeMethod = codeChallengeMethod!, + Expiry = DateTime.UtcNow.AddMinutes(5) + }; + var code = protector.Protect(JsonSerializer.Serialize(authCode)); + return Results.Redirect($"{redirectUri}?code={code}&state={state}&iss={HttpUtility.UrlEncode(iss)}"); + }).RequireAuthorization(); + + routeGroup.MapPost("/token", async ( + HttpRequest request, + IDataProtectionProvider dataProtectionProvider, + IOptions options, + IClientRepository clientRepository, + IKeyProvider keyProvider) => + { + var bodyBytes = await request.BodyReader.ReadAsync(); + var bodyContent = Encoding.UTF8.GetString(bodyBytes.Buffer); + request.BodyReader.AdvanceTo(bodyBytes.Buffer.End); + + string grantType = "", code = "", redirectUri = "", codeVerifier = "", clientId = "", clientSecret = "", refreshToken = ""; + foreach (var part in bodyContent.Split('&')) + { + var subParts = part.Split('='); + var key = subParts[0]; + var value = subParts[1]; + if (key == "grant_type") grantType = value; + else if (key == "code") code = value; + else if (key == "redirect_uri") redirectUri = HttpUtility.UrlDecode(value); + else if (key == "code_verifier") codeVerifier = HttpUtility.UrlDecode(value); + else if (key == "client_id") clientId = value; + else if (key == "client_secret") clientSecret = value; + else if (key == "refresh_token") refreshToken = value; + } + + var client = await clientRepository.Get(clientId); + if (client is null) + { + return Results.BadRequest(new { error = "invalid_client", error_description = "Invalid client id" }); + } + + if (client.TokenEndpointAuthMethod == "client_secret_post") + { + if (clientSecret != client.ClientSecret) + { + return Results.BadRequest(new { error = "invalid_client", error_description = "Invalid client secret" }); + } + } + else if (client.TokenEndpointAuthMethod == "none") + { + if (!string.IsNullOrEmpty(clientSecret)) + { + return Results.BadRequest(new { error = "invalid_client", error_description = "Client secret not allowed" }); + } + } + else + { + return Results.BadRequest(new { error = "invalid_client", error_description = "Invalid client auth method" }); + } + + + if (string.IsNullOrEmpty(grantType) || !client.GrantTypes.Contains(grantType)) + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Invalid grant type" }); + } + + string userId; + string userName; + string[] scopes; + if (grantType == "authorization_code") + { + if (code == string.Empty) + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Authorization code missing" }); + } + + var protector = dataProtectionProvider.CreateProtector("oauth"); + var codeString = protector.Unprotect(code); + var authCode = JsonSerializer.Deserialize(codeString); + + if (authCode == null) + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Authorization code missing" }); + } + + if (authCode.Expiry < DateTime.UtcNow) + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Authorization code expired" }); + } + + if (authCode.RedirectUri != redirectUri) + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Invalid redirect uri" }); + } + + using var sha256 = SHA256.Create(); + var codeChallenge = Base64UrlEncoder.Encode(sha256.ComputeHash(Encoding.ASCII.GetBytes(codeVerifier))); + if (authCode.CodeChallenge != codeChallenge) + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Invalid code verifier" }); + } + + userId = authCode.UserId; + userName = authCode.UserName ?? string.Empty; + scopes = authCode.Scopes ?? []; + } + else if (grantType == "refresh_token") + { + throw new NotImplementedException(); + } + else + { + return Results.BadRequest(new { error = "invalid_grant", error_description = "Invalid grant type" }); + } + + var handler = new JsonWebTokenHandler(); + var iss = new Uri($"{request.Scheme}://{request.Host}").AbsoluteUri.TrimEnd('/'); + var accessToken = handler.CreateToken(new SecurityTokenDescriptor + { + Issuer = iss, + Subject = new ClaimsIdentity( + [ + new Claim("sub", userId), + new Claim("name", userName), + ..scopes.Select(s => new Claim("scope", s)), + ]), + Audience = options.Value.Audience, + Expires = DateTime.UtcNow.AddMinutes(5), + TokenType = "Bearer", + SigningCredentials = new SigningCredentials(await keyProvider.GetSigningKey(), SecurityAlgorithms.RsaSha256), + }); + return Results.Ok(new + { + access_token = accessToken, + token_type = "Bearer", + //refresh_token = "", + }); + }); + + return routeGroup; + } + } +} \ No newline at end of file diff --git a/samples/AspNetCoreSseServerAuthPrototype/Program.cs b/samples/AspNetCoreSseServerAuthPrototype/Program.cs new file mode 100644 index 00000000..e78fcb8b --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/Program.cs @@ -0,0 +1,191 @@ +using AspNetCoreSseServerAuthPrototype; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Security.Cryptography; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMcpServer().WithToolsFromAssembly(); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddAuthentication(config => +{ + config.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; + config.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + config.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; +}) + //This is the cookie we are going to use for the OIDC authentication flow. + .AddCookie(options => + { + options.Cookie.Name = "MyMCPServer.Sse.Session"; + }) + //This is the remote OIDC authentication the MCP server should use. + .AddOpenIdConnect(o => + { + o.Authority = "https://localhost:5001"; + o.ClientId = "mcp_server"; + o.ClientSecret = "secret"; + o.ResponseType = OpenIdConnectResponseType.Code; + o.Scope.Add("openid"); + o.Scope.Add("profile"); + o.Scope.Add("api"); + o.SaveTokens = true; + o.Events.OnTicketReceived = ctx => + { + // Add the access token claims to the cookie + var accesstoken = ctx.Properties?.GetTokenValue("access_token"); + var handler = new JsonWebTokenHandler(); + var token = handler.ReadJsonWebToken(accesstoken); + ctx.Principal!.AddIdentity(new ClaimsIdentity(token.Claims, OpenIdConnectDefaults.AuthenticationScheme)); + + // Remove the access token from the cookie + ctx.Properties?.GetTokens().ToList().ForEach(token => ctx.Properties.UpdateTokenValue(token.Name, string.Empty)); + + return Task.CompletedTask; + }; + }) + //This is the bearer authentication we are going to require for the MCP endpoints. + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false,//We dont want to provide a valid issuer URL here, which is changing based on local config. Validating the issuer signing key is enough. + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidAudience = "mcp_server", + }; + }); + +builder.Services.AddAuthorization(options => +{ + //With this policy we are going to require bearer authentication for the MCP endpoints. + options.AddPolicy(JwtBearerDefaults.AuthenticationScheme, policy => + { + policy.AuthenticationSchemes = [JwtBearerDefaults.AuthenticationScheme]; + policy.RequireAuthenticatedUser(); + }); +}); + +//Lets configure MCP OAuth by providing the token signing key, a client repository for dynamic registration, and the JWT audience. +builder.Services.AddOAuth(options => +{ + options.Audience = "mcp_server"; +}); + +//We need to configure the signing key for the JWT bearer authentication. +builder.Services.AddSingleton, JwtBearerOptionsSigningKeyConfiguration>(); + + + + + + + +var app = builder.Build(); + +app.UseCors(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/", () => "Hello MCP!"); +app.MapMcp().RequireAuthorization(JwtBearerDefaults.AuthenticationScheme); +app.MapOAuth(); + +app.Run(); + + + + + + + + +class ClientRespository : OAuth.IClientRepository +{ + readonly ConcurrentDictionary clientRegistrations; + + public ClientRespository() + { + clientRegistrations = new ConcurrentDictionary(StringComparer.Ordinal); + } + + public Task Get(string clientId) + { + return clientRegistrations.TryGetValue(clientId, out var clientRegistration) + ? Task.FromResult(clientRegistration) + : Task.FromResult(null); + } + + public Task Register(string clientId, OAuth.ClientRegistration clientRegistration) + { + clientRegistrations.AddOrUpdate(clientId, clientRegistration, (key, oldValue) => clientRegistration); + return Task.CompletedTask; + } +} + +class SigningKey : OAuth.IKeyProvider +{ + public SigningKey(IWebHostEnvironment env) + { + var rsaKey = RSA.Create(); + var path = Path.Combine(env.ContentRootPath, "devkey.key"); + if (File.Exists(path)) + { + rsaKey.ImportRSAPrivateKey(File.ReadAllBytes(path), out _); + } + else + { + var privateKey = rsaKey.ExportRSAPrivateKey(); + File.WriteAllBytes(path, privateKey); + } + + this.SecurityKey = new RsaSecurityKey(rsaKey); + } + + public SecurityKey SecurityKey { get; } + + public Task GetSigningKey() + { + return Task.FromResult(this.SecurityKey); + } + + public void PostConfigure(string? name, JwtBearerOptions options) + { + options.TokenValidationParameters.IssuerSigningKey = SecurityKey; + } +} + +class JwtBearerOptionsSigningKeyConfiguration : IPostConfigureOptions +{ + private readonly OAuth.IKeyProvider keyProvider; + + public JwtBearerOptionsSigningKeyConfiguration(OAuth.IKeyProvider keyProvider) + { + this.keyProvider = keyProvider; + } + + public void PostConfigure(string? name, JwtBearerOptions options) + { + options.TokenValidationParameters.IssuerSigningKey = keyProvider.GetSigningKey().Result; + } +} \ No newline at end of file diff --git a/samples/AspNetCoreSseServerAuthPrototype/Properties/launchSettings.json b/samples/AspNetCoreSseServerAuthPrototype/Properties/launchSettings.json new file mode 100644 index 00000000..5efd4e62 --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5235", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7214;http://localhost:5235", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/AspNetCoreSseServerAuthPrototype/README.md b/samples/AspNetCoreSseServerAuthPrototype/README.md new file mode 100644 index 00000000..665dc10b --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/README.md @@ -0,0 +1,18 @@ +# AspNetCoreSseServerAuthPrototype + +## Usage + +Start the inspector. + +```powershell +npx @modelcontextprotocol/inspector +``` + +Run the web app and copy the non-https address () into the inspectors URL field. Make sure to keep the `/sse` path. + +Let the inspector connect. + +## Limitations + +- Not 100% OAuth compatible yet. +- No support for refresh tokens yet. diff --git a/samples/AspNetCoreSseServerAuthPrototype/VibeTool.cs b/samples/AspNetCoreSseServerAuthPrototype/VibeTool.cs new file mode 100644 index 00000000..066dfbd2 --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/VibeTool.cs @@ -0,0 +1,23 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace AspNetCoreSseServerAuthPrototype +{ + [McpServerToolType] + public class VibeTool + { + private readonly ILogger logger; + + public VibeTool(ILogger logger) + { + this.logger = logger; + } + + [McpServerTool, Description("Gets the vibe in the provided location.")] + public string GetVibe(string location) + { + this.logger.LogInformation("Getting vibe in {location}.", location); + return $"Curious vibes in {location}."; + } + } +} diff --git a/samples/AspNetCoreSseServerAuthPrototype/appsettings.Development.json b/samples/AspNetCoreSseServerAuthPrototype/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/AspNetCoreSseServerAuthPrototype/appsettings.json b/samples/AspNetCoreSseServerAuthPrototype/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/AspNetCoreSseServerAuthPrototype/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}