-
Notifications
You must be signed in to change notification settings - Fork 896
feat(desktop): add OAuth authentication for desktop app #360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
919ccde
6adcc0d
91bb1d2
7fcd32a
df5b73e
c9611f7
d180d35
e6255a9
6b85a98
9ebbec6
1aff8e5
bee0d8e
4b8f902
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }, | ||
| ); | ||
| } | ||
|
|
||
| // 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, explore the repository structure to understand the codebase
git ls-files | grep -E "(auth|token|session)" | head -20Repository: superset-sh/superset Length of output: 953 🏁 Script executed: # Search for RefreshTokenPayload type definition
rg "RefreshTokenPayload" --type ts --type tsx -A 5 -B 2Repository: 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 2Repository: 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 -15Repository: 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 -40Repository: 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 1Repository: superset-sh/superset Length of output: 91 🏁 Script executed: # Search for RefreshTokenPayload type without tsx filter
rg "RefreshTokenPayload" --type ts -A 5 -B 2Repository: superset-sh/superset Length of output: 1731 🏁 Script executed: # Search for TOKEN_CONFIG definition
rg "TOKEN_CONFIG\s*=" --type ts -A 8Repository: 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.tsRepository: 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 1Repository: superset-sh/superset Length of output: 3680 🏁 Script executed: # Check token storage implementation
cat -n apps/desktop/src/main/lib/auth/token-storage.tsRepository: 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 -100Repository: 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 -10Repository: 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.tsRepository: 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 2Repository: 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 -20Repository: 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.tsRepository: 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 3Repository: 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.")
EOFRepository: 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 Fix options:
Additionally, the 🤖 Prompt for AI Agents |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add 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 Also applies to: 113-124 🤖 Prompt for AI Agents |
||
| // 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replay protection isn’t actually enforced here (auth code If replay resistance is a requirement, you’ll need a server-side “redeem once” mechanism (e.g., store 🤖 Prompt for AI Agents |
||
| } catch (error) { | ||
| console.error("[token] Token exchange failed:", error); | ||
| return NextResponse.json( | ||
| { error: "Token exchange failed" }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+70
to
+145
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 20Repository: superset-sh/superset Length of output: 91 🏁 Script executed: #!/bin/bash
# Find AuthCodePayload type definition
rg -t ts -t tsx "AuthCodePayload" -A 5 -B 2Repository: 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 1Repository: 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 -60Repository: 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 20Repository: superset-sh/superset Length of output: 1066 🏁 Script executed: #!/bin/bash
# Find AuthCodePayload type definition
rg "AuthCodePayload" -t ts -A 5 -B 2Repository: 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 1Repository: 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 -100Repository: 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.tsRepository: 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.tsRepository: 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.tsRepository: superset-sh/superset Length of output: 46 Add algorithm restriction to The 🤖 Prompt for AI Agents |
||
| 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 }); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
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:
Harden
jwtVerifyoptions 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