Repository navigation
Truthful 401 notice: distinguish a server-rejected valid credential from a missing login #516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| namespace Capacitor.Cli.Core.Auth; | ||
|
|
||
| /// <summary>How the stored credential relates to a 401 the server just returned.</summary> | ||
| public enum StoredCredentialState { | ||
| /// <summary>No usable stored token — the caller genuinely is not logged in.</summary> | ||
| Missing, | ||
|
|
||
| /// <summary>A token is stored but was minted by a different server than the target.</summary> | ||
| WrongServer, | ||
|
|
||
| /// <summary>A token is stored but expired (and the recovery's refresh evidently failed).</summary> | ||
| Expired, | ||
|
|
||
| /// <summary>The stored token is unexpired and bound to the target — the server rejected a | ||
| /// credential that looks perfectly valid locally.</summary> | ||
| LooksValid, | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builds the user-facing line for a request the server answered 401 AFTER the one-shot | ||
| /// re-read-and-refresh recovery (<c>TokenStore.RecoverForServerAsync</c>) already ran. | ||
| /// | ||
| /// <para>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 | ||
| /// <c>kcap status</c> concurrently (and correctly) reported as valid, so the user read the | ||
| /// two surfaces as contradicting each other, concluded <c>kcap login</c> 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.</para> | ||
| /// </summary> | ||
| public static class AuthRejectionNotice { | ||
| public const string NotLoggedIn = "Not logged in. Run 'kcap login' on the host shell."; | ||
|
|
||
| /// <summary>Pure classification of a raw store snapshot against the request's target server.</summary> | ||
| 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.", | ||
| }; | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| public static async Task<string> 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; | ||
| } | ||
|
Comment on lines
+85
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Token-store io masked AuthRejectionNotice.ForPersistentUnauthorizedAsync catches all exceptions and returns the legacy NotLoggedIn text, masking real IO/permission/cancellation failures during profile/token reads as “not logged in”. This contradicts TokenStore’s documented rule that real IO/permission faults must not be masked as unauthenticated, and it removes critical debugging signal. Agent Prompt
|
||
| } | ||
|
|
||
| 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"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -246,7 +246,7 @@ async Task<string> 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Capability 401 misdiagnosed In McpFlowResultServer borrowed-reviewer (daemon capability) mode, a 401 now calls AuthRejectionNotice.ForPersistentUnauthorizedAsync(apiRoot), which reads the local token store and can render “WrongServer/LooksValid” diagnoses even though this process intentionally does not authenticate and may not even be talking to the server. This can mislead users and add unnecessary IO on the only result-delivery channel. Agent Prompt
|
||
|
|
||
| var errorNode = TryParse(responseBody); | ||
| var code = errorNode?["error"]?.GetValue<string>(); | ||
|
|
@@ -322,7 +322,7 @@ async Task<string> 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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Verbose authrejectionnotice xml comment
📘 Rule violation⚙ MaintainabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools