using System.Buffers.Text; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Nostos.Backend.Data; using Nostos.Backend.Integrations.Mcp; using Nostos.Backend.Services.Mcp; using Nostos.Backend.Services.Mcp.OAuth; using Nostos.Backend.Tests.Support; using Xunit; namespace Nostos.Backend.Tests.Mcp; /// /// The SelfHosted host with Mcp:PublicBaseUrl set (issue #1470). The /// one stand-in is the client metadata fetch, which would need the internet; /// its limits are covered by . /// public sealed class McpOAuthFactory : LibraryEndpointFactory { public const string PublicBase = "https://nostos.example.ts.net"; public const string ClaudeClientId = "https://claude.ai/oauth/mcp-oauth-client-metadata"; public const string ClaudeRedirect = "https://claude.ai/api/mcp/auth_callback"; protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.UseSetting(McpOAuthSettings.ConfigurationKey, PublicBase); builder.ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(new KnownClients( new McpClientMetadata(ClaudeClientId, "Claude", [ClaudeRedirect]))); }); } private sealed class KnownClients(params McpClientMetadata[] clients) : IMcpClientMetadataFetcher { public Task FetchAsync(string clientId, CancellationToken cancellationToken) => Task.FromResult(clients.FirstOrDefault(c => c.ClientId == clientId) is { } client ? new McpClientMetadataResult(client, null) : McpClientMetadataResult.Fail("The app's address answered 404.")); } } public sealed class McpOAuthTests : IAsyncLifetime { private const string Resource = McpOAuthFactory.PublicBase + "/mcp"; private readonly McpOAuthFactory _factory = new(); /// The owner, on the private network. private HttpClient _local = null!; /// The app, through the tunnel: requests carry the public host. private HttpClient _public = null!; public async Task InitializeAsync() { _local = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); _public = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri(McpOAuthFactory.PublicBase), }); (await _local.GetAsync("/health/live")).EnsureSuccessStatusCode(); } public async Task DisposeAsync() { _local.Dispose(); _public.Dispose(); await _factory.DisposeAsync(); } // --- metadata ------------------------------------------------------- [Theory] [InlineData("/.well-known/oauth-protected-resource")] [InlineData("/.well-known/oauth-protected-resource/mcp")] public async Task The_protected_resource_metadata_names_the_public_mcp_url(string path) { var prm = await _public.GetFromJsonAsync(path); prm.GetProperty("resource").GetString().Should().Be(Resource); prm.GetProperty("authorization_servers").EnumerateArray().Select(e => e.GetString()).Should().Equal(McpOAuthFactory.PublicBase); prm.GetProperty("scopes_supported").EnumerateArray().Select(e => e.GetString()).Should().Equal("nostos:read", "nostos:write"); prm.GetProperty("bearer_methods_supported").EnumerateArray().Select(e => e.GetString()).Should().Equal("header"); } [Fact] public async Task The_authorization_server_metadata_offers_code_pkce_and_cimd_only() { var meta = await _public.GetFromJsonAsync("/.well-known/oauth-authorization-server"); meta.GetProperty("issuer").GetString().Should().Be(McpOAuthFactory.PublicBase); meta.GetProperty("authorization_endpoint").GetString().Should().Be(McpOAuthFactory.PublicBase + "/oauth/authorize"); meta.GetProperty("token_endpoint").GetString().Should().Be(McpOAuthFactory.PublicBase + "/oauth/token"); meta.GetProperty("revocation_endpoint").GetString().Should().Be(McpOAuthFactory.PublicBase + "/oauth/revoke"); Strings(meta, "response_types_supported").Should().Equal("code"); Strings(meta, "grant_types_supported").Should().Equal("authorization_code", "refresh_token"); Strings(meta, "code_challenge_methods_supported").Should().Equal("S256"); Strings(meta, "token_endpoint_auth_methods_supported").Should().Equal("none"); Strings(meta, "scopes_supported").Should().Equal("nostos:read", "nostos:write"); meta.GetProperty("client_id_metadata_document_supported").GetBoolean().Should().BeTrue(); meta.TryGetProperty("registration_endpoint", out _).Should().BeFalse(); } [Fact] public async Task A_401_on_mcp_points_at_the_protected_resource_metadata() { var none = await ListToolsRawAsync(_public, null); var wrong = await ListToolsRawAsync(_public, "nsa_" + new string('A', 43)); none.StatusCode.Should().Be(HttpStatusCode.Unauthorized); var challenge = none.Headers.WwwAuthenticate.ToString(); challenge.Should().Contain($"resource_metadata=\"{McpOAuthFactory.PublicBase}/.well-known/oauth-protected-resource/mcp\""); challenge.Should().Contain("scope=\"nostos:read nostos:write\""); wrong.StatusCode.Should().Be(HttpStatusCode.Unauthorized); wrong.Headers.WwwAuthenticate.ToString().Should().Contain("invalid_token").And.Contain("resource_metadata="); } // --- the whole flow ------------------------------------------------- [Fact] public async Task Claude_connects_after_the_owner_approves_and_appears_in_connected_apps() { var bookId = await CreateBookAsync("Moby-Dick"); var verifier = Verifier(); var page = await AuthorizeAsync(Challenge(verifier), state: "xyz"); page.StatusCode.Should().Be(HttpStatusCode.OK); page.Content.Headers.ContentType!.MediaType.Should().Be("text/html"); page.Headers.GetValues("Content-Security-Policy").Single().Should().Contain("default-src 'none'"); var html = await page.Content.ReadAsStringAsync(); html.Should().Contain("Claude wants to connect to your Nostos").And.Contain("Settings → Connected apps"); var pending = await PendingAsync(); var request = pending.Single(); html.Should().Contain(request.GetProperty("code").GetString()); request.GetProperty("code").GetString().Should().MatchRegex("^[A-Z]{4}$"); request.GetProperty("clientName").GetString().Should().Be("Claude"); request.GetProperty("clientHost").GetString().Should().Be("claude.ai"); request.GetProperty("redirectHost").GetString().Should().Be("claude.ai"); request.GetProperty("requestedScope").GetString().Should().Be("read_write"); (await PollAsync(RequestId(html))).GetProperty("status").GetString().Should().Be("pending"); (await _local.PostAsJsonAsync($"/api/mcp/oauth/requests/{request.GetProperty("id").GetString()}/approve", new { scope = "read_write" })) .StatusCode.Should().Be(HttpStatusCode.NoContent); var redirect = new Uri((await PollAsync(RequestId(html))).GetProperty("redirect").GetString()!); (await PendingAsync()).Should().BeEmpty(); redirect.GetLeftPart(UriPartial.Path).Should().Be(McpOAuthFactory.ClaudeRedirect); var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(redirect.Query); query["state"].ToString().Should().Be("xyz"); query["iss"].ToString().Should().Be(McpOAuthFactory.PublicBase); var token = await TokenAsync(new() { ["grant_type"] = "authorization_code", ["code"] = query["code"].ToString(), ["redirect_uri"] = McpOAuthFactory.ClaudeRedirect, ["client_id"] = McpOAuthFactory.ClaudeClientId, ["code_verifier"] = verifier, ["resource"] = Resource, }); token.StatusCode.Should().Be(HttpStatusCode.OK); token.Headers.CacheControl!.NoStore.Should().BeTrue(); var body = await token.Content.ReadFromJsonAsync(); body.GetProperty("token_type").GetString().Should().Be("Bearer"); body.GetProperty("expires_in").GetInt32().Should().Be(3600); body.GetProperty("scope").GetString().Should().Be("nostos:read nostos:write"); var access = body.GetProperty("access_token").GetString()!; access.Should().StartWith("nsa_"); body.GetProperty("refresh_token").GetString().Should().StartWith("nsr_"); await using (var mcp = await ConnectAsync(access)) { var save = await mcp.CallToolAsync("save_note", new Dictionary { ["bookId"] = bookId, ["thought"] = "Call me Ishmael.", }); save.IsError.Should().NotBe(true, Text(save)); // The public address serves no app pages: no link may point there. var book = await mcp.CallToolAsync("get_book", new Dictionary { ["bookId"] = bookId }); book.IsError.Should().NotBe(true, Text(book)); Text(book).Should().NotContain(McpOAuthFactory.PublicBase).And.Contain($"/library/{bookId:D}"); } var apps = (await _local.GetFromJsonAsync(McpConnectedAppsEndpoints.Route)).GetProperty("apps"); var app = apps.EnumerateArray().Single(); app.GetProperty("kind").GetString().Should().Be("oauth_app"); app.GetProperty("name").GetString().Should().Be("Claude"); app.GetProperty("scope").GetString().Should().Be("read_write"); app.GetProperty("lastUsedAt").ValueKind.Should().NotBe(JsonValueKind.Null); apps.GetRawText().Should().NotContain(access); await using var db = await Contexts().CreateDbContextAsync(); (await db.McpOAuthTokens.AnyAsync(t => t.TokenHash == McpAccessTokenService.Hash(access))).Should().BeTrue(); (await db.McpOAuthTokens.Select(t => t.TokenHash).ToListAsync()).Should().NotContain(access); } [Fact] public async Task A_read_only_approval_is_refused_on_write_tools() { var bookId = await CreateBookAsync("Walden"); var (access, _) = await ConnectClaudeAsync(approveScope: "read"); await using var mcp = await ConnectAsync(access); var save = await mcp.CallToolAsync("save_note", new Dictionary { ["bookId"] = bookId, ["thought"] = "Simplify.", }); var read = await mcp.CallToolAsync("list_books", new Dictionary()); save.IsError.Should().BeTrue(); Text(save).Should().Be(McpScopes.ReadOnlyRefusal); read.IsError.Should().NotBe(true, Text(read)); await using var db = await Contexts().CreateDbContextAsync(); (await db.Notes.CountAsync()).Should().Be(0); } [Fact] public async Task A_denied_request_sends_the_app_back_with_access_denied() { var html = await (await AuthorizeAsync(Challenge(Verifier()), state: "s1")).Content.ReadAsStringAsync(); var id = (await PendingAsync()).Single().GetProperty("id").GetString(); (await _local.PostAsJsonAsync($"/api/mcp/oauth/requests/{id}/deny", new { })).StatusCode.Should().Be(HttpStatusCode.NoContent); var redirect = new Uri((await PollAsync(RequestId(html))).GetProperty("redirect").GetString()!); var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(redirect.Query); query["error"].ToString().Should().Be("access_denied"); query["state"].ToString().Should().Be("s1"); query.ContainsKey("code").Should().BeFalse(); (await _local.PostAsJsonAsync($"/api/mcp/oauth/requests/{id}/approve", new { scope = "read" })) .StatusCode.Should().Be(HttpStatusCode.NotFound); } // --- authorize errors --------------------------------------------- [Fact] public async Task A_redirect_uri_the_client_does_not_list_gets_an_error_page_and_no_redirect() { var response = await _public.GetAsync(AuthorizeUrl(Challenge(Verifier()), redirectUri: "https://evil.example/callback")); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.Headers.Location.Should().BeNull(); (await response.Content.ReadAsStringAsync()).Should().Contain("cannot connect"); (await PendingAsync()).Should().BeEmpty(); } [Fact] public async Task An_unknown_client_gets_an_error_page_and_no_redirect() { var response = await _public.GetAsync(AuthorizeUrl(Challenge(Verifier()), clientId: "https://unknown.example/client.json")); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.Headers.Location.Should().BeNull(); } [Theory] [InlineData("code_challenge_method=plain", "invalid_request")] [InlineData("no_challenge", "invalid_request")] [InlineData("response_type=token", "unsupported_response_type")] [InlineData("resource=https://elsewhere.example/mcp", "invalid_target")] [InlineData("scope=openid", "invalid_scope")] public async Task A_bad_authorization_request_is_sent_back_with_an_error(string variant, string error) { var url = AuthorizeUrl(Challenge(Verifier()), state: "st"); url = variant switch { "no_challenge" => Regex.Replace(url, "&code_challenge=[^&]*", ""), "code_challenge_method=plain" => url.Replace("code_challenge_method=S256", "code_challenge_method=plain"), "response_type=token" => url.Replace("response_type=code", "response_type=token"), "resource=https://elsewhere.example/mcp" => Regex.Replace(url, "&resource=[^&]*", "&resource=" + Uri.EscapeDataString("https://elsewhere.example/mcp")), "scope=openid" => Regex.Replace(url, "&scope=[^&]*", "&scope=openid"), _ => url, }; var response = await _public.GetAsync(url); response.StatusCode.Should().Be(HttpStatusCode.Redirect); var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(response.Headers.Location!.Query); response.Headers.Location!.GetLeftPart(UriPartial.Path).Should().Be(McpOAuthFactory.ClaudeRedirect); query["error"].ToString().Should().Be(error); query["state"].ToString().Should().Be("st"); (await PendingAsync()).Should().BeEmpty(); } // --- token errors --------------------------------------------------- [Fact] public async Task A_wrong_code_verifier_fails_and_leaves_the_code_for_the_real_client() { var verifier = Verifier(); var code = await ApprovedCodeAsync(Challenge(verifier), "read_write"); var wrong = await RedeemAsync(code, Verifier()); var right = await RedeemAsync(code, verifier); await ExpectOAuthErrorAsync(wrong, "invalid_grant"); right.StatusCode.Should().Be(HttpStatusCode.OK); await using var db = await Contexts().CreateDbContextAsync(); (await db.McpOAuthGrants.CountAsync()).Should().Be(1); } [Fact] public async Task A_wrong_redirect_uri_or_client_at_the_token_endpoint_fails() { var verifier = Verifier(); var first = await ApprovedCodeAsync(Challenge(verifier), "read"); var second = await ApprovedCodeAsync(Challenge(verifier), "read"); await ExpectOAuthErrorAsync(await RedeemAsync(first, verifier, redirectUri: "https://claude.ai/other"), "invalid_grant"); await ExpectOAuthErrorAsync(await RedeemAsync(second, verifier, clientId: "https://other.example/client.json"), "invalid_grant"); } [Fact] public async Task A_code_used_twice_fails_and_revokes_what_the_first_use_made() { var verifier = Verifier(); var code = await ApprovedCodeAsync(Challenge(verifier), "read"); var first = await RedeemAsync(code, verifier); first.StatusCode.Should().Be(HttpStatusCode.OK); var access = (await first.Content.ReadFromJsonAsync()).GetProperty("access_token").GetString()!; (await ListToolsRawAsync(_public, access)).StatusCode.Should().Be(HttpStatusCode.OK); await ExpectOAuthErrorAsync(await RedeemAsync(code, verifier), "invalid_grant"); (await ListToolsRawAsync(_public, access)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task Refresh_rotates_and_reusing_an_old_refresh_token_revokes_the_grant() { var (access1, refresh1) = await ConnectClaudeAsync("read_write"); var rotated = await RefreshAsync(refresh1); rotated.StatusCode.Should().Be(HttpStatusCode.OK); var body = await rotated.Content.ReadFromJsonAsync(); var access2 = body.GetProperty("access_token").GetString()!; var refresh2 = body.GetProperty("refresh_token").GetString()!; refresh2.Should().NotBe(refresh1); body.GetProperty("scope").GetString().Should().Be("nostos:read nostos:write"); (await ListToolsRawAsync(_public, access2)).StatusCode.Should().Be(HttpStatusCode.OK); await AgeSpentRefreshTokensAsync(McpOAuthService.RefreshReuseGrace + TimeSpan.FromSeconds(1)); var replay = await RefreshAsync(refresh1); await ExpectOAuthErrorAsync(replay, "invalid_grant"); await ExpectOAuthErrorAsync(await RefreshAsync(refresh2), "invalid_grant"); (await ListToolsRawAsync(_public, access2)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); (await ListToolsRawAsync(_public, access1)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); (await _local.GetFromJsonAsync(McpConnectedAppsEndpoints.Route)).GetProperty("apps").GetArrayLength().Should().Be(0); } [Fact] public async Task A_refresh_retried_within_the_grace_window_gets_a_fresh_pair_and_keeps_the_grant() { var (_, refresh1) = await ConnectClaudeAsync("read"); var first = await RefreshAsync(refresh1); var retry = await RefreshAsync(refresh1); first.StatusCode.Should().Be(HttpStatusCode.OK); retry.StatusCode.Should().Be(HttpStatusCode.OK); var a = await first.Content.ReadFromJsonAsync(); var b = await retry.Content.ReadFromJsonAsync(); b.GetProperty("access_token").GetString().Should().NotBe(a.GetProperty("access_token").GetString()); (await ListToolsRawAsync(_public, a.GetProperty("access_token").GetString())).StatusCode.Should().Be(HttpStatusCode.OK); (await ListToolsRawAsync(_public, b.GetProperty("access_token").GetString())).StatusCode.Should().Be(HttpStatusCode.OK); (await RefreshAsync(b.GetProperty("refresh_token").GetString()!)).StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task A_grant_idle_past_the_refresh_lifetime_is_no_longer_listed() { var (access, _) = await ConnectClaudeAsync("read"); await using (var db = await Contexts().CreateDbContextAsync()) { await db.McpOAuthTokens.ExecuteUpdateAsync(set => set.SetProperty(t => t.ExpiresAtUtc, DateTime.UtcNow.AddMinutes(-1))); } (await _local.GetFromJsonAsync(McpConnectedAppsEndpoints.Route)).GetProperty("apps").GetArrayLength().Should().Be(0); (await ListToolsRawAsync(_public, access)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); await using var check = await Contexts().CreateDbContextAsync(); (await check.McpOAuthGrants.CountAsync()).Should().Be(0); } [Fact] public async Task One_address_can_keep_only_a_few_requests_waiting() { for (var i = 0; i < McpOAuthRequests.MaxPendingPerAddress; i++) (await AuthorizeAsync(Challenge(Verifier()))).StatusCode.Should().Be(HttpStatusCode.OK); var refused = await AuthorizeAsync(Challenge(Verifier()), state: "st"); refused.StatusCode.Should().Be(HttpStatusCode.Redirect); Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(refused.Headers.Location!.Query)["error"].ToString() .Should().Be("temporarily_unavailable"); (await PendingAsync()).Should().HaveCount(McpOAuthRequests.MaxPendingPerAddress); } [Fact] public void A_code_tried_again_while_its_grant_is_written_is_caught() { var requests = new McpOAuthRequests(); var client = new McpClientMetadata(McpOAuthFactory.ClaudeClientId, "Claude", [McpOAuthFactory.ClaudeRedirect]); var (id, _) = requests.Create(client, McpOAuthFactory.ClaudeRedirect, null, Challenge(Verifier()), "read", McpOAuthFactory.PublicBase)!.Value; requests.Approve(id, "read").Should().BeTrue(); var redirect = ((McpOAuthPollResult.Done)requests.Poll(id)).RedirectUrl; var code = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri(redirect).Query)["code"].ToString(); requests.Redeem(code, _ => false, out _, out var none).Should().BeNull(); none.Should().BeNull("a failed check leaves the code unspent"); requests.Redeem(code, _ => true, out var grantId, out _).Should().NotBeNull(); requests.Redeem(code, _ => true, out _, out var spent).Should().BeNull(); spent.Should().Be(grantId); requests.FinishRedeem(code).Should().BeTrue(); } [Fact] public async Task A_refresh_for_another_client_fails_without_revoking() { var (access, refresh) = await ConnectClaudeAsync("read"); await ExpectOAuthErrorAsync(await RefreshAsync(refresh, "https://other.example/client.json"), "invalid_grant"); (await RefreshAsync(refresh)).StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Revoking_in_settings_stops_the_next_refresh_and_the_next_mcp_call() { var (access, refresh) = await ConnectClaudeAsync("read_write"); (await ListToolsRawAsync(_public, access)).StatusCode.Should().Be(HttpStatusCode.OK); var id = (await _local.GetFromJsonAsync(McpConnectedAppsEndpoints.Route)) .GetProperty("apps").EnumerateArray().Single().GetProperty("id").GetString(); (await _local.DeleteAsync($"{McpConnectedAppsEndpoints.Route}/{id}")).StatusCode.Should().Be(HttpStatusCode.NoContent); (await ListToolsRawAsync(_public, access)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); await ExpectOAuthErrorAsync(await RefreshAsync(refresh), "invalid_grant"); await using var db = await Contexts().CreateDbContextAsync(); (await db.McpOAuthTokens.CountAsync()).Should().Be(0); } [Fact] public async Task The_revocation_endpoint_revokes_the_grant_for_a_refresh_token() { var (access, refresh) = await ConnectClaudeAsync("read"); var revoke = await _public.PostAsync("/oauth/revoke", new FormUrlEncodedContent(new Dictionary { ["token"] = refresh, ["token_type_hint"] = "refresh_token", })); var unknown = await _public.PostAsync("/oauth/revoke", new FormUrlEncodedContent(new Dictionary { ["token"] = "nsr_" + new string('Q', 43), })); revoke.StatusCode.Should().Be(HttpStatusCode.OK); unknown.StatusCode.Should().Be(HttpStatusCode.OK); (await ListToolsRawAsync(_public, access)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Theory] [InlineData("password", "unsupported_grant_type")] [InlineData(null, "invalid_request")] public async Task Other_grant_types_are_refused(string? grantType, string error) { var form = new Dictionary { ["client_id"] = McpOAuthFactory.ClaudeClientId }; if (grantType is not null) form["grant_type"] = grantType; await ExpectOAuthErrorAsync(await TokenAsync(form), error); } // --- the public host ---------------------------------------------- [Theory] [InlineData("GET", "/")] [InlineData("GET", "/index.html")] [InlineData("GET", "/library")] [InlineData("GET", "/settings")] [InlineData("GET", "/api/books")] [InlineData("GET", "/api/runtime/capabilities")] [InlineData("GET", "/api/mcp/apps")] [InlineData("POST", "/api/mcp/tokens")] [InlineData("GET", "/api/mcp/oauth")] [InlineData("POST", "/api/mcp/oauth/requests/abc/approve")] [InlineData("POST", "/api/mcp/oauth/requests/abc/deny")] [InlineData("GET", "/opds")] [InlineData("GET", "/opds/")] [InlineData("GET", "/health/live")] [InlineData("GET", "/openapi/v1.json")] [InlineData("GET", "/mcp/../api/books")] [InlineData("GET", "/mcpx")] [InlineData("GET", "/oauth/other")] [InlineData("GET", "/.well-known/openid-configuration")] public async Task Everything_outside_the_allowlist_is_404_on_the_public_host(string method, string path) { var response = await _public.SendAsync(new HttpRequestMessage(new HttpMethod(method), path) { Content = method == "POST" ? JsonContent.Create(new { scope = "read_write", name = "x" }) : null, }); response.StatusCode.Should().Be(HttpStatusCode.NotFound); (await response.Content.ReadAsStringAsync()).Should().NotContain(LibraryEndpointFactory.SpaShellMarker); } [Theory] [InlineData("X-Forwarded-Host", "nostos.example.ts.net")] [InlineData("X-Forwarded-Host", "NOSTOS.example.ts.net:443")] [InlineData("Tailscale-Funnel-Request", "?1")] public async Task A_request_forwarded_for_the_public_host_or_through_funnel_is_public_too(string header, string value) { var request = new HttpRequestMessage(HttpMethod.Get, "/api/books"); request.Headers.TryAddWithoutValidation(header, value); (await _local.SendAsync(request)).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _local.GetAsync("/api/books")).StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task The_allowed_paths_answer_on_the_public_host() { (await _public.GetAsync("/.well-known/oauth-protected-resource/mcp")).StatusCode.Should().Be(HttpStatusCode.OK); (await _public.GetAsync("/.well-known/oauth-authorization-server")).StatusCode.Should().Be(HttpStatusCode.OK); (await _public.GetAsync(AuthorizeUrl(Challenge(Verifier())))).StatusCode.Should().Be(HttpStatusCode.OK); (await _public.GetAsync("/oauth/authorize/status?request=nope")).StatusCode.Should().Be(HttpStatusCode.NotFound); (await TokenAsync(new() { ["grant_type"] = "refresh_token", ["refresh_token"] = "x" })).StatusCode.Should().Be(HttpStatusCode.BadRequest); (await ListToolsRawAsync(_public, null)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public void The_owner_routes_recognise_the_public_host_in_every_form() { var settings = McpOAuthSettings.From(McpOAuthFactory.PublicBase); HttpContext Request(string host, params (string Name, string Value)[] headers) { var context = new DefaultHttpContext(); context.Request.Host = new HostString(host); foreach (var (name, value) in headers) context.Request.Headers[name] = value; return context; } McpPublicHost.IsPublicRequest(Request("nostos.example.ts.net"), settings).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("Nostos.Example.TS.net:443"), settings).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("nostos.example.ts.net."), settings).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("nostos.example.ts.net:8443"), settings).Should().BeFalse(); McpPublicHost.IsPublicRequest(Request("nostos.example.ts.net:8443"), McpOAuthSettings.From(McpOAuthFactory.PublicBase + ":8443")).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("127.0.0.1:5214", ("X-Original-Host", "nostos.example.ts.net")), settings).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("127.0.0.1:5214", ("X-Forwarded-Host", "a.example, nostos.example.ts.net")), settings).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("192.168.1.20:5214", ("Tailscale-Funnel-Request", "?1")), settings).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("192.168.1.20:5214", ("Tailscale-Funnel-Request", "?1")), McpOAuthSettings.Disabled).Should().BeTrue(); McpPublicHost.IsPublicRequest(Request("192.168.1.20:5214"), settings).Should().BeFalse(); McpPublicHost.IsPublicRequest(Request("nostos.example.ts.net.evil.example"), settings).Should().BeFalse(); McpPublicHost.IsPublicRequest(Request("nostos.example.ts.net"), McpOAuthSettings.Disabled).Should().BeFalse(); } [Fact] public async Task Approve_and_deny_need_a_json_body_from_this_site() { await AuthorizeAsync(Challenge(Verifier())); var id = (await PendingAsync()).Single().GetProperty("id").GetString(); var approve = $"/api/mcp/oauth/requests/{id}/approve"; // A cross-site page can send these without a CORS preflight. (await _local.PostAsync(approve, null)).StatusCode.Should().Be(HttpStatusCode.Forbidden); // A form or text body fails JSON binding first (415): refused either way. (await _local.PostAsync(approve, new FormUrlEncodedContent(new Dictionary { ["scope"] = "read" }))) .StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.UnsupportedMediaType); (await _local.PostAsync(approve, new StringContent("{\"scope\":\"read\"}", Encoding.UTF8, "text/plain"))) .StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.UnsupportedMediaType); (await _local.PostAsync($"/api/mcp/oauth/requests/{id}/deny", null)).StatusCode.Should().Be(HttpStatusCode.Forbidden); var crossSite = new HttpRequestMessage(HttpMethod.Post, approve) { Content = JsonContent.Create(new { scope = "read" }) }; crossSite.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site"); (await _local.SendAsync(crossSite)).StatusCode.Should().Be(HttpStatusCode.Forbidden); (await PendingAsync()).Should().ContainSingle("nothing above approved the request"); var sameSite = new HttpRequestMessage(HttpMethod.Post, approve) { Content = JsonContent.Create(new { scope = "read" }) }; sameSite.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "same-origin"); (await _local.SendAsync(sameSite)).StatusCode.Should().Be(HttpStatusCode.NoContent); } [Theory] [InlineData(null, null, null, null, true)] // direct, any browser [InlineData("same-origin", null, null, null, true)] [InlineData("none", null, null, null, true)] [InlineData("cross-site", null, null, null, false)] [InlineData("same-site", null, null, null, false)] [InlineData(null, "http://evil.example", null, null, false)] // no Sec-Fetch-Site: Origin decides [InlineData(null, "http://nas.local:5214", null, null, true)] [InlineData(null, null, "100.101.102.103", "127.0.0.1", true)] // Tailscale serve, applied [InlineData(null, null, "203.0.113.9, 8.8.8.8", "127.0.0.1", false)] // proxied from the internet [InlineData(null, null, null, "8.8.8.8", true)] // direct: address not checked [InlineData(null, null, "192.168.1.4", "172.17.0.1", true)] // proxy Nostos does not trust, private [InlineData(null, null, "8.8.4.4", "172.17.0.1", false)] // same, public caller public void An_owner_decision_comes_from_this_site_and_the_owners_network( string? fetchSite, string? origin, string? forwardedFor, string? remote, bool allowed) { var context = new DefaultHttpContext(); context.Request.Method = "POST"; context.Request.Host = new HostString("nas.local:5214"); context.Request.ContentType = "application/json"; if (fetchSite is not null) context.Request.Headers["Sec-Fetch-Site"] = fetchSite; if (origin is not null) context.Request.Headers.Origin = origin; if (forwardedFor is not null) context.Request.Headers["X-Forwarded-For"] = forwardedFor; if (remote is not null) context.Connection.RemoteIpAddress = IPAddress.Parse(remote); McpOAuthEndpoints.IsOwnerDecision(context).Should().Be(allowed); } [Fact] public async Task The_authorize_endpoint_is_rate_limited_per_address() { var url = AuthorizeUrl(Challenge(Verifier()), clientId: "https://unknown.example/client.json"); for (var i = 0; i < McpOAuthRateLimiter.PermitsPerMinute(McpOAuthRateLimiter.Endpoint.Authorize); i++) (await _public.GetAsync(url)).StatusCode.Should().Be(HttpStatusCode.BadRequest); (await _public.GetAsync(url)).StatusCode.Should().Be(HttpStatusCode.TooManyRequests); } [Theory] [InlineData(null, false)] [InlineData("", false)] [InlineData("http://nostos.example.ts.net", false)] [InlineData("https://nostos.example.ts.net/nostos", false)] [InlineData("https://nostos.example.ts.net?x=1", false)] [InlineData("nostos.example.ts.net", false)] [InlineData("https://nostos.example.ts.net", true)] [InlineData("https://Nostos.Example.ts.net/", true)] [InlineData("https://nostos.example.ts.net:8443", true)] public void Oauth_is_on_only_for_an_https_base_url_without_a_path(string? value, bool enabled) { var settings = McpOAuthSettings.From(value); settings.Enabled.Should().Be(enabled); if (enabled) { settings.Resource.Should().Be(settings.Issuer + "/mcp"); settings.Issuer.Should().NotEndWith("/"); } else if (!string.IsNullOrEmpty(value)) { settings.Problem.Should().NotBeNull(); } } [Theory] [InlineData(null, "read_write")] [InlineData("nostos:read nostos:write", "read_write")] [InlineData("nostos:read", "read")] [InlineData("nostos:write", "read_write")] [InlineData("nostos:read offline_access", "read")] [InlineData("openid", null)] public void The_requested_scope_maps_to_what_settings_offers(string? scope, string? expected) => McpOAuthEndpoints.ParseScope(scope).Should().Be(expected); // ------------------------------------------------------------------ private async Task<(string Access, string Refresh)> ConnectClaudeAsync(string approveScope) { var verifier = Verifier(); var code = await ApprovedCodeAsync(Challenge(verifier), approveScope); var response = await RedeemAsync(code, verifier); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); return (body.GetProperty("access_token").GetString()!, body.GetProperty("refresh_token").GetString()!); } private async Task ApprovedCodeAsync(string challenge, string approveScope) { var html = await (await AuthorizeAsync(challenge)).Content.ReadAsStringAsync(); var requestId = RequestId(html); var id = (await PendingAsync()).Single().GetProperty("id").GetString(); (await _local.PostAsJsonAsync($"/api/mcp/oauth/requests/{id}/approve", new { scope = approveScope })).EnsureSuccessStatusCode(); var redirect = new Uri((await PollAsync(requestId)).GetProperty("redirect").GetString()!); return Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(redirect.Query)["code"].ToString(); } private Task RedeemAsync(string code, string verifier, string? redirectUri = null, string? clientId = null) => TokenAsync(new() { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = redirectUri ?? McpOAuthFactory.ClaudeRedirect, ["client_id"] = clientId ?? McpOAuthFactory.ClaudeClientId, ["code_verifier"] = verifier, }); private Task RefreshAsync(string refresh, string? clientId = null) => TokenAsync(new() { ["grant_type"] = "refresh_token", ["refresh_token"] = refresh, ["client_id"] = clientId ?? McpOAuthFactory.ClaudeClientId, }); private Task TokenAsync(Dictionary form) => _public.PostAsync("/oauth/token", new FormUrlEncodedContent(form)); private static async Task ExpectOAuthErrorAsync(HttpResponseMessage response, string error) { response.StatusCode.Should().Be(HttpStatusCode.BadRequest); var body = await response.Content.ReadFromJsonAsync(); body.GetProperty("error").GetString().Should().Be(error); body.GetProperty("error_description").GetString().Should().NotBeNullOrEmpty(); } private Task AuthorizeAsync(string challenge, string? state = null) => _public.GetAsync(AuthorizeUrl(challenge, state: state)); private static string AuthorizeUrl(string challenge, string? redirectUri = null, string? clientId = null, string? state = null) => "/oauth/authorize?response_type=code" + "&client_id=" + Uri.EscapeDataString(clientId ?? McpOAuthFactory.ClaudeClientId) + "&redirect_uri=" + Uri.EscapeDataString(redirectUri ?? McpOAuthFactory.ClaudeRedirect) + "&code_challenge=" + challenge + "&code_challenge_method=S256" + "&scope=" + Uri.EscapeDataString("nostos:read nostos:write") + "&resource=" + Uri.EscapeDataString(Resource) + (state is null ? "" : "&state=" + Uri.EscapeDataString(state)); private async Task> PendingAsync() { var status = await _local.GetFromJsonAsync("/api/mcp/oauth"); status.GetProperty("enabled").GetBoolean().Should().BeTrue(); status.GetProperty("connectorUrl").GetString().Should().Be(Resource); return status.GetProperty("requests").EnumerateArray().ToList(); } private async Task PollAsync(string requestId) { var response = await _public.GetAsync($"/oauth/authorize/status?request={Uri.EscapeDataString(requestId)}"); return await response.Content.ReadFromJsonAsync(); } private static string RequestId(string html) { var match = Regex.Match(html, "data-status=\"[^\"]*request=([^\"&]+)\""); match.Success.Should().BeTrue("the waiting page names its status URL"); return Uri.UnescapeDataString(System.Net.WebUtility.HtmlDecode(match.Groups[1].Value)); } private static string Verifier() => Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); private static string Challenge(string verifier) => Base64Url.EncodeToString(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); private static IEnumerable Strings(JsonElement element, string property) => element.GetProperty(property).EnumerateArray().Select(e => e.GetString()); private async Task ConnectAsync(string token) { var authed = _factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri(McpOAuthFactory.PublicBase) }); authed.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return await McpClient.CreateAsync( new HttpClientTransport( new HttpClientTransportOptions { Endpoint = new Uri(authed.BaseAddress!, NostosMcp.Path), TransportMode = HttpTransportMode.StreamableHttp, }, authed, ownsHttpClient: true), new McpClientOptions { InitializationTimeout = TimeSpan.FromMinutes(3), DiscoverProbeTimeout = TimeSpan.FromMinutes(1), }); } private static Task ListToolsRawAsync(HttpClient http, string? token) { var request = new HttpRequestMessage(HttpMethod.Post, NostosMcp.Path) { Content = new StringContent( """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""", Encoding.UTF8, "application/json"), }; request.Headers.Accept.ParseAdd("application/json"); request.Headers.Accept.ParseAdd("text/event-stream"); if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return http.SendAsync(request); } private async Task CreateBookAsync(string title) { var response = await _local.PostAsJsonAsync("/api/books", new { type = "physical", title, author = "Someone" }); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); } private IDbContextFactory Contexts() => _factory.Services.GetRequiredService>(); private async Task AgeSpentRefreshTokensAsync(TimeSpan by) { var at = DateTime.UtcNow - by; await using var db = await Contexts().CreateDbContextAsync(); await db.McpOAuthTokens.Where(t => t.ConsumedAtUtc != null) .ExecuteUpdateAsync(set => set.SetProperty(t => t.ConsumedAtUtc, at)); } private static string Text(CallToolResult result) => string.Concat(result.Content.OfType().Select(c => c.Text)); } /// OAuth off: the SelfHosted host as it ships without Mcp:PublicBaseUrl. public sealed class McpOAuthOffTests : IAsyncLifetime { private readonly McpAccessTokenFactory _factory = new(); private HttpClient _http = null!; public Task InitializeAsync() { _http = _factory.CreateClient(); return Task.CompletedTask; } public async Task DisposeAsync() { _http.Dispose(); await _factory.DisposeAsync(); } [Fact] public async Task Without_a_public_base_url_there_is_no_oauth_and_the_challenge_stays_plain() { (await _http.GetAsync("/.well-known/oauth-protected-resource/mcp")).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _http.GetAsync("/.well-known/oauth-authorization-server")).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _http.GetAsync("/oauth/authorize")).StatusCode.Should().Be(HttpStatusCode.NotFound); (await _http.PostAsync("/oauth/token", new FormUrlEncodedContent([]))).StatusCode.Should().NotBe(HttpStatusCode.OK); var status = await _http.GetFromJsonAsync("/api/mcp/oauth"); status.GetProperty("enabled").GetBoolean().Should().BeFalse(); status.GetProperty("requests").GetArrayLength().Should().Be(0); var mcp = await _http.PostAsync(NostosMcp.Path, new StringContent("{}", Encoding.UTF8, "application/json")); mcp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); mcp.Headers.WwwAuthenticate.ToString().Should().NotContain("resource_metadata"); } [Fact] public async Task An_oauth_access_token_made_while_oauth_was_on_stops_working_when_it_is_turned_off() { var oauth = _factory.Services.GetRequiredService(); var tokens = await oauth.CreateGrantAsync( new McpOAuthCodeGrant(McpOAuthFactory.ClaudeClientId, "Claude", McpOAuthFactory.ClaudeRedirect, "unused", "read"), Guid.NewGuid()); (await oauth.AuthenticateAsync(tokens!.AccessToken)).Should().NotBeNull("the token itself is live"); var request = new HttpRequestMessage(HttpMethod.Post, NostosMcp.Path) { Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""", Encoding.UTF8, "application/json"), }; request.Headers.Accept.ParseAdd("application/json"); request.Headers.Accept.ParseAdd("text/event-stream"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); (await _http.SendAsync(request)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); } }