diff --git a/.env.example b/.env.example index 279dd6e245b..83145f284ae 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 1e78d1f4da0..bbd5ad8d046 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index bdb0156a81f..a4245f61626 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -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 @@ -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 @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index bc312191b4e..42f35bf9809 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app/api/auth/desktop/refresh/route.ts b/apps/api/src/app/api/auth/desktop/refresh/route.ts new file mode 100644 index 00000000000..0b6c65e1c15 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/refresh/route.ts @@ -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 }, + ); + } +} diff --git a/apps/api/src/app/api/auth/desktop/token/route.ts b/apps/api/src/app/api/auth/desktop/token/route.ts new file mode 100644 index 00000000000..de2563c2818 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/token/route.ts @@ -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 }, + ); + } + + // 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, + }); + } catch (error) { + console.error("[token] Token exchange failed:", error); + return NextResponse.json( + { error: "Token exchange failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index c6119fcd638..ba5402030ea 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -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(), diff --git a/apps/api/src/trpc/context.ts b/apps/api/src/trpc/context.ts index fc5a0c5e06a..0c6371eb20c 100644 --- a/apps/api/src/trpc/context.ts +++ b/apps/api/src/trpc/context.ts @@ -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 }); }; diff --git a/apps/api/src/trpc/utils/verifyDesktopToken.ts b/apps/api/src/trpc/utils/verifyDesktopToken.ts new file mode 100644 index 00000000000..c4fe3dd8238 --- /dev/null +++ b/apps/api/src/trpc/utils/verifyDesktopToken.ts @@ -0,0 +1,33 @@ +import { jwtVerify } from "jose"; + +import { env } from "@/env"; + +/** + * Verify a desktop JWT token and extract userId + * + * Only accepts access tokens - rejects auth_code and refresh tokens + */ +export async function verifyDesktopToken( + token: string, +): Promise { + try { + const secret = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); + const { payload } = await jwtVerify(token, secret); + + // Require access tokens only (allowlist, not blocklist) + if (payload.type !== "access") { + console.warn( + `[auth] Rejected token - expected type 'access', got '${payload.type}'`, + ); + return null; + } + + if (typeof payload.userId !== "string") { + return null; + } + + return payload.userId; + } catch { + return null; + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 15e5cacd203..5ddcf597dc0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,7 +16,7 @@ "scripts": { "clean": "git clean -xdf .cache .turbo dist dist-electron release node_modules", "start": "electron-vite preview", - "predev": "bun run clean:dev", + "predev": "bun run clean:dev && bun run scripts/patch-dev-protocol.ts", "dev": "cross-env NODE_ENV=development electron-vite dev --watch", "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=4096 electron-vite build", "copy:native-modules": "bun run scripts/copy-native-modules.ts", @@ -37,7 +37,10 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@superset/shared": "workspace:*", + "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", + "@t3-oss/env-core": "^0.13.8", "@tanstack/react-query": "^5.90.10", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", @@ -67,6 +70,7 @@ "fast-glob": "^3.3.3", "framer-motion": "^12.23.24", "http-proxy": "^1.18.1", + "jose": "^6.1.3", "line-column-path": "^3.0.0", "lodash": "^4.17.21", "lowdb": "^7.0.1", diff --git a/apps/desktop/scripts/patch-dev-protocol.ts b/apps/desktop/scripts/patch-dev-protocol.ts new file mode 100644 index 00000000000..49600242480 --- /dev/null +++ b/apps/desktop/scripts/patch-dev-protocol.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun +/** + * Patches the development Electron.app's Info.plist to register + * the superset-dev:// URL scheme for deep linking. + * + * This is needed because on macOS, app.setAsDefaultProtocolClient() + * only works when the app is packaged. In development, we need to + * manually add the URL scheme to the Electron binary's Info.plist. + * + * Runs automatically as part of `bun dev`. + */ + +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { PROTOCOL_SCHEMES } from "../src/shared/constants"; + +// Only needed on macOS +if (process.platform !== "darwin") { + console.log("[patch-dev-protocol] Skipping - not macOS"); + process.exit(0); +} + +const PROTOCOL_SCHEME = PROTOCOL_SCHEMES.DEV; +const BUNDLE_ID = "com.superset.desktop.dev"; +const ELECTRON_APP_PATH = resolve( + import.meta.dirname, + "../node_modules/electron/dist/Electron.app", +); +const PLIST_PATH = resolve(ELECTRON_APP_PATH, "Contents/Info.plist"); + +if (!existsSync(PLIST_PATH)) { + console.log("[patch-dev-protocol] Electron.app not found, skipping"); + process.exit(0); +} + +// Check if already patched +try { + const result = execSync( + `/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:0:CFBundleURLSchemes:0" "${PLIST_PATH}" 2>/dev/null`, + { encoding: "utf-8" }, + ).trim(); + + if (result === PROTOCOL_SCHEME) { + console.log( + `[patch-dev-protocol] ${PROTOCOL_SCHEME}:// already registered`, + ); + process.exit(0); + } +} catch { + // Not patched yet, continue +} + +console.log(`[patch-dev-protocol] Registering ${PROTOCOL_SCHEME}:// scheme...`); + +// Set unique bundle ID to avoid conflicts with other Electron apps +try { + execSync( + `/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "${PLIST_PATH}"`, + ); +} catch { + // Ignore errors +} + +// Add URL scheme to Info.plist +const commands = [ + `Add :CFBundleURLTypes array`, + `Add :CFBundleURLTypes:0 dict`, + `Add :CFBundleURLTypes:0:CFBundleURLName string 'Superset Dev'`, + `Add :CFBundleURLTypes:0:CFBundleURLSchemes array`, + `Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string '${PROTOCOL_SCHEME}'`, + `Add :CFBundleURLTypes:0:CFBundleTypeRole string 'Editor'`, +]; + +for (const cmd of commands) { + try { + execSync(`/usr/libexec/PlistBuddy -c "${cmd}" "${PLIST_PATH}" 2>/dev/null`); + } catch { + // Ignore errors (e.g., key already exists) + } +} + +// Register with Launch Services +try { + execSync( + `/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "${ELECTRON_APP_PATH}"`, + ); + console.log( + `[patch-dev-protocol] Registered ${PROTOCOL_SCHEME}:// with Launch Services`, + ); +} catch (err) { + console.warn( + "[patch-dev-protocol] Failed to register with Launch Services:", + err, + ); +} diff --git a/apps/desktop/src/env.ts b/apps/desktop/src/env.ts new file mode 100644 index 00000000000..3c3db5cb54a --- /dev/null +++ b/apps/desktop/src/env.ts @@ -0,0 +1,18 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod/v4"; + +export const env = createEnv({ + server: { + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), + NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + }, + + runtimeEnv: process.env, + emptyStringAsUndefined: true, + + // Electron runs in a trusted environment - treat renderer as server context + isServer: true, +}); diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 760ce53ad61..28112c40ffa 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -4,8 +4,9 @@ import { installExtension, REACT_DEVELOPER_TOOLS, } from "electron-extension-installer"; -import { ENVIRONMENT, PLATFORM } from "shared/constants"; +import { PLATFORM } from "shared/constants"; import { makeAppId } from "shared/utils"; +import { env } from "../../../../env"; import { ignoreConsoleWarnings } from "../../utils/ignore-console-warnings"; ignoreConsoleWarnings(["Manifest version 2 is deprecated"]); @@ -14,7 +15,7 @@ export async function makeAppSetup( createWindow: () => Promise, restoreWindows?: () => Promise, ) { - if (ENVIRONMENT.IS_DEV) { + if (env.NODE_ENV === "development") { try { await installExtension([REACT_DEVELOPER_TOOLS], { loadExtensionOptions: { @@ -71,6 +72,8 @@ export async function makeAppSetup( PLATFORM.IS_LINUX && app.disableHardwareAcceleration(); PLATFORM.IS_WINDOWS && - app.setAppUserModelId(ENVIRONMENT.IS_DEV ? process.execPath : makeAppId()); + app.setAppUserModelId( + env.NODE_ENV === "development" ? process.execPath : makeAppId(), + ); app.commandLine.appendSwitch("force-color-profile", "srgb"); diff --git a/apps/desktop/src/lib/electron-router-dom.ts b/apps/desktop/src/lib/electron-router-dom.ts index f8d49a4569f..6af34dbdb51 100644 --- a/apps/desktop/src/lib/electron-router-dom.ts +++ b/apps/desktop/src/lib/electron-router-dom.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from "electron"; import { createElectronRouter } from "electron-router-dom"; import { PORTS } from "shared/constants"; +import { env } from "../env"; const electronRouter = createElectronRouter({ port: PORTS.VITE_DEV_SERVER, @@ -28,7 +29,7 @@ export function registerRoute(props: { htmlFile: string; query?: Record; }): void { - const isDev = process.env.NODE_ENV === "development"; + const isDev = env.NODE_ENV === "development"; if (isDev) { // Development: use the library's default behavior (loads from dev server) diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts new file mode 100644 index 00000000000..d580391a015 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -0,0 +1,61 @@ +import { observable } from "@trpc/server/observable"; +import type { BrowserWindow } from "electron"; +import { authService } from "main/lib/auth"; +import { AUTH_PROVIDERS } from "shared/auth"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Authentication router for desktop app + * Handles sign in/out and state management + */ +export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { + return router({ + /** + * Get current authentication state + */ + getState: publicProcedure.query(() => { + return authService.getState(); + }), + + /** + * Subscribe to auth state changes + */ + onStateChange: publicProcedure.subscription(() => { + return observable<{ isSignedIn: boolean }>((emit) => { + const handler = (state: { isSignedIn: boolean }) => { + emit.next(state); + }; + + // Send initial state + emit.next(authService.getState()); + + // Listen for changes + authService.on("state-changed", handler); + + return () => { + authService.off("state-changed", handler); + }; + }); + }), + + /** + * Sign in with OAuth provider + */ + signIn: publicProcedure + .input(z.object({ provider: z.enum(AUTH_PROVIDERS) })) + .mutation(async ({ input }) => { + return authService.signIn(input.provider, getWindow); + }), + + /** + * Sign out + */ + signOut: publicProcedure.mutation(async () => { + await authService.signOut(); + return { success: true }; + }), + }); +}; + +export type AuthRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 55663a86558..4f72b79fca5 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; +import { createAuthRouter } from "./auth"; import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; @@ -10,6 +11,7 @@ import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; +import { createUserRouter } from "./user"; import { createWindowRouter } from "./window"; import { createWorkspacesRouter } from "./workspaces"; @@ -22,6 +24,8 @@ import { createWorkspacesRouter } from "./workspaces"; */ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ + auth: createAuthRouter(getWindow), + user: createUserRouter(), window: createWindowRouter(getWindow), projects: createProjectsRouter(getWindow), workspaces: createWorkspacesRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/user/index.ts b/apps/desktop/src/lib/trpc/routers/user/index.ts new file mode 100644 index 00000000000..ef03414ccb9 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/user/index.ts @@ -0,0 +1,18 @@ +import { apiClient } from "main/lib/api-client"; +import { publicProcedure, router } from "../.."; + +/** + * User router - proxies to API tRPC endpoints + */ +export const createUserRouter = () => { + return router({ + /** + * Get current user info + */ + me: publicProcedure.query(async () => { + return apiClient.user.me.query(); + }), + }); +}; + +export type UserRouter = ReturnType; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6f8c0cca596..3a0a302c6a8 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,16 +1,15 @@ import path from "node:path"; -import { app } from "electron"; +import { app, BrowserWindow } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; +import { PROTOCOL_SCHEME } from "shared/constants"; import { setupAgentHooks } from "./lib/agent-setup"; import { initAppState } from "./lib/app-state"; +import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; import { initDb } from "./lib/db"; import { terminalManager } from "./lib/terminal"; import { MainWindow } from "./windows/main"; -// Protocol scheme for deep linking -const PROTOCOL_SCHEME = "superset"; - // Register protocol handler for deep linking // In development, we need to provide the execPath and args if (process.defaultApp) { @@ -23,30 +22,90 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); } -// TODO: Handle deep link when app is already running -app.on("open-url", (event, _url) => { +/** + * Process a deep link URL for auth + */ +async function processDeepLink(url: string): Promise { + if (isAuthDeepLink(url)) { + const result = await handleAuthDeepLink(url); + if (result.success && result.session) { + await authService.handleDeepLinkAuth(result.session); + } else { + console.error("[main] Auth deep link failed:", result.error); + } + } +} + +/** + * Find a deep link URL in argv + */ +function findDeepLinkInArgv(argv: string[]): string | undefined { + return argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`)); +} + +/** + * Focus the main window (show and bring to front) + */ +function focusMainWindow(): void { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + } +} + +// Handle deep links when app is already running (macOS) +app.on("open-url", async (event, url) => { event.preventDefault(); + await processDeepLink(url); }); -// Allow multiple instances - removed single instance lock -(async () => { - await app.whenReady(); +// Single instance lock - required for second-instance event on Windows/Linux +const gotTheLock = app.requestSingleInstanceLock(); - await initDb(); - await initAppState(); +if (!gotTheLock) { + // Another instance is already running, quit this one + app.quit(); +} else { + // Handle deep links when second instance is launched (Windows/Linux) + app.on("second-instance", async (_event, argv) => { + focusMainWindow(); + const url = findDeepLinkInArgv(argv); + if (url) { + await processDeepLink(url); + } + }); - try { - setupAgentHooks(); - } catch (error) { - console.error("[main] Failed to set up agent hooks:", error); - // App can continue without agent hooks, but log the failure - } + (async () => { + await app.whenReady(); - await makeAppSetup(() => MainWindow()); - setupAutoUpdater(); + await initDb(); + await initAppState(); + await authService.initialize(); - // Clean up all terminals when app is quitting - app.on("before-quit", async () => { - await terminalManager.cleanup(); - }); -})(); + try { + setupAgentHooks(); + } catch (error) { + console.error("[main] Failed to set up agent hooks:", error); + // App can continue without agent hooks, but log the failure + } + + await makeAppSetup(() => MainWindow()); + setupAutoUpdater(); + + // Handle cold-start deep links (Windows/Linux - app launched via deep link) + const coldStartUrl = findDeepLinkInArgv(process.argv); + if (coldStartUrl) { + await processDeepLink(coldStartUrl); + } + + // Clean up all terminals when app is quitting + app.on("before-quit", async () => { + await terminalManager.cleanup(); + }); + })(); +} diff --git a/apps/desktop/src/main/lib/api-client.ts b/apps/desktop/src/main/lib/api-client.ts new file mode 100644 index 00000000000..c3c6aa9ea2a --- /dev/null +++ b/apps/desktop/src/main/lib/api-client.ts @@ -0,0 +1,27 @@ +import type { AppRouter } from "@superset/trpc"; +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import superjson from "superjson"; +import { env } from "../../env"; +import { authService } from "./auth"; + +/** + * tRPC client for calling the Superset API + * Automatically includes the access token in requests + */ +export const apiClient = createTRPCClient({ + links: [ + httpBatchLink({ + url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + transformer: superjson, + async headers() { + const token = await authService.getAccessToken(); + if (token) { + return { + Authorization: `Bearer ${token}`, + }; + } + return {}; + }, + }), + ], +}); diff --git a/apps/desktop/src/main/lib/app-environment.ts b/apps/desktop/src/main/lib/app-environment.ts index e510d810fbd..704aac6eac6 100644 --- a/apps/desktop/src/main/lib/app-environment.ts +++ b/apps/desktop/src/main/lib/app-environment.ts @@ -1,9 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { ENVIRONMENT, SUPERSET_DIR_NAME } from "shared/constants"; - -export const IS_DEV = ENVIRONMENT.IS_DEV; -export const IS_TEST = process.env.NODE_ENV === "test"; +import { SUPERSET_DIR_NAME } from "shared/constants"; export const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); diff --git a/apps/desktop/src/main/lib/auth/auth-service.ts b/apps/desktop/src/main/lib/auth/auth-service.ts new file mode 100644 index 00000000000..274c498ba06 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/auth-service.ts @@ -0,0 +1,272 @@ +import { EventEmitter } from "node:events"; +import { TOKEN_CONFIG } from "@superset/shared/constants"; +import { type BrowserWindow, shell } from "electron"; +import type { AuthProvider, AuthSession, SignInResult } from "shared/auth"; +import { env } from "../../../env"; +import { pkceStore } from "./pkce"; +import { tokenStorage } from "./token-storage"; + +/** + * Response from the refresh endpoint (includes rotated refresh token) + */ +interface RefreshResponse { + access_token: string; + access_token_expires_at: number; + refresh_token: string; + refresh_token_expires_at: number; +} + +/** + * Main authentication service + * Handles OAuth flows, token management, and session state with auto-refresh + */ +class AuthService extends EventEmitter { + private session: AuthSession | null = null; + private refreshTimer: ReturnType | null = null; + private isRefreshing = false; + + /** + * Initialize auth service - load persisted session + */ + async initialize(): Promise { + const session = await tokenStorage.load(); + + if (!session) { + return; + } + + // Check if refresh token is expired (session is truly over) + if (session.refreshTokenExpiresAt < Date.now()) { + console.log("[auth] Refresh token expired, clearing session"); + await this.clearSession(); + return; + } + + // Restore session + this.session = session; + console.log("[auth] Session restored"); + + // Check if access token needs refresh + if (this.shouldRefreshAccessToken()) { + console.log("[auth] Access token expired/expiring, refreshing..."); + await this.refreshAccessToken(); + } + + // Schedule next refresh + this.scheduleRefresh(); + } + + /** + * Get current authentication state + */ + getState() { + return { + isSignedIn: !!this.session, + }; + } + + /** + * Get access token for API calls + * Automatically refreshes if needed + */ + async getAccessToken(): Promise { + if (!this.session) { + return null; + } + + // Check if refresh token is expired + if (this.session.refreshTokenExpiresAt < Date.now()) { + console.log("[auth] Refresh token expired"); + await this.clearSession(); + return null; + } + + // Refresh access token if needed + if (this.shouldRefreshAccessToken()) { + await this.refreshAccessToken(); + } + + return this.session?.accessToken ?? null; + } + + /** + * Sign in with OAuth provider + * Opens system browser to web app OAuth endpoint with PKCE + */ + async signIn( + provider: AuthProvider, + _getWindow: () => BrowserWindow | null, + ): Promise { + try { + // Generate PKCE challenge + state for CSRF protection + const { codeChallenge, state } = pkceStore.createChallenge(); + + // Build auth URL with PKCE + state parameters + const authUrl = new URL( + `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/${provider}`, + ); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("state", state); + + // Open OAuth flow in system browser + await shell.openExternal(authUrl.toString()); + + // The rest happens async via deep link callback + console.log("[auth] Opened OAuth flow in browser for:", provider); + return { success: true }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to open browser"; + console.error("[auth] Sign in failed:", message); + pkceStore.clear(); // Clean up on failure + return { success: false, error: message }; + } + } + + /** + * Handle session received from deep link callback + */ + async handleDeepLinkAuth(session: AuthSession): Promise { + try { + this.session = session; + await tokenStorage.save(session); + this.scheduleRefresh(); + this.emitStateChange(); + + console.log("[auth] Signed in"); + return { success: true }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to complete sign in"; + console.error("[auth] Auth handling failed:", message); + await this.clearSession(); + return { success: false, error: message }; + } + } + + /** + * Sign out - clear session + */ + async signOut(): Promise { + await this.clearSession(); + console.log("[auth] Signed out"); + } + + /** + * Check if access token should be refreshed + */ + private shouldRefreshAccessToken(): boolean { + if (!this.session) return false; + + const timeUntilExpiry = this.session.accessTokenExpiresAt - Date.now(); + return timeUntilExpiry < TOKEN_CONFIG.REFRESH_THRESHOLD * 1000; + } + + /** + * Refresh the access token using the refresh token + */ + private async refreshAccessToken(): Promise { + if (!this.session || this.isRefreshing) return; + + this.isRefreshing = true; + + try { + console.log("[auth] Refreshing access token..."); + + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/refresh`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refresh_token: this.session.refreshToken, + }), + }, + ); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + + // If refresh token is invalid/expired, clear session + if (response.status === 401) { + console.log("[auth] Refresh token invalid, clearing session"); + await this.clearSession(); + return; + } + + throw new Error( + errorBody.error || `Refresh failed: ${response.status}`, + ); + } + + const data: RefreshResponse = await response.json(); + + // Update session with new access token and rotated refresh token + this.session = { + ...this.session, + accessToken: data.access_token, + accessTokenExpiresAt: data.access_token_expires_at, + refreshToken: data.refresh_token, + refreshTokenExpiresAt: data.refresh_token_expires_at, + }; + + // Persist updated session + await tokenStorage.save(this.session); + console.log("[auth] Access token refreshed"); + + // Reschedule next refresh + this.scheduleRefresh(); + } catch (err) { + console.error("[auth] Token refresh failed:", err); + } finally { + this.isRefreshing = false; + } + } + + /** + * Schedule automatic token refresh + */ + private scheduleRefresh(): void { + // Clear existing timer + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + if (!this.session) return; + + // Calculate when to refresh (before expiry threshold) + const timeUntilRefresh = + this.session.accessTokenExpiresAt - + Date.now() - + TOKEN_CONFIG.REFRESH_THRESHOLD * 1000; + + if (timeUntilRefresh > 0) { + console.log( + `[auth] Scheduled token refresh in ${Math.round(timeUntilRefresh / 1000 / 60)} minutes`, + ); + this.refreshTimer = setTimeout(() => { + this.refreshAccessToken(); + }, timeUntilRefresh); + } + } + + private async clearSession(): Promise { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.session = null; + await tokenStorage.clear(); + this.emitStateChange(); + } + + private emitStateChange(): void { + this.emit("state-changed", this.getState()); + } +} + +export const authService = new AuthService(); diff --git a/apps/desktop/src/main/lib/auth/deep-link-handler.ts b/apps/desktop/src/main/lib/auth/deep-link-handler.ts new file mode 100644 index 00000000000..3af0368fa7b --- /dev/null +++ b/apps/desktop/src/main/lib/auth/deep-link-handler.ts @@ -0,0 +1,139 @@ +import type { AuthSession } from "shared/auth"; +import { PROTOCOL_SCHEMES } from "shared/constants"; +import { env } from "../../../env"; +import { pkceStore } from "./pkce"; + +/** + * Token exchange response from the API (no user info - fetch via tRPC) + */ +interface TokenExchangeResponse { + access_token: string; + access_token_expires_at: number; + refresh_token: string; + refresh_token_expires_at: number; +} + +/** + * Result of handling an auth deep link + */ +export interface AuthDeepLinkResult { + success: boolean; + session?: AuthSession; + error?: string; +} + +/** + * Handle authentication deep links from the web app + * Implements PKCE flow: exchanges auth code for access + refresh tokens + */ +export async function handleAuthDeepLink( + url: string, +): Promise { + try { + const parsedUrl = new URL(url); + + // Check if this is an auth callback + if (parsedUrl.host !== "auth" || parsedUrl.pathname !== "/callback") { + return { success: false, error: "Not an auth callback URL" }; + } + + // Check for error response + const error = parsedUrl.searchParams.get("error"); + if (error) { + pkceStore.clear(); + return { success: false, error }; + } + + // Get the auth code and state (PKCE flow with CSRF protection) + const code = parsedUrl.searchParams.get("code"); + const state = parsedUrl.searchParams.get("state"); + + if (!code) { + pkceStore.clear(); + return { success: false, error: "No auth code in callback" }; + } + + if (!state) { + pkceStore.clear(); + return { success: false, error: "No state in callback" }; + } + + // Get the stored code verifier (also verifies state matches) + const codeVerifier = pkceStore.consumeVerifier(state); + if (!codeVerifier) { + return { + success: false, + error: "Invalid or expired auth session", + }; + } + + // Exchange the code for tokens + const tokenResponse = await exchangeCodeForTokens(code, codeVerifier); + + return { + success: true, + session: { + accessToken: tokenResponse.access_token, + accessTokenExpiresAt: tokenResponse.access_token_expires_at, + refreshToken: tokenResponse.refresh_token, + refreshTokenExpiresAt: tokenResponse.refresh_token_expires_at, + }, + }; + } catch (err) { + pkceStore.clear(); + const message = + err instanceof Error ? err.message : "Failed to process auth callback"; + console.error("[auth] Deep link handling failed:", message); + return { success: false, error: message }; + } +} + +/** + * Exchange auth code + code_verifier for access and refresh tokens + */ +async function exchangeCodeForTokens( + code: string, + codeVerifier: string, +): Promise { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/token`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + code_verifier: codeVerifier, + }), + }, + ); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw new Error( + errorBody.error || `Token exchange failed: ${response.status}`, + ); + } + + return response.json(); +} + +/** + * Check if a URL is an auth-related deep link + */ +export function isAuthDeepLink(url: string): boolean { + try { + const parsedUrl = new URL(url); + // Accept both production and dev protocols + const validProtocols = [ + `${PROTOCOL_SCHEMES.PROD}:`, + `${PROTOCOL_SCHEMES.DEV}:`, + ]; + return ( + validProtocols.includes(parsedUrl.protocol) && parsedUrl.host === "auth" + ); + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/lib/auth/index.ts b/apps/desktop/src/main/lib/auth/index.ts new file mode 100644 index 00000000000..1749da60e22 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/index.ts @@ -0,0 +1,4 @@ +export { authService } from "./auth-service"; +export type { AuthDeepLinkResult } from "./deep-link-handler"; +export { handleAuthDeepLink, isAuthDeepLink } from "./deep-link-handler"; +export { tokenStorage } from "./token-storage"; diff --git a/apps/desktop/src/main/lib/auth/pkce.ts b/apps/desktop/src/main/lib/auth/pkce.ts new file mode 100644 index 00000000000..dd0c6a0ae37 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/pkce.ts @@ -0,0 +1,104 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * PKCE (Proof Key for Code Exchange) utilities + * Provides security for OAuth flows by preventing authorization code interception attacks + */ + +/** + * Generate a cryptographically random code verifier + * Must be 43-128 characters, using unreserved URI characters + */ +export function generateCodeVerifier(): string { + // 32 bytes = 43 characters when base64url encoded + return randomBytes(32).toString("base64url"); +} + +/** + * Generate code challenge from code verifier using SHA256 + * This is the S256 method as recommended by RFC 7636 + */ +export function generateCodeChallenge(codeVerifier: string): string { + const hash = createHash("sha256").update(codeVerifier).digest(); + return hash.toString("base64url"); +} + +/** + * Generate a random state value for CSRF protection + */ +export function generateState(): string { + return randomBytes(16).toString("base64url"); +} + +interface PkceData { + codeVerifier: string; + state: string; + createdAt: number; +} + +/** + * PKCE + state storage + * Stores code verifier and state temporarily during OAuth flow + */ +class PkceStore { + private data: PkceData | null = null; + + // Expires after 10 minutes + private readonly EXPIRY_MS = 10 * 60 * 1000; + + /** + * Generate and store a new PKCE pair + state + * Returns the code challenge and state to send to the authorization server + */ + createChallenge(): { codeChallenge: string; state: string } { + const codeVerifier = generateCodeVerifier(); + const state = generateState(); + + this.data = { + codeVerifier, + state, + createdAt: Date.now(), + }; + + return { + codeChallenge: generateCodeChallenge(codeVerifier), + state, + }; + } + + /** + * Retrieve and consume the stored verifier if state matches + * Returns null if expired, not found, or state mismatch + */ + consumeVerifier(state: string): string | null { + if (!this.data) { + return null; + } + + // Check expiry + if (Date.now() - this.data.createdAt > this.EXPIRY_MS) { + this.clear(); + return null; + } + + // Verify state matches (CSRF protection) + if (this.data.state !== state) { + console.warn("[auth] State mismatch - possible CSRF attack"); + this.clear(); + return null; + } + + const verifier = this.data.codeVerifier; + this.clear(); + return verifier; + } + + /** + * Clear stored PKCE state + */ + clear(): void { + this.data = null; + } +} + +export const pkceStore = new PkceStore(); diff --git a/apps/desktop/src/main/lib/auth/token-storage.ts b/apps/desktop/src/main/lib/auth/token-storage.ts new file mode 100644 index 00000000000..506e1515af6 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/token-storage.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import { join } from "node:path"; +import { safeStorage } from "electron"; +import type { AuthSession } from "shared/auth"; +import { SUPERSET_HOME_DIR } from "../app-environment"; + +const SESSION_FILE_NAME = "auth-session.enc"; + +/** + * Securely stores authentication session using Electron's safeStorage API + * Session data is encrypted at rest using the OS keychain + */ +class TokenStorage { + private readonly filePath: string; + + constructor() { + this.filePath = join(SUPERSET_HOME_DIR, SESSION_FILE_NAME); + } + + async save(session: AuthSession): Promise { + if (!safeStorage.isEncryptionAvailable()) { + console.warn( + "[auth] Secure storage not available, session will not be persisted", + ); + return; + } + + const encrypted = safeStorage.encryptString(JSON.stringify(session)); + await fs.writeFile(this.filePath, encrypted); + } + + async load(): Promise { + if (!safeStorage.isEncryptionAvailable()) { + return null; + } + + try { + const encrypted = await fs.readFile(this.filePath); + const decrypted = safeStorage.decryptString(encrypted); + return JSON.parse(decrypted) as AuthSession; + } catch { + // File doesn't exist or can't be decrypted + return null; + } + } + + async clear(): Promise { + try { + await fs.unlink(this.filePath); + } catch { + // File doesn't exist, that's fine + } + } +} + +export const tokenStorage = new TokenStorage(); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index d3888a07496..a77ac78487b 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -1,6 +1,7 @@ import { app, type BrowserWindow, dialog } from "electron"; import { autoUpdater } from "electron-updater"; -import { ENVIRONMENT, PLATFORM } from "shared/constants"; +import { PLATFORM } from "shared/constants"; +import { env } from "../../env"; const UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 4; // 4 hours const UPDATE_FEED_URL = @@ -14,7 +15,7 @@ export function setMainWindow(window: BrowserWindow): void { } export function checkForUpdates(): void { - if (ENVIRONMENT.IS_DEV || !PLATFORM.IS_MAC) { + if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { return; } autoUpdater.checkForUpdates().catch((error) => { @@ -23,7 +24,7 @@ export function checkForUpdates(): void { } export function checkForUpdatesInteractive(): void { - if (ENVIRONMENT.IS_DEV) { + if (env.NODE_ENV === "development") { dialog.showMessageBox({ type: "info", title: "Updates", @@ -62,7 +63,7 @@ export function checkForUpdatesInteractive(): void { } export function setupAutoUpdater(): void { - if (ENVIRONMENT.IS_DEV || !PLATFORM.IS_MAC) { + if (env.NODE_ENV === "development" || !PLATFORM.IS_MAC) { return; } diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index 60649f7558e..40cc898263c 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,5 +1,5 @@ +import { COMPANY } from "@superset/shared/constants"; import { app, Menu, shell } from "electron"; -import { HELP_MENU } from "shared/constants"; import { checkForUpdatesInteractive } from "./auto-updater"; import { menuEmitter } from "./menu-events"; @@ -50,19 +50,19 @@ export function createApplicationMenu() { { label: "Contact Us", click: () => { - shell.openExternal(HELP_MENU.CONTACT_URL); + shell.openExternal(COMPANY.CONTACT_URL); }, }, { label: "Report Issue", click: () => { - shell.openExternal(HELP_MENU.REPORT_ISSUE_URL); + shell.openExternal(COMPANY.REPORT_ISSUE_URL); }, }, { label: "Join Discord", click: () => { - shell.openExternal(HELP_MENU.DISCORD_URL); + shell.openExternal(COMPANY.DISCORD_URL); }, }, { type: "separator" }, diff --git a/apps/desktop/src/main/lib/sound-paths.ts b/apps/desktop/src/main/lib/sound-paths.ts index d09ed660d5a..e914829d1aa 100644 --- a/apps/desktop/src/main/lib/sound-paths.ts +++ b/apps/desktop/src/main/lib/sound-paths.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { app } from "electron"; +import { env } from "../../env"; /** * Gets the path to a ringtone sound file. @@ -29,7 +30,7 @@ export function getSoundsDirectory(): string { return join(process.resourcesPath, "app.asar.unpacked/resources/sounds"); } - const isDev = process.env.NODE_ENV === "development"; + const isDev = env.NODE_ENV === "development"; if (isDev) { // Development: source files in project diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index 292a7aea7b6..69e74a6a28c 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -1,7 +1,8 @@ import { createWriteStream, promises as fs, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { IS_TEST, SUPERSET_HOME_DIR } from "./app-environment"; +import { env } from "../../env"; +import { SUPERSET_HOME_DIR } from "./app-environment"; export interface SessionMetadata { cwd: string; @@ -13,9 +14,10 @@ export interface SessionMetadata { } export function getHistoryDir(workspaceId: string, paneId: string): string { - const baseDir = IS_TEST - ? join(tmpdir(), "superset-test", ".superset") - : SUPERSET_HOME_DIR; + const baseDir = + env.NODE_ENV === "test" + ? join(tmpdir(), "superset-test", ".superset") + : SUPERSET_HOME_DIR; return join(baseDir, "terminal-history", workspaceId, paneId); } diff --git a/apps/desktop/src/renderer/components/ConfigFilePreview/ConfigFilePreview.tsx b/apps/desktop/src/renderer/components/ConfigFilePreview/ConfigFilePreview.tsx index 9dfa214bdce..83c29d8341a 100644 --- a/apps/desktop/src/renderer/components/ConfigFilePreview/ConfigFilePreview.tsx +++ b/apps/desktop/src/renderer/components/ConfigFilePreview/ConfigFilePreview.tsx @@ -1,3 +1,4 @@ +import { COMPANY } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { cn } from "@superset/ui/utils"; import { HiArrowTopRightOnSquare } from "react-icons/hi2"; @@ -7,7 +8,6 @@ import { CONFIG_FILE_NAME, CONFIG_TEMPLATE, PROJECT_SUPERSET_DIR_NAME, - WEBSITE_URL, } from "shared/constants"; export interface ConfigFilePreviewProps { @@ -29,7 +29,7 @@ export function ConfigFilePreview({ ); const handleLearnMore = () => { - window.open(`${WEBSITE_URL}/scripts`, "_blank"); + window.open(COMPANY.SCRIPTS_URL, "_blank"); }; const displayContent = diff --git a/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx b/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx index 47ae4f04950..f2b0e1cb7e1 100644 --- a/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx +++ b/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx @@ -1,3 +1,4 @@ +import { COMPANY } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { Dialog, @@ -14,7 +15,6 @@ import { useConfigModalOpen, useConfigModalProjectId, } from "renderer/stores/config-modal"; -import { WEBSITE_URL } from "shared/constants"; const CONFIG_TEMPLATE = `{ "setup": [], @@ -39,7 +39,7 @@ export function SetupConfigModal() { const projectName = project?.name ?? "your-project"; const handleLearnMore = () => { - window.open(`${WEBSITE_URL}/scripts`, "_blank"); + window.open(COMPANY.SCRIPTS_URL, "_blank"); }; return ( diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx new file mode 100644 index 00000000000..9f16c773f9b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx @@ -0,0 +1,76 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Button } from "@superset/ui/button"; +import { Skeleton } from "@superset/ui/skeleton"; +import { toast } from "@superset/ui/sonner"; +import { trpc } from "renderer/lib/trpc"; + +export function AccountSettings() { + const { data: user, isLoading } = trpc.user.me.useQuery(); + const signOutMutation = trpc.auth.signOut.useMutation({ + onSuccess: () => toast.success("Signed out"), + }); + + const signOut = () => signOutMutation.mutate(); + + const initials = user?.name + ?.split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + return ( +
+
+

Account

+

+ Manage your account settings +

+
+ +
+ {/* Profile Section */} +
+

Profile

+
+ {isLoading ? ( + <> + +
+ + +
+ + ) : user ? ( + <> + + + + {initials || "?"} + + +
+

{user.name}

+

{user.email}

+
+ + ) : ( +

Unable to load user info

+ )} +
+
+ + {/* Sign Out Section */} +
+

Sign Out

+

+ Sign out of your Superset account on this device. +

+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/index.ts b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/index.ts new file mode 100644 index 00000000000..f6b6c7c72e6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/index.ts @@ -0,0 +1 @@ +export { AccountSettings } from "./AccountSettings"; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx index b5c85608158..49d652901a2 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx @@ -1,4 +1,5 @@ import type { SettingsSection } from "renderer/stores"; +import { AccountSettings } from "./AccountSettings"; import { AppearanceSettings } from "./AppearanceSettings"; import { KeyboardShortcutsSettings } from "./KeyboardShortcutsSettings"; import { PresetsSettings } from "./PresetsSettings"; @@ -13,6 +14,7 @@ interface SettingsContentProps { export function SettingsContent({ activeSection }: SettingsContentProps) { return (
+ {activeSection === "account" && } {activeSection === "project" && } {activeSection === "workspace" && } {activeSection === "appearance" && } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx index afa75ffb669..35882334afb 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx @@ -4,6 +4,7 @@ import { HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlinePaintBrush, + HiOutlineUser, } from "react-icons/hi2"; import type { SettingsSection } from "renderer/stores"; @@ -17,6 +18,11 @@ const GENERAL_SECTIONS: { label: string; icon: React.ReactNode; }[] = [ + { + id: "account", + label: "Account", + icon: , + }, { id: "appearance", label: "Appearance", diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/HelpMenu/HelpMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/HelpMenu/HelpMenu.tsx index 344da7ad6c8..fe32b33be97 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/HelpMenu/HelpMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/HelpMenu/HelpMenu.tsx @@ -1,3 +1,4 @@ +import { COMPANY } from "@superset/shared/constants"; import { DropdownMenu, DropdownMenuContent, @@ -15,22 +16,21 @@ import { HiOutlineQuestionMarkCircle, } from "react-icons/hi2"; import { useOpenSettings } from "renderer/stores"; -import { HELP_MENU } from "shared/constants"; import { HOTKEYS } from "shared/hotkeys"; export function HelpMenu() { const openSettings = useOpenSettings(); const handleContactUs = () => { - window.open(HELP_MENU.CONTACT_URL, "_blank"); + window.open(COMPANY.CONTACT_URL, "_blank"); }; const handleReportIssue = () => { - window.open(HELP_MENU.REPORT_ISSUE_URL, "_blank"); + window.open(COMPANY.REPORT_ISSUE_URL, "_blank"); }; const handleJoinDiscord = () => { - window.open(HELP_MENU.DISCORD_URL, "_blank"); + window.open(COMPANY.DISCORD_URL, "_blank"); }; const handleViewHotkeys = () => { diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index d590cb314f3..a0253c21488 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -14,6 +14,7 @@ import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener" import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; import { HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; +import { SignInScreen } from "../sign-in"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; import { SettingsView } from "./components/SettingsView"; @@ -28,16 +29,28 @@ function LoadingSpinner() { } export function MainScreen() { + const utils = trpc.useUtils(); + const { data: authState } = trpc.auth.getState.useQuery(); + const isSignedIn = authState?.isSignedIn ?? false; + const isAuthLoading = !authState; + + // Subscribe to auth state changes + trpc.auth.onStateChange.useSubscription(undefined, { + onData: () => utils.auth.getState.invalidate(), + }); + const currentView = useCurrentView(); const openSettings = useOpenSettings(); const { toggleSidebar } = useSidebarStore(); const { data: activeWorkspace, - isLoading, + isLoading: isWorkspaceLoading, isError, failureCount, refetch, - } = trpc.workspaces.getActive.useQuery(); + } = trpc.workspaces.getActive.useQuery(undefined, { + enabled: isSignedIn, + }); const [isRetrying, setIsRetrying] = useState(false); const splitPaneAuto = useTabsStore((s) => s.splitPaneAuto); const splitPaneVertical = useTabsStore((s) => s.splitPaneVertical); @@ -140,9 +153,35 @@ export function MainScreen() { isWorkspaceView, ]); + const isLoading = isWorkspaceLoading; const showStartView = !isLoading && !activeWorkspace && currentView !== "settings"; + // Show sign-in screen if not authenticated + if (isAuthLoading) { + return ( + <> + + +
+ +
+
+ + ); + } + + if (!isSignedIn) { + return ( + <> + + + + + + ); + } + const renderContent = () => { if (currentView === "settings") { return ; diff --git a/apps/desktop/src/renderer/screens/sign-in/components/SupersetLogo/SupersetLogo.tsx b/apps/desktop/src/renderer/screens/sign-in/components/SupersetLogo/SupersetLogo.tsx new file mode 100644 index 00000000000..5ac65d3ceac --- /dev/null +++ b/apps/desktop/src/renderer/screens/sign-in/components/SupersetLogo/SupersetLogo.tsx @@ -0,0 +1,25 @@ +import { cn } from "@superset/ui/utils"; + +interface SupersetLogoProps { + className?: string; +} + +export function SupersetLogo({ className }: SupersetLogoProps) { + return ( + + Superset + + + ); +} diff --git a/apps/desktop/src/renderer/screens/sign-in/components/SupersetLogo/index.ts b/apps/desktop/src/renderer/screens/sign-in/components/SupersetLogo/index.ts new file mode 100644 index 00000000000..f0b39d9214e --- /dev/null +++ b/apps/desktop/src/renderer/screens/sign-in/components/SupersetLogo/index.ts @@ -0,0 +1 @@ +export { SupersetLogo } from "./SupersetLogo"; diff --git a/apps/desktop/src/renderer/screens/sign-in/index.tsx b/apps/desktop/src/renderer/screens/sign-in/index.tsx new file mode 100644 index 00000000000..90cba2317b9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/sign-in/index.tsx @@ -0,0 +1,102 @@ +import { COMPANY } from "@superset/shared/constants"; +import { Button } from "@superset/ui/button"; +import { useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { FcGoogle } from "react-icons/fc"; +import { trpc } from "renderer/lib/trpc"; +import type { AuthProvider } from "shared/auth"; +import { SupersetLogo } from "./components/SupersetLogo"; + +function LoadingSpinner() { + return ( +
+ ); +} + +export function SignInScreen() { + const [isSigningIn, setIsSigningIn] = useState(false); + + const signInMutation = trpc.auth.signIn.useMutation({ + onMutate: () => setIsSigningIn(true), + onError: () => setIsSigningIn(false), + }); + + const signIn = (provider: AuthProvider) => + signInMutation.mutate({ provider }); + + return ( +
+
+ +
+
+
+ +
+ +
+

+ Welcome to Superset +

+

+ Sign in to get started +

+
+ +
+ + + +
+ +

+ By signing in, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + +

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts index 1f7890321b1..f15063920b2 100644 --- a/apps/desktop/src/renderer/stores/app-state.ts +++ b/apps/desktop/src/renderer/stores/app-state.ts @@ -3,6 +3,7 @@ import { devtools } from "zustand/middleware"; export type AppView = "workspace" | "settings"; export type SettingsSection = + | "account" | "project" | "workspace" | "appearance" diff --git a/apps/desktop/src/shared/auth.ts b/apps/desktop/src/shared/auth.ts new file mode 100644 index 00000000000..2f3348da304 --- /dev/null +++ b/apps/desktop/src/shared/auth.ts @@ -0,0 +1,16 @@ +export { AUTH_PROVIDERS, type AuthProvider } from "@superset/shared/constants"; + +/** + * Auth session - just tokens, user data fetched separately via tRPC + */ +export interface AuthSession { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; +} + +export interface SignInResult { + success: boolean; + error?: string; +} diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 55746cb81ea..305fedccfbd 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -1,6 +1,4 @@ -export const ENVIRONMENT = { - IS_DEV: process.env.NODE_ENV === "development", -}; +import { env } from "../env"; export const PLATFORM = { IS_MAC: process.platform === "darwin", @@ -11,9 +9,9 @@ export const PLATFORM = { // Ports - different for dev vs prod to allow running both simultaneously export const PORTS = { // Vite dev server port - VITE_DEV_SERVER: ENVIRONMENT.IS_DEV ? 5927 : 4927, + VITE_DEV_SERVER: env.NODE_ENV === "development" ? 5927 : 4927, // Notification HTTP server port - NOTIFICATIONS: ENVIRONMENT.IS_DEV ? 31416 : 31415, + NOTIFICATIONS: env.NODE_ENV === "development" ? 31416 : 31415, }; // Note: For environment-aware paths, use main/lib/app-environment.ts instead. @@ -22,24 +20,23 @@ export const SUPERSET_DIR_NAMES = { DEV: ".superset-dev", PROD: ".superset", } as const; -export const SUPERSET_DIR_NAME = ENVIRONMENT.IS_DEV - ? SUPERSET_DIR_NAMES.DEV - : SUPERSET_DIR_NAMES.PROD; +export const SUPERSET_DIR_NAME = + env.NODE_ENV === "development" + ? SUPERSET_DIR_NAMES.DEV + : SUPERSET_DIR_NAMES.PROD; + +// Deep link protocol scheme +export const PROTOCOL_SCHEMES = { + DEV: "superset-dev", + PROD: "superset", +} as const; +export const PROTOCOL_SCHEME = + env.NODE_ENV === "development" ? PROTOCOL_SCHEMES.DEV : PROTOCOL_SCHEMES.PROD; // Project-level directory name (always .superset, not conditional) export const PROJECT_SUPERSET_DIR_NAME = ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; export const CONFIG_FILE_NAME = "config.json"; -// Website URL - defaults to production, can be overridden via env var for local dev -export const WEBSITE_URL = process.env.WEBSITE_URL || "https://superset.sh"; - -// Help menu URLs -export const HELP_MENU = { - CONTACT_URL: "https://x.com/superset_sh", - REPORT_ISSUE_URL: "https://github.com/superset-sh/superset/issues/new", - DISCORD_URL: "https://discord.gg/cZeD9WYcV7", -} as const; - // Config file template export const CONFIG_TEMPLATE = `{ "setup": [], diff --git a/apps/web/package.json b/apps/web/package.json index 802b998b207..11aa42e7e97 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "geist": "^1.5.1", + "jose": "^6.1.3", "lucide-react": "^0.560.0", "next": "^16.0.10", "next-themes": "^0.4.6", diff --git a/apps/web/src/app/api/auth/desktop/[provider]/route.ts b/apps/web/src/app/api/auth/desktop/[provider]/route.ts new file mode 100644 index 00000000000..c8460bfdc54 --- /dev/null +++ b/apps/web/src/app/api/auth/desktop/[provider]/route.ts @@ -0,0 +1,126 @@ +import { randomUUID } from "node:crypto"; +import { currentUser } from "@clerk/nextjs/server"; +import { AUTH_PROVIDERS, type AuthProvider } from "@superset/shared/constants"; +import { SignJWT } from "jose"; +import { type NextRequest, NextResponse } from "next/server"; + +import { env } from "@/env"; + +/** + * Desktop auth endpoint with PKCE support + * + * Flow: + * 1. Desktop opens browser to /api/auth/desktop/google?code_challenge=XXX + * 2. If not authenticated, redirect to Clerk sign-in + * 3. Once authenticated, create auth code (JWT with user info + code_challenge) + * 4. Redirect to desktop via deep link with auth code + * 5. Desktop exchanges code + code_verifier at /api/auth/desktop/token endpoint + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> }, +) { + const { provider } = await params; + + // Validate provider + if (!AUTH_PROVIDERS.includes(provider as AuthProvider)) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + + // Get PKCE + state parameters + const codeChallenge = request.nextUrl.searchParams.get("code_challenge"); + const codeChallengeMethod = request.nextUrl.searchParams.get( + "code_challenge_method", + ); + const state = request.nextUrl.searchParams.get("state"); + + // Validate required parameters + if (!codeChallenge) { + return NextResponse.json( + { error: "Missing code_challenge parameter" }, + { status: 400 }, + ); + } + + if (!state) { + return NextResponse.json( + { error: "Missing state parameter" }, + { status: 400 }, + ); + } + + // Validate code_challenge format: base64url charset, 43-128 chars (RFC 7636) + const base64urlRegex = /^[A-Za-z0-9_-]+$/; + if ( + codeChallenge.length < 43 || + codeChallenge.length > 128 || + !base64urlRegex.test(codeChallenge) + ) { + return NextResponse.json( + { error: "Invalid code_challenge format" }, + { status: 400 }, + ); + } + + if (codeChallengeMethod && codeChallengeMethod !== "S256") { + return NextResponse.json( + { error: "Only S256 code_challenge_method is supported" }, + { status: 400 }, + ); + } + + // Check if user is authenticated + const user = await currentUser(); + + if (!user) { + // Redirect to sign-in with callback to this endpoint (preserving PKCE params) + const callbackUrl = new URL(request.url); + const signInUrl = new URL("/sign-in", request.url); + signInUrl.searchParams.set( + "redirect_url", + `${callbackUrl.pathname}${callbackUrl.search}`, + ); + return NextResponse.redirect(signInUrl); + } + + // User is authenticated - create auth code with minimal claims (no PII in URLs) + // User profile is looked up server-side during token exchange + const authCode = await createAuthCode({ + userId: user.id, + codeChallenge, + }); + + // Redirect to web callback page (which will open the desktop app) + const callbackUrl = new URL("/auth/desktop/callback", request.url); + callbackUrl.searchParams.set("code", authCode); + callbackUrl.searchParams.set("state", state); + + return NextResponse.redirect(callbackUrl.toString()); +} + +interface AuthCodePayload { + userId: string; + codeChallenge: string; +} + +/** + * Create a short-lived auth code with minimal claims (no PII) + * User profile is looked up server-side during token exchange + * Includes jti (JWT ID) for replay protection + */ +async function createAuthCode(payload: AuthCodePayload): Promise { + const secret = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); + + const jwt = await new SignJWT({ + userId: payload.userId, + codeChallenge: payload.codeChallenge, + type: "auth_code", + }) + .setProtectedHeader({ alg: "HS256" }) + .setJti(randomUUID()) // Unique ID for replay protection + .setIssuedAt() + .setExpirationTime("5m") // Auth code expires in 5 minutes + .sign(secret); + + return jwt; +} diff --git a/apps/web/src/app/auth/desktop/callback/page.tsx b/apps/web/src/app/auth/desktop/callback/page.tsx new file mode 100644 index 00000000000..33c898f4bf1 --- /dev/null +++ b/apps/web/src/app/auth/desktop/callback/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useCallback, useEffect, useState } from "react"; + +const DESKTOP_PROTOCOL = + process.env.NODE_ENV === "development" ? "superset-dev" : "superset"; + +function CallbackContent() { + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + const [hasAttempted, setHasAttempted] = useState(false); + + const desktopUrl = + code && state + ? `${DESKTOP_PROTOCOL}://auth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + : null; + + const openDesktopApp = useCallback(() => { + if (!desktopUrl) return; + window.location.href = desktopUrl; + }, [desktopUrl]); + + useEffect(() => { + if (error || !code) return; + + if (!hasAttempted) { + setHasAttempted(true); + openDesktopApp(); + } + }, [code, error, hasAttempted, openDesktopApp]); + + if (error) { + return ( +
+
+ Superset +

Authentication failed

+

{error}

+
+
+ ); + } + + if (!code || !state) { + return ( +
+
+ Superset +

Invalid request

+

+ Missing authentication parameters. Please try again. +

+
+
+ ); + } + + return ( +
+
+ Superset +

+ Redirecting to the desktop app... +

+
+ + If you weren't redirected, click here. + +
+
+
+ ); +} + +export default function DesktopCallbackPage() { + return ( + +
+ Superset +

Loading...

+
+
+ } + > + + + ); +} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index d9db3a4f590..e621bfb19b2 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -14,6 +14,7 @@ export const env = createEnv({ DATABASE_URL: z.string().url(), DATABASE_URL_UNPOOLED: z.string().url(), CLERK_SECRET_KEY: z.string(), + DESKTOP_AUTH_SECRET: z.string().min(32), }, client: { diff --git a/bun.lock b/bun.lock index e3fe05067c1..20f35dbd9f0 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,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", @@ -114,7 +115,10 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@superset/shared": "workspace:*", + "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", + "@t3-oss/env-core": "^0.13.8", "@tanstack/react-query": "^5.90.10", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", @@ -144,6 +148,7 @@ "fast-glob": "^3.3.3", "framer-motion": "^12.23.24", "http-proxy": "^1.18.1", + "jose": "^6.1.3", "line-column-path": "^3.0.0", "lodash": "^4.17.21", "lowdb": "^7.0.1", @@ -285,6 +290,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "geist": "^1.5.1", + "jose": "^6.1.3", "lucide-react": "^0.560.0", "next": "^16.0.10", "next-themes": "^0.4.6", @@ -2129,6 +2135,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index cbbfa5c49f7..a64f390078e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,9 +1,19 @@ +// Auth +export const AUTH_PROVIDERS = ["github", "google"] as const; +export type AuthProvider = (typeof AUTH_PROVIDERS)[number]; + // Company export const COMPANY = { NAME: "Superset", DOMAIN: "superset.sh", EMAIL_DOMAIN: "@superset.sh", GITHUB_URL: "https://github.com/superset-sh/superset", + TERMS_URL: "https://superset.sh/terms-of-service", + PRIVACY_URL: "https://superset.sh/privacy-policy", + CONTACT_URL: "https://x.com/superset_sh", + REPORT_ISSUE_URL: "https://github.com/superset-sh/superset/issues/new", + DISCORD_URL: "https://discord.gg/cZeD9WYcV7", + SCRIPTS_URL: "https://superset.sh/scripts", } as const; // Theme @@ -11,3 +21,13 @@ export const THEME_STORAGE_KEY = "superset-theme"; // Download URLs export const DOWNLOAD_URL_MAC_ARM64 = `${COMPANY.GITHUB_URL}/releases/latest/download/Superset-arm64.dmg`; + +// Auth token configuration +export const TOKEN_CONFIG = { + /** Access token lifetime in seconds (1 hour) */ + ACCESS_TOKEN_EXPIRY: 60 * 60, + /** Refresh token lifetime in seconds (30 days) */ + REFRESH_TOKEN_EXPIRY: 30 * 24 * 60 * 60, + /** Refresh access token when this many seconds remain (5 minutes) */ + REFRESH_THRESHOLD: 5 * 60, +} as const; diff --git a/packages/trpc/src/router/organization.ts b/packages/trpc/src/router/organization.ts index ef3731ea7ea..a0fadf96cc9 100644 --- a/packages/trpc/src/router/organization.ts +++ b/packages/trpc/src/router/organization.ts @@ -58,7 +58,7 @@ export const organizationRouter = { ) .mutation(async ({ ctx, input }) => { const user = await db.query.users.findFirst({ - where: eq(users.clerkId, ctx.session.userId), + where: eq(users.clerkId, ctx.userId), }); const [organization] = await db diff --git a/packages/trpc/src/router/task.ts b/packages/trpc/src/router/task.ts index d1d9d7c730a..a1a2dd3fe63 100644 --- a/packages/trpc/src/router/task.ts +++ b/packages/trpc/src/router/task.ts @@ -73,7 +73,7 @@ export const taskRouter = { ) .mutation(async ({ ctx, input }) => { const user = await db.query.users.findFirst({ - where: eq(users.clerkId, ctx.session.userId), + where: eq(users.clerkId, ctx.userId), }); if (!user) throw new Error("User not found"); diff --git a/packages/trpc/src/router/user.ts b/packages/trpc/src/router/user.ts index 46f704974ee..cb690d74bff 100644 --- a/packages/trpc/src/router/user.ts +++ b/packages/trpc/src/router/user.ts @@ -8,7 +8,7 @@ import { protectedProcedure } from "../trpc"; export const userRouter = { me: protectedProcedure.query(async ({ ctx }) => { return db.query.users.findFirst({ - where: eq(users.clerkId, ctx.session.userId), + where: eq(users.clerkId, ctx.userId), }); }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 3ef87c1600b..8f0d1a0d5be 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -1,4 +1,3 @@ -import type { SessionAuthObject } from "@clerk/backend"; import { db } from "@superset/db/client"; import { users } from "@superset/db/schema"; import { COMPANY } from "@superset/shared/constants"; @@ -7,31 +6,21 @@ import { eq } from "drizzle-orm"; import superjson from "superjson"; import { ZodError } from "zod"; -// SignedInAuthObject isn't exported from @clerk/backend main entry, -// so we extract it from the SessionAuthObject union -type SignedInAuthObject = Extract; - /** * tRPC Context * - * We use SessionAuthObject from @clerk/backend (not @clerk/nextjs) because: - * - The API is hosted on Next.js with clerkMiddleware handling auth - * - Expo/Desktop clients send Bearer tokens to this API - * - clerkMiddleware handles both cookie auth (web) and Bearer tokens (mobile/desktop) - * - @clerk/backend types work across all clients, while @clerk/nextjs would - * cause dependency issues in Expo/Desktop which don't have Next.js - * - * SessionAuthObject = SignedInAuthObject | SignedOutAuthObject - * Public procedures may be called by unauthenticated users (SignedOutAuthObject) + * Simple auth context with just userId. Supports both: + * - Clerk sessions (web/admin via cookies) + * - Desktop auth (custom JWT tokens) */ export type TRPCContext = { - session: SessionAuthObject; + userId: string | null; }; export const createTRPCContext = (opts: { - session: SessionAuthObject; + userId: string | null; }): TRPCContext => { - return { session: opts.session }; + return { userId: opts.userId }; }; const t = initTRPC.context().create({ @@ -55,7 +44,7 @@ export const createCallerFactory = t.createCallerFactory; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { - if (!ctx.session.userId) { + if (!ctx.userId) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated. Please sign in.", @@ -64,16 +53,14 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { return next({ ctx: { - // Cast needed because TypeScript doesn't propagate type narrowing through next() - // After the userId check above, we know session is SignedInAuthObject - session: ctx.session as SignedInAuthObject, + userId: ctx.userId, }, }); }); export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => { const user = await db.query.users.findFirst({ - where: eq(users.clerkId, ctx.session.userId), + where: eq(users.clerkId, ctx.userId), }); if (!user) {