Repository navigation
Send the WorkOS refresh once and stop re-sending a refused token - #875
Conversation
A retried refresh re-presents a token WorkOS already consumed, tripping reuse detection and revoking the family; callers now separate a refused token from a transport blip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A WorkOS refusal (invalid_grant) means the refresh token is dead, so the daemon loop backs off an hour and points at `kcap login` rather than re-sending it every interval. The proactive window drops to 2 minutes — still above the 60s tick plus the 30s reactive margin — so a token minted at login is no longer refreshed on the spot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 4xx means the refresh token was refused (terminal, needs kcap login); a 5xx/408/429 is the server faltering, so it maps to TransportFailed and the normal retry cadence rather than the hour-long rejected backoff.
PR Summary by QodoPrevent repeated WorkOS refresh-token reuse
AI Description
Diagram
High-Level Assessment
Files changed (11)
|
Code Review by Qodo
1.
|
The shared HttpClient keeps the 100s default and a refresh runs under the cross-process lock, so a stalled WorkOS would hold auth and every peer's refresh for that long; a linked CTS bounds the one attempt and a timeout reads as TransportFailed.
A success status proves WorkOS consumed and rotated the token; if the new one is unreadable it is lost and the old one spent, so re-sending it would trip reuse detection. Map that to Rejected, not TransportFailed.
|
Ran a Codex review of this branch (
Qodo's other two comments were also addressed in this push: an unreadable WorkOS success now maps to |
Closes #867 — AI-2691
What & why
The shared single-use WorkOS refresh token was being re-sent after WorkOS had already consumed it, which trips AuthKit reuse-detection and revokes the whole token family — the "I have to run
kcap loginevery few minutes" bug. The per-profile file lock already serialized concurrent refreshers correctly; the gap was re-sending a consumed token. Two changes close it: the refresh POST is now single-shot (a single-use token must be at-most-once on the wire — a retry re-presents a token a lost/timed-out first attempt may already have consumed), and its outcome is classified so a refusal is distinguishable from a blip. A refused refresh token (4xx) is terminal: the daemon's proactive loop logs one clear "runkcap login" and backs off an hour instead of re-sending the dead token every few minutes; a transient 5xx/408/429 stays a retryable transport failure. The proactive window drops from 5 minutes to 2 so a token minted seconds ago at login is not refreshed on the spot.Where to look
WorkOSClient.RefreshAsyncreturns a typedWorkOSRefreshResult(Rotated / Rejected / TransportFailed); the login-flow callers keep their old behavior by reading.Response.RefreshIfExpiringAsyncthreads the WorkOS classification through a captured-outcome callback, mirroring the existingonLockContendedpattern, so a null result plus a Rejected outcome is a genuine refusal (a peer-refresh or still-fresh re-read returns non-null without calling the delegate).Not in scope (follow-ups, AI-2691's analysis Fix 4/5): a cross-process "auth broken" marker so hooks/app also stop on a refusal, and making the daemon the sole refresher. This PR closes the primary re-send and the daemon-loop escalation.
Verification
RefreshIfExpiringAsync400 →ProactiveRefreshOutcome.Rejected; the 2-minute window boundary.TokenRefreshLoopTests: a Rejected tick logs once and arms the hour-long backoff.