Skip to content

Bind stored tokens to their minting server; make whoami tell the truth - #387

Open
realtonyyoung wants to merge 6 commits into
mainfrom
tonyyoung/ai-1504-token-server-binding
Open

Bind stored tokens to their minting server; make whoami tell the truth#387
realtonyyoung wants to merge 6 commits into
mainfrom
tonyyoung/ai-1504-token-server-binding

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

Closes #386
Linear: AI-1504

Spec (codex-reviewed clean over 4 rounds): docs/superpowers/specs/2026-07-28-ai1504-token-server-binding-design.md in kcap-server.

Why

A stored token carried no record of which server issued it. A multi-profile config — or a single profile whose server_url was re-pointed after login — would send one server's token to another. The server rejects it with a bare 401, and /auth/refresh can't heal it (it validates the token's own signature), so the CLI reported "not authenticated" for a token that was perfectly valid somewhere else.

kcap whoami made that much harder to diagnose than it should have been: it only printed local file metadata, so it called such a token valid while every request was being rejected. That gap is what turned the original incident into a multi-day misdiagnosis.

What changed

D0 — canonical server identity. One ServerIdentity helper: scheme + host + effective port + case-sensitive path. Userinfo, query and fragment are rejected rather than dropped, so …/base?tenant=a and ?tenant=b can't compare equal. A non-canonicalizable side never matches (fail closed).

D1 — tokens bound to their minting server. StoredTokens gains a nullable server_url, stamped at login and carried through refresh. Stamping authority is explicit per site — generic SaveAsync never infers it from ambient state, and the localhost fallback is never mistaken for evidence. GitHub refresh now targets tokens.ServerUrl first, so a bound token can't be posted to a server that never issued it.

Enforcement lives in one accessor, GetValidTokensForServerAsync, which is now the only way a bearer reaches a request. It checks the binding on the raw snapshot before any refresh (a token we've already decided not to use shouldn't spend a rotating credential), and returns the issuing URL alongside the verdict so callers name both servers without re-reading storage. Every attachment site moved onto it: the HTTP client factory, daemon SignalR + event POSTs, the watcher, attachment downloads, and the setup ping.

Pre-upgrade tokens have no binding and stay unenforced until a login or unambiguous refresh stamps them; old CLIs ignore the new field.

D2 — lookup follows the resolved profile. TokenStore now prefers the profile the process resolved (KCAP_PROFILE, .kcap.json, git-remote, kcap use) over the on-disk active profile. Two carve-outs:

  • kcap setup deliberately configures the active profile, so it now does every token operation against that profile explicitly (no parameterless token calls remain in it).
  • The legacy tokens.json fallback is scoped to its migration owner. An unscoped fallback would hand that unbound credential to any repo-resolved profile lacking a token — and being unbound, D1 couldn't catch it. There is deliberately no "default" exemption.

D3 — whoami verifies. Prints the resolving profile, then asks the server. Only 401/403 are a verdict; unreachable, missing endpoint (older server), redirect and 5xx all report "could not verify" at exit 0, so nobody is sent to re-run kcap login over an outage.

D4 — one-shot 401 recovery, single owner. A DelegatingHandler refreshes once and resends. The refresh is conditional on the persisted token still being the rejected one, so a peer that already refreshed is adopted rather than rotated again — which for WorkOS (single-use refresh token) would invalidate the peer's session. The handler applies its own token per request (a stale client default would resend exactly what was just rejected), disposes the first 401, and resends exactly once, non-reentrantly.

Deviation from the spec, and why

The spec asserted MCP servers use the handler-free factory and could keep their retry loops untouched. They don't — all eight call CreateAuthenticatedClientAsync, so installing the handler there would have stacked two retry owners (the exact hazard the spec was trying to avoid). Resolved per the spec's own PR-audit rule — one retry owner per path — by having the MCP servers opt out of the handler (autoRetryUnauthorized: false) and keep their existing loops, now upgraded to the deduped force-refresh. The two MCP servers with no loop of their own keep the handler.

Testing

New: ServerIdentityTests (21), TokenServerBindingTests (15), UnauthorizedRetryHandlerTests (9), WhoamiProbeTests (12).

Key assertions were mutation-tested — skipping the retry header update, dropping the first-response dispose, and reverting the refresh dedup each fail a test. Tests that could hang on an unintended refresh point at an unroutable address so a regression fails in milliseconds.

Full unit suite vs. an origin/main baseline worktree on the same machine: 42 failures on this branch vs 72 on baseline, with zero failures unique to this branch (the remainder are pre-existing macOS environment flakes).

Two adjustments were needed because token lookup now consults process-global resolved state — correct in production (resolved once at startup) but leaky in a shared test process: the token-store test classes reset it, and McpFlowsServerTests.SeedDefaultTokenAsync clears it alongside the config.json it already cleared for the same reason. That was found by bisecting a real cross-test failure, not assumed.

AOT publish: 0 IL2026/IL3050 warnings. README + help-whoami/help-usage updated for the new whoami output and exit codes.

A stored token carried no record of which server issued it, so a multi-profile
config (or a profile whose server_url was re-pointed after login) would happily
send one server's token to another. The server rejects it with a bare 401, and
/auth/refresh cannot heal it — it validates the token's own signature — so the
CLI reported "not authenticated" for a token that was perfectly valid somewhere
else. `kcap whoami` made this much harder to diagnose than it should have been:
it only ever printed local file metadata, so it cheerfully called such a token
valid while every request was being rejected.

- Tokens now record the canonical URL of the server that minted them, compared
  via a single ServerIdentity helper (origin + case-sensitive path; userinfo,
  query and fragment are rejected rather than silently dropped, so two
  path-routed tenants can't collide). Pre-upgrade tokens have no binding and
  stay unenforced until a login or an unambiguous refresh stamps them.

- One accessor, GetValidTokensForServerAsync, is now the only way a bearer
  reaches a request. It checks the binding on the raw snapshot BEFORE any
  refresh, so a token we've already decided not to use never spends a rotating
  credential, and it returns the issuing URL alongside the verdict so callers
  can name both servers without re-reading storage. Every attachment site moved
  onto it: the HTTP client factory, daemon SignalR and event POSTs, the watcher,
  attachment downloads, and the setup ping.

- Token lookup follows the profile the process actually resolved rather than
  always the on-disk active profile. `kcap setup` is the exception that proves
  the rule: it deliberately configures the active profile, so it now performs
  every token operation against that profile explicitly. The legacy tokens.json
  fallback is scoped to its migration owner, since an unscoped fallback would
  hand that unbound credential to any repo-resolved profile lacking a token.

- whoami prints the resolving profile and asks the server whether it accepts the
  token. Only 401/403 are treated as a verdict; an unreachable server, a missing
  endpoint on an older server, a redirect or a 5xx all report "could not verify"
  and keep exit 0, so nobody is sent to re-run `kcap login` over an outage.

- Interactive clients recover from a 401 by refreshing once and resending. The
  refresh is conditional on the persisted token still being the rejected one, so
  a peer process that already refreshed is adopted instead of rotated again —
  which for WorkOS, whose refresh token is single-use, would invalidate the
  peer's session. Exactly one component owns 401-retry per client: the MCP
  servers keep their existing loops and opt out of the handler.

Closes #386
AI-1504
@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

AI-1504

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Bind tokens to issuing servers and verify whoami remotely

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Bind stored tokens to canonical minting-server identities and withhold mismatched credentials.
• Resolve credentials from process-selected profiles and constrain legacy-token migration ownership.
• Verify whoami remotely and retry eligible 401s once after deduplicated refresh.
Diagram

sequenceDiagram
    participant C as CLI Caller
    participant H as Auth Client
    participant T as Token Store
    participant P as Profile Config
    participant I as Server Identity
    participant R as Refresh Provider
    participant S as Target Server
    C->>H: Create for target
    H->>T: Resolve bearer
    T->>P: Select resolved profile
    P-->>T: Profile token path
    T->>I: Compare server binding
    alt Server mismatch
        I-->>T: Different server
        T-->>H: WrongServer
        H-->>C: Withhold bearer
    else Server matches
        I-->>T: Same server
        opt Token expired
            T->>R: Refresh bound token
            R-->>T: Persist refreshed token
        end
        T-->>H: Valid token
        H->>S: Authenticated request
        alt Server returns 401
            S-->>H: Unauthorized
            H->>T: Refresh rejected token
            T->>R: Conditional refresh
            R-->>H: Fresh or peer token
            H->>S: Retry once
        else Request accepted
            S-->>H: Response
        end
        H-->>C: Final response
    end
Loading
High-Level Assessment

The centralized server-aware token accessor is the strongest approach because it establishes one enforcement boundary for every bearer attachment path and checks mismatches before consuming refresh credentials. Inferring bindings inside generic persistence was appropriately avoided because ambient configuration can be ambiguous, while relying on server rejection or per-caller checks would leak credentials and duplicate security logic. Keeping pre-upgrade tokens temporarily unbound also provides a practical backward-compatible migration path.

Files changed (33) +1149 / -123

Enhancement (3) +202 / -0
ServerIdentity.csAdd canonical server identity comparison +43/-0

Add canonical server identity comparison

• Introduces fail-closed canonicalization for HTTP(S) server bases, normalizing scheme, host, effective port, and trailing slashes while preserving case-sensitive paths. URLs containing userinfo, queries, or fragments are rejected.

src/Capacitor.Cli.Core/Auth/ServerIdentity.cs

UnauthorizedRetryHandler.csRetry replayable requests once after a 401 +58/-0

Retry replayable requests once after a 401

• Adds a delegating handler that attributes a 401 to the token actually sent, conditionally refreshes it, and retries once with the replacement. Stream-backed request bodies are not replayed, and concurrent requests share the refreshed token safely.

src/Capacitor.Cli.Core/Auth/UnauthorizedRetryHandler.cs

WhoamiCommand.csVerify stored identity against the configured server +101/-0

Verify stored identity against the configured server

• Introduces a dedicated 'whoami' command that reports local token metadata and probes the server without redirects or refreshes. Only 401 and 403 are treated as credential rejection; outages, unavailable endpoints, redirects, and server errors remain non-fatal verification failures.

src/Capacitor.Cli/Commands/WhoamiCommand.cs

Bug fix (16) +310 / -85
OAuthLoginFlow.csStamp login tokens with server and profile identity +34/-20

Stamp login tokens with server and profile identity

• Passes optional profile targets through GitHub and WorkOS login flows. Newly exchanged tokens record the canonical Capacitor server URL, and setup can save credentials explicitly to its active profile.

src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs

TokenStore.csEnforce server-bound, resolved-profile token access +104/-9

Enforce server-bound, resolved-profile token access

• Adds persisted server bindings and a server-aware token resolution result that withholds mismatched credentials before refresh. Token lookup now follows the process-resolved profile, limits legacy fallback to its migration owner, refreshes GitHub tokens against their issuer, and deduplicates forced refreshes using the rejected access token.

src/Capacitor.Cli.Core/Auth/TokenStore.cs

WorkOSDiscovery.csBind tenant-switch tokens to tenant origins +4/-1

Bind tenant-switch tokens to tenant origins

• Records the selected tenant's canonical origin when persisting WorkOS tokens after an organization switch.

src/Capacitor.Cli.Core/Auth/WorkOSDiscovery.cs

HttpClientExtensions.csCentralize server-aware bearer attachment and 401 recovery +60/-14

Centralize server-aware bearer attachment and 401 recovery

• Adds 'WrongServer' authentication status and routes client creation through server-bound token resolution. Authenticated clients can install the one-time 401 retry handler, while callers with custom retry loops can opt out.

src/Capacitor.Cli.Core/HttpClientExtensions.cs

AgentOrchestrator.csGate attachment bearer tokens by server binding +3/-3

Gate attachment bearer tokens by server binding

• Uses server-aware token resolution before attaching credentials to daemon attachment downloads.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

ServerConnection.csProtect daemon SignalR and event credentials +5/-5

Protect daemon SignalR and event credentials

• Resolves tokens against the daemon's configured server before supplying SignalR access tokens or authorizing queued event posts.

src/Capacitor.Cli.Daemon/Services/ServerConnection.cs

ClaudeHookCommand.csAttribute hook retries to rejected bearer tokens +5/-5

Attribute hook retries to rejected bearer tokens

• Changes memory client factories from a boolean refresh flag to the specific rejected access token, enabling conditional refresh deduplication.

src/Capacitor.Cli/Commands/ClaudeHookCommand.cs

McpAnalyticsServer.csPrevent duplicate analytics MCP 401 retries +12/-2

Prevent duplicate analytics MCP 401 retries

• Disables the shared retry handler because this MCP server owns its retry loop. Its loop now force-refreshes the bearer actually rejected or adopts newly stored credentials when none were attached.

src/Capacitor.Cli/Commands/McpAnalyticsServer.cs

McpFlowResultServer.csDeduplicate flow-result MCP token refreshes +12/-2

Deduplicate flow-result MCP token refreshes

• Keeps 401 recovery under the MCP server's existing retry loop and refreshes conditionally using the rejected bearer token.

src/Capacitor.Cli/Commands/McpFlowResultServer.cs

McpFlowsServer.csDeduplicate flows MCP token refreshes +12/-2

Deduplicate flows MCP token refreshes

• Opts out of the generic retry handler and updates the existing 401 retry path to identify the rejected token before refreshing.

src/Capacitor.Cli/Commands/McpFlowsServer.cs

McpMemoryServer.csDeduplicate memory MCP token refreshes +12/-2

Deduplicate memory MCP token refreshes

• Prevents nested retry ownership and conditionally refreshes the token sent by the failed memory request.

src/Capacitor.Cli/Commands/McpMemoryServer.cs

McpSessionsServer.csDeduplicate sessions MCP token refreshes +12/-2

Deduplicate sessions MCP token refreshes

• Retains the sessions server's custom 401 loop while ensuring refresh is tied to the rejected bearer token.

src/Capacitor.Cli/Commands/McpSessionsServer.cs

McpWorkItemsServer.csDeduplicate work-items MCP token refreshes +12/-2

Deduplicate work-items MCP token refreshes

• Disables automatic handler retries and makes the existing retry loop refresh only the credential proven stale by a 401.

src/Capacitor.Cli/Commands/McpWorkItemsServer.cs

SetupCommand.csKeep setup token operations on the active profile +13/-11

Keep setup token operations on the active profile

• Makes setup explicitly load, save, and ping with the profile it configures instead of following repository-resolved state. Import eligibility also requires a valid token bound to the configured server.

src/Capacitor.Cli/Commands/SetupCommand.cs

WatchCommand.csBind watcher SignalR credentials to its server +2/-2

Bind watcher SignalR credentials to its server

• Uses server-aware token resolution before supplying a bearer to the watcher hub connection.

src/Capacitor.Cli/Commands/WatchCommand.cs

SessionStartMemoryContextProvider.csRefresh session memory with rejected-token context +8/-3

Refresh session memory with rejected-token context

• Passes the bearer used by a failed request into the retry client factory, allowing peer refresh adoption and avoiding unnecessary credential rotation.

src/Capacitor.Cli/SessionStartMemory/SessionStartMemoryContextProvider.cs

Refactor (2) +5 / -29
CursorHookCommand.csPropagate rejected-token retry contracts +3/-3

Propagate rejected-token retry contracts

• Updates Cursor hook and transcript backfill signatures to pass rejected access tokens through memory client creation.

src/Capacitor.Cli/Commands/CursorHookCommand.cs

Program.csDelegate whoami execution to the new command +2/-26

Delegate whoami execution to the new command

• Replaces inline local-token reporting with the server-verifying 'WhoamiCommand' implementation.

src/Capacitor.Cli/Program.cs

Tests (9) +620 / -6
AppConfig.csAdd resolved-state reset test seam +10/-0

Add resolved-state reset test seam

• Adds an internal helper that clears process-global server and profile resolution state so token-store tests remain isolated.

src/Capacitor.Cli.Core/Config/AppConfig.cs

CrossProcessRefreshTests.csReset resolved profiles in refresh tests +3/-0

Reset resolved profiles in refresh tests

• Clears process-global profile resolution during cleanup so cross-process refresh tests read their intended token files.

test/Capacitor.Cli.Tests.Unit/CrossProcessRefreshTests.cs

McpFlowsServerTests.csIsolate MCP flow token profile state +3/-1

Isolate MCP flow token profile state

• Resets the resolved profile while seeding default tokens, preventing state leaked from other tests from redirecting token lookup.

test/Capacitor.Cli.Tests.Unit/McpFlowsServerTests.cs

ServerIdentityTests.csCover canonical server identity rules +58/-0

Cover canonical server identity rules

• Tests default ports, casing, trailing slashes, path sensitivity, distinct origins, and rejection of inadmissible URL components. Also verifies canonicalization failures never compare equal.

test/Capacitor.Cli.Tests.Unit/ServerIdentityTests.cs

SessionStartMemoryFoundationTests.csVerify rejected-token propagation after 401 +9/-5

Verify rejected-token propagation after 401

• Updates the session-memory retry test to assert that the first request has no rejection context and the retry receives the bearer actually rejected.

test/Capacitor.Cli.Tests.Unit/SessionStartMemory/SessionStartMemoryFoundationTests.cs

TokenServerBindingTests.csCover token binding, profile lookup, and refresh deduplication +250/-0

Cover token binding, profile lookup, and refresh deduplication

• Adds comprehensive tests for withholding cross-server tokens, preserving unbound legacy compatibility, checking bindings before refresh, selecting resolved profiles, scoping legacy fallback, deduplicating forced refreshes, and persisting bindings.

test/Capacitor.Cli.Tests.Unit/TokenServerBindingTests.cs

TokenStoreProfileTests.csReset resolved state in profile tests +3/-0

Reset resolved state in profile tests

• Clears process-global profile resolution during cleanup to keep profile-scoped token assertions deterministic.

test/Capacitor.Cli.Tests.Unit/TokenStoreProfileTests.cs

UnauthorizedRetryHandlerTests.csExercise one-time 401 recovery and replay safety +222/-0

Exercise one-time 401 recovery and replay safety

• Tests token application, peer refresh adoption, single retry limits, response disposal, buffered-body replay, stream-body refusal, and concurrent request behavior using the real token store.

test/Capacitor.Cli.Tests.Unit/UnauthorizedRetryHandlerTests.cs

WhoamiProbeTests.csValidate whoami server-verdict classification +62/-0

Validate whoami server-verdict classification

• Confirms only 401 and 403 produce authentication failure, successful responses report acceptance, and transport, redirect, endpoint, or server failures remain unverified rather than rejected.

test/Capacitor.Cli.Tests.Unit/WhoamiProbeTests.cs

Documentation (3) +12 / -3
README.mdClarify whoami performs server verification +1/-1

Clarify whoami performs server verification

• Updates the command reference to explain that 'whoami' asks the configured server whether it accepts the stored token.

README.md

help-usage.txtDescribe whoami server verification in usage help +1/-1

Describe whoami server verification in usage help

• Updates the top-level command summary to state that 'whoami' verifies server acceptance of the token.

src/Capacitor.Cli.Core/Resources/help-usage.txt

help-whoami.txtDocument whoami verification behavior and exit codes +10/-1

Document whoami verification behavior and exit codes

• Explains the remote token check and distinguishes authentication failures from unavailable or unsupported verification endpoints.

src/Capacitor.Cli.Core/Resources/help-whoami.txt

@qodo-code-review

qodo-code-review Bot commented Jul 28, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Retry leaks replacement token ✓ Resolved 🐞 Bug ⛨ Security
Description
ForceRefreshAsync can adopt a newer persisted token based only on its access-token value, without
knowing whether its ServerUrl matches the rejected request's target. After a concurrent login or
profile update, long-lived clients targeting server A can retry a 401 using a server-B bearer.
Code

src/Capacitor.Cli.Core/Auth/TokenStore.cs[R358-359]

+    public static async Task<StoredTokens?> ForceRefreshAsync(
+            string rejectedAccessToken, CancellationToken ct = default) {
Evidence
ForceRefreshAsync accepts only the rejected token, and the lock helper returns a different latest
token without checking ServerUrl. The retry handler then installs that token and resends the same
request, while MCP retry paths perform the same unchecked replacement.

src/Capacitor.Cli.Core/Auth/TokenStore.cs[347-377]
src/Capacitor.Cli.Core/Auth/TokenStore.cs[513-525]
src/Capacitor.Cli.Core/Auth/UnauthorizedRetryHandler.cs[37-51]
src/Capacitor.Cli/Commands/McpFlowsServer.cs[293-317]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Forced refresh and peer-token adoption lack the target server needed to enforce binding. Propagate the request target into this API and refuse to return or attach any refreshed/adopted token that does not match it.

## Issue Context
Both `UnauthorizedRetryHandler` and manual MCP retry paths immediately attach the returned token to the original request target. Validation must cover peer-adoption as well as an actual provider refresh.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/TokenStore.cs[347-377]
- src/Capacitor.Cli.Core/Auth/TokenStore.cs[513-539]
- src/Capacitor.Cli.Core/Auth/UnauthorizedRetryHandler.cs[37-51]
- src/Capacitor.Cli/Commands/McpFlowsServer.cs[293-317]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Whoami comment recounts incident ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The added XML summary recounts a past multi-day incident instead of concisely describing
WhoamiCommand. This creates an unnecessarily verbose and non-durable source comment.
Code

src/Capacitor.Cli/Commands/WhoamiCommand.cs[R11-14]

+/// Printing local token metadata alone is misleading: "expires tomorrow" says only that a clock
+/// hasn't passed, not that any server accepts the token. That gap once turned a real 401 into a
+/// multi-day misdiagnosis, because whoami cheerfully reported a valid token for a server that was
+/// rejecting every request. So this asks the server directly.
Evidence
Rule 4 requires concise comments. The cited summary devotes four lines to explaining a past 401
incident and multi-day misdiagnosis rather than briefly documenting the class behavior.

CLAUDE.md: Keep Code Comments Concise and Free of Linear Issue Numbers
src/Capacitor.Cli/Commands/WhoamiCommand.cs[11-14]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `WhoamiCommand` XML summary contains a lengthy historical incident narrative instead of a concise description of the command's responsibility.

## Issue Context
Compliance rule 4 requires source comments to remain concise, necessary, and durable.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/WhoamiCommand.cs[8-15]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Invalid URLs erase binding ✓ Resolved 🐞 Bug ⛨ Security
Description
Login saves a null binding when an absolute HTTP(S) URL contains userinfo, a query, or a fragment,
because Canonicalize rejects these forms while null is treated as an unenforced legacy token. The
newly minted bearer can consequently be returned for and disclosed to a later, unrelated target
server.
Code

src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[327]

+                ServerUrl      = ServerIdentity.Canonicalize(serverUrl)
Evidence
The login exchange sends to the supplied URL and stores the nullable canonicalization result.
Canonicalize returns null for userinfo/query/fragment, IsAcceptableUrl still accepts such
absolute HTTP(S) URLs, and the binding accessor skips comparison whenever the stored value is null.

src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[307-329]
src/Capacitor.Cli.Core/Auth/ServerIdentity.cs[18-24]
src/Capacitor.Cli.Core/HttpClientExtensions.cs[211-217]
src/Capacitor.Cli.Core/Auth/TokenStore.cs[298-315]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Login persists a null `ServerUrl` when canonicalization fails. Null denotes an intentionally unenforced pre-upgrade token, so canonicalization failure must instead abort login before credentials are saved.

## Issue Context
The general HTTP URL validator accepts absolute HTTP(S) URLs containing components that `ServerIdentity` rejects. Apply the same hard validation at every login and tenant-switch stamping site, while preserving null only for tokens actually read from older files.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[298-329]
- src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[347-383]
- src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[545-573]
- src/Capacitor.Cli.Core/Auth/ServerIdentity.cs[18-30]
- src/Capacitor.Cli.Core/Auth/WorkOSDiscovery.cs[144-156]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Setup ping leaks token ✓ Resolved 🐞 Bug ⛨ Security
Description
PingCliSetupAsync directly loads the selected profile's token and attaches it without comparing
its binding to serverUrl. Re-running setup against a no-auth server leaves a non-expired token
from the prior server intact and posts that bearer to the new target.
Code

src/Capacitor.Cli/Commands/SetupCommand.cs[844]

+            var tokens = await TokenStore.LoadAsync(profile);
Evidence
Setup skips login when discovery reports provider None. Its later ping loads the profile token
directly and unconditionally sets the bearer when it is non-expired, bypassing the binding-aware
accessor added by this PR.

src/Capacitor.Cli/Commands/SetupCommand.cs[139-169]
src/Capacitor.Cli/Commands/SetupCommand.cs[833-860]
src/Capacitor.Cli.Core/Auth/TokenStore.cs[298-315]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The setup completion ping bypasses the target-aware token accessor. Attach authorization only when the selected profile has a valid token bound to the setup server, and omit it entirely for a `None` provider.

## Issue Context
The provider-None branch performs no login or token replacement, so an existing non-expired credential can remain in the active profile after setup changes its server.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/SetupCommand.cs[139-169]
- src/Capacitor.Cli/Commands/SetupCommand.cs[833-860]
- src/Capacitor.Cli.Core/Auth/TokenStore.cs[298-315]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Whoami Getting Started unchanged ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The PR updates the per-command whoami summary but leaves the Getting started guidance unchanged
despite adding a server probe and new exit-code behavior. Rule 5 requires user-facing CLI changes to
be reflected in both README locations.
Code

README.md[1082]

+kcap whoami         # show current identity + ask the server if it accepts your token
Evidence
Rule 5 requires every user-facing CLI behavior change to update both Getting started and the
relevant command section. The command section was changed at README line 1082, while Getting started
still only says to verify with kcap whoami at line 110 and does not describe the new
server-verification or exit-code behavior implemented by WhoamiCommand.

CLAUDE.md: Keep README Documentation Synchronized with User-Facing CLI Changes
README.md[110-110]
README.md[1082-1082]
src/Capacitor.Cli/Commands/WhoamiCommand.cs[40-81]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The changed `kcap whoami` behavior is documented only in the README's command listing, while the Getting started section still provides no explanation of server verification or its result semantics.

## Issue Context
`whoami` now probes the configured server and may return failure when a token is rejected or belongs to another server. Compliance rule 5 requires documentation updates in both Getting started and the relevant command section.

## Fix Focus Areas
- README.md[110-110]
- README.md[1078-1089]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Reload bypasses binding check ✓ Resolved 🐞 Bug ⛨ Security
Description
GetValidTokensForServerAsync validates one snapshot and then calls GetValidTokensAsync, which
independently resolves and reloads storage without rechecking the returned token against
targetBaseUrl. A concurrent login, refresh, or profile change can therefore make the accessor
return a different server's token as Ok.
Code

src/Capacitor.Cli.Core/Auth/TokenStore.cs[311]

+        var valid = await GetValidTokensAsync();
Evidence
The accessor checks snapshot.ServerUrl, but its returned valid comes from GetValidTokensAsync.
That method calls parameterless LoadAsync and resolves the profile again, after which the client
factory attaches any result marked Ok to the original base URL.

src/Capacitor.Cli.Core/Auth/TokenStore.cs[298-341]
src/Capacitor.Cli.Core/Auth/TokenStore.cs[211-215]
src/Capacitor.Cli.Core/HttpClientExtensions.cs[84-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The target-server check and the token eventually returned are based on separate storage reads. Keep profile resolution stable through refresh and verify the final token's binding before returning `AuthStatus.Ok`.

## Issue Context
Concurrent CLI, daemon, watcher, and MCP processes can replace the profile token during this interval. The central accessor must guarantee that the exact token in `TokenResolution.Tokens` matches the requested target.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/TokenStore.cs[298-345]
- src/Capacitor.Cli.Core/Auth/TokenStore.cs[465-539]
- src/Capacitor.Cli.Core/HttpClientExtensions.cs[84-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/Capacitor.Cli/Commands/WhoamiCommand.cs Outdated
Comment thread README.md
Comment thread src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs Outdated
Comment thread src/Capacitor.Cli.Core/Auth/TokenStore.cs Outdated
Comment thread src/Capacitor.Cli.Core/Auth/TokenStore.cs Outdated
Comment thread src/Capacitor.Cli/Commands/SetupCommand.cs
Swapping the MCP servers' 401 recovery from "re-read the store" to "force a
refresh" quietly removed a case the original recovery covered: a token the
server rejects while the refresh endpoint is unreachable or refuses. The old
path resent whatever was stored and succeeded against a server that had only
transiently rejected; the new one gave up as soon as the rotation failed, so a
long-lived MCP session surfaced "Not logged in" where it previously recovered.

Keep the rotation attempt — it is what heals a genuinely stale token — but treat
its failure as no worse than not having tried, falling back to the stored token
for the single retry.

Caught by the integration suite on CI (Refreshed_token_succeeds_after_401, in
both the flows and sessions MCP servers), which I had not run locally.
Eight findings, all real. The theme: checking a binding once, in one place, is
not the same as a token never reaching the wrong server.

- The accessor vetted its first snapshot, then re-read storage to load/refresh.
  A concurrent login or profile repoint between those reads produced a token for
  a different server carrying an Ok verdict. Re-check the token that is actually
  returned.

- Forced refresh may hand back a token a PEER persisted rather than one we
  rotated, and that peer could have logged into a different server. It now takes
  the target and refuses an adopted token that doesn't belong to it.

- The hook, spool and daemon drain paths classified only Expired and
  NotAuthenticated as an auth lapse, so a WrongServer client posted anyway; the
  resulting 401 read as permanent and the spool DROPPED queued lifecycle and
  transcript data over a fixable profile mismatch.

- Setup's GitHub discovery activates the tenant you pick, which makes the profile
  captured before it ran stale — the freshly minted token was being saved under
  the previous profile while setup went on to configure the new one. Re-read the
  active profile after discovery.

- The setup completion ping loaded a profile's token directly, so re-pointing an
  authenticated profile at another server sent the old server's bearer to the new
  one. Checked inline, keeping the ping refresh-free and time-bound.

- The MCP retry helpers' no-token fallback attached whatever was stored without
  checking it against that server's fixed base URL. They now take the base URL
  and resolve through the server-aware accessor.

- The retry handler only replayed ByteArrayContent, silently skipping recovery
  for the two MCP servers that post JsonContent and own no retry loop of their
  own. JsonContent re-serializes per send, so it is replayable.

- whoami claimed to work from one raw, non-refreshing snapshot but went through
  the refresh-aware accessor, so diagnosing your auth could rotate a single-use
  WorkOS refresh token and print one token's expiry while probing another's.
… call

Two more from review.

A failed forced refresh fell through to the refresh-aware accessor, which saw
the same expired token and refreshed again — two provider calls for one
rejection, and for WorkOS that re-spends a refresh token the provider only
honours once. Recovery is now a single operation: attempt the rotation, and on
failure fall back to a RAW read rather than to an accessor that can refresh.

The accessor also re-resolved the profile for its second read, so a concurrent
`kcap use` could switch profiles mid-call and return B's token while the result
still reported profile A. Load-and-refresh is now pinned to the profile resolved
once at entry, and whoami reads the raw snapshot for that same profile.

Deliberately NOT adopted: the suggestion that recovery withhold a stored token
identical to the rejected one. That is a separate behaviour change from the
double-refresh fix, and it regresses a shipped recovery the integration suite
pins (Refreshed_token_succeeds_after_401) — a server that rejects transiently,
during a rolling restart or from a node with stale key material, accepts the
very next attempt. Callers retry at most once either way, so the cost of trying
is one request; the cost of not trying is a session that stays broken.
Recovery resolved the active profile twice: once inside the forced rotation and
again for the raw fallback. A `kcap use` landing during a slow rotation could
therefore rotate profile A and then fall back to profile B's token — and when
both profiles point at the same server the binding check has nothing to object
to, so a long-lived client would quietly switch identity mid-session.

Resolve once at entry and thread that profile through a profile-pinned rotation
core, mirroring what the accessor already does.
…a bearer

From Qodo review.

A null server_url means "pre-upgrade token, binding unenforced". Login stamped
it with whatever Canonicalize returned — including null, for a URL carrying user
info, a query or a fragment. That silently minted a NEW credential in the legacy
unenforced state, free to be sent to any later target. Login and tenant-switch
now fail with a message naming the offending URL rather than saving a token they
cannot bind.

The setup completion ping also attached a bearer when the target server's
provider is None. Setup performs no login on that path, so the token still in the
profile belongs to whatever server it pointed at before — the ping would disclose
it to an unrelated host. Skip it.

Also: trim the whoami summary to what the class does rather than the incident
that motivated it, and document the new probe and its exit codes in the README's
Getting started section, not only the command listing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stored tokens are not bound to the server that issued them; whoami never verifies with the server

1 participant