Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ jobs:
"$DIST/bin/superset" --version
"$DIST/bin/superset" --help
"$DIST/lib/node" --version
test -f "$DIST/lib/host-service.js" || (echo "missing host-service.js" >&2; exit 1)
test -f "$DIST/lib/pty-daemon.js" || (echo "missing pty-daemon.js" >&2; exit 1)
NODE_PATH="$DIST/lib/node_modules" "$DIST/lib/node" -e '
for (const m of ["better-sqlite3", "node-pty", "@parcel/watcher", "libsql"]) {
require(m);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/scripts/build-dist-linux-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ docker run --rm --platform "$PLATFORM" \
"$DIST/bin/superset" --version
"$DIST/bin/superset" --help | head -5
"$DIST/lib/node" --version
test -f "$DIST/lib/host-service.js" || (echo "missing host-service.js" >&2; exit 1)
test -f "$DIST/lib/pty-daemon.js" || (echo "missing pty-daemon.js" >&2; exit 1)
NODE_PATH="$DIST/lib/node_modules" "$DIST/lib/node" -e "
for (const m of [\"better-sqlite3\", \"node-pty\", \"@parcel/watcher\", \"libsql\"]) {
require(m);
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/scripts/build-dist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,12 @@ async function buildHostService(): Promise<string> {
return join(hostServiceDir, "dist", "host-service.js");
}

async function buildPtyDaemon(): Promise<string> {
const ptyDaemonDir = resolve(import.meta.dir, "../../pty-daemon");
await exec("bun", ["run", "build:daemon"], ptyDaemonDir);
return join(ptyDaemonDir, "dist", "pty-daemon.js");
}

function writeHostWrapper(binDir: string): void {
const wrapper = `#!/bin/sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
Expand Down Expand Up @@ -396,6 +402,14 @@ async function main(): Promise<void> {
const hostServiceBundle = await buildHostService();
cpSync(hostServiceBundle, join(stagingRoot, "lib", "host-service.js"));

// pty-daemon ships side-by-side with host-service.js. The host-service
// resolves the script path via `resolveSupervisorScriptPath()` which
// looks for `pty-daemon.js` next to itself first; without this copy the
// supervisor falls back to the workspace path and bricks at spawn time.
console.log("[build-dist] building pty-daemon bundle");
const ptyDaemonBundle = await buildPtyDaemon();
cpSync(ptyDaemonBundle, join(stagingRoot, "lib", "pty-daemon.js"));

console.log("[build-dist] fetching Node.js");
await downloadAndExtractNode(target, join(stagingRoot, "lib"));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import type { ApiAuthProvider } from "../types";
const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
const JWT_CACHE_DURATION_MS = 55 * 60 * 1000;

function looksLikeJwt(token: string): boolean {
const parts = token.split(".");
return parts.length === 3 && parts.every(Boolean);
}

export class JwtApiAuthProvider implements ApiAuthProvider {
private readonly sessionToken: string;
private readonly apiUrl: string;
Expand All @@ -20,15 +25,30 @@ export class JwtApiAuthProvider implements ApiAuthProvider {
}

async getJwt(): Promise<string> {
// CLI OAuth code+PKCE login stores the OAuth access token directly,
// which is already a JWT signed by the same JWKS the relay verifies
// against and carries `organizationIds` via customAccessTokenClaims.
// Pass it through — no /api/auth/token exchange needed (and the
// better-auth jwt plugin endpoint doesn't accept OAuth tokens
// anyway, only sessions and api keys).
if (looksLikeJwt(this.sessionToken)) {
return this.sessionToken;
}
Comment on lines +34 to +36
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.

P1 JWT passthrough has no expiry check

When looksLikeJwt() returns true, the stored token is returned unconditionally on every single call, completely bypassing the cachedJwtExpiresAt guard. OAuth access tokens carry an exp claim that the relay's jwtVerify actively enforces. Once that claim passes, every relay request will return 401 forever — there's no retry, no re-fetch, and no signal to the user — until they manually re-run auth login. The non-JWT path at least re-fetches from /api/auth/token every 55 minutes; the JWT passthrough never does.

A minimal guard: decode the payload, extract exp, and throw a descriptive error when the token is near or past expiry:

if (looksLikeJwt(this.sessionToken)) {
    try {
        const payload = JSON.parse(
            Buffer.from(this.sessionToken.split(".")[1], "base64url").toString(),
        );
        if (
            typeof payload.exp === "number" &&
            Date.now() / 1000 >= payload.exp - JWT_REFRESH_BUFFER_MS / 1000
        ) {
            throw new Error(
                "OAuth access token has expired — please re-run `superset auth login`",
            );
        }
    } catch (e) {
        if (!(e instanceof SyntaxError)) throw e;
    }
    return this.sessionToken;
}
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: 34-36

Comment:
**JWT passthrough has no expiry check**

When `looksLikeJwt()` returns `true`, the stored token is returned unconditionally on every single call, completely bypassing the `cachedJwtExpiresAt` guard. OAuth access tokens carry an `exp` claim that the relay's `jwtVerify` actively enforces. Once that claim passes, every relay request will return 401 forever — there's no retry, no re-fetch, and no signal to the user — until they manually re-run `auth login`. The non-JWT path at least re-fetches from `/api/auth/token` every 55 minutes; the JWT passthrough never does.

A minimal guard: decode the payload, extract `exp`, and throw a descriptive error when the token is near or past expiry:

```ts
if (looksLikeJwt(this.sessionToken)) {
    try {
        const payload = JSON.parse(
            Buffer.from(this.sessionToken.split(".")[1], "base64url").toString(),
        );
        if (
            typeof payload.exp === "number" &&
            Date.now() / 1000 >= payload.exp - JWT_REFRESH_BUFFER_MS / 1000
        ) {
            throw new Error(
                "OAuth access token has expired — please re-run `superset auth login`",
            );
        }
    } catch (e) {
        if (!(e instanceof SyntaxError)) throw e;
    }
    return this.sessionToken;
}
```

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


if (
this.cachedJwt &&
Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS
) {
return this.cachedJwt;
}

// better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not
// Authorization: Bearer; mirror what the CLI's tRPC client does in
// packages/cli/src/lib/api-client.ts.
const response = await fetch(`${this.apiUrl}/api/auth/token`, {
headers: { Authorization: `Bearer ${this.sessionToken}` },
headers: this.sessionToken.startsWith("sk_live_")
? { "x-api-key": this.sessionToken }
: { Authorization: `Bearer ${this.sessionToken}` },
});
if (!response.ok) {
throw new Error(`Failed to mint JWT: ${response.status}`);
Expand Down
Loading