diff --git a/electron/main.cjs b/electron/main.cjs index 18e13a97..ce8602de 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1088,6 +1088,42 @@ ipcMain.handle("get-embedded-server-status", () => { }; }); +// OIDC System Browser Authentication (RFC 8252) +ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort) => { + const http = require("http"); + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${callbackPort}`); + if (url.pathname === "/oidc-callback") { + const success = url.searchParams.get("success"); + const error = url.searchParams.get("error"); + const token = url.searchParams.get("token"); + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(`

${success === "true" ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`); + + server.close(); + if (success === "true") { + resolve({ success: true, token }); + } else { + resolve({ success: false, error: error || "Authentication failed" }); + } + } + }); + + server.listen(callbackPort, "127.0.0.1", () => { + shell.openExternal(authUrl); + }); + + // Timeout after 5 minutes + setTimeout(() => { + server.close(); + reject(new Error("OIDC authentication timed out")); + }, 5 * 60 * 1000); + }); +}); + ipcMain.handle("get-server-config", () => { try { const userDataPath = app.getPath("userData"); diff --git a/electron/preload.js b/electron/preload.js index a19eb5c3..ea3becad 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -43,6 +43,9 @@ contextBridge.exposeInMainWorld("electronAPI", { timeoutMs, ), + oidcSystemBrowserAuth: (authUrl, callbackPort) => + ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d417598a..d3fb4642 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -819,7 +819,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { */ router.get("/oidc/authorize", async (req, res) => { try { - const { rememberMe } = req.query; + const { rememberMe, desktopCallbackPort } = req.query; const origin = getRequestOriginWithForceHTTPS(req); const backendCallbackUri = `${origin}/users/oidc/callback`; @@ -842,7 +842,9 @@ router.get("/oidc/authorize", async (req, res) => { const referer = req.get("Referer"); let frontendOrigin; - if (referer) { + if (desktopCallbackPort) { + frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`; + } else if (referer) { const refererUrl = new URL(referer); frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; } else { @@ -1365,6 +1367,8 @@ router.get("/oidc/callback", async (req, res) => { const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("success", "true"); + const isDesktopCallback = frontendOrigin.startsWith("http://127.0.0.1:"); + const maxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 @@ -1374,6 +1378,11 @@ router.get("/oidc/callback", async (req, res) => { res.clearCookie("jwt", authManager.getClearCookieOptions(req)); + if (isDesktopCallback) { + redirectUrl.searchParams.set("token", token); + return res.redirect(redirectUrl.toString()); + } + return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index ed130512..4ff8776f 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -646,6 +646,22 @@ export function Auth({ onLogin }: AuthProps) { async function handleOIDCLogin() { setOidcLoading(true); try { + if (isElectron()) { + const electronAPI = (window as unknown as { electronAPI?: { oidcSystemBrowserAuth?: (authUrl: string, port: number) => Promise<{ success: boolean; token?: string; error?: string }> } }).electronAPI; + if (electronAPI?.oidcSystemBrowserAuth) { + const callbackPort = 17832 + Math.floor(Math.random() * 100); + const authResponse = await getOIDCAuthorizeUrl(rememberMe, callbackPort); + const { auth_url: authUrl } = authResponse; + if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); + const result = await electronAPI.oidcSystemBrowserAuth(authUrl, callbackPort); + if (result.success && result.token) { + localStorage.setItem("jwt_token", result.token); + window.location.reload(); + return; + } + throw new Error(result.error || "Authentication failed"); + } + } const authResponse = await getOIDCAuthorizeUrl(rememberMe); const { auth_url: authUrl } = authResponse; if (!authUrl || authUrl === "undefined") diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 04010ac8..df68b5a4 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -3121,10 +3121,11 @@ export async function changePassword(oldPassword: string, newPassword: string) { export async function getOIDCAuthorizeUrl( rememberMe = false, + desktopCallbackPort?: number, ): Promise { try { const response = await authApi.get("/users/oidc/authorize", { - params: { rememberMe }, + params: { rememberMe, desktopCallbackPort }, }); return response.data; } catch (error) {