diff --git a/.env.example b/.env.example index 819ed888bae..2cfef7f3066 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,14 @@ CLERK_SECRET_KEY= CLERK_WEBHOOK_SECRET= NEXT_PUBLIC_COOKIE_DOMAIN=localhost +# ----------------------------------------------------------------------------- +# OAuth Credentials (for Desktop App direct auth) +# ----------------------------------------------------------------------------- +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GH_CLIENT_ID= +GH_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Blob Storage # ----------------------------------------------------------------------------- diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 758434b0df6..8904035981c 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -127,6 +127,10 @@ jobs: 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 }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} + GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -139,7 +143,11 @@ jobs: --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 DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET) + --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ + --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ + --env GH_CLIENT_ID=$GH_CLIENT_ID \ + --env GH_CLIENT_SECRET=$GH_CLIENT_SECRET) vercel alias $VERCEL_URL ${{ env.API_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 25f4208ed2d..3955cb22efd 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -3,8 +3,6 @@ name: Deploy Production on: push: branches: [main] - paths-ignore: - - "apps/desktop/**" workflow_dispatch: jobs: @@ -79,6 +77,10 @@ jobs: 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 }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} + GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -91,7 +93,11 @@ jobs: --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 DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET + --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ + --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ + --env GH_CLIENT_ID=$GH_CLIENT_ID \ + --env GH_CLIENT_SECRET=$GH_CLIENT_SECRET deploy-web: name: Deploy Web to Vercel diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 068c26bf13f..22322f30235 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -58,6 +58,10 @@ jobs: env: NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} + NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} run: bun run compile:app # Build the Electron app for macOS diff --git a/apps/api/src/app/api/auth/desktop/github/route.ts b/apps/api/src/app/api/auth/desktop/github/route.ts new file mode 100644 index 00000000000..4f95b096832 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/github/route.ts @@ -0,0 +1,207 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { env } from "@/env"; +import { generateTokens } from "../tokens"; + +/** + * GitHub OAuth token response + */ +interface GitHubTokenResponse { + access_token: string; + token_type: string; + scope: string; +} + +/** + * GitHub user response + */ +interface GitHubUser { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + +/** + * GitHub email response + */ +interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} + +/** + * Exchange GitHub auth code for tokens and create desktop session + * + * POST /api/auth/desktop/github + * Body: { code: string, redirectUri: string } + * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { code, redirectUri } = body as { + code: string; + redirectUri: string; + }; + + if (!code || !redirectUri) { + return Response.json( + { error: "Missing code or redirectUri" }, + { status: 400 }, + ); + } + + // Exchange code for access token with GitHub + const tokenResponse = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: env.GH_CLIENT_ID, + client_secret: env.GH_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + }), + }, + ); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json().catch(() => ({})); + console.error("[auth/github] Token exchange failed:", errorData); + return Response.json({ error: "Token exchange failed" }, { status: 400 }); + } + + const tokenData: GitHubTokenResponse = await tokenResponse.json(); + + if (!tokenData.access_token) { + console.error("[auth/github] No access token in response:", tokenData); + return Response.json( + { error: "No access token received" }, + { status: 400 }, + ); + } + + // Fetch user info from GitHub + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!userResponse.ok) { + console.error("[auth/github] Failed to fetch user info"); + return Response.json( + { error: "Failed to fetch user info" }, + { status: 400 }, + ); + } + + const githubUser: GitHubUser = await userResponse.json(); + + // Always fetch verified email from /user/emails endpoint + // Never trust githubUser.email as it could be unverified + const emailsResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!emailsResponse.ok) { + console.error("[auth/github] Failed to fetch user emails"); + return Response.json( + { error: "Failed to fetch user emails" }, + { status: 400 }, + ); + } + + const emails: GitHubEmail[] = await emailsResponse.json(); + // Only trust verified emails - prefer primary+verified, fallback to any verified + const primaryVerifiedEmail = emails.find((e) => e.primary && e.verified); + const anyVerifiedEmail = emails.find((e) => e.verified); + const email = + primaryVerifiedEmail?.email || anyVerifiedEmail?.email || null; + + if (!email) { + return Response.json( + { error: "No verified email found on GitHub account" }, + { status: 400 }, + ); + } + + // Parse name into first/last + const nameParts = (githubUser.name || "").split(" "); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(" ") || undefined; + + // Find or create user in Clerk + const clerk = await clerkClient(); + const existingUsers = await clerk.users.getUserList({ + emailAddress: [email], + }); + + let userId: string; + const existingUser = existingUsers.data[0]; + + if (existingUser) { + userId = existingUser.id; + console.log("[auth/github] Found existing user:", userId); + } else { + // Create new user + try { + const newUser = await clerk.users.createUser({ + emailAddress: [email], + firstName, + lastName, + skipPasswordRequirement: true, + }); + userId = newUser.id; + console.log("[auth/github] Created new user:", userId); + + // Mark the email as verified since GitHub already verified it + const emailId = newUser.emailAddresses[0]?.id; + if (emailId) { + await clerk.emailAddresses.updateEmailAddress(emailId, { + verified: true, + }); + console.log("[auth/github] Marked email as verified"); + } + } catch (clerkError: unknown) { + // Log and return detailed Clerk error + const errorDetails = + clerkError && typeof clerkError === "object" && "errors" in clerkError + ? (clerkError as { errors: unknown[] }).errors + : clerkError; + console.error( + "[auth/github] Clerk createUser failed:", + JSON.stringify(errorDetails, null, 2), + ); + return Response.json( + { + error: "Failed to create user account", + details: errorDetails, + }, + { status: 400 }, + ); + } + } + + // Generate access and refresh tokens + const tokens = await generateTokens(userId, email); + + return Response.json(tokens); + } catch (error) { + console.error("[auth/github] Error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/api/src/app/api/auth/desktop/google/route.ts b/apps/api/src/app/api/auth/desktop/google/route.ts new file mode 100644 index 00000000000..622408b47cc --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/google/route.ts @@ -0,0 +1,173 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { env } from "@/env"; +import { generateTokens } from "../tokens"; + +/** + * Google OAuth token response + */ +interface GoogleTokenResponse { + access_token: string; + expires_in: number; + token_type: string; + scope: string; + id_token: string; + refresh_token?: string; +} + +/** + * Google ID token payload (verified) + */ +interface GoogleIdTokenPayload { + iss: string; + azp: string; + aud: string; + sub: string; + email: string; + email_verified: boolean; + name?: string; + picture?: string; + given_name?: string; + family_name?: string; + iat: number; + exp: number; +} + +// Google's JWKS endpoint - jose handles caching internally +const GOOGLE_JWKS = createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), +); + +/** + * Exchange Google auth code for tokens and create desktop session + * + * POST /api/auth/desktop/google + * Body: { code: string, redirectUri: string } + * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { code, redirectUri } = body as { + code: string; + redirectUri: string; + }; + + if (!code || !redirectUri) { + return Response.json( + { error: "Missing code or redirectUri" }, + { status: 400 }, + ); + } + + // Exchange code for tokens with Google + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json().catch(() => ({})); + console.error("[auth/google] Token exchange failed:", errorData); + return Response.json( + { error: errorData.error_description || "Token exchange failed" }, + { status: 400 }, + ); + } + + const googleTokens: GoogleTokenResponse = await tokenResponse.json(); + + // Verify the ID token signature and claims using Google's JWKS + let payload: GoogleIdTokenPayload; + try { + const { payload: verifiedPayload } = await jwtVerify( + googleTokens.id_token, + GOOGLE_JWKS, + { + issuer: ["https://accounts.google.com", "accounts.google.com"], + audience: env.GOOGLE_CLIENT_ID, + }, + ); + payload = verifiedPayload as unknown as GoogleIdTokenPayload; + } catch (jwtError) { + console.error("[auth/google] JWT verification failed:", jwtError); + return Response.json( + { error: "Invalid or expired ID token" }, + { status: 401 }, + ); + } + + if (!payload.email_verified) { + return Response.json({ error: "Email not verified" }, { status: 400 }); + } + + // Find or create user in Clerk + const clerk = await clerkClient(); + const existingUsers = await clerk.users.getUserList({ + emailAddress: [payload.email], + }); + + let userId: string; + const existingUser = existingUsers.data[0]; + + if (existingUser) { + userId = existingUser.id; + console.log("[auth/google] Found existing user:", userId); + } else { + // Create new user + try { + const newUser = await clerk.users.createUser({ + emailAddress: [payload.email], + firstName: payload.given_name, + lastName: payload.family_name, + skipPasswordRequirement: true, + }); + userId = newUser.id; + console.log("[auth/google] Created new user:", userId); + + // Mark the email as verified since Google already verified it + const emailId = newUser.emailAddresses[0]?.id; + if (emailId) { + await clerk.emailAddresses.updateEmailAddress(emailId, { + verified: true, + }); + console.log("[auth/google] Marked email as verified"); + } + } catch (clerkError: unknown) { + // Log and return detailed Clerk error + const errorDetails = + clerkError && typeof clerkError === "object" && "errors" in clerkError + ? (clerkError as { errors: unknown[] }).errors + : clerkError; + console.error( + "[auth/google] Clerk createUser failed:", + JSON.stringify(errorDetails, null, 2), + ); + return Response.json( + { + error: "Failed to create user account", + details: errorDetails, + }, + { status: 400 }, + ); + } + } + + // Generate access and refresh tokens + const tokens = await generateTokens(userId, payload.email); + + return Response.json(tokens); + } catch (error) { + console.error("[auth/google] Error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/api/src/app/api/auth/desktop/refresh/route.ts b/apps/api/src/app/api/auth/desktop/refresh/route.ts index 0b6c65e1c15..808cc500a7a 100644 --- a/apps/api/src/app/api/auth/desktop/refresh/route.ts +++ b/apps/api/src/app/api/auth/desktop/refresh/route.ts @@ -1,98 +1,42 @@ -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"; -} +import { generateTokens, verifyRefreshToken } from "../tokens"; /** - * Refresh endpoint for desktop auth + * Refresh access token using a valid refresh token * * POST /api/auth/desktop/refresh - * Body: { refresh_token: string } + * Body: { refreshToken: string } + * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } * - * Exchanges a valid refresh token for new access + refresh tokens + * This endpoint allows the desktop app to get new tokens without + * requiring the user to re-authenticate through Google OAuth. */ -export async function POST(request: NextRequest) { +export async function POST(request: Request) { try { const body = await request.json(); - const { refresh_token } = body; + const { refreshToken } = body as { refreshToken: string }; - // Validate required parameters - if (!refresh_token || typeof refresh_token !== "string") { - return NextResponse.json( - { error: "Missing or invalid refresh_token parameter" }, - { status: 400 }, - ); + if (!refreshToken) { + return Response.json({ error: "Missing refresh token" }, { status: 400 }); } - // Verify and decode the refresh token - const secret = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); - let payload: RefreshTokenPayload; + // Verify the refresh token + const tokenData = await verifyRefreshToken(refreshToken); - 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( + if (!tokenData) { + return Response.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; + // Generate new tokens (rotate both access and refresh tokens) + const tokens = await generateTokens(tokenData.userId, tokenData.email); - const newRefreshToken = await new SignJWT({ - userId: payload.userId, - type: "refresh", - }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(`${TOKEN_CONFIG.REFRESH_TOKEN_EXPIRY}s`) - .sign(secret); + console.log("[auth/refresh] Tokens refreshed for user:", tokenData.userId); - return NextResponse.json({ - access_token: accessToken, - access_token_expires_at: accessTokenExpiresAt, - refresh_token: newRefreshToken, - refresh_token_expires_at: refreshTokenExpiresAt, - }); + return Response.json(tokens); } catch (error) { - console.error("[refresh] Token refresh failed:", error); - return NextResponse.json( - { error: "Token refresh failed" }, - { status: 500 }, - ); + console.error("[auth/refresh] Error:", error); + return Response.json({ error: "Internal server error" }, { 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 deleted file mode 100644 index de2563c2818..00000000000 --- a/apps/api/src/app/api/auth/desktop/token/route.ts +++ /dev/null @@ -1,145 +0,0 @@ -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/app/api/auth/desktop/tokens.ts b/apps/api/src/app/api/auth/desktop/tokens.ts new file mode 100644 index 00000000000..f3aff406563 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/tokens.ts @@ -0,0 +1,81 @@ +import { jwtVerify, SignJWT } from "jose"; +import { env } from "@/env"; + +// Token expiration times +export const ACCESS_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour +export const REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000; // 30 days + +/** + * Get the secret key for signing/verifying tokens + */ +export function getSecretKey(): Uint8Array { + return new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); +} + +/** + * Generate access and refresh tokens for a user + */ +export async function generateTokens(userId: string, email: string) { + const secretKey = getSecretKey(); + const now = Date.now(); + const accessTokenExpiresAt = now + ACCESS_TOKEN_EXPIRY; + const refreshTokenExpiresAt = now + REFRESH_TOKEN_EXPIRY; + + // Access token - short-lived, used for API calls + const accessToken = await new SignJWT({ + sub: userId, + email, + type: "access", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(Math.floor(accessTokenExpiresAt / 1000)) + .setIssuer("superset-desktop") + .sign(secretKey); + + // Refresh token - long-lived, used to get new access tokens + const refreshToken = await new SignJWT({ + sub: userId, + email, + type: "refresh", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(Math.floor(refreshTokenExpiresAt / 1000)) + .setIssuer("superset-desktop") + .sign(secretKey); + + return { + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + }; +} + +/** + * Verify a refresh token and return its payload + */ +export async function verifyRefreshToken(token: string): Promise<{ + userId: string; + email: string; +} | null> { + try { + const secretKey = getSecretKey(); + const { payload } = await jwtVerify(token, secretKey, { + issuer: "superset-desktop", + }); + + // Ensure it's a refresh token + if (payload.type !== "refresh") { + return null; + } + + return { + userId: payload.sub as string, + email: payload.email as string, + }; + } catch { + return null; + } +} diff --git a/apps/api/src/app/api/webhooks/clerk/route.ts b/apps/api/src/app/api/webhooks/clerk/route.ts deleted file mode 100644 index 1fe958bec9d..00000000000 --- a/apps/api/src/app/api/webhooks/clerk/route.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { verifyWebhook } from "@clerk/backend/webhooks"; -import { db } from "@superset/db/client"; -import { users } from "@superset/db/schema"; -import { put } from "@vercel/blob"; -import { eq } from "drizzle-orm"; - -import { env } from "../../../../env"; - -async function uploadAvatar( - imageUrl: string | undefined, - userId: string, -): Promise { - if (!imageUrl) return null; - - try { - const response = await fetch(imageUrl); - if (!response.ok) return null; - - const blob = await response.blob(); - const { url } = await put(`users/${userId}/avatar.png`, blob, { - access: "public", - token: env.BLOB_READ_WRITE_TOKEN, - }); - return url; - } catch { - return null; - } -} - -export async function POST(req: Request) { - try { - const evt = await verifyWebhook(req, { - signingSecret: env.CLERK_WEBHOOK_SECRET, - }); - - if (evt.type === "user.created" || evt.type === "user.updated") { - const clerkUser = evt.data; - const primaryEmail = clerkUser.email_addresses.find( - (email) => email.id === clerkUser.primary_email_address_id, - )?.email_address; - - if (!primaryEmail) { - return new Response("No primary email", { status: 200 }); - } - - const name = - [clerkUser.first_name, clerkUser.last_name].filter(Boolean).join(" ") || - primaryEmail.split("@")[0] || - "User"; - - // Insert/update user first to get the internal UUID - const [user] = await db - .insert(users) - .values({ - clerkId: clerkUser.id, - email: primaryEmail, - name, - }) - .onConflictDoUpdate({ - target: users.clerkId, - set: { - email: primaryEmail, - name, - }, - }) - .returning({ id: users.id }); - - // Upload avatar using internal UUID, then update user - if (user) { - const avatarUrl = await uploadAvatar(clerkUser.image_url, user.id); - if (avatarUrl) { - await db - .update(users) - .set({ avatarUrl }) - .where(eq(users.id, user.id)); - } - } - } - - if (evt.type === "user.deleted" && evt.data.id) { - await db.delete(users).where(eq(users.clerkId, evt.data.id)); - } - - return new Response("Success", { status: 200 }); - } catch (err) { - console.error("Webhook verification failed:", err); - return new Response("Webhook verification failed", { status: 400 }); - } -} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index ba5402030ea..f887acb2921 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -6,9 +6,12 @@ export const env = createEnv({ DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), CLERK_SECRET_KEY: z.string(), - CLERK_WEBHOOK_SECRET: z.string(), BLOB_READ_WRITE_TOKEN: z.string(), DESKTOP_AUTH_SECRET: z.string().min(32), + GOOGLE_CLIENT_ID: z.string().min(1), + GOOGLE_CLIENT_SECRET: z.string().min(1), + GH_CLIENT_ID: z.string().min(1), + GH_CLIENT_SECRET: z.string().min(1), }, 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 0c6371eb20c..00471c635b8 100644 --- a/apps/api/src/trpc/context.ts +++ b/apps/api/src/trpc/context.ts @@ -1,14 +1,44 @@ import { auth } from "@clerk/nextjs/server"; import { createTRPCContext } from "@superset/trpc"; +import { jwtVerify } from "jose"; +import { env } from "@/env"; -import { verifyDesktopToken } from "./utils/verifyDesktopToken"; +/** + * Verify desktop JWT access token + * Only accepts access tokens (type: "access"), not refresh tokens + */ +async function verifyDesktopToken(token: string): Promise { + try { + const secretKey = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); + const { payload } = await jwtVerify(token, secretKey, { + issuer: "superset-desktop", + }); + + // Only accept access tokens for API authentication + if (payload.type !== "access") { + return null; + } + + return payload.sub as string; + } catch { + return null; + } +} /** - * Create tRPC context with support for both Clerk and desktop JWT auth + * Create tRPC context with support for multiple auth methods + * + * Auth methods supported (in order of precedence): + * 1. Clerk session (cookie-based, web app) + * 2. Clerk OAuth token (Bearer token from desktop app - legacy) + * 3. Desktop JWT token (Bearer token from desktop app with Google OAuth) * - * Auth priority: - * 1. Clerk session (cookie or Clerk Bearer token) - * 2. Desktop JWT (Bearer token signed with DESKTOP_AUTH_SECRET) + * The `acceptsToken: 'oauth_token'` option allows the desktop app to + * authenticate using Clerk OAuth access tokens obtained through the + * PKCE OAuth flow (legacy method). + * + * Desktop JWT tokens are signed with DESKTOP_AUTH_SECRET and contain + * the Clerk user ID in the `sub` claim. */ export const createContext = async ({ req, @@ -16,19 +46,18 @@ export const createContext = async ({ req: Request; resHeaders: Headers; }) => { - // First, try Clerk auth (handles cookies and Clerk Bearer tokens) - const clerkAuth = await auth(); + // First try Clerk auth (handles both session cookies and OAuth Bearer tokens) + const clerkAuth = await auth({ acceptsToken: "oauth_token" }); if (clerkAuth.userId) { return createTRPCContext({ userId: clerkAuth.userId }); } - // No Clerk session, check for desktop JWT + // If no Clerk auth, try desktop JWT token 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 }); } diff --git a/apps/api/src/trpc/utils/verifyDesktopToken.ts b/apps/api/src/trpc/utils/verifyDesktopToken.ts deleted file mode 100644 index c4fe3dd8238..00000000000 --- a/apps/api/src/trpc/utils/verifyDesktopToken.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/scripts/patch-dev-protocol.ts b/apps/desktop/scripts/patch-dev-protocol.ts index 49600242480..be7dc190d85 100644 --- a/apps/desktop/scripts/patch-dev-protocol.ts +++ b/apps/desktop/scripts/patch-dev-protocol.ts @@ -13,7 +13,13 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; -import { PROTOCOL_SCHEMES } from "../src/shared/constants"; + +// Import directly from shared package to avoid env.ts validation during predev script +// (The desktop's shared/constants.ts imports env.ts which validates env vars at import time) +const PROTOCOL_SCHEMES = { + DEV: "superset-dev", + PROD: "superset", +} as const; // Only needed on macOS if (process.platform !== "darwin") { diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index 207e32d81f4..9004408af65 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -16,6 +16,8 @@ export const env = createEnv({ .default("development"), NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + GOOGLE_CLIENT_ID: z.string().min(1), + GH_CLIENT_ID: z.string().min(1), }, runtimeEnv: { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8a499e116f1..3c45f4d588d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -33,8 +33,22 @@ if (process.defaultApp) { async function processDeepLink(url: string): Promise { if (isAuthDeepLink(url)) { const result = await handleAuthDeepLink(url); - if (result.success && result.session) { - await authService.handleDeepLinkAuth(result.session); + if ( + result.success && + result.accessToken && + result.accessTokenExpiresAt && + result.refreshToken && + result.refreshTokenExpiresAt && + result.state + ) { + await authService.handleAuthCallback({ + accessToken: result.accessToken, + accessTokenExpiresAt: result.accessTokenExpiresAt, + refreshToken: result.refreshToken, + refreshTokenExpiresAt: result.refreshTokenExpiresAt, + state: result.state, + }); + focusMainWindow(); } else { console.error("[main] Auth deep link failed:", result.error); } diff --git a/apps/desktop/src/main/lib/auth/auth-service.ts b/apps/desktop/src/main/lib/auth/auth-service.ts index cb850aac41c..558fb8e5422 100644 --- a/apps/desktop/src/main/lib/auth/auth-service.ts +++ b/apps/desktop/src/main/lib/auth/auth-service.ts @@ -1,29 +1,49 @@ +import crypto from "node:crypto"; import { EventEmitter } from "node:events"; -import { TOKEN_CONFIG } from "@superset/shared/constants"; import { type BrowserWindow, shell } from "electron"; import { env } from "main/env.main"; import type { AuthProvider, AuthSession, SignInResult } from "shared/auth"; -import { pkceStore } from "./pkce"; -import { tokenStorage } from "./token-storage"; /** - * Response from the refresh endpoint (includes rotated refresh token) + * Store for state parameter (CSRF protection) + */ +const stateStore = new Map(); // state -> timestamp + +/** + * Generate random state for CSRF protection + */ +function generateState(): string { + const state = crypto.randomBytes(32).toString("base64url"); + stateStore.set(state, Date.now()); + // Clean up old states (older than 10 minutes) + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + for (const [s, timestamp] of stateStore) { + if (timestamp < tenMinutesAgo) { + stateStore.delete(s); + } + } + return state; +} + +/** + * Verify and consume state */ -interface RefreshResponse { - access_token: string; - access_token_expires_at: number; - refresh_token: string; - refresh_token_expires_at: number; +function verifyState(state: string): boolean { + if (!stateStore.has(state)) { + return false; + } + stateStore.delete(state); + return true; } +import { tokenStorage } from "./token-storage"; + /** * Main authentication service - * Handles OAuth flows, token management, and session state with auto-refresh + * Handles direct Google OAuth flow, with token exchange via API */ class AuthService extends EventEmitter { private session: AuthSession | null = null; - private refreshTimer: ReturnType | null = null; - private isRefreshing = false; /** * Initialize auth service - load persisted session @@ -35,9 +55,23 @@ class AuthService extends EventEmitter { return; } - // Check if refresh token is expired (session is truly over) - if (session.refreshTokenExpiresAt < Date.now()) { - console.log("[auth] Refresh token expired, clearing session"); + // Check if access token is expired + if (session.accessTokenExpiresAt < Date.now()) { + console.log("[auth] Access token expired on startup"); + + // Check if refresh token is still valid + if (session.refreshToken && session.refreshTokenExpiresAt > Date.now()) { + console.log("[auth] Attempting to refresh tokens on startup"); + this.session = session; // Temporarily set to allow refresh + const refreshed = await this.refreshTokens(); + if (refreshed) { + console.log("[auth] Session restored via token refresh"); + return; + } + } + + // Refresh failed or no valid refresh token + console.log("[auth] Session fully expired, clearing"); await this.clearSession(); return; } @@ -45,15 +79,6 @@ class AuthService extends EventEmitter { // 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(); } /** @@ -67,79 +92,175 @@ class AuthService extends EventEmitter { /** * Get access token for API calls - * Automatically refreshes if needed + * Automatically refreshes if access token is expired but refresh token is valid */ 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"); + // Check if access token is expired + if (this.session.accessTokenExpiresAt < Date.now()) { + console.log("[auth] Access token expired, attempting refresh"); + + // Check if refresh token is still valid + if ( + this.session.refreshToken && + this.session.refreshTokenExpiresAt > Date.now() + ) { + const refreshed = await this.refreshTokens(); + if (refreshed) { + return this.session.accessToken; + } + } + + // Refresh failed or no valid refresh token + console.log("[auth] Session fully expired, clearing"); await this.clearSession(); return null; } - // Refresh access token if needed - if (this.shouldRefreshAccessToken()) { - await this.refreshAccessToken(); + return this.session.accessToken; + } + + /** + * Refresh tokens using the refresh token + */ + private async refreshTokens(): Promise { + if (!this.session?.refreshToken) { + return false; } - return this.session?.accessToken ?? null; + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/refresh`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refreshToken: this.session.refreshToken, + }), + }, + ); + + if (!response.ok) { + console.error("[auth] Token refresh failed:", response.status); + return false; + } + + const tokens = (await response.json()) as { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + }; + + // Update session with new tokens + this.session = { + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshToken: tokens.refreshToken, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + }; + + await tokenStorage.save(this.session); + console.log("[auth] Tokens refreshed successfully"); + return true; + } catch (err) { + console.error("[auth] Token refresh error:", err); + return false; + } } /** * Sign in with OAuth provider - * Opens system browser to web app OAuth endpoint with PKCE + * Opens system browser directly to provider's OAuth (bypasses Clerk UI) */ 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); + // Generate state for CSRF protection + const state = generateState(); + + let authUrl: URL; + + if (provider === "github") { + // Build GitHub OAuth URL + authUrl = new URL("https://github.com/login/oauth/authorize"); + authUrl.searchParams.set("client_id", env.GH_CLIENT_ID); + authUrl.searchParams.set( + "redirect_uri", + `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/github`, + ); + authUrl.searchParams.set("scope", "user:email"); + authUrl.searchParams.set("state", state); + } else { + // Build Google OAuth URL (default) + authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + authUrl.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); + authUrl.searchParams.set( + "redirect_uri", + `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/google`, + ); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid email profile"); + authUrl.searchParams.set("state", state); + // Force account selection every time + authUrl.searchParams.set("prompt", "select_account"); + authUrl.searchParams.set("access_type", "online"); + } // 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 + * Handle auth callback with all tokens from web */ - async handleDeepLinkAuth(session: AuthSession): Promise { + async handleAuthCallback(params: { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + state: string; + }): Promise { try { + // Verify state for CSRF protection + if (!verifyState(params.state)) { + return { success: false, error: "Invalid or expired auth session" }; + } + + // Create session with both access and refresh tokens + const session: AuthSession = { + accessToken: params.accessToken, + accessTokenExpiresAt: params.accessTokenExpiresAt, + refreshToken: params.refreshToken, + refreshTokenExpiresAt: params.refreshTokenExpiresAt, + }; + this.session = session; await tokenStorage.save(session); - this.scheduleRefresh(); this.emitStateChange(); - console.log("[auth] Signed in"); + console.log("[auth] Signed in via Google OAuth with refresh token"); return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : "Failed to complete sign in"; - console.error("[auth] Auth handling failed:", message); + console.error("[auth] Auth callback handling failed:", message); await this.clearSession(); return { success: false, error: message }; } @@ -153,112 +274,7 @@ class AuthService extends EventEmitter { 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(); diff --git a/apps/desktop/src/main/lib/auth/deep-link-handler.ts b/apps/desktop/src/main/lib/auth/deep-link-handler.ts index 08f84517cab..5f6d864e16c 100644 --- a/apps/desktop/src/main/lib/auth/deep-link-handler.ts +++ b/apps/desktop/src/main/lib/auth/deep-link-handler.ts @@ -1,30 +1,21 @@ -import { env } from "main/env.main"; -import type { AuthSession } from "shared/auth"; -import { PROTOCOL_SCHEMES } from "shared/constants"; -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; -} +import { PROTOCOL_SCHEMES } from "@superset/shared/constants"; /** * Result of handling an auth deep link */ export interface AuthDeepLinkResult { success: boolean; - session?: AuthSession; + accessToken?: string; + accessTokenExpiresAt?: number; + refreshToken?: string; + refreshTokenExpiresAt?: number; + state?: string; error?: string; } /** - * Handle authentication deep links from the web app - * Implements PKCE flow: exchanges auth code for access + refresh tokens + * Handle authentication deep links from web callback + * Format: superset-dev://auth/callback?accessToken=XXX&accessTokenExpiresAt=YYY&refreshToken=ZZZ&refreshTokenExpiresAt=WWW&state=SSS */ export async function handleAuthDeepLink( url: string, @@ -33,54 +24,82 @@ export async function handleAuthDeepLink( const parsedUrl = new URL(url); // Check if this is an auth callback - if (parsedUrl.host !== "auth" || parsedUrl.pathname !== "/callback") { + const isAuthCallback = + parsedUrl.host === "auth" && parsedUrl.pathname === "/callback"; + + if (!isAuthCallback) { 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 }; + const errorDescription = parsedUrl.searchParams.get("error_description"); + return { success: false, error: errorDescription || error }; } - // Get the auth code and state (PKCE flow with CSRF protection) - const code = parsedUrl.searchParams.get("code"); + // Get all tokens and metadata + const accessToken = parsedUrl.searchParams.get("accessToken"); + const accessTokenExpiresAtStr = parsedUrl.searchParams.get( + "accessTokenExpiresAt", + ); + const refreshToken = parsedUrl.searchParams.get("refreshToken"); + const refreshTokenExpiresAtStr = parsedUrl.searchParams.get( + "refreshTokenExpiresAt", + ); const state = parsedUrl.searchParams.get("state"); - if (!code) { - pkceStore.clear(); - return { success: false, error: "No auth code in callback" }; + if (!accessToken) { + return { success: false, error: "No access token in callback" }; + } + + if (!accessTokenExpiresAtStr) { + return { + success: false, + error: "No access token expiration in callback", + }; + } + + if (!refreshToken) { + return { success: false, error: "No refresh token in callback" }; + } + + if (!refreshTokenExpiresAtStr) { + return { + success: false, + error: "No refresh token expiration 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) { + const accessTokenExpiresAt = Number.parseInt(accessTokenExpiresAtStr, 10); + if (Number.isNaN(accessTokenExpiresAt)) { return { success: false, - error: "Invalid or expired auth session", + error: "Invalid access token expiration in callback", }; } - // Exchange the code for tokens - const tokenResponse = await exchangeCodeForTokens(code, codeVerifier); + const refreshTokenExpiresAt = Number.parseInt(refreshTokenExpiresAtStr, 10); + if (Number.isNaN(refreshTokenExpiresAt)) { + return { + success: false, + error: "Invalid refresh token expiration in callback", + }; + } return { success: true, - session: { - accessToken: tokenResponse.access_token, - accessTokenExpiresAt: tokenResponse.access_token_expires_at, - refreshToken: tokenResponse.refresh_token, - refreshTokenExpiresAt: tokenResponse.refresh_token_expires_at, - }, + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + state, }; } catch (err) { - pkceStore.clear(); const message = err instanceof Error ? err.message : "Failed to process auth callback"; console.error("[auth] Deep link handling failed:", message); @@ -88,37 +107,6 @@ export async function handleAuthDeepLink( } } -/** - * 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 */ @@ -130,6 +118,7 @@ export function isAuthDeepLink(url: string): boolean { `${PROTOCOL_SCHEMES.PROD}:`, `${PROTOCOL_SCHEMES.DEV}:`, ]; + // Accept "auth" host for callbacks return ( validProtocols.includes(parsedUrl.protocol) && parsedUrl.host === "auth" ); diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 745ff6801e0..0fc34dd3b23 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -1,7 +1,4 @@ -// TEMPORARILY DISABLED - PostHog bricked the desktop app -// import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; -// import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react"; import { useCallback, useState } from "react"; import { DndProvider } from "react-dnd"; import { useHotkeys } from "react-hotkeys-hook"; @@ -9,6 +6,7 @@ import { HiArrowPath } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { SetupConfigModal } from "renderer/components/SetupConfigModal"; import { trpc } from "renderer/lib/trpc"; +import { SignInScreen } from "renderer/screens/sign-in"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; import { useSidebarStore } from "renderer/stores/sidebar-state"; import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; @@ -33,25 +31,9 @@ function LoadingSpinner() { export function MainScreen() { const utils = trpc.useUtils(); - // TEMPORARILY DISABLED - PostHog bricked the desktop app - // const posthog = usePostHog(); const { data: authState } = trpc.auth.getState.useQuery(); - const _isSignedIn = authState?.isSignedIn ?? false; - const _isAuthLoading = !authState; - - // TEMPORARILY DISABLED - Auth blocking logic disabled - // // Feature flag to control auth requirement - // const requireAuth = useFeatureFlagEnabled(FEATURE_FLAGS.REQUIRE_DESKTOP_AUTH); - // const [flagsLoaded, setFlagsLoaded] = useState(false); - - // // Track when feature flags are loaded - // useEffect(() => { - // if (posthog) { - // posthog.onFeatureFlags(() => { - // setFlagsLoaded(true); - // }); - // } - // }, [posthog]); + const isSignedIn = authState?.isSignedIn ?? false; + const isAuthLoading = !authState; // Subscribe to auth state changes trpc.auth.onStateChange.useSubscription(undefined, { @@ -68,8 +50,7 @@ export function MainScreen() { failureCount, refetch, } = trpc.workspaces.getActive.useQuery(undefined, { - // TEMPORARILY DISABLED - Auth blocking logic disabled - // enabled: isSignedIn, + enabled: isSignedIn, }); const [isRetrying, setIsRetrying] = useState(false); const splitPaneAuto = useTabsStore((s) => s.splitPaneAuto); @@ -177,47 +158,31 @@ export function MainScreen() { const showStartView = !isLoading && !activeWorkspace && currentView !== "settings"; - // TEMPORARILY DISABLED - Auth blocking logic disabled - // // Wait for feature flags to load before deciding on auth - // const shouldRequireAuth = flagsLoaded && requireAuth === true; - - // // Show empty screen while feature flags are loading - // if (!flagsLoaded) { - // return ( - // <> - // - // - //
- // - // - // ); - // } - - // // Show loading while auth state is being determined (only if auth is required) - // if (shouldRequireAuth && isAuthLoading) { - // return ( - // <> - // - // - //
- // - //
- //
- // - // ); - // } + // Show loading while auth state is being determined + if (isAuthLoading) { + return ( + <> + + +
+ +
+
+ + ); + } - // // Show sign-in screen if auth is required and user is not signed in - // if (shouldRequireAuth && !isSignedIn) { - // return ( - // <> - // - // - // - // - // - // ); - // } + // Show sign-in screen if user is not signed in + if (!isSignedIn) { + return ( + <> + + + + + + ); + } const renderContent = () => { if (currentView === "settings") { diff --git a/apps/desktop/src/renderer/screens/sign-in/index.tsx b/apps/desktop/src/renderer/screens/sign-in/index.tsx index 90cba2317b9..ba7eae0c9f1 100644 --- a/apps/desktop/src/renderer/screens/sign-in/index.tsx +++ b/apps/desktop/src/renderer/screens/sign-in/index.tsx @@ -1,25 +1,13 @@ 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 signInMutation = trpc.auth.signIn.useMutation(); const signIn = (provider: AuthProvider) => signInMutation.mutate({ provider }); @@ -48,14 +36,9 @@ export function SignInScreen() { variant="outline" size="lg" onClick={() => signIn("github")} - disabled={isSigningIn} className="w-full gap-3" > - {isSigningIn ? ( - - ) : ( - - )} + Continue with GitHub @@ -63,14 +46,9 @@ export function SignInScreen() { variant="outline" size="lg" onClick={() => signIn("google")} - disabled={isSigningIn} className="w-full gap-3" > - {isSigningIn ? ( - - ) : ( - - )} + Continue with Google
diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 0d6e6de8aed..6c93528bf10 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -1,3 +1,4 @@ +import { PROTOCOL_SCHEMES } from "@superset/shared/constants"; import { env } from "./env.shared"; export const PLATFORM = { @@ -25,11 +26,7 @@ export const SUPERSET_DIR_NAME = ? SUPERSET_DIR_NAMES.DEV : SUPERSET_DIR_NAMES.PROD; -// Deep link protocol scheme -export const PROTOCOL_SCHEMES = { - DEV: "superset-dev", - PROD: "superset", -} as const; +// Deep link protocol scheme (environment-aware) export const PROTOCOL_SCHEME = env.NODE_ENV === "development" ? PROTOCOL_SCHEMES.DEV : PROTOCOL_SCHEMES.PROD; // Project-level directory name (always .superset, not conditional) diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index ca1768df626..e9282ae35b4 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -14,6 +14,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; process.env.NODE_ENV = "test"; +process.env.GOOGLE_CLIENT_ID = "test-google-client-id"; +process.env.GH_CLIENT_ID = "test-github-client-id"; const testTmpDir = join(tmpdir(), "superset-test"); diff --git a/apps/web/public/assets/social/google.svg b/apps/web/public/assets/social/google.svg deleted file mode 100644 index 088288fa3fb..00000000000 --- a/apps/web/public/assets/social/google.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index 475c98bf5ab..b5903f5973a 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -1,12 +1,21 @@ +import { auth } from "@clerk/nextjs/server"; import Image from "next/image"; +import { redirect } from "next/navigation"; import { env } from "@/env"; -export default function AuthLayout({ +export default async function AuthLayout({ children, }: { children: React.ReactNode; }) { + const { userId } = await auth(); + + // Redirect authenticated users to home + if (userId) { + redirect("/"); + } + return (
diff --git a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx index a64aa38a558..4e0c9c51b09 100644 --- a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -2,21 +2,23 @@ import { useSignIn } from "@clerk/nextjs"; import { Button } from "@superset/ui/button"; -import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { FcGoogle } from "react-icons/fc"; import { env } from "@/env"; export default function SignInPage() { const { signIn, isLoaded } = useSignIn(); - const [isLoading, setIsLoading] = useState(false); + const [isLoadingGoogle, setIsLoadingGoogle] = useState(false); + const [isLoadingGithub, setIsLoadingGithub] = useState(false); const [error, setError] = useState(null); const signInWithGoogle = async () => { if (!isLoaded) return; - setIsLoading(true); + setIsLoadingGoogle(true); setError(null); try { @@ -29,10 +31,32 @@ export default function SignInPage() { console.error("Sign in failed:", err); setError("Failed to sign in. Please try again."); } finally { - setIsLoading(false); + setIsLoadingGoogle(false); } }; + const signInWithGithub = async () => { + if (!isLoaded) return; + + setIsLoadingGithub(true); + setError(null); + + try { + await signIn.authenticateWithRedirect({ + strategy: "oauth_github", + redirectUrl: "/sso-callback", + redirectUrlComplete: "/", + }); + } catch (err) { + console.error("Sign in failed:", err); + setError("Failed to sign in. Please try again."); + } finally { + setIsLoadingGithub(false); + } + }; + + const isLoading = isLoadingGoogle || isLoadingGithub; + return (
@@ -41,24 +65,27 @@ export default function SignInPage() { Sign in to continue to Superset

-
+
{error && (

{error}

)} +

By clicking continue, you agree to our{" "} diff --git a/apps/web/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/apps/web/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx index bdbb34d9b9f..35b9fdeb7a8 100644 --- a/apps/web/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/apps/web/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -2,21 +2,23 @@ import { useSignUp } from "@clerk/nextjs"; import { Button } from "@superset/ui/button"; -import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { FcGoogle } from "react-icons/fc"; import { env } from "@/env"; export default function SignUpPage() { const { signUp, isLoaded } = useSignUp(); - const [isLoading, setIsLoading] = useState(false); + const [isLoadingGoogle, setIsLoadingGoogle] = useState(false); + const [isLoadingGithub, setIsLoadingGithub] = useState(false); const [error, setError] = useState(null); const signUpWithGoogle = async () => { if (!isLoaded) return; - setIsLoading(true); + setIsLoadingGoogle(true); setError(null); try { @@ -29,10 +31,32 @@ export default function SignUpPage() { console.error("Sign up failed:", err); setError("Failed to sign up. Please try again."); } finally { - setIsLoading(false); + setIsLoadingGoogle(false); } }; + const signUpWithGithub = async () => { + if (!isLoaded) return; + + setIsLoadingGithub(true); + setError(null); + + try { + await signUp.authenticateWithRedirect({ + strategy: "oauth_github", + redirectUrl: "/sso-callback", + redirectUrlComplete: "/", + }); + } catch (err) { + console.error("Sign up failed:", err); + setError("Failed to sign up. Please try again."); + } finally { + setIsLoadingGithub(false); + } + }; + + const isLoading = isLoadingGoogle || isLoadingGithub; + return (

@@ -43,24 +67,27 @@ export default function SignUpPage() { Sign up to get started with Superset

-
+
{error && (

{error}

)} +

By clicking continue, you agree to our{" "} diff --git a/apps/web/src/app/(auth)/sso-callback/layout.tsx b/apps/web/src/app/(auth)/sso-callback/layout.tsx deleted file mode 100644 index aa04351e50c..00000000000 --- a/apps/web/src/app/(auth)/sso-callback/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function SSOCallbackLayout({ - children, -}: { - children: React.ReactNode; -}) { - return <>{children}; -} diff --git a/apps/web/src/app/api/auth/desktop/[provider]/route.ts b/apps/web/src/app/api/auth/desktop/[provider]/route.ts deleted file mode 100644 index c8460bfdc54..00000000000 --- a/apps/web/src/app/api/auth/desktop/[provider]/route.ts +++ /dev/null @@ -1,126 +0,0 @@ -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/api/auth/desktop/github/route.ts b/apps/web/src/app/api/auth/desktop/github/route.ts new file mode 100644 index 00000000000..0cdc4819cb0 --- /dev/null +++ b/apps/web/src/app/api/auth/desktop/github/route.ts @@ -0,0 +1,81 @@ +import { redirect } from "next/navigation"; +import { env } from "@/env"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + const errorUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + errorUrl.searchParams.set("error", errorDescription || error); + redirect(errorUrl.toString()); + } + + if (!code || !state) { + const errorUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + errorUrl.searchParams.set("error", "Missing authentication parameters"); + redirect(errorUrl.toString()); + } + + let tokenData: { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + } | null = null; + let exchangeError: string | null = null; + + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/github`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + redirectUri: `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/github`, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + exchangeError = errorData.error || "Failed to complete sign in"; + } else { + tokenData = (await response.json()) as { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + }; + } + } catch (err) { + console.error("[api/auth/desktop/github] Error:", err); + exchangeError = "An unexpected error occurred"; + } + + if (exchangeError || !tokenData) { + const errorUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + errorUrl.searchParams.set("error", exchangeError || "Failed to sign in"); + redirect(errorUrl.toString()); + } + + const successUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + successUrl.searchParams.set("accessToken", tokenData.accessToken); + successUrl.searchParams.set( + "accessTokenExpiresAt", + tokenData.accessTokenExpiresAt.toString(), + ); + successUrl.searchParams.set("refreshToken", tokenData.refreshToken); + successUrl.searchParams.set( + "refreshTokenExpiresAt", + tokenData.refreshTokenExpiresAt.toString(), + ); + successUrl.searchParams.set("state", state); + redirect(successUrl.toString()); +} diff --git a/apps/web/src/app/api/auth/desktop/google/route.ts b/apps/web/src/app/api/auth/desktop/google/route.ts new file mode 100644 index 00000000000..affa5d6d88d --- /dev/null +++ b/apps/web/src/app/api/auth/desktop/google/route.ts @@ -0,0 +1,81 @@ +import { redirect } from "next/navigation"; +import { env } from "@/env"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + const errorUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + errorUrl.searchParams.set("error", errorDescription || error); + redirect(errorUrl.toString()); + } + + if (!code || !state) { + const errorUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + errorUrl.searchParams.set("error", "Missing authentication parameters"); + redirect(errorUrl.toString()); + } + + let tokenData: { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + } | null = null; + let exchangeError: string | null = null; + + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/google`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + redirectUri: `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/google`, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + exchangeError = errorData.error || "Failed to complete sign in"; + } else { + tokenData = (await response.json()) as { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + }; + } + } catch (err) { + console.error("[api/auth/desktop/google] Error:", err); + exchangeError = "An unexpected error occurred"; + } + + if (exchangeError || !tokenData) { + const errorUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + errorUrl.searchParams.set("error", exchangeError || "Failed to sign in"); + redirect(errorUrl.toString()); + } + + const successUrl = new URL("/auth/desktop/success", env.NEXT_PUBLIC_WEB_URL); + successUrl.searchParams.set("accessToken", tokenData.accessToken); + successUrl.searchParams.set( + "accessTokenExpiresAt", + tokenData.accessTokenExpiresAt.toString(), + ); + successUrl.searchParams.set("refreshToken", tokenData.refreshToken); + successUrl.searchParams.set( + "refreshTokenExpiresAt", + tokenData.refreshTokenExpiresAt.toString(), + ); + successUrl.searchParams.set("state", state); + redirect(successUrl.toString()); +} diff --git a/apps/web/src/app/auth/desktop/callback/page.tsx b/apps/web/src/app/auth/desktop/success/page.tsx similarity index 60% rename from apps/web/src/app/auth/desktop/callback/page.tsx rename to apps/web/src/app/auth/desktop/success/page.tsx index 33c898f4bf1..d02644f3ca6 100644 --- a/apps/web/src/app/auth/desktop/callback/page.tsx +++ b/apps/web/src/app/auth/desktop/success/page.tsx @@ -3,23 +3,32 @@ import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { Suspense, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; const DESKTOP_PROTOCOL = process.env.NODE_ENV === "development" ? "superset-dev" : "superset"; -function CallbackContent() { +export default function Page() { const searchParams = useSearchParams(); - const code = searchParams.get("code"); + const accessToken = searchParams.get("accessToken"); + const accessTokenExpiresAt = searchParams.get("accessTokenExpiresAt"); + const refreshToken = searchParams.get("refreshToken"); + const refreshTokenExpiresAt = searchParams.get("refreshTokenExpiresAt"); 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 hasAllTokens = + accessToken && + accessTokenExpiresAt && + refreshToken && + refreshTokenExpiresAt && + state; + + const desktopUrl = hasAllTokens + ? `${DESKTOP_PROTOCOL}://auth/callback?accessToken=${encodeURIComponent(accessToken)}&accessTokenExpiresAt=${encodeURIComponent(accessTokenExpiresAt)}&refreshToken=${encodeURIComponent(refreshToken)}&refreshTokenExpiresAt=${encodeURIComponent(refreshTokenExpiresAt)}&state=${encodeURIComponent(state)}` + : null; const openDesktopApp = useCallback(() => { if (!desktopUrl) return; @@ -27,13 +36,10 @@ function CallbackContent() { }, [desktopUrl]); useEffect(() => { - if (error || !code) return; - - if (!hasAttempted) { - setHasAttempted(true); - openDesktopApp(); - } - }, [code, error, hasAttempted, openDesktopApp]); + if (error || !desktopUrl || hasAttempted) return; + setHasAttempted(true); + openDesktopApp(); + }, [error, desktopUrl, hasAttempted, openDesktopApp]); if (error) { return ( @@ -53,7 +59,7 @@ function CallbackContent() { ); } - if (!code || !state) { + if (!hasAllTokens) { return (

@@ -87,37 +93,16 @@ function CallbackContent() { Redirecting to the desktop app...

- - If you weren't redirected, click here. - + {desktopUrl && ( + + 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 6e283f0094a..49cc1c14377 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -19,6 +19,7 @@ export const env = createEnv({ client: { NEXT_PUBLIC_API_URL: z.string().url(), + NEXT_PUBLIC_WEB_URL: z.string().url(), NEXT_PUBLIC_MARKETING_URL: z.string().url(), NEXT_PUBLIC_DOCS_URL: z.string().url(), NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(), @@ -30,6 +31,7 @@ export const env = createEnv({ experimental__runtimeEnv: { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, NEXT_PUBLIC_MARKETING_URL: process.env.NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_DOCS_URL: process.env.NEXT_PUBLIC_DOCS_URL, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 95f3f694348..cb845512d36 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -5,6 +5,9 @@ const isPublicRoute = createRouteMatcher([ "/sign-in(.*)", "/sign-up(.*)", "/sso-callback(.*)", + "/auth/desktop(.*)", + "/api/auth/desktop(.*)", + "/ingest(.*)", ]); export default clerkMiddleware(async (auth, req) => { diff --git a/bun.lock b/bun.lock index 414701a9c15..fe100153fe2 100644 --- a/bun.lock +++ b/bun.lock @@ -378,6 +378,7 @@ "@superset/shared": "workspace:*", "@t3-oss/env-core": "^0.13.8", "@trpc/server": "^11.7.1", + "@vercel/blob": "^2.0.0", "drizzle-orm": "0.45.1", "superjson": "^2.2.5", "zod": "^4.1.13", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 4f358251753..6498fa4c4a4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -2,6 +2,12 @@ export const AUTH_PROVIDERS = ["github", "google"] as const; export type AuthProvider = (typeof AUTH_PROVIDERS)[number]; +// Deep link protocol schemes (used for desktop OAuth callbacks) +export const PROTOCOL_SCHEMES = { + DEV: "superset-dev", + PROD: "superset", +} as const; + // Company export const COMPANY = { NAME: "Superset", diff --git a/packages/trpc/package.json b/packages/trpc/package.json index bceef6e42bf..17897d05e0b 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -19,6 +19,7 @@ "@superset/shared": "workspace:*", "@t3-oss/env-core": "^0.13.8", "@trpc/server": "^11.7.1", + "@vercel/blob": "^2.0.0", "drizzle-orm": "0.45.1", "superjson": "^2.2.5", "zod": "^4.1.13" diff --git a/packages/trpc/src/env.ts b/packages/trpc/src/env.ts index e8de5c3a5a3..5a3282fcff3 100644 --- a/packages/trpc/src/env.ts +++ b/packages/trpc/src/env.ts @@ -1,7 +1,11 @@ import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; export const env = createEnv({ - server: {}, + server: { + CLERK_SECRET_KEY: z.string().min(1), + BLOB_READ_WRITE_TOKEN: z.string().min(1), + }, clientPrefix: "PUBLIC_", client: {}, runtimeEnv: process.env, diff --git a/packages/trpc/src/router/admin.ts b/packages/trpc/src/router/admin/admin.ts similarity index 96% rename from packages/trpc/src/router/admin.ts rename to packages/trpc/src/router/admin/admin.ts index 7d615dcaba9..c8bc2e956d2 100644 --- a/packages/trpc/src/router/admin.ts +++ b/packages/trpc/src/router/admin/admin.ts @@ -4,7 +4,7 @@ import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; import { desc, eq, isNotNull, isNull } from "drizzle-orm"; import { z } from "zod"; -import { adminProcedure } from "../trpc"; +import { adminProcedure } from "../../trpc"; export const adminRouter = { listActiveUsers: adminProcedure.query(() => { diff --git a/packages/trpc/src/router/admin/index.ts b/packages/trpc/src/router/admin/index.ts new file mode 100644 index 00000000000..27cfd6ec8e1 --- /dev/null +++ b/packages/trpc/src/router/admin/index.ts @@ -0,0 +1 @@ +export { adminRouter } from "./admin"; diff --git a/packages/trpc/src/router/organization/index.ts b/packages/trpc/src/router/organization/index.ts new file mode 100644 index 00000000000..cbec48554ce --- /dev/null +++ b/packages/trpc/src/router/organization/index.ts @@ -0,0 +1 @@ +export { organizationRouter } from "./organization"; diff --git a/packages/trpc/src/router/organization.ts b/packages/trpc/src/router/organization/organization.ts similarity index 97% rename from packages/trpc/src/router/organization.ts rename to packages/trpc/src/router/organization/organization.ts index a0fadf96cc9..31057c1dbc2 100644 --- a/packages/trpc/src/router/organization.ts +++ b/packages/trpc/src/router/organization/organization.ts @@ -3,7 +3,7 @@ import { organizationMembers, organizations, users } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; -import { protectedProcedure, publicProcedure } from "../trpc"; +import { protectedProcedure, publicProcedure } from "../../trpc"; export const organizationRouter = { all: publicProcedure.query(() => { diff --git a/packages/trpc/src/router/repository/index.ts b/packages/trpc/src/router/repository/index.ts new file mode 100644 index 00000000000..176668015ac --- /dev/null +++ b/packages/trpc/src/router/repository/index.ts @@ -0,0 +1 @@ +export { repositoryRouter } from "./repository"; diff --git a/packages/trpc/src/router/repository.ts b/packages/trpc/src/router/repository/repository.ts similarity index 97% rename from packages/trpc/src/router/repository.ts rename to packages/trpc/src/router/repository/repository.ts index c01d9ac4133..428a7aee205 100644 --- a/packages/trpc/src/router/repository.ts +++ b/packages/trpc/src/router/repository/repository.ts @@ -3,7 +3,7 @@ import { repositories } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; -import { protectedProcedure, publicProcedure } from "../trpc"; +import { protectedProcedure, publicProcedure } from "../../trpc"; export const repositoryRouter = { all: publicProcedure.query(() => { diff --git a/packages/trpc/src/router/task/index.ts b/packages/trpc/src/router/task/index.ts new file mode 100644 index 00000000000..dfb03ca7e1e --- /dev/null +++ b/packages/trpc/src/router/task/index.ts @@ -0,0 +1 @@ +export { taskRouter } from "./task"; diff --git a/packages/trpc/src/router/task.ts b/packages/trpc/src/router/task/task.ts similarity index 97% rename from packages/trpc/src/router/task.ts rename to packages/trpc/src/router/task/task.ts index a1a2dd3fe63..dcf7d137201 100644 --- a/packages/trpc/src/router/task.ts +++ b/packages/trpc/src/router/task/task.ts @@ -4,7 +4,7 @@ import { tasks, users } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { desc, eq } from "drizzle-orm"; import { z } from "zod"; -import { protectedProcedure, publicProcedure } from "../trpc"; +import { protectedProcedure, publicProcedure } from "../../trpc"; export const taskRouter = { all: publicProcedure.query(() => { diff --git a/packages/trpc/src/router/user/index.ts b/packages/trpc/src/router/user/index.ts new file mode 100644 index 00000000000..32aec3c99b0 --- /dev/null +++ b/packages/trpc/src/router/user/index.ts @@ -0,0 +1 @@ +export { userRouter } from "./user"; diff --git a/packages/trpc/src/router/user.ts b/packages/trpc/src/router/user/user.ts similarity index 56% rename from packages/trpc/src/router/user.ts rename to packages/trpc/src/router/user/user.ts index cb690d74bff..36b7a738239 100644 --- a/packages/trpc/src/router/user.ts +++ b/packages/trpc/src/router/user/user.ts @@ -3,12 +3,19 @@ import { users } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { eq } from "drizzle-orm"; -import { protectedProcedure } from "../trpc"; +import { protectedProcedure } from "../../trpc"; +import { syncUserFromClerk } from "./utils/sync-user-from-clerk"; export const userRouter = { me: protectedProcedure.query(async ({ ctx }) => { - return db.query.users.findFirst({ + const existingUser = await db.query.users.findFirst({ where: eq(users.clerkId, ctx.userId), }); + + if (existingUser) { + return existingUser; + } + + return syncUserFromClerk(ctx.userId); }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/user/utils/sync-user-from-clerk.ts b/packages/trpc/src/router/user/utils/sync-user-from-clerk.ts new file mode 100644 index 00000000000..7b8b2419308 --- /dev/null +++ b/packages/trpc/src/router/user/utils/sync-user-from-clerk.ts @@ -0,0 +1,83 @@ +import { createClerkClient } from "@clerk/backend"; +import { db } from "@superset/db/client"; +import { users } from "@superset/db/schema"; +import { put } from "@vercel/blob"; +import { eq } from "drizzle-orm"; + +import { env } from "../../../env"; + +const clerkClient = createClerkClient({ + secretKey: env.CLERK_SECRET_KEY, +}); + +async function uploadAvatar( + imageUrl: string, + userId: string, +): Promise { + try { + const response = await fetch(imageUrl); + if (!response.ok) return null; + + const blob = await response.blob(); + const { url } = await put(`users/${userId}/avatar.png`, blob, { + access: "public", + token: env.BLOB_READ_WRITE_TOKEN, + }); + return url; + } catch { + return null; + } +} + +/** + * Fetch user from Clerk and create in our database. + * Only called when user doesn't exist locally. + */ +export async function syncUserFromClerk(clerkId: string) { + const clerkUser = await clerkClient.users.getUser(clerkId); + + const primaryEmail = clerkUser.emailAddresses.find( + (email) => email.id === clerkUser.primaryEmailAddressId, + )?.emailAddress; + + if (!primaryEmail) { + return null; + } + + const name = + [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || + primaryEmail.split("@")[0] || + "User"; + + // Upsert user - email is source of truth + const [user] = await db + .insert(users) + .values({ + clerkId, + email: primaryEmail, + name, + }) + .onConflictDoUpdate({ + target: users.email, + set: { + clerkId, + name, + }, + }) + .returning(); + + if (!user) { + return null; + } + + // Upload avatar if needed + if (!user.avatarUrl && clerkUser.imageUrl) { + const avatarUrl = await uploadAvatar(clerkUser.imageUrl, user.id); + if (avatarUrl) { + await db.update(users).set({ avatarUrl }).where(eq(users.id, user.id)); + return { ...user, avatarUrl }; + } + } + + return user; +}