diff --git a/backend/biome.json b/backend/biome.json deleted file mode 100644 index 5a13758f..00000000 --- a/backend/biome.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "ignore": ["node_modules", "build", "coverage"] - }, - "formatter": { - "enabled": true, - "indentStyle": "tab", - "indentWidth": 2, - "lineWidth": 100, - "lineEnding": "lf" - }, - "organizeImports": { - "enabled": false - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noConsole": "off" - } - } - }, - "javascript": { - "formatter": { - "arrowParentheses": "always", - "bracketSameLine": false, - "bracketSpacing": true, - "jsxQuoteStyle": "double", - "semicolons": "always", - "trailingCommas": "all" - } - }, - "json": { - "formatter": { - "trailingCommas": "none" - } - } -} diff --git a/backend/js-core.tgz b/backend/js-core.tgz new file mode 100644 index 00000000..cdf03d64 Binary files /dev/null and b/backend/js-core.tgz differ diff --git a/backend/package.json b/backend/package.json index ee1c3bed..42fa7121 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,8 @@ }, "dependencies": { "@bcgov/citz-imb-express-utilities": "1.0.0-beta4", + "@bcgov/citz-imb-sso-js-core": "file:./js-core.tgz", + "cookie-parser": "1.4.7", "cors": "2.8.5", "express": "4.21.0", "express-rate-limit": "7.4.1", @@ -16,6 +18,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.3", + "@types/cookie-parser": "1.4.7", "@types/cors": "2.8.17", "@types/express": "5.0.0", "@types/node": "22.7.4", diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 03bfac61..d940984e 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -8,6 +8,11 @@ const { MONGO_PASSWORD, MONGO_DATABASE_NAME, MONGO_HOST, + SSO_ENVIRONMENT = "dev", + SSO_REALM = "standard", + SSO_PROTOCOL = "openid-connect", + SSO_CLIENT_ID = "", + SSO_CLIENT_SECRET = "", } = process.env; // Exported configuration values. @@ -21,4 +26,9 @@ export default { MONGO_PASSWORD, MONGO_DATABASE_NAME, MONGO_HOST, + SSO_ENVIRONMENT, + SSO_REALM, + SSO_PROTOCOL, + SSO_CLIENT_ID, + SSO_CLIENT_SECRET, }; diff --git a/backend/src/express.ts b/backend/src/express.ts index 20ab7392..58a21281 100644 --- a/backend/src/express.ts +++ b/backend/src/express.ts @@ -6,8 +6,12 @@ import { import cors from "cors"; import express from "express"; import rateLimit from "express-rate-limit"; +import cookieParser from "cookie-parser"; import { CORS_OPTIONS, RATE_LIMIT_OPTIONS } from "./config"; import { ENV } from "./config"; +import { router } from "./modules/auth"; +import { protectedRoute } from "./modules/auth/middleware"; +import type { Request, Response } from "express"; const { ENVIRONMENT } = ENV; @@ -19,10 +23,14 @@ app.use(express.urlencoded({ extended: false })); app.use(express.json()); app.use(cors(CORS_OPTIONS)); app.use(rateLimit(RATE_LIMIT_OPTIONS)); +app.use(cookieParser()); +app.set("view engine", "ejs"); // Disabled because it exposes information about the used framework to potential attackers. app.disable("x-powered-by"); +app.use("/auth", router); + // Add express utils middleware. app.use(expressUtilitiesMiddleware); @@ -30,4 +38,9 @@ app.use(expressUtilitiesMiddleware); healthModule(app); // Route (/health) configModule(app, { ENVIRONMENT }); // Route (/config) +app.use("/test", protectedRoute(["Admin"]), (req: Request, res: Response) => { + console.log("HERE"); + res.json({ message: "YES" }); +}); + export default app; diff --git a/backend/src/index.ts b/backend/src/index.ts index 02f49c9f..0de4dd77 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,7 +29,6 @@ app.listen(PORT, () => { try { // Log server start information. serverStartupLogs(PORT); - console.log(`API has started, and is listening on ${PORT}`) } catch (error) { // Log any error that occurs during the server start. console.error(error); diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts new file mode 100644 index 00000000..006e571a --- /dev/null +++ b/backend/src/modules/auth/controllers/index.ts @@ -0,0 +1,5 @@ +export * from "./login"; +export * from "./loginCallback"; +export * from "./logout"; +export * from "./logoutCallback"; +export * from "./token"; diff --git a/backend/src/modules/auth/controllers/login.ts b/backend/src/modules/auth/controllers/login.ts new file mode 100644 index 00000000..9e2987fb --- /dev/null +++ b/backend/src/modules/auth/controllers/login.ts @@ -0,0 +1,32 @@ +import type { Request, Response } from "express"; +import { getLoginURL } from "@bcgov/citz-imb-sso-js-core"; +import type { SSOEnvironment, SSOProtocol } from "@bcgov/citz-imb-sso-js-core"; +import { ENV } from "src/config"; +import { errorWrapper } from "@bcgov/citz-imb-express-utilities"; + +const { SSO_ENVIRONMENT, SSO_REALM, SSO_PROTOCOL, SSO_CLIENT_ID, BACKEND_URL } = + ENV; + +export const login = errorWrapper(async (req: Request, res: Response) => { + try { + const redirectURL = getLoginURL({ + idpHint: "idir", + clientID: SSO_CLIENT_ID, + redirectURI: `${BACKEND_URL}/auth/login/callback`, + ssoEnvironment: SSO_ENVIRONMENT as SSOEnvironment, + ssoRealm: SSO_REALM, + ssoProtocol: SSO_PROTOCOL as SSOProtocol, + }); + + // Redirect the user to the SSO login page + res.redirect(redirectURL); + } catch (error) { + res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "An unknown error occurred during login.", + }); + } +}); diff --git a/backend/src/modules/auth/controllers/loginCallback.ts b/backend/src/modules/auth/controllers/loginCallback.ts new file mode 100644 index 00000000..da601879 --- /dev/null +++ b/backend/src/modules/auth/controllers/loginCallback.ts @@ -0,0 +1,56 @@ +import type { Request, Response } from "express"; +import { getTokens } from "@bcgov/citz-imb-sso-js-core"; +import type { SSOEnvironment, SSOProtocol } from "@bcgov/citz-imb-sso-js-core"; +import { ENV } from "src/config"; +import { errorWrapper } from "@bcgov/citz-imb-express-utilities"; + +const { SSO_ENVIRONMENT, SSO_REALM, SSO_PROTOCOL, SSO_CLIENT_ID, SSO_CLIENT_SECRET, BACKEND_URL } = + ENV; + +export const loginCallback = errorWrapper(async (req: Request, res: Response) => { + try { + const { code } = req.query; + + const tokens = await getTokens({ + code: code as string, + clientID: SSO_CLIENT_ID, + clientSecret: SSO_CLIENT_SECRET, + redirectURI: `${BACKEND_URL}/auth/login/callback`, + ssoEnvironment: SSO_ENVIRONMENT as SSOEnvironment, + ssoRealm: SSO_REALM, + ssoProtocol: SSO_PROTOCOL as SSOProtocol, + }); + + // Sets tokens + res + .cookie("refresh_token", tokens.refresh_token, { + httpOnly: true, + secure: true, + sameSite: "none", + }) + .cookie("access_token", tokens.access_token, { + secure: true, + sameSite: "none", + }) + .cookie("id_token", tokens.id_token, { + secure: true, + sameSite: "none", + }) + .cookie("expires_in", tokens.expires_in, { + secure: true, + sameSite: "none", + }) + .cookie("refresh_expires_in", tokens.refresh_expires_in, { + secure: true, + sameSite: "none", + }) + .status(200) + .json(tokens); + } catch (error) { + res.status(500).json({ + success: false, + error: + error instanceof Error ? error.message : "An unknown error occurred during login callback.", + }); + } +}); diff --git a/backend/src/modules/auth/controllers/logout.ts b/backend/src/modules/auth/controllers/logout.ts new file mode 100644 index 00000000..702fb109 --- /dev/null +++ b/backend/src/modules/auth/controllers/logout.ts @@ -0,0 +1,31 @@ +import type { Request, Response } from "express"; +import { getLogoutURL } from "@bcgov/citz-imb-sso-js-core"; +import type { SSOEnvironment, SSOProtocol } from "@bcgov/citz-imb-sso-js-core"; +import { ENV } from "src/config"; +import { errorWrapper } from "@bcgov/citz-imb-express-utilities"; + +const { SSO_ENVIRONMENT, SSO_REALM, SSO_PROTOCOL, BACKEND_URL } = ENV; + +export const logout = errorWrapper(async (req: Request, res: Response) => { + try { + const { id_token } = req.query; + + const redirectURL = getLogoutURL({ + idToken: id_token as string, + postLogoutRedirectURI: `${BACKEND_URL}/auth/logout/callback`, + ssoEnvironment: SSO_ENVIRONMENT as SSOEnvironment, + ssoProtocol: SSO_PROTOCOL as SSOProtocol, + ssoRealm: SSO_REALM, + }); + + res.redirect(redirectURL); + } catch (error) { + res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "An unknown error occurred during logout.", + }); + } +}); diff --git a/backend/src/modules/auth/controllers/logoutCallback.ts b/backend/src/modules/auth/controllers/logoutCallback.ts new file mode 100644 index 00000000..e09cb024 --- /dev/null +++ b/backend/src/modules/auth/controllers/logoutCallback.ts @@ -0,0 +1,20 @@ +import { errorWrapper } from "@bcgov/citz-imb-express-utilities"; +import type { Request, Response } from "express"; + +// This endpoint is only needed because SSO needs to redirect somewhere +// and the desktop application does not have a URL to redirect to. +export const logoutCallback = errorWrapper( + async (req: Request, res: Response) => { + try { + res.status(204).send("Logged out."); + } catch (error) { + res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "An unknown error occurred during logout.", + }); + } + }, +); diff --git a/backend/src/modules/auth/controllers/token.ts b/backend/src/modules/auth/controllers/token.ts new file mode 100644 index 00000000..a346d64d --- /dev/null +++ b/backend/src/modules/auth/controllers/token.ts @@ -0,0 +1,47 @@ +import type { Request, Response } from "express"; +import { getNewTokens } from "@bcgov/citz-imb-sso-js-core"; +import type { SSOEnvironment, SSOProtocol } from "@bcgov/citz-imb-sso-js-core"; +import { ENV } from "src/config"; +import { errorWrapper } from "@bcgov/citz-imb-express-utilities"; + +const { SSO_ENVIRONMENT, SSO_REALM, SSO_PROTOCOL, SSO_CLIENT_ID, SSO_CLIENT_SECRET } = ENV; + +export const token = errorWrapper(async (req: Request, res: Response) => { + try { + const refresh_token = req.cookies.refresh_token; + + if (!refresh_token) + return res.status(401).json({ + success: false, + message: "Refresh token is missing. Please log in again.", + }); + + const tokens = await getNewTokens({ + refreshToken: refresh_token as string, + clientID: SSO_CLIENT_ID, + clientSecret: SSO_CLIENT_SECRET, + ssoEnvironment: SSO_ENVIRONMENT as SSOEnvironment, + ssoRealm: SSO_REALM, + ssoProtocol: SSO_PROTOCOL as SSOProtocol, + }); + + if (!tokens) return res.status(401).json({ success: false, message: "Invalid token." }); + + // Set token + res + .cookie("access_token", tokens.access_token, { + secure: true, + sameSite: "none", + }) + .status(200) + .json(tokens); + } catch (error) { + res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "An unknown error occurred while refreshing tokens.", + }); + } +}); diff --git a/backend/src/modules/auth/index.ts b/backend/src/modules/auth/index.ts new file mode 100644 index 00000000..ef779361 --- /dev/null +++ b/backend/src/modules/auth/index.ts @@ -0,0 +1 @@ +export { default as router } from "./router"; diff --git a/backend/src/modules/auth/middleware.ts b/backend/src/modules/auth/middleware.ts new file mode 100644 index 00000000..cf7059cf --- /dev/null +++ b/backend/src/modules/auth/middleware.ts @@ -0,0 +1,92 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import { + isJWTValid, + decodeJWT, + hasAllRoles, + hasAtLeastOneRole, + normalizeUser, +} from "@bcgov/citz-imb-sso-js-core"; +import type { SSOUser, SSOProtocol, SSOEnvironment } from "@bcgov/citz-imb-sso-js-core"; +import { ENV } from "src/config"; + +const { SSO_CLIENT_ID, SSO_CLIENT_SECRET, SSO_ENVIRONMENT, SSO_PROTOCOL, SSO_REALM } = ENV; + +type ProtectedRouteOptions = { + requireAllRoles?: boolean; +}; + +declare global { + namespace Express { + interface Request { + token?: string; + user?: SSOUser; + } + } +} + +export const protectedRoute = ( + roles?: string[], + options?: ProtectedRouteOptions, +): RequestHandler => { + const routeMiddleware = async (req: Request, res: Response, next: NextFunction) => { + // Check if Authorization header exists. + const header = req.headers.authorization; + if (!header) + return res.status(401).json({ success: false, message: "No authorization header found." }); + + // Extract token from header and check if it is valid. + const token = header.split(" ")[1]; + const isTokenValid = await isJWTValid({ + jwt: token, + clientID: SSO_CLIENT_ID, + clientSecret: SSO_CLIENT_SECRET, + ssoEnvironment: SSO_ENVIRONMENT as SSOEnvironment, + ssoProtocol: SSO_PROTOCOL as SSOProtocol, + ssoRealm: SSO_REALM, + }); + if (!isTokenValid) + return res.status(401).json({ + success: false, + message: "Unauthorized: Invalid token, re-log to get a new one.", + }); + + // Get user info and check role. + const userInfo = decodeJWT(token); + const normalizedUser = normalizeUser(userInfo); + if (!userInfo || !normalizedUser) return res.status(404).json({ error: "User not found." }); + const userRoles = userInfo?.client_roles; + + // Ensure proper use of function. + if (roles && (!Array.isArray(roles) || !roles.every((item) => typeof item === "string"))) + throw new Error("Error: Pass roles as an array of strings to protectedRoute."); + + // Check for roles. + if (roles) { + if (options?.requireAllRoles === false) { + if (!userRoles || !hasAtLeastOneRole(userRoles, roles)) { + // User does not have at least one of the required roles. + return res.status(403).json({ + success: false, + message: `User must have at least one of the following roles: [${roles}]`, + }); + } + } else { + if (!userRoles || !hasAllRoles(userRoles, roles)) { + // User does not have all the required roles. + return res.status(403).json({ + sucess: false, + message: `User must have all of the following roles: [${roles}]`, + }); + } + } + } + + // Set decoded token and user information in request object. + req.token = token; + req.user = normalizedUser; + + // Pass control to the next middleware function. + next(); + }; + return routeMiddleware as RequestHandler; +}; diff --git a/backend/src/modules/auth/router.ts b/backend/src/modules/auth/router.ts new file mode 100644 index 00000000..7913f802 --- /dev/null +++ b/backend/src/modules/auth/router.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { + login, + loginCallback, + logout, + logoutCallback, + token, +} from "./controllers"; + +const router = Router(); + +router.get("/login", login); +router.get("/login/callback", loginCallback); +router.get("/logout", logout); +router.get("/logout/callback", logoutCallback); +router.get("/token", token); + +export default router; diff --git a/backend/src/modules/index.ts b/backend/src/modules/index.ts new file mode 100644 index 00000000..97ccf764 --- /dev/null +++ b/backend/src/modules/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/desktop/biome.json b/biome.json similarity index 91% rename from desktop/biome.json rename to biome.json index 78f9662e..cb78ae41 100644 --- a/desktop/biome.json +++ b/biome.json @@ -22,7 +22,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": { + "noForEach": "off" + } } }, "javascript": { diff --git a/desktop/js-core.tgz b/desktop/js-core.tgz new file mode 100644 index 00000000..736e71fe Binary files /dev/null and b/desktop/js-core.tgz differ diff --git a/desktop/package.json b/desktop/package.json index 23389c0b..a3d664b8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -16,8 +16,10 @@ "build:linux": "electron-vite build && electron-builder --linux" }, "dependencies": { + "@bcgov/citz-imb-sso-js-core": "file:./js-core.tgz", "@electron-toolkit/preload": "3.0.1", - "@electron-toolkit/utils": "3.0.0" + "@electron-toolkit/utils": "3.0.0", + "electron-cookies": "1.1.0" }, "devDependencies": { "@biomejs/biome": "1.9.3", diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index c3909333..04317d34 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -7,18 +7,29 @@ import { type MenuItemConstructorOptions, } from "electron"; import { join } from "node:path"; -import { electronApp, optimizer, is } from "@electron-toolkit/utils"; +import { is } from "@electron-toolkit/utils"; import icon from "../../resources/icon.png?asset"; +const DEBUG = true; + let mainWindow: BrowserWindow; +let authWindow: BrowserWindow | null = null; +let refreshInterval: NodeJS.Timeout | undefined; + +const tokens: Record = { + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + accessExpiresIn: undefined, + refreshExpiresIn: undefined, +}; function createWindow(): void { - // Create the browser window. mainWindow = new BrowserWindow({ width: 900, height: 670, show: false, - autoHideMenuBar: false, // Set this to false to make sure the menu is visible during development + autoHideMenuBar: false, ...(process.platform === "linux" ? { icon } : {}), webPreferences: { preload: join(__dirname, "../preload/index.mjs"), @@ -27,11 +38,8 @@ function createWindow(): void { }); mainWindow.on("ready-to-show", () => { - const menu = Menu.buildFromTemplate( - menuTemplate as MenuItemConstructorOptions[], - ); + const menu = Menu.buildFromTemplate(menuTemplate as MenuItemConstructorOptions[]); Menu.setApplicationMenu(menu); - mainWindow.show(); }); @@ -40,7 +48,6 @@ function createWindow(): void { return { action: "deny" }; }); - // Load the remote URL for development or the local html file for production. if (is.dev && process.env.ELECTRON_RENDERER_URL) { mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); } else { @@ -48,44 +55,176 @@ function createWindow(): void { } } -app.whenReady().then(() => { - // Set app user model id for windows - electronApp.setAppUserModelId("com.electron"); +ipcMain.handle("start-login-process", async () => { + debug('Beginning "start-login-process" of main process.'); + + // Create a new browser window for the authentication process + authWindow = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + authWindow.loadURL("http://localhost:3200/auth/login"); + + // Listen for redirect navigation to the callback URL with the code parameter + authWindow.webContents.on("did-redirect-navigation", async (_event, url) => { + const urlObj = new URL(url); + if (urlObj.pathname === "/auth/login/callback") { + const code = urlObj.searchParams.get("code"); + if (code) { + try { + setTimeout(async () => { + const cookies = await authWindow?.webContents.session.cookies.get({ + url: "http://localhost:3200", + }); - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - app.on("browser-window-created", (_, window) => { - optimizer.watchWindowShortcuts(window); + if (!cookies || cookies?.length === 0) + throw new Error("No cookies found for the session."); + + // Find cookies that contain the token information + cookies.forEach((cookie) => { + if (cookie.name === "access_token") tokens.accessToken = cookie.value; + if (cookie.name === "refresh_token") tokens.refreshToken = cookie.value; + if (cookie.name === "id_token") tokens.idToken = cookie.value; + if (cookie.name === "expires_in") tokens.accessExpiresIn = cookie.value; + if (cookie.name === "refresh_expires_in") tokens.refreshExpiresIn = cookie.value; + }); + + // Notify the renderer process and schedule token refreshing + if (tokens.accessToken && tokens.refreshToken) { + debug("Tokens retrieved from cookies."); + scheduleRefreshTokens(); + mainWindow.webContents.send("auth-success", tokens); + } else throw new Error("Required tokens not found in cookies."); + }, 1000); + } catch (error) { + console.error("Error during login process:", error); + authWindow?.close(); // Close the window even in case of error + authWindow = null; + } finally { + // Hide window + authWindow?.hide(); + } + } + } }); - // IPC test - ipcMain.on("ping", () => console.log("pong")); + // Handle the case where the user closes the auth window manually + authWindow.on("closed", () => { + authWindow = null; + }); +}); + +ipcMain.handle("start-logout-process", async (_, idToken: string) => { + debug('Beginning "start-logout-process" of main process.'); - createWindow(); + // Open a new window for the logout process + if (!authWindow) { + authWindow = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + } + + authWindow.loadURL(`http://localhost:3200/auth/logout?id_token=${idToken}`); + + authWindow.webContents.on("did-redirect-navigation", async (_event, url) => { + if (url.includes("/auth/logout/callback")) clearAuthState(); + }); - app.on("activate", () => { - // On macOS it's common to re-create a window in the app when the dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow(); + authWindow.on("closed", () => { + authWindow = null; }); }); -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits explicitly with Cmd + Q. +const clearAuthState = () => { + debug("Beginning clearAuthState function of main process."); + + // Close auth window + authWindow?.close(); + authWindow = null; + + mainWindow.webContents.send("auth-logout"); + + clearInterval(refreshInterval); + tokens.accessToken = undefined; + tokens.refreshToken = undefined; + tokens.idToken = undefined; + tokens.accessExpiresIn = undefined; + tokens.refreshExpiresIn = undefined; +}; + +const scheduleRefreshTokens = () => { + debug("Beginning scheduleRefreshTokens function of main process."); + + if (!tokens.refreshExpiresIn || !tokens.accessExpiresIn) + return console.error("Missing token expiry values after login."); + + const { accessExpiresIn, refreshExpiresIn } = tokens; + + // Clear any existing intervals before setting a new one + if (refreshInterval) clearInterval(refreshInterval); + + // Refresh tokens + refreshInterval = setInterval(() => { + refreshTokens(); + }, Number(accessExpiresIn) * 1000); + + // Clear auth state when refresh token expires. + setTimeout(() => { + clearAuthState(); + return; + }, Number(refreshExpiresIn) * 1000); +}; + +const refreshTokens = async () => { + debug("Beginning refreshTokens function of main process."); + + if (authWindow) { + try { + authWindow.loadURL("http://localhost:3200/auth/token"); + + setTimeout(async () => { + const cookies = await authWindow?.webContents.session.cookies.get({ + url: "http://localhost:3200", + }); + + if (!cookies || cookies?.length === 0) throw new Error("No cookies found for the session."); + + // Find cookies that contain the token information + cookies.forEach((cookie) => { + if (cookie.name === "access_token") tokens.accessToken = cookie.value; + }); + + // Send the updated tokens to the renderer process + mainWindow.webContents.send("token-refresh-success", tokens); + }, 1000); + } catch (error) { + console.error("Error executing refreshTokens in authWindow:", error); + } + } +}; + +const debug = (log: string) => { + if (DEBUG) console.info(log); +}; + +app.whenReady().then(createWindow); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); -// Menu template configuration const menuTemplate = [ - ...(process.platform === "darwin" - ? [ - { - label: app.getName(), - submenu: [{ role: "quit" }], - }, - ] - : []), + ...(process.platform === "darwin" ? [{ label: app.getName(), submenu: [{ role: "quit" }] }] : []), { role: "viewMenu" }, ]; diff --git a/desktop/src/preload/api/fetchProtectedRoute.ts b/desktop/src/preload/api/fetchProtectedRoute.ts new file mode 100644 index 00000000..9fa3eae0 --- /dev/null +++ b/desktop/src/preload/api/fetchProtectedRoute.ts @@ -0,0 +1,14 @@ +import { safePromise } from "./safePromise"; + +export const fetchProtectedRoute = async ( + url: string, + accessToken: string | undefined, + options: RequestInit = {}, +): Promise<[Error | null, Response | null]> => { + if (!accessToken) return [new Error("Access token is missing or undefined."), null]; + + const authHeaderValue = `Bearer ${accessToken}`; + options.headers = { ...options.headers, authorization: authHeaderValue }; + + return safePromise(fetch(url, options)); +}; diff --git a/desktop/src/preload/api/getUser.ts b/desktop/src/preload/api/getUser.ts new file mode 100644 index 00000000..b48eb1e5 --- /dev/null +++ b/desktop/src/preload/api/getUser.ts @@ -0,0 +1,12 @@ +import { decodeJWT, normalizeUser } from "@bcgov/citz-imb-sso-js-core"; +import type { IdirIdentityProvider, OriginalSSOUser, SSOUser } from "@bcgov/citz-imb-sso-js-core"; + +// Get user details from the decoded token +export const getUser = ( + accessToken: string | undefined, +): SSOUser | undefined => { + if (!accessToken) return undefined; + + const decoded = decodeJWT(accessToken) as OriginalSSOUser; + return normalizeUser(decoded); +}; diff --git a/desktop/src/preload/api/index.ts b/desktop/src/preload/api/index.ts index ee325040..29a676fd 100644 --- a/desktop/src/preload/api/index.ts +++ b/desktop/src/preload/api/index.ts @@ -1,2 +1,5 @@ export * from "./checkAPIStatus"; export * from "./checkIPRange"; +export * from "./getUser"; +export * from "./safePromise"; +export * from "./fetchProtectedRoute"; diff --git a/desktop/src/preload/api/safePromise.ts b/desktop/src/preload/api/safePromise.ts new file mode 100644 index 00000000..41025565 --- /dev/null +++ b/desktop/src/preload/api/safePromise.ts @@ -0,0 +1,21 @@ +export const safePromise = async ( + promise: Promise, +): Promise<[Error | null, T | null]> => { + try { + const response = await promise; + + // Check if there's a content-type header and if it's JSON + const contentType = response.headers.get("content-type"); + + // If the content type includes 'application/json', attempt to parse it as JSON + if (contentType?.includes("application/json")) { + const jsonData = (await response.json()) as T; + return [null, jsonData]; + } + + // Otherwise, return null as the result if no JSON is present + return [null, null]; + } catch (error) { + return [error as Error, null]; + } +}; diff --git a/desktop/src/preload/index.d.ts b/desktop/src/preload/index.d.ts index 7d6ae851..256930ac 100644 --- a/desktop/src/preload/index.d.ts +++ b/desktop/src/preload/index.d.ts @@ -1,3 +1,4 @@ +import type { SSOUser, IdirIdentityProvider } from "@bcgov/citz-imb-sso-js-core"; import type { ElectronAPI } from "@electron-toolkit/preload"; declare global { @@ -7,6 +8,17 @@ declare global { versions: NodeJS.Process.Versions; checkApiStatus: () => Promise; checkIpRange: () => Promise; + // biome-ignore lint/suspicious/noExplicitAny: + startLoginProcess: () => Promise; + // biome-ignore lint/suspicious/noExplicitAny: + logout: (idToken: string | undefined) => Promise; + getUser: (accessToken: string | undefined) => SSOUser | undefined; + safePromise: (promise: Promise) => Promise<[Error | null, T | null]>; + fetchProtectedRoute: ( + url: string, + accessToken: string | undefined, + options: RequestInit = {}, + ) => Promise<[Error | null, Response | null]>; }; } } diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index ee3e64ba..58afb238 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -1,17 +1,19 @@ -import { contextBridge } from "electron"; +import { contextBridge, ipcRenderer } from "electron"; import { electronAPI } from "@electron-toolkit/preload"; -import { checkApiStatus, checkIpRange } from "./api"; +import { checkApiStatus, checkIpRange, fetchProtectedRoute, getUser, safePromise } from "./api"; -// Custom APIs for renderer const api = { versions: process.versions, checkApiStatus, checkIpRange, + startLoginProcess: () => ipcRenderer.invoke("start-login-process"), + logout: (idToken: string | undefined) => ipcRenderer.invoke("start-logout-process", idToken), + getUser, + safePromise, + fetchProtectedRoute, }; -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. +// Expose APIs to the renderer process if (process.contextIsolated) { try { contextBridge.exposeInMainWorld("electron", electronAPI); diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index dc8b0330..6b81be12 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -3,11 +3,42 @@ import { Versions, VPNPopup } from "./components"; import electronLogo from "./assets/electron.svg"; function App(): JSX.Element { + const [api] = useState(window.api); // Preload scripts const [apiStatus, setApiStatus] = useState("Checking..."); const [showVPNPopup, setShowVPNPopup] = useState(null); - const [api] = useState(window.api); - const ipcHandle = (): void => window.electron.ipcRenderer.send("ping"); + // Authentication state + const [accessToken, setAccessToken] = useState(undefined); + const [idToken, setIdToken] = useState(undefined); + + const handleLogin = async () => await api.startLoginProcess(); + const handleLogout = async () => await api.logout(idToken); + + useEffect(() => { + // Handle "auth-success" message from main process + // Triggered upon successful login + window.electron.ipcRenderer.on("auth-success", (_, tokens) => { + setAccessToken(tokens.accessToken); + setIdToken(tokens.idToken); + }); + + // Handle "token-refresh-success" message from main process + // Triggered upon successful refresh of tokens + window.electron.ipcRenderer.on("token-refresh-success", (_, tokens) => + setAccessToken(tokens.accessToken), + ); + + // Handle "auth-logout" message from main process + // Triggered upon logout + window.electron.ipcRenderer.on("auth-logout", () => setAccessToken(undefined)); + + // Cleanup + return () => { + window.electron.ipcRenderer.removeAllListeners("auth-success"); + window.electron.ipcRenderer.removeAllListeners("auth-logout"); + window.electron.ipcRenderer.removeAllListeners("token-refresh-success"); + }; + }, []); const handleIPStatusUpdate = async () => { const ipStatusOK = await api.checkIpRange(); @@ -21,15 +52,28 @@ function App(): JSX.Element { // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { + // Check if API is health upon first load handleAPIStatusUpdate(); + // Check for VPN or gov Network use on load, and every 5 seconds handleIPStatusUpdate(); - // Set an interval to check the API status every 5 seconds const interval = setInterval(handleIPStatusUpdate, 5 * 1000); - return () => clearInterval(interval); // Clean up interval on unmount + return () => { + clearInterval(interval); + }; }, []); + const handleTestRoute = async () => { + const [error, result] = await api.fetchProtectedRoute( + "http://localhost:3200/test", + accessToken, + ); + + if (error) console.log("Error in fetch: ", error); + console.log("Result: ", result); + }; + return ( <> logo @@ -39,16 +83,26 @@ function App(): JSX.Element { TypeScript

- Please try pressing Ctrl/Cmd + Shift + I to open the - devTools + Please try pressing Ctrl/Cmd + Shift + I to open the devTools

API Status: {apiStatus}
+
{accessToken && `Hello ${api.getUser(accessToken)?.display_name}!`}
- +
+
+ +
+
+
diff --git a/package.json b/package.json index 6260d72c..5d77a008 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "up": "docker compose up -d", "down": "docker compose down -v --remove-orphans", "prune": "docker compose down --rmi all --volumes && npm run clear-docker-cache", + "prune:api": "docker compose down express-api && docker rmi citz-grs-dats-express-api && npm run clear-docker-cache", "rebuild": "npm run prune && npm run up -- --force-recreate", + "rebuild:api": "npm run prune:api && npm run up -- express-api", "clear-docker-cache": "docker builder prune -f", "shell:mongo": "docker exec -it mongo bash" }