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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0",
"better-auth": "^1.4.9",
"better-sqlite3": "12.5.0",
"bindings": "^1.5.0",
"clsx": "^2.1.1",
Expand Down
121 changes: 86 additions & 35 deletions apps/desktop/src/lib/trpc/routers/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,113 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { AUTH_PROVIDERS } from "@superset/shared/constants";
import { observable } from "@trpc/server/observable";
import { type AuthSession, authService } from "main/lib/auth";
import { shell } from "electron";
import { env } from "main/env.main";
import { z } from "zod";
import { publicProcedure, router } from "../..";

/** Auth state emitted by onAuthState subscription */
export type AuthState = (AuthSession & { token: string | null }) | null;
import {
authEvents,
loadToken,
saveToken,
stateStore,
TOKEN_FILE,
} from "./utils/auth-functions";

export const createAuthRouter = () => {
return router({
onAuthState: publicProcedure.subscription(() => {
return observable<AuthState>((emit) => {
const emitCurrent = () => {
const sessionData = authService.getSession();
const token = authService.getAccessToken();
/**
* Get initial token from encrypted disk storage.
* Called once on app startup for hydration.
*/
getStoredToken: publicProcedure.query(async () => {
return await loadToken();
}),

if (!sessionData) {
emit.next(null);
return;
/**
* Persist token to encrypted disk storage.
* Called when renderer saves token to localStorage.
*/
persistToken: publicProcedure
.input(
z.object({
token: z.string(),
expiresAt: z.string(),
}),
)
.mutation(async ({ input }) => {
await saveToken(input);
return { success: true };
}),

/**
* Subscribe to token changes from deep link callbacks.
* CRITICAL: Notifies renderer when OAuth callback saves new token.
* Without this, renderer wouldn't know to update localStorage after OAuth.
*/
onTokenChanged: publicProcedure.subscription(() => {
return observable<{ token: string; expiresAt: string } | null>((emit) => {
// Emit initial token on subscription
loadToken().then((initial) => {
if (initial.token && initial.expiresAt) {
emit.next({ token: initial.token, expiresAt: initial.expiresAt });
}
});

Comment on lines +48 to 56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle potential rejection from initial token load.

The loadToken().then() call has no error handler. If loadToken() rejects, it will cause an unhandled promise rejection.

Suggested fix
 // Emit initial token on subscription
-loadToken().then((initial) => {
+loadToken().then((initial) => {
 	if (initial.token && initial.expiresAt) {
 		emit.next({ token: initial.token, expiresAt: initial.expiresAt });
 	}
+}).catch((err) => {
+	console.error("[auth/onTokenChanged] Failed to load initial token:", err);
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onTokenChanged: publicProcedure.subscription(() => {
return observable<{ token: string; expiresAt: string } | null>((emit) => {
// Emit initial token on subscription
loadToken().then((initial) => {
if (initial.token && initial.expiresAt) {
emit.next({ token: initial.token, expiresAt: initial.expiresAt });
}
});
onTokenChanged: publicProcedure.subscription(() => {
return observable<{ token: string; expiresAt: string } | null>((emit) => {
// Emit initial token on subscription
loadToken().then((initial) => {
if (initial.token && initial.expiresAt) {
emit.next({ token: initial.token, expiresAt: initial.expiresAt });
}
}).catch((err) => {
console.error("[auth/onTokenChanged] Failed to load initial token:", err);
});
🤖 Prompt for AI Agents
In @apps/desktop/src/lib/trpc/routers/auth/index.ts around lines 48 - 56, The
subscription in onTokenChanged calls loadToken().then(...) without handling
rejections, causing unhandled promise rejections; wrap the initial token load
with proper error handling (e.g., add a .catch(...) to the Promise chain or use
an async IIFE with try/catch) and on failure call emit.error(error) or
emit.next(null) as appropriate so the observable handles errors gracefully;
update the loadToken call inside the observable returned by
publicProcedure.subscription to ensure any rejection is caught and forwarded via
emit.

emit.next({ ...sessionData, token });
const handler = (data: { token: string; expiresAt: string }) => {
emit.next(data);
};

emitCurrent();

const sessionHandler = () => {
emitCurrent();
};
const stateHandler = () => {
emitCurrent();
};

authService.on("session-changed", sessionHandler);
authService.on("state-changed", stateHandler);
authEvents.on("token-saved", handler);

return () => {
authService.off("session-changed", sessionHandler);
authService.off("state-changed", stateHandler);
authEvents.off("token-saved", handler);
};
});
}),

setActiveOrganization: publicProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
await authService.setActiveOrganization(input.organizationId);
return { success: true };
}),

/**
* Start OAuth sign-in flow.
* Opens browser for OAuth, token delivered via deep link callback.
*/
signIn: publicProcedure
.input(z.object({ provider: z.enum(AUTH_PROVIDERS) }))
.mutation(async ({ input }) => {
return authService.signIn(input.provider);
try {
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, ts] of stateStore) {
if (ts < tenMinutesAgo) stateStore.delete(s);
}

const connectUrl = new URL(
`${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/connect`,
);
connectUrl.searchParams.set("provider", input.provider);
connectUrl.searchParams.set("state", state);
await shell.openExternal(connectUrl.toString());
return { success: true };
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to open browser",
};
}
}),

/**
* Sign out - clears token from disk.
* Renderer should also clear localStorage and call authClient.signOut().
*/
signOut: publicProcedure.mutation(async () => {
await authService.signOut();
console.log("[auth] Clearing token");
try {
await fs.unlink(TOKEN_FILE);
} catch {}
return { success: true };
}),
Comment on lines 106 to 112
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty catch block silently swallows errors.

Per coding guidelines, errors should never be swallowed silently. The fs.unlink failure (e.g., permission issues) should be logged.

Suggested fix
 signOut: publicProcedure.mutation(async () => {
 	console.log("[auth] Clearing token");
 	try {
 		await fs.unlink(TOKEN_FILE);
-	} catch {}
+	} catch (err) {
+		// ENOENT is expected if token file doesn't exist
+		if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
+			console.error("[auth/signOut] Failed to clear token file:", err);
+		}
+	}
 	return { success: true };
 }),
🤖 Prompt for AI Agents
In @apps/desktop/src/lib/trpc/routers/auth/index.ts around lines 105 - 111, The
signOut mutation currently swallows any fs.unlink errors silently; update the
try/catch in the signOut publicProcedure to catch the error and log it (e.g.,
using console.error or the app's logger) with context including TOKEN_FILE and
the error object so failures (like permission issues) are visible; keep
returning { success: true } if desired but ensure the catch does not remain
empty.

});
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import { join } from "node:path";
import { PROTOCOL_SCHEMES } from "@superset/shared/constants";
import { SUPERSET_HOME_DIR } from "main/lib/app-environment";
import { decrypt, encrypt } from "./crypto-storage";

interface StoredAuth {
token: string;
expiresAt: string;
}

export const TOKEN_FILE = join(SUPERSET_HOME_DIR, "auth-token.enc");
export const stateStore = new Map<string, number>();

/**
* Event emitter for auth-related events.
* Used by tRPC subscription to notify renderer of token changes.
*/
export const authEvents = new EventEmitter();

/**
* Load token from encrypted disk storage.
*/
export async function loadToken(): Promise<{
token: string | null;
expiresAt: string | null;
}> {
try {
const data = decrypt(await fs.readFile(TOKEN_FILE));
const parsed: StoredAuth = JSON.parse(data);
console.log("[auth] Token loaded from disk");
return { token: parsed.token, expiresAt: parsed.expiresAt };
} catch {
return { token: null, expiresAt: null };
}
Comment on lines +34 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent error swallowing masks potential issues.

Per coding guidelines, errors should never be swallowed silently. If token loading fails due to corruption, permission issues, or decryption errors, this should be logged for debugging.

Proposed fix
-	} catch {
+	} catch (error) {
+		console.log("[auth] Failed to load token from disk:", error);
 		return { token: null, expiresAt: null };
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch {
return { token: null, expiresAt: null };
}
} catch (error) {
console.log("[auth] Failed to load token from disk:", error);
return { token: null, expiresAt: null };
}
🤖 Prompt for AI Agents
In @apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts around lines
34 - 36, The catch block in
apps/desktop/src/lib/trpc/routers/auth/utils/auth-functions.ts that currently
swallows errors and returns { token: null, expiresAt: null } should capture the
caught error (e.g., catch (err)) and log it before returning; update that catch
to log the error using the project's logging utility (or console.error if none
exists) with a clear message like "Failed to load auth token" and the error
object, then continue to return { token: null, expiresAt: null } so behavior is
unchanged but failures are recorded for debugging.

}

/**
* Persist token to encrypted disk storage and notify subscribers.
*/
export async function saveToken({
token,
expiresAt,
}: {
token: string;
expiresAt: string;
}): Promise<void> {
const storedAuth: StoredAuth = { token, expiresAt };
await fs.writeFile(TOKEN_FILE, encrypt(JSON.stringify(storedAuth)));
console.log("[auth] Token saved to disk");

// Emit event for onTokenChanged subscription
authEvents.emit("token-saved", { token, expiresAt });
}

/**
* Handle OAuth callback from deep link.
* Validates CSRF state and saves token.
*/
export async function handleAuthCallback(params: {
token: string;
expiresAt: string;
state: string;
}): Promise<{ success: boolean; error?: string }> {
if (!stateStore.has(params.state)) {
return { success: false, error: "Invalid or expired auth session" };
}
stateStore.delete(params.state);

await saveToken({ token: params.token, expiresAt: params.expiresAt });

return { success: true };
}

/**
* Parse and validate auth deep link URL.
*/
export function parseAuthDeepLink(
url: string,
): { token: string; expiresAt: string; state: string } | null {
try {
const parsed = new URL(url);
const validProtocols = [
`${PROTOCOL_SCHEMES.PROD}:`,
`${PROTOCOL_SCHEMES.DEV}:`,
];
if (!validProtocols.includes(parsed.protocol)) return null;
if (parsed.host !== "auth" || parsed.pathname !== "/callback") return null;

const token = parsed.searchParams.get("token");
const expiresAt = parsed.searchParams.get("expiresAt");
const state = parsed.searchParams.get("state");
if (!token || !expiresAt || !state) return null;
return { token, expiresAt, state };
} catch {
return null;
}
}
4 changes: 0 additions & 4 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ import { createPortsRouter } from "./ports";
import { createProjectsRouter } from "./projects";
import { createRingtoneRouter } from "./ringtone";
import { createSettingsRouter } from "./settings";
import { createTasksRouter } from "./tasks";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
import { createUserRouter } from "./user";
import { createWindowRouter } from "./window";
import { createWorkspacesRouter } from "./workspaces";

Expand All @@ -25,7 +23,6 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
analytics: createAnalyticsRouter(),
auth: createAuthRouter(),
autoUpdate: createAutoUpdateRouter(),
user: createUserRouter(),
window: createWindowRouter(getWindow),
projects: createProjectsRouter(getWindow),
workspaces: createWorkspacesRouter(),
Expand All @@ -40,7 +37,6 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
config: createConfigRouter(),
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(),
tasks: createTasksRouter(),
});
};

Expand Down
31 changes: 0 additions & 31 deletions apps/desktop/src/lib/trpc/routers/tasks/index.ts

This file was deleted.

22 changes: 0 additions & 22 deletions apps/desktop/src/lib/trpc/routers/user/index.ts

This file was deleted.

8 changes: 5 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import path from "node:path";
import { settings } from "@superset/local-db";
import { app, BrowserWindow, dialog } from "electron";
import { makeAppSetup } from "lib/electron-app/factories/app/setup";
import {
handleAuthCallback,
parseAuthDeepLink,
} from "lib/trpc/routers/auth/utils/auth-functions";
import { DEFAULT_CONFIRM_ON_QUIT, PROTOCOL_SCHEME } from "shared/constants";
import { setupAgentHooks } from "./lib/agent-setup";
import { posthog } from "./lib/analytics";
import { initAppState } from "./lib/app-state";
import { authService, parseAuthDeepLink } from "./lib/auth";
import { setupAutoUpdater } from "./lib/auto-updater";
import { localDb } from "./lib/local-db";
import { ensureShellEnvVars } from "./lib/shell-env";
Expand Down Expand Up @@ -41,7 +44,7 @@ async function processDeepLink(url: string): Promise<void> {
const authParams = parseAuthDeepLink(url);
if (!authParams) return;

const result = await authService.handleAuthCallback(authParams);
const result = await handleAuthCallback(authParams);
if (result.success) {
focusMainWindow();
} else {
Expand Down Expand Up @@ -206,7 +209,6 @@ if (!gotTheLock) {
await app.whenReady();

await initAppState();
await authService.initialize();

// Resolve shell environment before setting up agent hooks
// This ensures ZDOTDIR and PATH are available for terminal initialization
Expand Down
Loading