Skip to content

Refresh WorkOS token during kcap setup provisioning poll - #259

Merged
alexeyzimarev merged 2 commits into
mainfrom
fix/workos-token-refresh-provisioning-poll
Jul 3, 2026
Merged

alexeyzimarev merged 2 commits into
mainfrom
fix/workos-token-refresh-provisioning-poll

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Problem

During kcap setup (WorkOS org SSO, no existing tenant) the CLI offers to create a tenant, provisions it, then polls GET /api/signup/status for it to go live. The poll never completes even after the tenant is provisioned and live — the spinner sits at "Provisioning {slug}.kcap.ai…" until it times out (~10 min) and no profile is saved.

Root cause

The poll reused a single org-less WorkOS access token for the entire ~10-minute wait, but WorkOS AuthKit access tokens live only ~5 minutes. Once it expired, the signup server returned 401 for every status check, and GetStatusAsync swallowed any non-2xx to a null "still provisioning" — so the CLI spun silently until timeout even though the tenant had gone live.

Same poll, contributing defects: GetStatusAsync collapsed 401/403/404/transport-error all to null (zero diagnostics), and an active row without workosOrgId (which the server contract explicitly allows) was treated as not-done — another infinite-spin path.

Fix

  • WorkOSTokenSource — refreshes the access token (public-client refresh_token grant, OAuthLoginFlow.RefreshWorkOSTokenAsync) before it lapses; every provision/poll call pulls a fresh token. WorkOS rotates refresh tokens single-use, so the final org-switch now uses WorkOSTokenSource.CurrentRefreshToken, not the login-time value (otherwise the switch would 401 after a long poll).
  • GetStatusAsync → StatusOutcome surfaces the HTTP status; ProvisioningPoll.Classify turns each poll result into a verdict so 403/404/failed/active-without-org end with a clear message instead of a silent spin, and the spinner shows liveness per attempt.

Tests

  • WorkOSTokenSourceTests (6), ProvisioningPollTests (11), GetStatusAsync status-surfacing (TenantProvisioningClientTests), and a WorkOSDiscoveryTests guard proving the rotated refresh token is used for the org-switch. All written test-first (watched fail).
  • Full unit suite green (2152/2152). dotnet publish -c Release clean — no IL3050/IL2026 AOT warnings.

No README change: internal bugfix, no user-facing CLI surface change (no new/renamed command, flag, default, or prerequisite).

Closes #258
AI-1171

🤖 Generated with Claude Code

The create-a-tenant flow provisions then polls /api/signup/status for up to
~10 minutes, but it reused the single org-less WorkOS access token for the whole
wait. WorkOS AuthKit access tokens live only ~5 minutes, so it expired mid-poll;
the server then 401'd every status check, which GetStatusAsync swallowed to a null
"still provisioning", and the CLI spun silently until timeout even though the
tenant had gone live.

- WorkOSTokenSource: refreshes the access token (public-client refresh_token grant,
  OAuthLoginFlow.RefreshWorkOSTokenAsync) before it lapses; every provision/poll call
  pulls a fresh token. WorkOS rotates refresh tokens single-use, so the final
  org-switch now uses WorkOSTokenSource.CurrentRefreshToken, not the login-time one.
- GetStatusAsync surfaces the HTTP status (StatusOutcome); ProvisioningPoll.Classify
  turns each poll result into a verdict so 403/404/failed/active-without-org end with
  a clear message instead of an infinite silent spin, and the spinner shows liveness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Refresh WorkOS tokens during tenant provisioning poll in kcap setup

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Keep WorkOS org-less access tokens fresh during long-running tenant provisioning polls.
• Surface poll HTTP status to stop on terminal errors instead of silently spinning.
• Add unit coverage for token refresh/rotation and poll classification outcomes.
Diagram

graph TD
  A["WorkOSDiscovery"] --> B["SpectreTenantProvisioner"] --> C["WorkOSTokenSource"] --> D["TenantProvisioningClient"] --> E{{"kcap-web /api/signup/*"}}
  C --> F{{"WorkOS AuthKit"}}
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Refresh-on-401 only (reactive refresh)
  • ➕ Less refresh traffic during normal provisioning
  • ➕ Simpler mental model: only refresh when needed
  • ➖ Still risks multi-minute silent spins if 401 handling regresses or is swallowed
  • ➖ Harder to reason about token validity across multiple endpoints (availability/provision/status)
2. HttpClient auth handler (centralized token injection/refresh)
  • ➕ Removes need to thread token source through call sites
  • ➕ Standard pattern for bearer auth + refresh, reusable elsewhere
  • ➖ More intrusive refactor (client construction, handler lifetime, test complexity)
  • ➖ May be overkill for a single long-running flow
3. Server-side longer-lived session/token for provisioning
  • ➕ Eliminates client refresh concerns during long polls
  • ➕ Potentially improves robustness for other clients
  • ➖ Requires server changes and deployment coordination
  • ➖ May conflict with WorkOS/AuthKit token policy expectations

Recommendation: The PR’s approach (explicit WorkOSTokenSource + status-aware poll classification) is the best fit: it fixes the root cause (5-minute TTL vs ~10-minute poll), keeps the behavior local to the provisioning flow, and makes failure modes observable and testable without requiring broader auth refactors or server-side changes.

Files changed (11) +442 / -28

Bug fix (6) +162 / -21
OAuthLoginFlow.csAdd org-less WorkOS refresh_token grant helper +20/-0

Add org-less WorkOS refresh_token grant helper

• Introduces RefreshWorkOSTokenAsync to exchange a refresh token for a new org-less access token using the public-client refresh_token flow. Returns null on non-success responses to keep calling flows best-effort.

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

ProvisioningPoll.csExtract status-poll classification into a pure decision function +25/-0

Extract status-poll classification into a pure decision function

• Adds PollVerdict and ProvisioningPoll.Classify to map HTTP/status responses into terminal vs wait outcomes. Enables unit testing and prevents silent infinite spins on previously-collapsed error cases.

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

TenantProvisioningClient.csReturn StatusOutcome from GetStatusAsync to preserve HTTP status +15/-4

Return StatusOutcome from GetStatusAsync to preserve HTTP status

• Reworks GetStatusAsync to return an HTTP status code plus an optional parsed body instead of collapsing all failures to null. Treats transport/parse failures as StatusCode=0 so polling can decide whether to continue or stop.

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

WorkOSDiscovery.csWire token refresh into provisioning and use rotated refresh token for org switch +15/-2

Wire token refresh into provisioning and use rotated refresh token for org switch

• Adds an optional org-less refresh delegate and constructs a WorkOSTokenSource for provisioning/polling. Ensures the final org-switch uses the latest rotated refresh token from the token source to avoid post-poll 401s.

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

WorkOSTokenSource.csIntroduce WorkOSTokenSource to proactively refresh expiring access tokens +57/-0

Introduce WorkOSTokenSource to proactively refresh expiring access tokens

• Adds a small stateful token source that refreshes the org-less access token when nearing expiry and tracks rotated refresh tokens. Designed for serial use in the provisioning flow and degrades gracefully on refresh failures.

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

SpectreTenantProvisioner.csUse WorkOSTokenSource during provisioning/poll and stop on terminal poll outcomes +30/-15

Use WorkOSTokenSource during provisioning/poll and stop on terminal poll outcomes

• Updates the interactive provisioner to pull a fresh access token for availability, provision, and each poll iteration. Uses ProvisioningPoll.Classify to terminate on forbidden/not-found/failed/active-without-org and updates the spinner status each tick for liveness.

src/Capacitor.Cli/Commands/SpectreTenantProvisioner.cs

Refactor (1) +5 / -1
ITenantProvisioner.csPass a refreshing token source into the provisioner +5/-1

Pass a refreshing token source into the provisioner

• Changes the provisioning interface to accept a WorkOSTokenSource instead of a raw access token. Documents the long-poll TTL mismatch that necessitates per-call token refresh.

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

Tests (4) +275 / -6
ProvisioningPollTests.csAdd unit coverage for poll classification (terminal vs wait outcomes) +54/-0

Add unit coverage for poll classification (terminal vs wait outcomes)

• Introduces tests for ProvisioningPoll.Classify covering active/failed/provisioning/reserved as well as 401/403/404, transport failure (0), and server errors. Ensures previously-silent branches are explicitly handled.

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

TenantProvisioningClientTests.csUpdate status tests for StatusOutcome and add error/transport cases +46/-2

Update status tests for StatusOutcome and add error/transport cases

• Adjusts existing assertions to account for StatusOutcome (StatusCode + Body). Adds new tests verifying that 401 and 404 are surfaced and that transport failures map to StatusCode=0.

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

WorkOSDiscoveryTests.csVerify provisioning receives WorkOSTokenSource and org switch uses rotated refresh token +50/-4

Verify provisioning receives WorkOSTokenSource and org switch uses rotated refresh token

• Updates provisioner mocks to accept WorkOSTokenSource and asserts the source is seeded with the org-less login token. Adds a regression test ensuring the org-switch uses the rotated refresh token after a refresh during polling.

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

WorkOSTokenSourceTests.csAdd unit tests for proactive refresh and refresh-token rotation behavior +125/-0

Add unit tests for proactive refresh and refresh-token rotation behavior

• Adds coverage for refresh-before-expiry behavior, no-refresh when still valid, fallback when refresh fails, refresh token rotation across calls, CurrentRefreshToken semantics, and behavior with no refresh token.

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

@qodo-code-review

qodo-code-review Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used

Grey Divider


Action required

1. Refresh exceptions crash setup ✓ Resolved 🐞 Bug ☼ Reliability
Description
WorkOSTokenSource.GetAsync awaits the injected refresh delegate without guarding exceptions, so
any network/JSON error during refresh will bubble up and abort provisioning/polling.
OAuthLoginFlow.RefreshWorkOSTokenAsync performs HTTP + JSON without try/catch, making such
transient exceptions plausible and user-visible.
Code

src/Capacitor.Cli.Core/Auth/WorkOSTokenSource.cs[R44-53]

+    public async Task<string> GetAsync(CancellationToken ct) {
+        if (refreshToken is null) return accessToken;
+        if (now() < expiresAt - margin) return accessToken;
+
+        var refreshed = await refresh(refreshToken, ct);
+        if (refreshed is { AccessToken.Length: > 0 }) {
+            accessToken  = refreshed.AccessToken;
+            refreshToken = refreshed.RefreshToken ?? refreshToken;
+            expiresAt    = TokenStore.JwtExpiry(refreshed.AccessToken);
+        }
Evidence
WorkOSTokenSource.GetAsync has no exception handling around the refresh call, so exceptions
propagate. The wired refresh implementation (RefreshWorkOSTokenAsync) performs HTTP and JSON
parsing without any try/catch, so transient failures can throw and crash the provisioning flow.

src/Capacitor.Cli.Core/Auth/WorkOSTokenSource.cs[44-53]
src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[601-614]
src/Capacitor.Cli.Core/Auth/WorkOSDiscovery.cs[21-36]

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

### Issue description
`WorkOSTokenSource.GetAsync()` directly awaits the refresh delegate and will throw if the delegate throws (e.g., `HttpRequestException`, `JsonException`). This can terminate `kcap setup` mid-provisioning poll.

### Issue Context
The production refresh delegate is `WorkOSDiscovery.RunWithLiveAuthAsync` → `OAuthLoginFlow.RefreshWorkOSTokenAsync`, which currently does a `PostAsync` + `ReadFromJsonAsync` without exception handling.

### Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/WorkOSTokenSource.cs[44-55]
- src/Capacitor.Cli.Core/Auth/OAuthLoginFlow.cs[601-614]
- src/Capacitor.Cli.Core/Auth/WorkOSDiscovery.cs[21-36]

### Suggested fix
- Add a `try/catch` around `await refresh(refreshToken, ct)` inside `WorkOSTokenSource.GetAsync()` and treat failures as a failed refresh (return current `accessToken` without throwing).
- Optionally also harden `OAuthLoginFlow.RefreshWorkOSTokenAsync` to catch transient exceptions and return `null` (and consider adding a `CancellationToken` and disposing the `HttpResponseMessage` with `using`).

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


Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli.Core/Auth/WorkOSTokenSource.cs
The refresh runs automatically and repeatedly during the ~10-minute provisioning
poll, so a network/timeout/JSON blip in the refresh must not throw and abort the
whole flow (it contradicted WorkOSTokenSource.GetAsync's own "degrades to the
existing token" contract).

- WorkOSTokenSource.GetAsync now catches transient exceptions from the refresh
  delegate and degrades to the current token; a genuine ct cancellation still
  propagates (!ct.IsCancellationRequested).
- RefreshWorkOSTokenAsync wraps its HTTP + JSON in the same swallow-and-degrade
  guard as TenantProvisioningClient, returning null on transport/parse failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit e2496ab into main Jul 3, 2026
5 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/workos-token-refresh-provisioning-poll branch July 3, 2026 14:53
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.

kcap setup: create-tenant flow hangs at 'Provisioning…' and never completes

1 participant