Skip to content

fix(host-service): refresh stale auth token on remote workspace ops - #4106

Merged
saddlepaddle merged 1 commit into
mainfrom
fix-remote-token-refresh
May 5, 2026
Merged

saddlepaddle merged 1 commit into
mainfrom
fix-remote-token-refresh

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented May 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Workspace create/delete against remote hosts was failing with "Failed to register host: Not authenticated. Provide a bearer JWT, x-api-key, or session." once the host-service's session token aged out.
  • The host-service's JwtApiAuthProvider captured the token at child-process spawn time and never observed re-logins in the desktop renderer (which rewrite auth-token.enc). The cached JWT also had no 401-driven invalidation, so once the cloud started rejecting it (clock skew, JWKS rotation, mid-flight session revoke) every retry served the same stale value.
  • Provider now takes a getSessionToken: () => Promise<string> loader consulted on each cache miss, exposes invalidateCache(), and createApiClient wraps httpBatchLink with a one-shot retry-on-UNAUTHORIZED link that drops the cache and retries.
  • Desktop entry's loader reads auth-token.enc from disk; standalone serve.ts keeps the env-var path.

Test plan

Verified end-to-end against https://api.superset.sh with the user's real auth token:

  • Cache hit/miss: loader fires once on cold start, cached on hit, re-invoked after invalidateCache().
  • Loader rotation pickup: rotating the loader's return value + invalidateCache causes the next mint to use the new value.
  • Retry link on UNAUTHORIZED: retries exactly once, calls invalidateCache.
  • Retry link ignores non-401: a 500 surfaces immediately with no retry.
  • End-to-end recovery from poisoned cache: corrupted the cached JWT, made a real host.list call, received a real 401 from the cloud, link invalidated and re-minted, request returned the user's actual hosts.
  • Lint, typecheck, and full host-service test suite (499 pass, 0 fail) green.

Out of scope (follow-ups)

  • Cross-user re-login on the same machine leaves the cached JWT (with the old user's identity) valid until expiry — the cloud accepts it because it's cryptographically valid. Real fix is coordinator-driven host-service restart on auth changes.

Summary by cubic

Fixes remote workspace create/delete failures caused by stale auth tokens by loading fresh session tokens and retrying once on 401. This makes @superset/host-service recover automatically from expired or rotated credentials.

  • Bug Fixes
    • JwtApiAuthProvider now accepts getSessionToken to fetch a fresh token on cache miss and exposes invalidateCache().
    • Added one-shot retry-on-UNAUTHORIZED in createApiClient that drops the JWT cache and retries, wrapping httpBatchLink.
    • Desktop entry reads auth-token.enc for token minting; serve.ts continues using the env var path.

Written for commit b8ad7c1. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Implemented automatic retry mechanism for failed authentication requests, improving resilience to temporary authorization issues.
    • Added dynamic session token retrieval with automatic fallback to environment configuration.
  • Bug Fixes

    • Enhanced authentication flow to handle and recover from unauthorized errors more gracefully.

Workspace create/delete on remote hosts surfaced "Failed to register
host: Not authenticated" once the host-service's session token aged
out. Two layered causes, both inside the auth provider:

1. JwtApiAuthProvider held the session token captured at child-process
   spawn time as a `private readonly` field, so re-logins in the
   desktop renderer (which rewrite auth-token.enc) were never observed.
2. The cached JWT had no error-driven invalidation — once the cloud
   started rejecting it (clock skew, JWKS rotation, mid-flight session
   revoke) every retry served the same stale value.

Provider now takes a `getSessionToken: () => Promise<string>` loader
that's consulted on each cache miss, so token rotations are picked up
without restarting the host-service. Adds `invalidateCache()` and a
one-shot retry-on-UNAUTHORIZED tRPC link in `createApiClient` that
drops the cache and retries when the cloud rejects.

Desktop entry's loader reads `auth-token.enc` from disk; standalone
`serve.ts` keeps the env-var path. Verified end-to-end against prod
api.superset.sh: poisoning the cached JWT triggers a real 401, the
link invalidates and re-mints, and the request succeeds.
@coderabbitai

coderabbitai Bot commented May 5, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b8dd783-1e78-4b53-a707-56c8826d84fc

📥 Commits

Reviewing files that changed from the base of the PR and between ed48e49 and b8ad7c1.

📒 Files selected for processing (6)
  • apps/desktop/src/main/host-service/index.ts
  • packages/host-service/src/api/createApiClient/createApiClient.ts
  • packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts
  • packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts
  • packages/host-service/src/providers/auth/types.ts
  • packages/host-service/src/serve.ts

📝 Walkthrough

Walkthrough

The PR implements automatic retry on 401 unauthorized responses by refactoring authentication providers to support dynamic token retrieval and adding a cache invalidation mechanism. A new retry link intercepts 401 errors, clears cached credentials, and retries the request once.

Changes

Authentication & Retry Flow

Layer / File(s) Summary
Contract / Interface Updates
packages/host-service/src/providers/auth/types.ts
ApiAuthProvider interface adds invalidateCache(): void method to enable explicit cache clearing.
Auth Provider Refactoring
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts, packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts
JwtApiAuthProvider refactored to accept JwtApiAuthProviderOptions with async getSessionToken callback instead of static token string; invalidateCache() added to both providers to support cache clearing on 401.
Retry Link Implementation
packages/host-service/src/api/createApiClient/createApiClient.ts
New retryOnUnauthorizedLink detects 401 errors via isUnauthorizedError helper, invalidates auth cache, and retries request once; composed as first link in createApiClient before httpBatchLink.
Integration & Wiring
packages/host-service/src/serve.ts, apps/desktop/src/main/host-service/index.ts
Both files update JwtApiAuthProvider instantiation to use options object with getSessionToken callback and apiUrl; desktop/index.ts adds loadToken utility and fallback to env.AUTH_TOKEN.

Sequence Diagram

sequenceDiagram
    participant Client as TRPC Client
    participant RetryLink as Retry Link
    participant AuthProvider as Auth Provider
    participant API as API Server
    
    Client->>RetryLink: Make request
    RetryLink->>API: Forward request (with cached JWT)
    API-->>RetryLink: 401 Unauthorized
    
    RetryLink->>AuthProvider: invalidateCache()
    AuthProvider->>AuthProvider: Clear JWT cache
    
    RetryLink->>AuthProvider: getHeaders()
    AuthProvider->>AuthProvider: getSessionToken() [fresh]
    AuthProvider-->>RetryLink: New JWT
    
    RetryLink->>API: Retry request (with fresh JWT)
    API-->>RetryLink: 200 Success
    RetryLink-->>Client: Forward response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A token grows old, the server says "no!"
But now we've got tricks to make retries go—
Clear the old cache, fetch anew with a bound,
One second attempt, and success is found!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: refreshing stale auth tokens on remote workspace operations, which directly addresses the core problem solved by this PR.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem statement, solution approach, detailed test plan with verification steps, and known limitations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-remote-token-refresh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 5, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@saddlepaddle
saddlepaddle merged commit 6b6ff07 into main May 5, 2026
17 checks passed
@greptile-apps

greptile-apps Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes stale-auth failures on remote workspace operations by making JwtApiAuthProvider accept a lazy getSessionToken loader instead of a fixed string, and wrapping the tRPC client with a one-shot retry link that drops the JWT cache and replays the request on 401.

  • JwtApiAuthProvider refactor: Constructor now takes { getSessionToken, apiUrl }; adds invalidateCache() to clear cachedJwt; the desktop entry reads fresh from auth-token.enc on every cache miss so re-logins are picked up without restarting the child process.
  • retryOnUnauthorizedLink: New tRPC link placed before httpBatchLink that intercepts UNAUTHORIZED errors, calls invalidateCache(), and retries exactly once, preventing stale-JWT 401s from surfacing in user-facing flows.
  • Interface update: ApiAuthProvider gains a required invalidateCache() method; DeviceKeyApiAuthProvider gets a no-op implementation.

Confidence Score: 4/5

The retry and cache-invalidation mechanism is correct end-to-end; the fix addresses the stated stale-token failure cleanly.

Two non-blocking issues exist: the OAuth JWT passthrough path never populates cachedJwt so the desktop loader incurs a disk read on every getJwt() call rather than only on cache misses, and the retry link reassigns subscription without an explicit prior unsubscribe, which is safe today but leaves a subtle timing edge under concurrent cancellation. Neither blocks correctness of the main fix.

JwtAuthProvider.ts (JWT passthrough caching) and createApiClient.ts (subscription teardown in retry link) warrant a second look.

Important Files Changed

Filename Overview
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts Refactored to accept a lazy getSessionToken loader; adds invalidateCache(). JWT passthrough path (looksLikeJwt) now skips populating cachedJwt, causing a disk read per API call for OAuth JWT users.
packages/host-service/src/api/createApiClient/createApiClient.ts Adds a one-shot retryOnUnauthorizedLink that calls invalidateCache() and retries on 401; subscription variable is reassigned on retry without explicit prior unsubscribe.
apps/desktop/src/main/host-service/index.ts Wires getSessionToken to read auth-token.enc from disk on each cache miss, with env.AUTH_TOKEN as a fallback.
packages/host-service/src/providers/auth/types.ts Adds invalidateCache() to the ApiAuthProvider interface with clear JSDoc.
packages/host-service/src/providers/auth/DeviceKeyAuthProvider/DeviceKeyAuthProvider.ts Adds no-op invalidateCache() to satisfy the updated interface; correct for device keys that do not expire.
packages/host-service/src/serve.ts Updates standalone serve.ts to use the new options-object constructor with a trivial getSessionToken that wraps the env var.

Sequence Diagram

sequenceDiagram
    participant C as Caller
    participant R as retryOnUnauthorizedLink
    participant H as httpBatchLink
    participant P as JwtApiAuthProvider
    participant D as disk (auth-token.enc)
    participant API as Cloud API

    C->>R: op (e.g. host.list)
    R->>H: next(op) [attempt 1]
    H->>P: getHeaders()
    P->>D: getSessionToken() [cache miss]
    D-->>P: stale session token
    P->>API: POST /api/auth/token
    API-->>P: JWT (now stale)
    P-->>H: Authorization: Bearer stale-jwt
    H->>API: tRPC request
    API-->>H: 401 UNAUTHORIZED
    H-->>R: error(UNAUTHORIZED)
    R->>P: invalidateCache()
    R->>H: next(op) [attempt 2 - retry]
    H->>P: getHeaders()
    P->>D: getSessionToken() [cache cleared]
    D-->>P: fresh session token
    P->>API: POST /api/auth/token
    API-->>P: new JWT
    P-->>H: Authorization: Bearer fresh-jwt
    H->>API: tRPC request
    API-->>H: 200 OK
    H-->>R: value
    R-->>C: value
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/host-service/src/api/createApiClient/createApiClient.ts:29-42
**Previous subscription not unsubscribed before retry reassignment**

When a 401 fires, the error handler reassigns `subscription` via `start()` without first calling `subscription?.unsubscribe()` on the still-live (erroring) reference. In tRPC's observable model an errored subscription is terminal and won't emit further, so no events leak — but if the outer teardown (`() => subscription?.unsubscribe()`) is invoked by the tRPC runtime at the same instant the error handler is mid-execution (e.g. request cancellation racing with a 401 response), the cleanup function would already point to the old subscription, leaving the newly started retry un-cancellable. Explicitly unsubscribing the previous handle before `start()` removes the ambiguity.

### Issue 2 of 2
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts:58-60
**OAuth JWT passthrough never populates `cachedJwt`**

When `sessionToken` passes `looksLikeJwt`, the value is returned immediately without assigning `this.cachedJwt`. Because `cachedJwt` stays `null`, the top-of-function cache check never hits for OAuth JWT users, so `getSessionToken()` (a disk read in the desktop entry) is called on every `getJwt()` invocation. Previously, `this.sessionToken` was an in-memory field. For a low-frequency desktop tool this is unlikely to surface, but populating `cachedJwt` on this path would restore parity while still allowing `invalidateCache()` to force a disk re-read when a re-login is detected.

Reviews (1): Last reviewed commit: "fix(host-service): refresh stale auth to..." | Re-trigger Greptile

Comment on lines +29 to +42
const start = () => {
subscription = next(op).subscribe({
next: (value) => observer.next(value),
error: (err) => {
if (!attempted && isUnauthorizedError(err)) {
attempted = true;
authProvider.invalidateCache();
start();
return;
}
observer.error(err);
},
complete: () => observer.complete(),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Previous subscription not unsubscribed before retry reassignment

When a 401 fires, the error handler reassigns subscription via start() without first calling subscription?.unsubscribe() on the still-live (erroring) reference. In tRPC's observable model an errored subscription is terminal and won't emit further, so no events leak — but if the outer teardown (() => subscription?.unsubscribe()) is invoked by the tRPC runtime at the same instant the error handler is mid-execution (e.g. request cancellation racing with a 401 response), the cleanup function would already point to the old subscription, leaving the newly started retry un-cancellable. Explicitly unsubscribing the previous handle before start() removes the ambiguity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/api/createApiClient/createApiClient.ts
Line: 29-42

Comment:
**Previous subscription not unsubscribed before retry reassignment**

When a 401 fires, the error handler reassigns `subscription` via `start()` without first calling `subscription?.unsubscribe()` on the still-live (erroring) reference. In tRPC's observable model an errored subscription is terminal and won't emit further, so no events leak — but if the outer teardown (`() => subscription?.unsubscribe()`) is invoked by the tRPC runtime at the same instant the error handler is mid-execution (e.g. request cancellation racing with a 401 response), the cleanup function would already point to the old subscription, leaving the newly started retry un-cancellable. Explicitly unsubscribing the previous handle before `start()` removes the ambiguity.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +58 to 60
if (looksLikeJwt(sessionToken)) {
return sessionToken;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 OAuth JWT passthrough never populates cachedJwt

When sessionToken passes looksLikeJwt, the value is returned immediately without assigning this.cachedJwt. Because cachedJwt stays null, the top-of-function cache check never hits for OAuth JWT users, so getSessionToken() (a disk read in the desktop entry) is called on every getJwt() invocation. Previously, this.sessionToken was an in-memory field. For a low-frequency desktop tool this is unlikely to surface, but populating cachedJwt on this path would restore parity while still allowing invalidateCache() to force a disk re-read when a re-login is detected.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts
Line: 58-60

Comment:
**OAuth JWT passthrough never populates `cachedJwt`**

When `sessionToken` passes `looksLikeJwt`, the value is returned immediately without assigning `this.cachedJwt`. Because `cachedJwt` stays `null`, the top-of-function cache check never hits for OAuth JWT users, so `getSessionToken()` (a disk read in the desktop entry) is called on every `getJwt()` invocation. Previously, `this.sessionToken` was an in-memory field. For a low-frequency desktop tool this is unlikely to surface, but populating `cachedJwt` on this path would restore parity while still allowing `invalidateCache()` to force a disk re-read when a re-login is detected.

How can I resolve this? If you propose a fix, please make it concise.

@saddlepaddle saddlepaddle mentioned this pull request May 5, 2026
3 tasks
saddlepaddle added a commit that referenced this pull request May 5, 2026
Changes since v0.2.8:

- `superset agents list` (and demoted "presets" to a UI-only concept).
  CLI now reads canonical agents from host-service. (#4097)
- Host-service: refresh stale OAuth access tokens on remote workspace
  ops instead of failing the request. (#4106)
- Host-service: workspace.create now adopts an existing worktree at any
  path, not just the canonical one. (#4096)
- Host-service: AI workspace naming times out after 5s and falls back
  to a deterministic name. (#4102)

Push cli-v0.2.9 after this lands to fire the release pipeline.
saddlepaddle added a commit that referenced this pull request May 6, 2026
…4106)

Workspace create/delete on remote hosts surfaced "Failed to register
host: Not authenticated" once the host-service's session token aged
out. Two layered causes, both inside the auth provider:

1. JwtApiAuthProvider held the session token captured at child-process
   spawn time as a `private readonly` field, so re-logins in the
   desktop renderer (which rewrite auth-token.enc) were never observed.
2. The cached JWT had no error-driven invalidation — once the cloud
   started rejecting it (clock skew, JWKS rotation, mid-flight session
   revoke) every retry served the same stale value.

Provider now takes a `getSessionToken: () => Promise<string>` loader
that's consulted on each cache miss, so token rotations are picked up
without restarting the host-service. Adds `invalidateCache()` and a
one-shot retry-on-UNAUTHORIZED tRPC link in `createApiClient` that
drops the cache and retries when the cloud rejects.

Desktop entry's loader reads `auth-token.enc` from disk; standalone
`serve.ts` keeps the env-var path. Verified end-to-end against prod
api.superset.sh: poisoning the cached JWT triggers a real 401, the
link invalidates and re-mints, and the request succeeds.
saddlepaddle added a commit that referenced this pull request May 6, 2026
Changes since v0.2.8:

- `superset agents list` (and demoted "presets" to a UI-only concept).
  CLI now reads canonical agents from host-service. (#4097)
- Host-service: refresh stale OAuth access tokens on remote workspace
  ops instead of failing the request. (#4106)
- Host-service: workspace.create now adopts an existing worktree at any
  path, not just the canonical one. (#4096)
- Host-service: AI workspace naming times out after 5s and falls back
  to a deterministic name. (#4102)

Push cli-v0.2.9 after this lands to fire the release pipeline.
@Kitenite
Kitenite deleted the fix-remote-token-refresh branch May 6, 2026 04:50
MocA-Love added a commit to MocA-Love/superset that referenced this pull request May 25, 2026

This branch was previously deployed

1 inactive deployment
preview — b8ad7c1a Deployed May 5, 2026 by saddlepaddle via Deploy Web #4937
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.

1 participant