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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ BLOB_READ_WRITE_TOKEN=your_blob_token_here

# Desktop App
VITE_DEV_SERVER_PORT=4927
DESKTOP_AUTH_SECRET=your_desktop_auth_secret_here_min_32_chars
8 changes: 6 additions & 2 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ jobs:
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }}
run: |
vercel pull --yes --environment=preview --token=$VERCEL_TOKEN
vercel build --token=$VERCEL_TOKEN
Expand All @@ -135,7 +136,8 @@ jobs:
--env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \
--env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \
--env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
--env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)
--env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET)
vercel alias $VERCEL_URL ${{ env.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN
echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT

Expand Down Expand Up @@ -205,13 +207,15 @@ jobs:
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }}
DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }}
run: |
vercel pull --yes --environment=preview --token=$VERCEL_TOKEN
vercel build --token=$VERCEL_TOKEN
VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \
--env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \
--env DATABASE_URL=$DATABASE_URL \
--env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED)
--env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET)
vercel alias $VERCEL_URL ${{ env.WEB_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN
echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT

Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }}
run: |
vercel pull --yes --environment=production --token=$VERCEL_TOKEN
vercel build --prod --token=$VERCEL_TOKEN
Expand All @@ -87,7 +88,8 @@ jobs:
--env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \
--env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \
--env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
--env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
--env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET

deploy-web:
name: Deploy Web to Vercel
Expand Down Expand Up @@ -129,13 +131,15 @@ jobs:
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }}
DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }}
run: |
vercel pull --yes --environment=production --token=$VERCEL_TOKEN
vercel build --prod --token=$VERCEL_TOKEN
vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \
--env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \
--env DATABASE_URL=$DATABASE_URL \
--env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED
--env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET

deploy-marketing:
name: Deploy Marketing to Vercel
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@trpc/server": "^11.7.1",
"@vercel/blob": "^2.0.0",
"drizzle-orm": "0.45.1",
"jose": "^6.1.3",
"next": "^16.0.10",
"react": "^19.2.3",
"react-dom": "^19.2.3",
Expand Down
98 changes: 98 additions & 0 deletions apps/api/src/app/api/auth/desktop/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { TOKEN_CONFIG } from "@superset/shared/constants";
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
import { type NextRequest, NextResponse } from "next/server";

import { env } from "@/env";

/**
* Refresh token payload structure (minimal claims)
*/
interface RefreshTokenPayload extends JWTPayload {
userId: string;
type: "refresh";
}

/**
* Refresh endpoint for desktop auth
*
* POST /api/auth/desktop/refresh
* Body: { refresh_token: string }
*
* Exchanges a valid refresh token for new access + refresh tokens
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { refresh_token } = body;

// Validate required parameters
if (!refresh_token || typeof refresh_token !== "string") {
return NextResponse.json(
{ error: "Missing or invalid refresh_token parameter" },
{ status: 400 },
);
}

// Verify and decode the refresh token
const secret = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET);
let payload: RefreshTokenPayload;

try {
const result = await jwtVerify(refresh_token, secret);
payload = result.payload as RefreshTokenPayload;
} catch (verifyError) {
console.error("[refresh] Token verification failed:", verifyError);
return NextResponse.json(
{ error: "Invalid or expired refresh token" },
{ status: 401 },
);
}

// Verify this is a refresh token
if (payload.type !== "refresh") {
return NextResponse.json(
{ error: "Invalid token type" },
{ status: 400 },
);
}
Comment on lines +36 to +57
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/api/src/app/api/auth/desktop/refresh/route.ts

Repository: superset-sh/superset

Length of output: 3672


🌐 Web query:

jose library jwtVerify algorithms option TypeScript

💡 Result:

Short answer: In TypeScript the jose jwtVerify options accept an algorithms?: string[] property — i.e. an optional array of JWA algorithm names (e.g. "RS256", "ES256", "HS256", "EdDSA", etc.). Pass it as the third-argument options object: { algorithms: ['RS256'] }.

Sources:

  • jose type definitions (VerifyOptions → algorithms?: string[]). [1]
  • jose usage examples showing algorithms option passed to jwtVerify. [2]

Harden jwtVerify options and add runtime validation for required payload fields.

The current implementation should specify jwtVerify(refresh_token, secret, { algorithms: ["HS256"] }) to restrict accepted algorithms and prevent algorithm substitution attacks. Additionally, required fields (userId, email, name) should be validated after decoding before they are used to create new tokens on lines 67–70 and 83–86. A missing or null field would result in malformed tokens being issued. Consider adding explicit runtime checks or using a validation library for the payload structure.

🤖 Prompt for AI Agents
In apps/api/src/app/api/auth/desktop/refresh/route.ts around lines 39 to 60,
update the jwtVerify call to harden options (e.g. pass { algorithms: ["HS256"]
}) to prevent algorithm substitution, and add explicit runtime validation after
decoding that payload contains non-empty userId, email and name (and still
ensure payload.type === "refresh"); if any required field is missing or invalid,
return a 400 error instead of proceeding to the token-creation code around lines
67–70 and 83–86. Ensure the validation is defensive (check types and non-empty
strings), log or return a clear error, and only use the payload to create new
tokens after these checks pass.


// Create a new access token
const accessTokenExpiresAt =
Date.now() + TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY * 1000;

const accessToken = await new SignJWT({
userId: payload.userId,
type: "access",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY}s`)
.sign(secret);

// Create a new refresh token (rotation - old one becomes invalid)
const refreshTokenExpiresAt =
Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY * 1000;

const newRefreshToken = await new SignJWT({
userId: payload.userId,
type: "refresh",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY}s`)
.sign(secret);

return NextResponse.json({
access_token: accessToken,
access_token_expires_at: accessTokenExpiresAt,
refresh_token: newRefreshToken,
refresh_token_expires_at: refreshTokenExpiresAt,
});
} catch (error) {
console.error("[refresh] Token refresh failed:", error);
return NextResponse.json(
{ error: "Token refresh failed" },
{ status: 500 },
);
}
}
Comment on lines +23 to +98
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, explore the repository structure to understand the codebase
git ls-files | grep -E "(auth|token|session)" | head -20

Repository: superset-sh/superset

Length of output: 953


🏁 Script executed:

# Search for RefreshTokenPayload type definition
rg "RefreshTokenPayload" --type ts --type tsx -A 5 -B 2

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Search for jti usage in the codebase (JWT ID for token tracking)
rg "jti" --type ts --type tsx -A 2 -B 2

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Search for any database or server-side token invalidation logic
rg "(tokenVersion|refreshToken|token.*store|token.*db|session)" --type ts --type tsx | grep -i "store\|db\|version\|invalid" | head -15

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Check the jose library usage and algorithm configuration
rg "jwtVerify\|SignJWT" --type ts --type tsx -A 3 -B 1 | head -40

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Look for any environment config or token config
rg "TOKEN_CONFIG" --type ts --type tsx -A 5 -B 1

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Search for RefreshTokenPayload type without tsx filter
rg "RefreshTokenPayload" --type ts -A 5 -B 2

Repository: superset-sh/superset

Length of output: 1731


🏁 Script executed:

# Search for TOKEN_CONFIG definition
rg "TOKEN_CONFIG\s*=" --type ts -A 8

Repository: superset-sh/superset

Length of output: 643


🏁 Script executed:

# Check the token route file
cat -n apps/api/src/app/api/auth/desktop/token/route.ts

Repository: superset-sh/superset

Length of output: 5696


🏁 Script executed:

# Look for session or token database schema
rg "refreshToken|tokenVersion|jti" --type ts -A 2 -B 1

Repository: superset-sh/superset

Length of output: 3680


🏁 Script executed:

# Check token storage implementation
cat -n apps/desktop/src/main/lib/auth/token-storage.ts

Repository: superset-sh/superset

Length of output: 1881


🏁 Script executed:

# Look for auth service implementation
cat -n apps/desktop/src/main/lib/auth/auth-service.ts | head -100

Repository: superset-sh/superset

Length of output: 3364


🏁 Script executed:

# Check for any database migrations or schema files related to tokens/sessions
find . -name "*.sql" -o -name "*migration*" | xargs grep -l "token\|session\|refresh" 2>/dev/null | head -10

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check the refresh route file fully to see if there's any database lookup
wc -l apps/api/src/app/api/auth/desktop/refresh/route.ts

Repository: superset-sh/superset

Length of output: 118


🏁 Script executed:

# Check if there's any database layer or token tracking
rg "database\|prisma\|drizzle\|db\." apps/api/src/app/api/auth/desktop/ --type ts -A 2

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Verify jose library's jwtVerify behavior with algorithm handling
rg "import.*jose\|from.*jose" --type ts -B 2 -A 2 | head -20

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Read the complete refresh route file
cat -n apps/api/src/app/api/auth/desktop/refresh/route.ts

Repository: superset-sh/superset

Length of output: 3672


🏁 Script executed:

# Check for any algorithm restrictions in jose usage
rg "jwtVerify.*algorithm\|jwtVerify.*alg\|algorithms:" --type ts -A 3 -B 3

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Quick verification: check jose library behavior with jwtVerify default options
python3 << 'EOF'
# Check what jose's jwtVerify does with algorithm handling
# Based on jose documentation: jwtVerify verifies signature but doesn't restrict algorithms
# by default unless you pass {algorithms: [...]} option
print("jose jwtVerify behavior:")
print("- Called with jwtVerify(token, secret) and no options")
print("- Verifies HMAC signature with the provided secret")
print("- Does NOT restrict algorithm unless options.algorithms is specified")
print("- However, if token was signed with HS256, verification succeeds")
print("")
print("Key finding: The signature check IMPLICITLY enforces HS256")
print("because the token must have been signed with HS256 to verify")
print("with the HS256 secret. But no explicit algorithm restriction.")
EOF

Repository: superset-sh/superset

Length of output: 150


Critical: "Token rotation" doesn't invalidate old refresh tokens—they remain valid until expiry with this stateless JWT implementation.

The code issues new refresh and access tokens but provides no mechanism to invalidate the old refresh token. Since tokens are stateless JWTs with no server-side tracking (no jti, tokenVersion, or token revocation list), an attacker who obtains a refresh token can reuse it repeatedly for the full 30-day expiry window, even after the client "rotates" it. The comment at line 78 claiming "old one becomes invalid" is misleading.

Fix options:

  1. Add server-side token state: Store a jti (JWT ID) or per-user tokenVersion in the database and validate against it on each refresh request.
  2. Switch to opaque tokens: Use server-stored refresh token secrets instead of stateless JWTs, allowing true invalidation on rotation.

Additionally, the payload cast at line 45 uses as RefreshTokenPayload without runtime validation—only the type field is checked. Consider validating the full payload structure to catch malformed tokens early.

🤖 Prompt for AI Agents
In apps/api/src/app/api/auth/desktop/refresh/route.ts around lines 26 to 107:
the current implementation issues a new refresh JWT but does not invalidate the
old one (the comment "old one becomes invalid" is false for stateless JWTs), and
the payload is cast without full runtime validation; fix by adding server-side
refresh state and payload validation — either (A) add a per-user tokenVersion
(or stored currentRefreshJti) in your DB, include that value in refresh JWTs,
validate it on jwtVerify, and on rotation persist the new tokenVersion/JTI (or
increment tokenVersion) so the previous token is rejected, or (B) switch to
opaque refresh tokens stored server-side and mark the old token revoked on
rotation; additionally perform runtime validation of all required payload fields
(userId, email, name, avatarUrl, type, jti/tokenVersion) before trusting them
and return 401 for malformed payloads.

145 changes: 145 additions & 0 deletions apps/api/src/app/api/auth/desktop/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { createHash } from "node:crypto";
import { TOKEN_CONFIG } from "@superset/shared/constants";
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
import { type NextRequest, NextResponse } from "next/server";

import { env } from "@/env";

/**
* Auth code payload structure (minimal claims, no PII)
*/
interface AuthCodePayload extends JWTPayload {
userId: string;
codeChallenge: string;
type: "auth_code";
}

/**
* Create an access token (short-lived, for API calls)
* Only contains userId - user info is fetched via tRPC
*/
async function createAccessToken(
userId: string,
secret: Uint8Array,
): Promise<{ token: string; expiresAt: number }> {
const expiresAt = Date.now() + TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY * 1000;

const token = await new SignJWT({
userId,
type: "access",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${TOKEN_CONFIG.ACCESS_TOKEN_EXPIRY}s`)
.sign(secret);

return { token, expiresAt };
}

/**
* Create a refresh token (long-lived, for getting new access tokens)
* Only contains userId - user info is fetched via tRPC
*/
async function createRefreshToken(
userId: string,
secret: Uint8Array,
): Promise<{ token: string; expiresAt: number }> {
const expiresAt = Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY * 1000;

const token = await new SignJWT({
userId,
type: "refresh",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY}s`)
.sign(secret);

return { token, expiresAt };
}

/**
* Token exchange endpoint for desktop PKCE flow
*
* POST /api/auth/desktop/token
* Body: { code: string, code_verifier: string }
*
* Verifies PKCE challenge and exchanges auth code for access + refresh tokens.
* Does NOT return user info - desktop should call user.me via tRPC.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { code, code_verifier } = body;

// Validate required parameters
if (!code || typeof code !== "string") {
return NextResponse.json(
{ error: "Missing or invalid code parameter" },
{ status: 400 },
);
}

if (!code_verifier || typeof code_verifier !== "string") {
return NextResponse.json(
{ error: "Missing or invalid code_verifier parameter" },
{ status: 400 },
);
}

Comment on lines +75 to +89
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.

⚠️ Potential issue | 🟠 Major

Add code_verifier size/format bounds; consider constant-time compare for the challenge.

 		if (!code_verifier || typeof code_verifier !== "string") {
 			return NextResponse.json(
 				{ error: "Missing or invalid code_verifier parameter" },
 				{ status: 400 },
 			);
 		}
+
+		// RFC 7636: code_verifier is 43-128 chars from [A-Z / a-z / 0-9 / "-" / "." / "_" / "~"]
+		if (
+			code_verifier.length < 43 ||
+			code_verifier.length > 128 ||
+			!/^[A-Za-z0-9._~-]+$/.test(code_verifier)
+		) {
+			return NextResponse.json(
+				{ error: "Invalid code_verifier format" },
+				{ status: 400 },
+			);
+		}

@@
-		if (computedChallenge !== payload.codeChallenge) {
+		if (computedChallenge !== payload.codeChallenge) {
 			console.error("[token] PKCE verification failed");
 			return NextResponse.json(
 				{ error: "PKCE verification failed" },
 				{ status: 401 },
 			);
 		}

(If you want the constant-time compare, use timingSafeEqual(Buffer.from(...), Buffer.from(...)) with equal-length inputs.)

Also applies to: 113-124

🤖 Prompt for AI Agents
In apps/api/src/app/api/auth/desktop/token/route.ts around lines 75-89 (and also
apply same changes at 113-124): the parameter validation currently only checks
presence and string type for code_verifier; add explicit PKCE bounds and format
checks (e.g., ensure code_verifier length is within PKCE limits, typically
43-128 chars, and contains only allowed URL-safe characters) and return 400 if
invalid. When comparing the derived challenge to the stored code_challenge,
perform a constant-time comparison using timingSafeEqual by converting both
values to Buffers of equal length (pad/normalize or reject mismatched lengths to
avoid leaking timing) to prevent timing attacks. Ensure the error responses
remain descriptive and consistent when these validations fail.

// Verify and decode the auth code
const secret = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET);
let payload: AuthCodePayload;

try {
const result = await jwtVerify(code, secret);
payload = result.payload as AuthCodePayload;
} catch (verifyError) {
console.error("[token] Auth code verification failed:", verifyError);
return NextResponse.json(
{ error: "Invalid or expired auth code" },
{ status: 401 },
);
}

// Verify this is an auth code (not a session token)
if (payload.type !== "auth_code") {
return NextResponse.json(
{ error: "Invalid token type" },
{ status: 400 },
);
}

// Verify PKCE: SHA256(code_verifier) should equal code_challenge
const computedChallenge = createHash("sha256")
.update(code_verifier)
.digest("base64url");

if (computedChallenge !== payload.codeChallenge) {
console.error("[token] PKCE verification failed");
return NextResponse.json(
{ error: "PKCE verification failed" },
{ status: 401 },
);
}

// PKCE verified! Create access and refresh tokens
const [accessToken, refreshToken] = await Promise.all([
createAccessToken(payload.userId, secret),
createRefreshToken(payload.userId, secret),
]);

return NextResponse.json({
access_token: accessToken.token,
access_token_expires_at: accessToken.expiresAt,
refresh_token: refreshToken.token,
refresh_token_expires_at: refreshToken.expiresAt,
});
Comment on lines +126 to +137
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.

⚠️ Potential issue | 🟠 Major

Replay protection isn’t actually enforced here (auth code jti is unused).

If replay resistance is a requirement, you’ll need a server-side “redeem once” mechanism (e.g., store jti with a 5-minute TTL and atomically reject re-use) before minting tokens.

🤖 Prompt for AI Agents
In apps/api/src/app/api/auth/desktop/token/route.ts around lines 126 to 137 the
auth code's jti is never recorded or checked so replay protection is not
enforced; before minting access/refresh tokens, atomically check-and-create a
server-side record for payload.jti (e.g., Redis SETNX or DB unique insert) with
a short TTL (≈5 minutes) and reject if the jti already exists, then proceed to
create tokens only after successful reservation; ensure the check+insert is
atomic to avoid race conditions and return a proper error if redemption fails.

} catch (error) {
console.error("[token] Token exchange failed:", error);
return NextResponse.json(
{ error: "Token exchange failed" },
{ status: 500 },
);
}
}
Comment on lines +70 to +145
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where auth codes are created
rg -t ts -t tsx "createAuth.*ode\|type.*auth.*code\|\"auth_code\"" --max-count 20

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Find AuthCodePayload type definition
rg -t ts -t tsx "AuthCodePayload" -A 5 -B 2

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Check how jwtVerify is used in the codebase for algorithm patterns
rg -t ts -t tsx "jwtVerify" -A 2 -B 1

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Find token creation functions (createAccessToken, createRefreshToken)
rg -t ts -t tsx "createAccessToken|createRefreshToken" -A 8 -B 1 | head -60

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Find where auth codes are created using ts type only
rg "createAuth|type.*auth.*code|\"auth_code\"" -t ts --max-count 20

Repository: superset-sh/superset

Length of output: 1066


🏁 Script executed:

#!/bin/bash
# Find AuthCodePayload type definition
rg "AuthCodePayload" -t ts -A 5 -B 2

Repository: superset-sh/superset

Length of output: 3004


🏁 Script executed:

#!/bin/bash
# Check how jwtVerify is used in the codebase
rg "jwtVerify" -t ts -A 2 -B 1

Repository: superset-sh/superset

Length of output: 2032


🏁 Script executed:

#!/bin/bash
# Find token creation functions
rg "createAccessToken|createRefreshToken|export.*function.*create" -t ts -A 8 -B 1 | head -100

Repository: superset-sh/superset

Length of output: 6848


🏁 Script executed:

#!/bin/bash
# Read the createAuthCode function in detail
cat -n apps/web/src/app/api/auth/desktop/[provider]/route.ts | sed -n '1,200p'

Repository: superset-sh/superset

Length of output: 4133


🏁 Script executed:

#!/bin/bash
# Check how SignJWT is used (algorithm specification)
rg "new SignJWT" -A 10 apps/api/src/app/api/auth/desktop/token/route.ts

Repository: superset-sh/superset

Length of output: 636


🏁 Script executed:

#!/bin/bash
# Check how SignJWT is used in auth creation
rg "new SignJWT" -A 10 apps/web/src/app/api/auth/desktop/[provider]/route.ts

Repository: superset-sh/superset

Length of output: 433


🏁 Script executed:

#!/bin/bash
# Check the full token route to see if there's validation after the cast
cat -n apps/api/src/app/api/auth/desktop/token/route.ts | sed -n '1,180p'

Repository: superset-sh/superset

Length of output: 5696


🏁 Script executed:

#!/bin/bash
# Check if there's a schema validation using Zod or similar
rg "z\.object|Zod|schema" apps/api/src/app/api/auth/desktop/token/route.ts

Repository: superset-sh/superset

Length of output: 46


Add algorithm restriction to jwtVerify and validate required payload fields at runtime.

The jwtVerify(code, secret) call lacks algorithm restriction (should be { algorithms: ["HS256"] }), creating a theoretical algorithm confusion vulnerability. Additionally, the TypeScript cast as AuthCodePayload does not provide runtime validation—if required fields like codeChallenge, userId, or email are missing or malformed, direct property access will silently produce undefined, leading to confusing error messages rather than explicit validation failures. Add explicit field checks or use a schema validator (e.g., Zod) to validate payload.userId, payload.email, payload.codeChallenge, and other required fields immediately after jwtVerify.

🤖 Prompt for AI Agents
In apps/api/src/app/api/auth/desktop/token/route.ts around lines 86 to 174, the
jwtVerify call lacks an algorithms restriction and the decoded payload is only
type-cast (no runtime validation); update jwtVerify to include { algorithms:
["HS256"] } and immediately validate required payload fields (at minimum: type
=== "auth_code", codeChallenge, userId, email and any other fields you later
access) using explicit runtime checks or a lightweight schema validator (e.g.,
Zod) so you return a 400/401 with a clear error when fields are missing/invalid
instead of proceeding with undefined values.

1 change: 1 addition & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const env = createEnv({
CLERK_SECRET_KEY: z.string(),
CLERK_WEBHOOK_SECRET: z.string(),
BLOB_READ_WRITE_TOKEN: z.string(),
DESKTOP_AUTH_SECRET: z.string().min(32),
},
client: {
NEXT_PUBLIC_WEB_URL: z.string().url(),
Expand Down
38 changes: 35 additions & 3 deletions apps/api/src/trpc/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import { auth } from "@clerk/nextjs/server";
import { createTRPCContext } from "@superset/trpc";

export const createContext = async () => {
const session = await auth();
return createTRPCContext({ session });
import { verifyDesktopToken } from "./utils/verifyDesktopToken";

/**
* Create tRPC context with support for both Clerk and desktop JWT auth
*
* Auth priority:
* 1. Clerk session (cookie or Clerk Bearer token)
* 2. Desktop JWT (Bearer token signed with DESKTOP_AUTH_SECRET)
*/
export const createContext = async ({
req,
}: {
req: Request;
resHeaders: Headers;
}) => {
// First, try Clerk auth (handles cookies and Clerk Bearer tokens)
const clerkAuth = await auth();

if (clerkAuth.userId) {
return createTRPCContext({ userId: clerkAuth.userId });
}

// No Clerk session, check for desktop JWT
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
const userId = await verifyDesktopToken(token);

if (userId) {
return createTRPCContext({ userId });
}
}

// No valid auth
return createTRPCContext({ userId: null });
};
Loading
Loading