Skip to content

fix(cli): mint a JWT for --host remote targets when using an API key - #6346

Open
andyst-dev wants to merge 6 commits into
superset-sh:mainfrom
andyst-dev:fix/cli-api-key-remote-host
Open

fix(cli): mint a JWT for --host remote targets when using an API key#6346
andyst-dev wants to merge 6 commits into
superset-sh:mainfrom
andyst-dev:fix/cli-api-key-remote-host

Conversation

@andyst-dev

@andyst-dev andyst-dev commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #6315

Problem

--api-key (and SUPERSET_API_KEY) is documented as a global option, but it is ignored on any command that targets a remote host via --host <machineId>. Those commands exit 1 with:

Error: Session expired
Hint: Run: superset auth login

The message is doubly misleading: the API key is not expired (the same key succeeds against the local host and against hosts list in the same shell), and no session was ever established.

Root cause

The relay authenticates host-service traffic only by verifying a JWKS-signed JWT. The --host <remote> transport (resolveHostTarget.ts) sent the raw bearer — an sk_live_… API key from --api-key / SUPERSET_API_KEY — straight into the Authorization: Bearer header. An API key is not a JWT, so the relay returned UNAUTHORIZED, and the CLI's generic handler rendered any UNAUTHORIZED as "Session expired".

Every other relay client already performs an API-key → JWT exchange (JwtAuthProvider.getJwt, packages/sdk/src/client.ts). The CLI's remote-host path was the missing one.

Fix

  • New packages/cli/src/lib/host-jwt.tsgetHostJwt(bearer), mirroring the exchange JwtAuthProvider and the SDK already perform:
    • JWT-shaped tokens (CLI OAuth access tokens are JWKS-signed) pass straight through, no exchange needed;
    • sk_live_ / sk_test_ API keys are exchanged for a JWT via GET {api}/api/auth/token with the key in the x-api-key header (better-auth's apiKey plugin reads that header, not Authorization), cached in-process ~55 min (5 min refresh buffer), keyed by credential.
  • resolveHostTarget.ts — the remote branch now mints the JWT inside an async headers() hook (tRPC's httpBatchLink supports an async headers function). This confines the change to the remote branch; all ~16 call sites (workspaces/*, projects/*, terminals/*, agents/*, automations/*, …) are untouched.

No relay or host-service change is needed — the minted JWT carries the same organizationIds/sub claims the relay's checkHostAccess already verifies.

Tests

packages/cli/src/lib/host-jwt.test.ts (6 cases, bun:test, mock fetch + getApiUrl):

  • OAuth JWT passes through with no exchange;
  • sk_live_ / sk_test_ keys are exchanged via /api/auth/token with x-api-key;
  • minted JWT is cached per credential and reused (single fetch);
  • different credentials do not share a cached JWT;
  • exchange failure throws.

Manual verification

The repro pair from the issue (superset workspaces list --host "$REMOTE" --api-key "$KEY") now authenticates; hosts list already worked via the x-api-key path and is unchanged.


Summary by cubic

Fixes remote --host CLI commands using --api-key by minting a JWT before calling the relay, so API key auth works instead of failing with "Session expired" (fixes #6315).

  • Bug Fixes
    • Added packages/cli/src/lib/host-jwt.ts: getHostJwt exchanges sk_live_/sk_test_ keys for a JWT via GET {api}/api/auth/token with x-api-key, caches 55 min per credential, validates a trimmed non-empty string before caching, passes JWT-like tokens through, and uses a 10s AbortSignal.timeout.
    • Updated resolveHostTarget.ts to mint the JWT in an async headers() hook for httpBatchLink, leaving call sites unchanged.
    • Expanded host-jwt.test.ts to cover invalid/missing/whitespace-only tokens (not cached), per-key cache isolation, a real timeout signal, and the 55‑min cache window boundary (re-mints just past the window).

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

Review in cubic

Summary by CodeRabbit

  • New Features

    • Remote host connections now support API-key authentication through automatic token exchange.
    • Existing JWT credentials continue to pass through directly.
    • Authentication tokens are securely reused for a limited time to reduce repeated requests.
  • Bug Fixes

    • Improved authentication handling for remote host relay requests.
    • Authentication failures now report relevant HTTP status information.
    • Invalid authentication responses are rejected.
    • Token exchanges now time out to prevent stalled requests.

The relay authenticates host-service traffic only by verifying a JWKS-signed
JWT. The --host <remote> transport sent the raw bearer (from --api-key /
SUPERSET_API_KEY / stored config) straight into the Authorization header, so
an sk_live_... API key was rejected as UNAUTHORIZED and surfaced as the
misleading 'Session expired' — even though the same key succeeds locally and
against the control plane.

New host-jwt helper mirrors the exchange JwtAuthProvider and the SDK already
perform: JWT-shaped tokens pass through, sk_live_/sk_test_ keys are exchanged
for a JWT via GET /api/auth/token with x-api-key, cached in-process (55m,
5m buffer, keyed by credential). resolveHostTarget's remote branch now mints
the JWT in an async headers() hook, leaving the ~16 call sites untouched.

Fixes superset-sh#6315
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d4d13aa9-69ef-41fc-bc83-6d23f1cbca4a

📥 Commits

Reviewing files that changed from the base of the PR and between 3137f77 and f7a2e33.

📒 Files selected for processing (1)
  • packages/cli/src/lib/host-jwt.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/lib/host-jwt.test.ts

📝 Walkthrough

Walkthrough

The CLI adds getHostJwt to pass through JWT credentials or exchange API keys for cached host JWTs. Remote host relay requests use the resulting JWT for authorization. Tests cover exchange, caching, isolation, request configuration, and failures.

Changes

Host JWT Authentication

Layer / File(s) Summary
Host JWT exchange and cache
packages/cli/src/lib/host-jwt.ts, packages/cli/src/lib/host-jwt.test.ts
getHostJwt passes through JWT credentials, exchanges API keys through the control-plane token endpoint, caches valid tokens per credential for approximately 55 minutes, applies a 10-second timeout, and rejects invalid responses.
Remote relay authentication
packages/cli/src/lib/host-target/resolveHostTarget.ts
Remote relay requests asynchronously obtain a host JWT and set it in the Authorization header. Local requests remain unchanged.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant resolveHostTarget
  participant getHostJwt
  participant ControlPlaneTokenEndpoint
  resolveHostTarget->>getHostJwt: Provide options.userJwt
  getHostJwt->>ControlPlaneTokenEndpoint: Exchange API key at /api/auth/token
  ControlPlaneTokenEndpoint-->>getHostJwt: Return host JWT
  getHostJwt-->>resolveHostTarget: Return JWT
  resolveHostTarget->>resolveHostTarget: Set Authorization header
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description check ✅ Passed The description explains the problem, root cause, fix, tests, and manual verification, although it omits the template checklist.
Title check ✅ Passed The title uses conventional commit format and clearly identifies the API-key JWT fix for remote CLI targets.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/lib/host-jwt.ts`:
- Around line 3-4: Update the JWT cache reuse logic near JWT_REFRESH_BUFFER_MS
and JWT_CACHE_DURATION_MS so a minted token remains reusable for the documented
55-minute interval, avoiding subtraction of the refresh buffer twice; preserve
the intended refresh-buffer behavior when determining token expiry. Add a
boundary test for the cache behavior using a mocked Date.now, without real-time
waiting.
- Around line 49-59: Validate the parsed response in the token-exchange function
before calling jwtCache.set: require that data.token is a string, and reject
otherwise instead of caching or returning it. Preserve the existing
successful-token flow and add a regression test covering a 200 response with a
missing or non-string token.
- Around line 44-48: Add an AbortSignal.timeout(...) signal to the
token-exchange fetch in the host JWT flow, using the existing CLI
network-timeout constant or configuration for its duration. Preserve the current
API URL and headers while ensuring a stalled control-plane request rejects
within that deadline so the surrounding headers() callback can proceed with
failure handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f4d69a7-7cbc-4664-b844-c4f70cb078bd

📥 Commits

Reviewing files that changed from the base of the PR and between 9b12164 and d5a6588.

📒 Files selected for processing (3)
  • packages/cli/src/lib/host-jwt.test.ts
  • packages/cli/src/lib/host-jwt.ts
  • packages/cli/src/lib/host-target/resolveHostTarget.ts

Comment thread packages/cli/src/lib/host-jwt.ts Outdated
Comment thread packages/cli/src/lib/host-jwt.ts
Comment thread packages/cli/src/lib/host-jwt.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 3 files

Architecture diagram
sequenceDiagram
    participant CLI as CLI Command
    participant RHT as resolveHostTarget
    participant HJ as getHostJwt
    participant CP as Control Plane API
    participant Relay as Host Relay
    participant Host as Remote Host Service

    Note over CLI,Host: Remote --host target with API key auth
    
    CLI->>RHT: resolveHostTarget(options)
    
    alt Bearer is JWT (OAuth access token)
        RHT->>HJ: getHostJwt(bearer)
        HJ-->>RHT: return bearer (pass-through)
    else Bearer is sk_live_/sk_test_ API key
        RHT->>HJ: getHostJwt(bearer)
        HJ->>HJ: Check in-process cache
        alt Cache hit and fresh (< 55min - 5min buffer)
            HJ-->>RHT: cached JWT
        else Cache miss or stale
            HJ->>CP: GET /api/auth/token (x-api-key: bearer)
            alt Exchange success
                CP-->>HJ: 200 { token: mintedJwt }
                HJ->>HJ: Cache JWT keyed by credential
                HJ-->>RHT: mintedJwt
            else Exchange failure
                CP-->>HJ: non-2xx status
                HJ-->>RHT: throw Error (Failed to authenticate API key)
                RHT-->>CLI: Error propagates
            end
        end
    end
    
    RHT->>Relay: tRPC httpBatchLink (async headers())
    Relay->>Relay: Verify JWT against JWKS
    
    alt JWT valid
        Relay->>Host: Forward request (machineId routing)
        Host-->>Relay: Service response
        Relay-->>RHT: tRPC response
        RHT-->>CLI: Command result
    else JWT invalid/expired
        Relay-->>RHT: UNAUTHORIZED
        RHT-->>CLI: Session expired error
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/cli/src/lib/host-jwt.ts Outdated
… buffer

Address review feedback on superset-sh#6315:

- A 2xx token response without a string token was cached and later sent as
  'Bearer undefined', surfacing as a misleading relay auth failure. Validate
  the response before caching; a bad response is not cached (a retry re-hits
  the endpoint).
- The token-exchange fetch had no timeout, so a stalled control plane could
  hang any --host remote command. Cap a single attempt with an AbortSignal
  timeout.
- The 55-minute cache already carries its refresh buffer; subtracting a
  further 5 minutes meant tokens were reused only for 50. Drop the second
  buffer and cache for the full documented interval.
@andyst-dev

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews — addressed in a3a8d60:

  • cubic P2 + coderabbit L59: a 2xx response without a string token is no longer cached (was sent as Bearer undefined); the response is validated before caching and a bad response is not cached, so a retry re-hits the endpoint. Added a no-token and a non-string-token test.
  • coderabbit L48 (Major): the token-exchange fetch now carries an AbortSignal.timeout so a stalled control plane can't hang a --host command.
  • coderabbit L4 (Minor): dropped the redundant 5-minute buffer — the 55-minute cache already carries its refresh buffer, so tokens are now reused for the full documented interval.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/lib/host-jwt.test.ts`:
- Around line 108-114: Update the test named “passes an abort signal so a
stalled fetch cannot hang” to use the repository’s existing fake-clock or
event-loop-safe timeout mechanism rather than real wall-clock waiting, keep the
fetch pending long enough for the timeout, then advance the clock and assert the
captured init.signal is aborted at the configured timeout.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 66488547-d4df-41f4-86a9-d1c5e0adbd9d

📥 Commits

Reviewing files that changed from the base of the PR and between d5a6588 and a3a8d60.

📒 Files selected for processing (2)
  • packages/cli/src/lib/host-jwt.test.ts
  • packages/cli/src/lib/host-jwt.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/lib/host-jwt.ts

Comment thread packages/cli/src/lib/host-jwt.test.ts Outdated
Comment on lines +108 to +114
it("passes an abort signal so a stalled fetch cannot hang", async () => {
stubFetch(true);
await getHostJwt("sk_live_signal");
const init = fetchCalls[0]!.init;
expect(init?.signal).toBeDefined();
});
});

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Test that the abort signal actually aborts.

The stubbed fetch returns immediately, so this test only proves that init.signal is defined. A non-timeout signal would pass. Advance the test framework’s fake clock, or use another event-loop-safe mechanism, and assert that the captured signal becomes aborted at the configured timeout. This verifies that a stalled token exchange cannot hang remote commands.

Based on learnings: Superset tests should avoid real wall-clock timers; prefer the existing non-wall-clock/event-loop-friendly approach.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/lib/host-jwt.test.ts` around lines 108 - 114, Update the
test named “passes an abort signal so a stalled fetch cannot hang” to use the
repository’s existing fake-clock or event-loop-safe timeout mechanism rather
than real wall-clock waiting, keep the fetch pending long enough for the
timeout, then advance the clock and assert the captured init.signal is aborted
at the configured timeout.

Source: Learnings

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/cli/src/lib/host-jwt.ts Outdated
Comment thread packages/cli/src/lib/host-jwt.test.ts Outdated
…eout

Address review feedback on superset-sh#6315:

- A 2xx with a whitespace-only token string was treated as usable and cached,
  so a malformed response could poison the cache and fail every remote-host
  command for up to 55 minutes. Trim before validating; such a response is now
  rejected (not cached), keeping the retry behaviour of other failures.
- The abort-signal test only asserted the signal was defined, which a plain
  non-timeout signal would also satisfy. It now intercepts AbortSignal.timeout
  and asserts a real timeout signal is attached (without a 10s wall-clock
  wait), so a regression in the hang-prevention cannot pass silently.
@andyst-dev

Copy link
Copy Markdown
Contributor Author

Thanks — addressed in 02e9d56:

  • cubic P2 (whitespace token): a 2xx with a whitespace-only token string is now rejected (trim before validating) and not cached, so a malformed response can't poison the cache for the full 55 minutes. Added a test.
  • coderabbit + cubic P3 (abort test): the test now intercepts AbortSignal.timeout and asserts a real timeout signal is attached (avoiding a 10s wall-clock wait), so a regression in the hang-prevention can't pass silently.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/cli/src/lib/host-jwt.test.ts Outdated
The committed test source had literal redaction markers (***, «redacted:sk_live_…»,
sk_liv...test) as the API-key inputs, left over from a sanitizing pass. Replace
them with clear, obviously-fake per-test keys so each assertion exercises the
exchange/cache path it names (the module-level JWT cache needs a distinct key
per test to avoid a cached mint short-circuiting the fetch).
@andyst-dev

Copy link
Copy Markdown
Contributor Author

Good catch — the committed test source had literal redaction markers left over from a sanitizing pass (***, «redacted:sk_live_…», sk_liv...test) as the API-key inputs. Fixed in 3137f77c3: each test now uses a clear, obviously-fake key (sk_live_4f9e3a2b1c, sk_test_xyz, sk_live_cache, …). I also gave each test its own distinct key, because the module-level JWT cache needs a fresh key per test to avoid a cached mint short-circuiting the fetch the assertion is checking.

Validation:

  • bun test packages/cli/src/lib/host-jwt.test.ts → 10 pass, 0 fail
  • bun run lint → clean
  • bunx tsc --noEmit -p packages/cli/tsconfig.json → no errors on the touched file

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/lib/host-jwt.test.ts`:
- Line 15: Update the LIVE_API_KEY and WHITESPACE_KEY test fixtures to construct
the Stripe-like prefix from separate string fragments, preserving the runtime
value while removing the literal “sk_live_” token pattern from source.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e7c5684d-497c-4f78-9c1f-7ac0df5061b0

📥 Commits

Reviewing files that changed from the base of the PR and between 02e9d56 and 3137f77.

📒 Files selected for processing (1)
  • packages/cli/src/lib/host-jwt.test.ts

Comment thread packages/cli/src/lib/host-jwt.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 1 file (changes from recent commits).

You’re at about 97% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/cli/src/lib/host-jwt.test.ts Outdated
…don't flag test fixtures

Betterleaks flags the literal sk_live_ prefix in these test keys as a real
Stripe access token. Build the prefix from joined literals so the source is
scanner-clean while the runtime value is unchanged.
@andyst-dev

Copy link
Copy Markdown
Contributor Author

Addressed the scanner flag: the fake sk_live_ prefix in the test key fixtures is now assembled from joined literals (["sk", "live"].join("_") + "_") so Betterleaks won't flag them as real Stripe access tokens, while the runtime value sent in x-api-key is unchanged. Commit f7a2e33fd. Tests: 10/10 pass.

The cache must serve the minted JWT for the full JWT_CACHE_DURATION_MS (55 min)
with no second expiry buffer subtracted, then re-mint on the first call past
the boundary. Mock Date.now so no real-time wait is needed (superset-sh#6346 review).
@andyst-dev

Copy link
Copy Markdown
Contributor Author

All three review points are addressed:

  1. Timeout on the token exchange (Major) — already covered by the AbortSignal.timeout(TOKEN_FETCH_TIMEOUT_MS) on the fetch (the commit the bot marked ✅ Addressed), with a test asserting the signal is attached.

  2. JWT cache double-buffer (Minor) — the current code serves the minted token for the full 55-minute window with no second buffer subtracted: expiresAt = now + JWT_CACHE_DURATION_MS and the reuse check is Date.now() < expiresAt. That behavior is now locked in by a boundary test (ad4459ad6): the token is reused at 54 min (1 fetch total) and re-minted just past 55 min (2 fetches), mocking Date.now so there's no real-time wait.

  3. 3rd point — the remaining analysis-chain item was also part of the timeout coverage.

Full host-jwt suite: 11 pass / 0 fail. Biome clean.

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.

[bug] --api-key is ignored on the --host <remote> path; commands exit 1 with a misleading "Session expired"

1 participant