From ae6a074ebd9c9cfac9b82a514b96cb9540bebe66 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:20:52 -0400 Subject: [PATCH] Say what is actually wrong when the server 401s a stored credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP servers answered every post-recovery 401 with "Not logged in. Run 'kcap login' on the host shell." — factually wrong in the incident that motivated this (AI-1843): the tenant server's ephemeral signing key rotated across pod replacements and 401'd tokens that `kcap status` concurrently (and correctly) reported as valid. Users read the two surfaces as contradicting each other, concluded `kcap login` was ineffective, and reached for a daemon restart — which never helps, because every kcap process re-reads the same token store on 401 (the issue's suspected "caches auth at process start" bug does not exist; the recovery path was working as designed on every surface). The new AuthRejectionNotice classifies the raw store snapshot (never the refresh-aware accessor — building an error string must not rotate a credential) and renders one of four truthful lines: - Missing -> the legacy wording, byte-identical; - Expired -> expired + refresh failed, run kcap login; - WrongServer -> names both servers, offers kcap login / kcap use; - LooksValid -> the server rejected a locally-valid credential: name the signed-in user and expiry, point at a server-side auth change, prescribe kcap login, and say explicitly that restarting the daemon will not help. All six MCP servers' 401 sites route through it (analytics keeps its pure MapResponse; the 401 is intercepted at the async call site), and the per-server NotLoggedInMessage consts now alias the single source. Any store fault while building the line degrades to the legacy message. Part of AI-1843 (the root cause — persisting the signing key — is kcap-server #1392). Co-Authored-By: Claude Fable 5 --- .../Auth/AuthRejectionNotice.cs | 99 +++++++++++++++ .../Commands/McpAnalyticsServer.cs | 10 +- .../Commands/McpFlowResultServer.cs | 4 +- src/Capacitor.Cli/Commands/McpFlowsServer.cs | 13 +- src/Capacitor.Cli/Commands/McpMemoryServer.cs | 7 +- .../Commands/McpSessionsServer.cs | 9 +- .../Commands/McpWorkItemsServer.cs | 7 +- .../AuthRejectionNoticeTests.cs | 116 ++++++++++++++++++ 8 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 src/Capacitor.Cli.Core/Auth/AuthRejectionNotice.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/AuthRejectionNoticeTests.cs diff --git a/src/Capacitor.Cli.Core/Auth/AuthRejectionNotice.cs b/src/Capacitor.Cli.Core/Auth/AuthRejectionNotice.cs new file mode 100644 index 0000000000..39d3547efa --- /dev/null +++ b/src/Capacitor.Cli.Core/Auth/AuthRejectionNotice.cs @@ -0,0 +1,99 @@ +namespace Capacitor.Cli.Core.Auth; + +/// How the stored credential relates to a 401 the server just returned. +public enum StoredCredentialState { + /// No usable stored token — the caller genuinely is not logged in. + Missing, + + /// A token is stored but was minted by a different server than the target. + WrongServer, + + /// A token is stored but expired (and the recovery's refresh evidently failed). + Expired, + + /// The stored token is unexpired and bound to the target — the server rejected a + /// credential that looks perfectly valid locally. + LooksValid, +} + +/// +/// Builds the user-facing line for a request the server answered 401 AFTER the one-shot +/// re-read-and-refresh recovery (TokenStore.RecoverForServerAsync) already ran. +/// +/// The MCP servers used to answer every such 401 with a flat +/// "Not logged in. Run 'kcap login' on the host shell." — factually wrong in the incident +/// that motivated this: a server-side signing-key rotation 401'd a token that +/// kcap status concurrently (and correctly) reported as valid, so the user read the +/// two surfaces as contradicting each other, concluded kcap login was ineffective, +/// and reached for a daemon restart — which never helps, because every process re-reads the +/// same token store. The message now says what is actually true for each store state; the +/// legacy wording survives byte-identical for the genuinely-logged-out case. +/// +public static class AuthRejectionNotice { + public const string NotLoggedIn = "Not logged in. Run 'kcap login' on the host shell."; + + /// Pure classification of a raw store snapshot against the request's target server. + public static StoredCredentialState Classify(StoredTokens? stored, string targetBaseUrl) { + if (stored is null) return StoredCredentialState.Missing; + + // An unbound (pre-upgrade) token is treated as bound — same rule as TokenStore's + // BoundToTarget: there is nothing to contradict. + if (stored.ServerUrl is not null && !ServerIdentity.SameServer(stored.ServerUrl, targetBaseUrl)) { + return StoredCredentialState.WrongServer; + } + + return stored.IsExpired ? StoredCredentialState.Expired : StoredCredentialState.LooksValid; + } + + public static string Render(StoredCredentialState state, StoredTokens? stored, string targetBaseUrl) => + state switch { + StoredCredentialState.Missing => NotLoggedIn, + + StoredCredentialState.Expired => + "Your kcap login has expired and an automatic refresh did not succeed. " + + "Run 'kcap login' on the host shell.", + + StoredCredentialState.WrongServer => + $"Stored login was issued by {stored?.ServerUrl} but this request targets {targetBaseUrl}. " + + $"Run 'kcap login' (or switch profiles with 'kcap use') to authenticate against {targetBaseUrl}.", + + // The stored token is unexpired and bound to this server, was re-read from disk and + // re-sent, and the server still said 401. Locally there is nothing left to fix — + // this is the server's auth state having moved (a restart that rotated the signing + // key, or an auth incident). Say so, and steer AWAY from the daemon-restart + // superstition the incident produced. + _ => + $"The server rejected kcap's credentials (HTTP 401) even after re-reading the token store — " + + $"yet the stored login for {stored?.GitHubUsername} looks valid locally ({DescribeExpiry(stored)}). " + + "This usually means the server's auth state changed (a restart or auth incident). " + + "Run 'kcap login' on the host shell to mint a fresh credential — restarting the daemon will not help. " + + "If a fresh login still hits this, the server is mid-incident; retry later.", + }; + + /// + /// Store-inspecting convenience for the MCP servers' 401 sites. Raw, non-mutating read — + /// deliberately never the refresh-aware accessor, which could rotate a credential just to + /// build an error string. Any store fault degrades to the legacy message rather than + /// replacing an auth diagnosis with an IO stack trace. + /// + public static async Task ForPersistentUnauthorizedAsync(string targetBaseUrl, CancellationToken ct = default) { + try { + var profile = await TokenStore.ResolveProfileNameAsync(ct); + var stored = await TokenStore.LoadForProfileAsync(profile, ct); + + return Render(Classify(stored, targetBaseUrl), stored, targetBaseUrl); + } catch { + return NotLoggedIn; + } + } + + static string DescribeExpiry(StoredTokens? stored) { + if (stored is null) return "expiry unknown"; + + var remaining = stored.ExpiresAt - DateTimeOffset.UtcNow; + + return remaining.TotalHours >= 1 + ? $"expires in {remaining.TotalHours:F0}h" + : $"expires in {Math.Max(1, remaining.TotalMinutes):F0}m"; + } +} diff --git a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs index 0bb32f9e2e..34831cef5c 100644 --- a/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs +++ b/src/Capacitor.Cli/Commands/McpAnalyticsServer.cs @@ -17,7 +17,7 @@ namespace Capacitor.Cli.Commands; /// rejection reasons. Structure cloned from McpMemoryServer. /// static class McpAnalyticsServer { - internal const string NotLoggedInMessage = "Not logged in. Run 'kcap login' on the host shell."; + internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; internal const string NotSupportedMessage = "This server does not support analytics queries — upgrade kcap-server."; @@ -182,6 +182,14 @@ internal static async Task HandleToolCallAsync( var body = await httpResponse.Content.ReadAsStringAsync(); + // The 401 wording is store-aware (a locally-valid stored credential the + // server rejects must not read as "not logged in"), which needs an async store + // read — resolved here so MapResponse stays a pure, unit-testable mapper (its own + // 401 arm remains as the fallback wording). + if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(baseUrl), isError: true); + } + return BuildToolResult(id, MapResponse(toolName, httpResponse.StatusCode, body, out var isError), isError); } catch (ArgumentException ex) { return BuildToolResult(id, $"Error: {ex.Message}", isError: true); diff --git a/src/Capacitor.Cli/Commands/McpFlowResultServer.cs b/src/Capacitor.Cli/Commands/McpFlowResultServer.cs index 51760556c8..bd9862a903 100644 --- a/src/Capacitor.Cli/Commands/McpFlowResultServer.cs +++ b/src/Capacitor.Cli/Commands/McpFlowResultServer.cs @@ -246,7 +246,7 @@ async Task TimedDispatchToolCallAsync(JsonNode callId, JsonObject callRe return ("Result recorded. You may end your reply now.", false); if (response.StatusCode == HttpStatusCode.Unauthorized) - return ("Not logged in. Run 'kcap login' on the host shell.", true); + return (await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), true); var errorNode = TryParse(responseBody); var code = errorNode?["error"]?.GetValue(); @@ -322,7 +322,7 @@ async Task TimedDispatchToolCallAsync(JsonNode callId, JsonObject callRe return ("Message sent to the flow driver. It will be delivered with the driver's next flow call — you may continue.", false); if (response.StatusCode == HttpStatusCode.Unauthorized) - return ("Not logged in. Run 'kcap login' on the host shell.", true); + return (await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), true); var responseBody = await response.Content.ReadAsStringAsync(); var errorNode = TryParse(responseBody); diff --git a/src/Capacitor.Cli/Commands/McpFlowsServer.cs b/src/Capacitor.Cli/Commands/McpFlowsServer.cs index f84356401e..20ae940849 100644 --- a/src/Capacitor.Cli/Commands/McpFlowsServer.cs +++ b/src/Capacitor.Cli/Commands/McpFlowsServer.cs @@ -242,7 +242,7 @@ internal static async Task HandleToolCallAsync( var postBody = await postResponse.Content.ReadAsStringAsync(); if (postResponse.StatusCode == HttpStatusCode.Unauthorized) - return BuildToolResult(id, "Not logged in. Run 'kcap login' on the host shell.", isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), isError: true); // Catalog-start protocol-v2 skew seam (404 means an old server, before any run // started) plus an explicit-vendor echo check once the route matched. @@ -334,7 +334,7 @@ internal static async Task HandleToolCallAsync( // send already had its go) is an auth problem, not a vendor one — say so, rather // than printing a raw HTTP 401 the caller would read as a flow rejection. if (retryResponse.StatusCode == HttpStatusCode.Unauthorized) - return BuildToolResult(id, "Not logged in. Run 'kcap login' on the host shell.", isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), isError: true); if (CheckVendorOverrideResult(toolName, preference, retryResponse.StatusCode, retryResponse.IsSuccessStatusCode, retryBody, out var retryRunIdToClose) is { } retryVendorCheck) { if (retryRunIdToClose is not null) @@ -389,7 +389,7 @@ internal static async Task HandleToolCallAsync( var body = await httpResponse.Content.ReadAsStringAsync(); if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { - return BuildToolResult(id, "Not logged in. Run 'kcap login' on the host shell.", isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), isError: true); } if (!httpResponse.IsSuccessStatusCode) { @@ -432,7 +432,8 @@ internal static async Task HandleToolCallAsync( /// the refresh flow for WorkOS / GitHubApp), update the client's Authorization /// header, and retry the same request once. If refresh fails (genuinely not logged in /// or refresh-token expired), the original 401 is returned and the caller surfaces the - /// friendly "Not logged in" message. + /// store-aware line (which keeps the legacy + /// "Not logged in" wording only for a genuinely missing login). /// static async Task SendWithRefreshRetryAsync( HttpClient client, string baseUrl, Func> send, @@ -1392,7 +1393,7 @@ static async Task PollUntilTerminalAsync(HttpClient client, string a } if (resp.StatusCode == HttpStatusCode.Unauthorized) - return new("Not logged in. Run 'kcap login' on the host shell.", true); + return new(await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), true); // Fix #4: non-transient 4xx (e.g. 400, 403, 409 budget_unverifiable) fail // immediately — coded bodies surface via FormatFlowStartError like the POST path. @@ -1502,7 +1503,7 @@ static async Task PollStatusUntilTerminalAsync( using (resp) { if (resp.StatusCode == HttpStatusCode.Unauthorized) - return new("Not logged in. Run 'kcap login' on the host shell.", true); + return new(await AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), true); var statusCode = (int)resp.StatusCode; if (statusCode is >= 400 and < 500) { diff --git a/src/Capacitor.Cli/Commands/McpMemoryServer.cs b/src/Capacitor.Cli/Commands/McpMemoryServer.cs index 964c5bd0f1..0f3f3239c0 100644 --- a/src/Capacitor.Cli/Commands/McpMemoryServer.cs +++ b/src/Capacitor.Cli/Commands/McpMemoryServer.cs @@ -12,7 +12,7 @@ namespace Capacitor.Cli.Commands; static class McpMemoryServer { - internal const string NotLoggedInMessage = "Not logged in. Run 'kcap login' on the host shell."; + internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; public static async Task RunAsync(string baseUrl) { var cwdRepoHash = await ResolveCwdRepoHashAsync(); @@ -187,7 +187,7 @@ internal static async Task HandleToolCallAsync( var body = await httpResponse.Content.ReadAsStringAsync(); if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { - return BuildToolResult(id, NotLoggedInMessage, isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(baseUrl), isError: true); } if (!httpResponse.IsSuccessStatusCode) { @@ -210,7 +210,8 @@ internal static async Task HandleToolCallAsync( /// the refresh flow for WorkOS / GitHubApp), update the client's Authorization /// header, and retry the same request once. If refresh fails (genuinely not logged in /// or refresh-token expired), the original 401 is returned and the caller surfaces the - /// friendly "Not logged in" message. + /// store-aware line (which keeps the legacy + /// "Not logged in" wording only for a genuinely missing login). /// static async Task SendWithRefreshRetryAsync(HttpClient client, string baseUrl, Func> send) { var response = await send(client); diff --git a/src/Capacitor.Cli/Commands/McpSessionsServer.cs b/src/Capacitor.Cli/Commands/McpSessionsServer.cs index 158b530374..9813e6893a 100644 --- a/src/Capacitor.Cli/Commands/McpSessionsServer.cs +++ b/src/Capacitor.Cli/Commands/McpSessionsServer.cs @@ -12,7 +12,7 @@ namespace Capacitor.Cli.Commands; static class McpSessionsServer { - internal const string NotLoggedInMessage = "Not logged in. Run 'kcap login' on the host shell."; + internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; public static async Task RunAsync(string baseUrl) { var cwdRepoHash = await ResolveCwdRepoHashAsync(); @@ -183,7 +183,7 @@ internal static async Task HandleToolCallAsync( var body = await httpResponse.Content.ReadAsStringAsync(); if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { - return BuildToolResult(id, NotLoggedInMessage, isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(baseUrl), isError: true); } if (!httpResponse.IsSuccessStatusCode) { @@ -219,7 +219,7 @@ static async Task HandleSearchSessionsAsync( var body = await first.Content.ReadAsStringAsync(); if (first.StatusCode == HttpStatusCode.Unauthorized) { - return BuildToolResult(id, NotLoggedInMessage, isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(baseUrl), isError: true); } if (!first.IsSuccessStatusCode) { @@ -269,7 +269,8 @@ static async Task HandleSearchSessionsAsync( /// the refresh flow for WorkOS / GitHubApp), update the client's Authorization /// header, and retry the same request once. If refresh fails (genuinely not logged in /// or refresh-token expired), the original 401 is returned and the caller surfaces the - /// friendly "Not logged in" message. + /// store-aware line (which keeps the legacy + /// "Not logged in" wording only for a genuinely missing login). /// static async Task SendWithRefreshRetryAsync(HttpClient client, string baseUrl, Func> send) { var response = await send(client); diff --git a/src/Capacitor.Cli/Commands/McpWorkItemsServer.cs b/src/Capacitor.Cli/Commands/McpWorkItemsServer.cs index 7f84fa82ae..7761f11935 100644 --- a/src/Capacitor.Cli/Commands/McpWorkItemsServer.cs +++ b/src/Capacitor.Cli/Commands/McpWorkItemsServer.cs @@ -17,7 +17,7 @@ namespace Capacitor.Cli.Commands; /// memory this server has no repo/machine context to resolve — the only per-call input is the /// session id and the declare selector, both carried in the tool arguments. static class McpWorkItemsServer { - internal const string NotLoggedInMessage = "Not logged in. Run 'kcap login' on the host shell."; + internal const string NotLoggedInMessage = AuthRejectionNotice.NotLoggedIn; internal const string NoSessionIdMessage = "No session id: pass session_id explicitly or run inside a kcap-hooked session (KCAP_SESSION_ID or CODEX_THREAD_ID)."; @@ -176,7 +176,7 @@ string baseUrl var body = await httpResponse.Content.ReadAsStringAsync(); if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { - return BuildToolResult(id, NotLoggedInMessage, isError: true); + return BuildToolResult(id, await AuthRejectionNotice.ForPersistentUnauthorizedAsync(baseUrl), isError: true); } if (!httpResponse.IsSuccessStatusCode) { @@ -199,7 +199,8 @@ string baseUrl /// the refresh flow for WorkOS / GitHubApp), update the client's Authorization /// header, and retry the same request once. If refresh fails (genuinely not logged in /// or refresh-token expired), the original 401 is returned and the caller surfaces the - /// friendly "Not logged in" message. + /// store-aware line (which keeps the legacy + /// "Not logged in" wording only for a genuinely missing login). /// static async Task SendWithRefreshRetryAsync(HttpClient client, string baseUrl, Func> send) { var response = await send(client); diff --git a/test/Capacitor.Cli.Tests.Unit/AuthRejectionNoticeTests.cs b/test/Capacitor.Cli.Tests.Unit/AuthRejectionNoticeTests.cs new file mode 100644 index 0000000000..dc7315f41f --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/AuthRejectionNoticeTests.cs @@ -0,0 +1,116 @@ +using Capacitor.Cli.Core.Auth; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// The MCP servers used to answer EVERY post-recovery 401 with +/// "Not logged in. Run 'kcap login' on the host shell." — including the incident shape where +/// the stored token was re-read from disk, re-sent, and rejected by a server whose signing +/// key had rotated. The user then saw `kcap status` report a valid login while the tools +/// insisted they were logged out, concluded `kcap login` was ineffective, and restarted the +/// daemon (which never helps — a fresh process reads the same store). These tests pin the +/// truthful classification: the legacy message survives byte-identical for a genuinely +/// missing login, and the other states say what is actually wrong. +/// +public class AuthRejectionNoticeTests { + const string Target = "https://kurrent.kcap.ai"; + + static StoredTokens Tokens( + DateTimeOffset expiresAt, string? serverUrl = "https://kurrent.kcap.ai:443", string user = "octocat") => + new() { + AccessToken = "token-under-test", + ExpiresAt = expiresAt, + GitHubUsername = user, + Provider = "GitHubApp", + ServerUrl = serverUrl, + }; + + [Test] + public async Task Missing_store_keeps_the_legacy_message_byte_identical() { + var state = AuthRejectionNotice.Classify(null, Target); + + await Assert.That(state).IsEqualTo(StoredCredentialState.Missing); + await Assert.That(AuthRejectionNotice.Render(state, null, Target)) + .IsEqualTo("Not logged in. Run 'kcap login' on the host shell."); + } + + [Test] + public async Task Locally_valid_token_is_reported_as_a_server_rejection_not_a_missing_login() { + var tokens = Tokens(DateTimeOffset.UtcNow.AddHours(23)); + var state = AuthRejectionNotice.Classify(tokens, Target); + + await Assert.That(state).IsEqualTo(StoredCredentialState.LooksValid); + + var text = AuthRejectionNotice.Render(state, tokens, Target); + + // The incident's wild-goose chase, point by point: the user IS logged in — never say + // otherwise; login IS the remedy (a fresh credential under the server's current key); + // a daemon restart is NOT (every process re-reads the same store). + await Assert.That(text).DoesNotContain("Not logged in"); + await Assert.That(text).Contains("octocat"); + await Assert.That(text).Contains("kcap login"); + await Assert.That(text).Contains("restarting the daemon will not help"); + await Assert.That(text).Contains("HTTP 401"); + } + + [Test] + public async Task Port_only_url_difference_still_counts_as_the_same_server() { + // Stored tokens are stamped with the canonicalized ":443" form while callers pass the + // configured URL without it — that pair must classify as LooksValid, never WrongServer. + var tokens = Tokens(DateTimeOffset.UtcNow.AddHours(2), serverUrl: "https://kurrent.kcap.ai:443"); + + await Assert.That(AuthRejectionNotice.Classify(tokens, "https://kurrent.kcap.ai")) + .IsEqualTo(StoredCredentialState.LooksValid); + } + + [Test] + public async Task Unbound_legacy_token_classifies_by_expiry_alone() { + var tokens = Tokens(DateTimeOffset.UtcNow.AddHours(2), serverUrl: null); + + await Assert.That(AuthRejectionNotice.Classify(tokens, Target)) + .IsEqualTo(StoredCredentialState.LooksValid); + } + + [Test] + public async Task Expired_token_says_expired_and_points_at_login() { + var tokens = Tokens(DateTimeOffset.UtcNow.AddHours(-1)); + var state = AuthRejectionNotice.Classify(tokens, Target); + + await Assert.That(state).IsEqualTo(StoredCredentialState.Expired); + + var text = AuthRejectionNotice.Render(state, tokens, Target); + + await Assert.That(text).Contains("expired"); + await Assert.That(text).Contains("kcap login"); + } + + [Test] + public async Task Wrong_server_token_names_both_servers() { + var tokens = Tokens(DateTimeOffset.UtcNow.AddHours(2), serverUrl: "https://other.kcap.ai"); + var state = AuthRejectionNotice.Classify(tokens, Target); + + await Assert.That(state).IsEqualTo(StoredCredentialState.WrongServer); + + var text = AuthRejectionNotice.Render(state, tokens, Target); + + await Assert.That(text).Contains("https://other.kcap.ai"); + await Assert.That(text).Contains(Target); + await Assert.That(text).Contains("kcap use"); + } + + [Test] + public async Task Every_state_renders_a_login_remedy() { + // Whatever the diagnosis, the reader must leave with an action; 'kcap login' is the + // one remedy that exists on every path (WrongServer additionally offers 'kcap use'). + foreach (var (tokens, target) in new (StoredTokens?, string)[] { + (null, Target), + (Tokens(DateTimeOffset.UtcNow.AddHours(-1)), Target), + (Tokens(DateTimeOffset.UtcNow.AddHours(2)), Target), + (Tokens(DateTimeOffset.UtcNow.AddHours(2), serverUrl: "https://other.kcap.ai"), Target), + }) { + var text = AuthRejectionNotice.Render(AuthRejectionNotice.Classify(tokens, target), tokens, target); + + await Assert.That(text).Contains("kcap login"); + } + } +}