From ecf9f3d7ca9a7605d7b15c3a37318f4aa683301c Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 5 Jun 2025 15:26:09 +0200 Subject: [PATCH 01/10] Replace secure-session with session --- package-lock.json | 136 +++--------------- package.json | 16 +-- server/plugins/http-proxy.js | 7 +- .../plugins/{secure-session.js => session.js} | 9 +- server/routes/auth.js | 6 +- 5 files changed, 36 insertions(+), 138 deletions(-) rename server/plugins/{secure-session.js => session.js} (65%) diff --git a/package-lock.json b/package-lock.json index 174359de..1e8a8454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,8 @@ "@fastify/cookie": "^11.0.2", "@fastify/env": "^5.0.2", "@fastify/http-proxy": "^11.1.2", - "@fastify/secure-session": "^8.2.0", "@fastify/sensible": "^6.0.3", + "@fastify/session": "^11.1.0", "@fastify/static": "^8.1.1", "@fastify/vite": "^8.1.3", "@hookform/resolvers": "^5.0.0", @@ -1418,30 +1418,6 @@ "undici": "^7.0.0" } }, - "node_modules/@fastify/secure-session": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@fastify/secure-session/-/secure-session-8.2.0.tgz", - "integrity": "sha512-E1linEHVV86c0Gt+ohujcuRsCeedhD2M3X5+a2aU9Ln0YDC0bbuA7bE6QQzf/HAacOpt9+CJqV5NqdlQr9ui0A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/cookie": "^11.0.1", - "fastify-plugin": "^5.0.0", - "sodium-native": "^4.0.10" - }, - "bin": { - "secure-session": "genkey.js" - } - }, "node_modules/@fastify/send": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.0.0.tgz", @@ -1490,6 +1466,26 @@ "vary": "^1.1.2" } }, + "node_modules/@fastify/session": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@fastify/session/-/session-11.1.0.tgz", + "integrity": "sha512-OxAX79PtVyTKxfmHT8e0jDFliw/2EmhOxe1Mj35jhL20j8CEpj5Li2zOVi5PqHc5Y+7N2w0tOmtM8mB6NjAIGw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.1", + "safe-stable-stringify": "^2.4.3" + } + }, "node_modules/@fastify/static": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.2.0.tgz", @@ -4668,74 +4664,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-addon-resolve": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.9.4.tgz", - "integrity": "sha512-unn6Vy/Yke6F99vg/7tcrvM2KUvIhTNniaSqDbam4AWkd4NhvDVSrQiRYVlNzUV2P7SPobkCK7JFVxrJk9btCg==", - "license": "Apache-2.0", - "dependencies": { - "bare-module-resolve": "^1.10.0", - "bare-semver": "^1.0.0" - }, - "peerDependencies": { - "bare-url": "*" - }, - "peerDependenciesMeta": { - "bare-url": { - "optional": true - } - } - }, - "node_modules/bare-module-resolve": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.10.2.tgz", - "integrity": "sha512-C9COe/GhWfVXKytW3DElTkiBU+Gb2OXeaVkdGdRB/lp26TVLESHkTGS876iceAGdvtPgohfp9nX8vXHGvN3++Q==", - "license": "Apache-2.0", - "dependencies": { - "bare-semver": "^1.0.0" - }, - "peerDependencies": { - "bare-url": "*" - }, - "peerDependenciesMeta": { - "bare-url": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-semver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.1.tgz", - "integrity": "sha512-UtggzHLiTrmFOC/ogQ+Hy7VfoKoIwrP1UFcYtTxoCUdLtsIErT8+SWtOC2DH/snT9h+xDrcBEPcwKei1mzemgg==", - "license": "Apache-2.0" - }, - "node_modules/bare-url": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.1.6.tgz", - "integrity": "sha512-FgjDeR+/yDH34By4I0qB5NxAoWv7dOTYcOXwn73kr+c93HyC2lU6tnjifqUe33LKMJcDyCYPQjEAqgOQiXkE2Q==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -11592,19 +11520,6 @@ "throttleit": "^1.0.0" } }, - "node_modules/require-addon": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.1.0.tgz", - "integrity": "sha512-KbXAD5q2+v1GJnkzd8zzbOxchTkStSyJZ9QwoCq3QwEXAaIlG3wDYRZGzVD357jmwaGY7hr5VaoEAL0BkF0Kvg==", - "license": "Apache-2.0", - "dependencies": { - "bare-addon-resolve": "^1.3.0", - "bare-url": "^2.1.0" - }, - "engines": { - "bare": ">=1.10.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -12655,15 +12570,6 @@ "tslib": "^2.0.3" } }, - "node_modules/sodium-native": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", - "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", - "license": "MIT", - "dependencies": { - "require-addon": "^1.1.0" - } - }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", diff --git a/package.json b/package.json index 89a8e887..63790b6d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,14 @@ }, "dependencies": { "@apollo/client": "^3.13.6", + "@fastify/autoload": "^6.3.0", + "@fastify/cookie": "^11.0.2", + "@fastify/env": "^5.0.2", + "@fastify/http-proxy": "^11.1.2", + "@fastify/sensible": "^6.0.3", + "@fastify/session": "^11.1.0", + "@fastify/static": "^8.1.1", + "@fastify/vite": "^8.1.3", "@hookform/resolvers": "^5.0.0", "@ui5/webcomponents": "^2.7.2", "@ui5/webcomponents-fiori": "^2.7.2", @@ -30,14 +38,6 @@ "dotenv": "^16.5.0", "fastify": "^5.3.3", "fastify-plugin": "^5.0.1", - "@fastify/autoload": "^6.3.0", - "@fastify/cookie": "^11.0.2", - "@fastify/env": "^5.0.2", - "@fastify/http-proxy": "^11.1.2", - "@fastify/secure-session": "^8.2.0", - "@fastify/sensible": "^6.0.3", - "@fastify/static": "^8.1.1", - "@fastify/vite": "^8.1.3", "graphql": "^16.10.0", "graphql-config": "^5.1.3", "i18next": "^25.0.0", diff --git a/server/plugins/http-proxy.js b/server/plugins/http-proxy.js index d793131f..1049581e 100644 --- a/server/plugins/http-proxy.js +++ b/server/plugins/http-proxy.js @@ -1,6 +1,5 @@ import fp from "fastify-plugin"; import httpProxy from "@fastify/http-proxy"; -import { COOKIE_NAME_ONBOARDING } from "./secure-session.js"; import { AuthenticationError } from "./auth-utils.js"; function proxyPlugin(fastify) { @@ -35,8 +34,7 @@ function proxyPlugin(fastify) { const refreshToken = request.session.get("refreshToken"); if (!refreshToken) { request.log.error("Missing refresh token; deleting session."); - request.session.delete(); - reply.clearCookie(COOKIE_NAME_ONBOARDING, { path: "/" }); + request.session.destroy(); return reply.unauthorized("Session expired without token refresh capability."); } @@ -50,8 +48,7 @@ function proxyPlugin(fastify) { }, issuerConfiguration.tokenEndpoint); if (!refreshedTokenData || !refreshedTokenData.accessToken) { request.log.error("Token refresh failed (no access token); deleting session."); - request.session.delete(); - reply.clearCookie(COOKIE_NAME_ONBOARDING, { path: "/" }); + request.session.destroy(); return reply.unauthorized("Session expired and token refresh failed."); } diff --git a/server/plugins/secure-session.js b/server/plugins/session.js similarity index 65% rename from server/plugins/secure-session.js rename to server/plugins/session.js index d9191408..213e9a17 100644 --- a/server/plugins/secure-session.js +++ b/server/plugins/session.js @@ -1,18 +1,15 @@ -import secureSession from "@fastify/secure-session"; +import fastifySession from "@fastify/session"; import fp from "fastify-plugin"; import fastifyCookie from "@fastify/cookie"; -export const COOKIE_NAME_ONBOARDING = "onboarding"; - async function secureSessionPlugin(fastify) { const { COOKIE_SECRET, NODE_ENV } = fastify.config; await fastify.register(fastifyCookie); - fastify.register(secureSession, { - secret: Buffer.from(COOKIE_SECRET, "hex"), - cookieName: COOKIE_NAME_ONBOARDING, + fastify.register(fastifySession, { + secret: COOKIE_SECRET, cookie: { path: "/", httpOnly: true, diff --git a/server/routes/auth.js b/server/routes/auth.js index 2bfcfcef..9ca1d75a 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,5 +1,4 @@ import fp from "fastify-plugin"; -import { COOKIE_NAME_ONBOARDING } from "../plugins/secure-session.js"; import { AuthenticationError } from "../plugins/auth-utils.js"; @@ -62,10 +61,9 @@ async function authPlugin(fastify) { }); - fastify.post("/auth/logout", async (_req, reply) => { + fastify.post("/auth/logout", async (req, reply) => { // TODO: Idp sign out flow - //_req.session.delete(); // remove payload - reply.clearCookie(COOKIE_NAME_ONBOARDING, { path: "/" }); + req.session.destroy(); reply.send({ message: "Logged out" }); }); } From a225c0e7538ab8ce6ac8d3e1a45dbdc0a40592b1 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Fri, 13 Jun 2025 14:12:04 +0200 Subject: [PATCH 02/10] support different client IDs for MCP --- .env.template | 1 + public/locales/en.json | 3 - server/config/env.js | 34 ++- server/plugins/auth-utils.js | 4 + server/plugins/http-proxy.js | 35 ++- server/routes/auth-mcp.js | 58 +++++ server/routes/{auth.js => auth-onboarding.js} | 25 +- src/App.tsx | 9 +- src/common/auth/AuthCallbackHandler.tsx | 72 ++++++ src/components/Core/ShellBar.tsx | 6 +- .../Dialogs/CreateProjectDialogContainer.tsx | 4 +- .../CreateWorkspaceDialogContainer.tsx | 4 +- src/lib/api/fetch.ts | 2 +- src/lib/shared/McpContext.tsx | 13 + src/main.tsx | 41 +-- src/spaces/mcp/auth/AuthContextMcp.tsx | 80 ++++++ src/spaces/mcp/auth/auth.schemas.ts | 5 + ...hContext.tsx => AuthContextOnboarding.tsx} | 52 ++-- src/views/ControlPlanes/ControlPlaneView.tsx | 237 +++++++++--------- src/views/Login.tsx | 4 +- 20 files changed, 464 insertions(+), 225 deletions(-) create mode 100644 server/routes/auth-mcp.js rename server/routes/{auth.js => auth-onboarding.js} (67%) create mode 100644 src/common/auth/AuthCallbackHandler.tsx create mode 100644 src/spaces/mcp/auth/AuthContextMcp.tsx create mode 100644 src/spaces/mcp/auth/auth.schemas.ts rename src/spaces/onboarding/auth/{AuthContext.tsx => AuthContextOnboarding.tsx} (65%) diff --git a/.env.template b/.env.template index 5a864be6..219faa18 100644 --- a/.env.template +++ b/.env.template @@ -1,6 +1,7 @@ # OpenID Connect Configuration for Onboarding API OIDC_ISSUER= OIDC_CLIENT_ID= +OIDC_CLIENT_ID_MCP= OIDC_SCOPES= OIDC_REDIRECT_URI=http://localhost:5173 diff --git a/public/locales/en.json b/public/locales/en.json index 63ff429e..f015f5a7 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -242,9 +242,6 @@ }, "learnButton": "Learn how to do this in code" }, - "App": { - "loading": "Loading..." - }, "Providers": { "headerProviders": "Providers", "tableHeaderVersion": "Version", diff --git a/server/config/env.js b/server/config/env.js index c137d93d..42c7cb0f 100644 --- a/server/config/env.js +++ b/server/config/env.js @@ -1,21 +1,31 @@ -import fastifyPlugin from "fastify-plugin"; -import fastifyEnv from "@fastify/env"; +import fastifyPlugin from 'fastify-plugin'; +import fastifyEnv from '@fastify/env'; const schema = { - type: "object", - required: ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_REDIRECT_URI", "OIDC_SCOPES", "POST_LOGIN_REDIRECT", "COOKIE_SECRET", "API_BACKEND_URL"], + type: 'object', + required: [ + 'OIDC_ISSUER', + 'OIDC_CLIENT_ID', + 'OIDC_CLIENT_ID_MCP', + 'OIDC_REDIRECT_URI', + 'OIDC_SCOPES', + 'POST_LOGIN_REDIRECT', + 'COOKIE_SECRET', + 'API_BACKEND_URL', + ], properties: { // Application variables (.env) - OIDC_ISSUER: { type: "string" }, - OIDC_CLIENT_ID: { type: "string" }, - OIDC_REDIRECT_URI: { type: "string" }, - OIDC_SCOPES: { type: "string" }, - POST_LOGIN_REDIRECT: { type: "string" }, - COOKIE_SECRET: { type: "string" }, - API_BACKEND_URL: { type: "string" }, + OIDC_ISSUER: { type: 'string' }, + OIDC_CLIENT_ID: { type: 'string' }, + OIDC_CLIENT_ID_MCP: { type: 'string' }, + OIDC_REDIRECT_URI: { type: 'string' }, + OIDC_SCOPES: { type: 'string' }, + POST_LOGIN_REDIRECT: { type: 'string' }, + COOKIE_SECRET: { type: 'string' }, + API_BACKEND_URL: { type: 'string' }, // System variables - NODE_ENV: { type: "string", enum: ["development", "production"] }, + NODE_ENV: { type: 'string', enum: ['development', 'production'] }, }, }; diff --git a/server/plugins/auth-utils.js b/server/plugins/auth-utils.js index a90689ad..f5fb8649 100644 --- a/server/plugins/auth-utils.js +++ b/server/plugins/auth-utils.js @@ -76,6 +76,9 @@ async function authUtilsPlugin(fastify) { fastify.decorate("prepareOidcLoginRedirect", (request, oidcConfig, authorizationEndpoint) => { request.log.info("Preparing OIDC login redirect."); + const { redirectTo } = request.query; + request.session.set("postLoginRedirectRoute", redirectTo); + const { clientId, redirectUri, scopes } = oidcConfig; const state = crypto.randomBytes(16).toString("hex"); @@ -143,6 +146,7 @@ async function authUtilsPlugin(fastify) { refreshToken: tokens.refresh_token, expiresAt: null, userInfo: extractUserInfoFromIdToken(request, tokens.id_token), + postLoginRedirectRoute: request.session.get("postLoginRedirectRoute") || "", }; if (tokens.expires_in && typeof tokens.expires_in === "number") { diff --git a/server/plugins/http-proxy.js b/server/plugins/http-proxy.js index 1049581e..9ab3bf58 100644 --- a/server/plugins/http-proxy.js +++ b/server/plugins/http-proxy.js @@ -12,15 +12,21 @@ function proxyPlugin(fastify) { preHandler: async (request, reply) => { request.log.info("Entering HTTP proxy preHandler."); + const useCrate = request.headers["x-use-crate"]; + + const keyAccessToken = useCrate ? "onboarding_accessToken" : "mcp_accessToken"; + const keyTokenExpiresAt = useCrate ? "onboarding_tokenExpiresAt" : "mcp_tokenExpiresAt"; + const keyRefreshToken = useCrate ? "onboarding_refreshToken" : "mcp_refreshToken"; + // Check if there is an access token - const accessToken = request.session.get("accessToken"); + const accessToken = request.session.get(keyAccessToken); if (!accessToken) { request.log.error("Missing access token."); return reply.unauthorized("Missing access token."); } // Check if the access token is expired or about to expire - const expiresAt = request.session.get("tokenExpiresAt"); + const expiresAt = request.session.get(keyTokenExpiresAt); const now = Date.now(); const REFRESH_BUFFER_SECONDS = 20; // to allow for network latency if (!expiresAt || now < expiresAt - REFRESH_BUFFER_SECONDS) { @@ -31,7 +37,7 @@ function proxyPlugin(fastify) { request.log.info({ expiresAt: new Date(expiresAt).toISOString() }, "Access token is expired or about to expire; attempting refresh."); // Check if there is a refresh token - const refreshToken = request.session.get("refreshToken"); + const refreshToken = request.session.get(keyRefreshToken); if (!refreshToken) { request.log.error("Missing refresh token; deleting session."); request.session.destroy(); @@ -54,17 +60,17 @@ function proxyPlugin(fastify) { request.log.info("Token refresh successful; updating the session."); - request.session.set("accessToken", refreshedTokenData.accessToken); + request.session.set(keyAccessToken, refreshedTokenData.accessToken); if (refreshedTokenData.refreshToken) { - request.session.set("refreshToken", refreshedTokenData.refreshToken); + request.session.set(keyRefreshToken, refreshedTokenData.refreshToken); } else { - request.session.delete("refreshToken"); + request.session.delete(keyRefreshToken); } if (refreshedTokenData.expiresIn) { const newExpiresAt = Date.now() + (refreshedTokenData.expiresIn * 1000); - request.session.set("tokenExpiresAt", newExpiresAt); + request.session.set(keyTokenExpiresAt, newExpiresAt); } else { - request.session.delete("tokenExpiresAt"); + request.session.delete(keyTokenExpiresAt); } request.log.info("Token refresh successful and session updated; continuing with the HTTP request."); @@ -78,10 +84,15 @@ function proxyPlugin(fastify) { } }, replyOptions: { - rewriteRequestHeaders: (req, headers) => ({ - ...headers, - authorization: req.session.get("accessToken") - }), + rewriteRequestHeaders: (req, headers) => { + const useCrate = req.headers["x-use-crate"]; + const accessToken = useCrate ? req.session.get("onboarding_accessToken") : `${req.session.get("onboarding_accessToken")},${req.session.get("mcp_accessToken")}`; + + return { + ...headers, + authorization: accessToken, + } + }, }, }); } diff --git a/server/routes/auth-mcp.js b/server/routes/auth-mcp.js new file mode 100644 index 00000000..65d245d7 --- /dev/null +++ b/server/routes/auth-mcp.js @@ -0,0 +1,58 @@ +import fp from "fastify-plugin"; +import { AuthenticationError } from "../plugins/auth-utils.js"; + + +async function authPlugin(fastify) { + const { OIDC_ISSUER, OIDC_CLIENT_ID_MCP, OIDC_REDIRECT_URI, OIDC_SCOPES, POST_LOGIN_REDIRECT } = fastify.config; + + // Make MCP issuer configuration globally available + // TODO: This is a temporary solution until we have a proper way to manage multiple issuers + const mcpIssuerConfiguration = await fastify.discoverIssuerConfiguration(OIDC_ISSUER); + fastify.decorate("mcpIssuerConfiguration", mcpIssuerConfiguration); + + fastify.get("/auth/mcp/login", async (req, reply) => { + const redirectUri = fastify.prepareOidcLoginRedirect(req, { + clientId: OIDC_CLIENT_ID_MCP, + redirectUri: OIDC_REDIRECT_URI, + scopes: OIDC_SCOPES, + }, mcpIssuerConfiguration.authorizationEndpoint); + + reply.redirect(redirectUri); + }); + + fastify.get("/auth/mcp/callback", async (req, reply) => { + try { + const callbackResult = await fastify.handleOidcCallback(req, { + clientId: OIDC_CLIENT_ID_MCP, + redirectUri: OIDC_REDIRECT_URI, + }, mcpIssuerConfiguration.tokenEndpoint); + + req.session.set("mcp_accessToken", callbackResult.accessToken); + req.session.set("mcp_refreshToken", callbackResult.refreshToken); + + if (callbackResult.expiresAt) { + req.session.set("mcp_tokenExpiresAt", callbackResult.expiresAt); + } else { + req.session.delete("mcp_tokenExpiresAt"); + } + + reply.redirect(POST_LOGIN_REDIRECT + callbackResult.postLoginRedirectRoute); + } catch (error) { + if (error instanceof AuthenticationError) { + req.log.error("AuthenticationError during OIDC callback: %s", error); + return reply.serviceUnavailable("Error during OIDC callback."); + } else { + throw error; + } + } + }); + + fastify.get("/auth/mcp/me", async (req, reply) => { + const accessToken = req.session.get("mcp_accessToken"); + + const isAuthenticated = Boolean(accessToken); + reply.send({ isAuthenticated }); + }); +} + +export default fp(authPlugin); diff --git a/server/routes/auth.js b/server/routes/auth-onboarding.js similarity index 67% rename from server/routes/auth.js rename to server/routes/auth-onboarding.js index 9ca1d75a..9dc668a8 100644 --- a/server/routes/auth.js +++ b/server/routes/auth-onboarding.js @@ -11,7 +11,7 @@ async function authPlugin(fastify) { fastify.decorate("issuerConfiguration", issuerConfiguration); - fastify.get("/auth/login", async (req, reply) => { + fastify.get("/auth/onboarding/login", async (req, reply) => { const redirectUri = fastify.prepareOidcLoginRedirect(req, { clientId: OIDC_CLIENT_ID, redirectUri: OIDC_REDIRECT_URI, @@ -22,22 +22,24 @@ async function authPlugin(fastify) { }); - fastify.get("/auth/callback", async (req, reply) => { + fastify.get("/auth/onboarding/callback", async (req, reply) => { try { const callbackResult = await fastify.handleOidcCallback(req, { clientId: OIDC_CLIENT_ID, redirectUri: OIDC_REDIRECT_URI, }, issuerConfiguration.tokenEndpoint); - req.session.set("accessToken", callbackResult.accessToken); - req.session.set("refreshToken", callbackResult.refreshToken); - req.session.set("userInfo", callbackResult.userInfo); + req.session.set("onboarding_accessToken", callbackResult.accessToken); + req.session.set("onboarding_refreshToken", callbackResult.refreshToken); + req.session.set("onboarding_userInfo", callbackResult.userInfo); if (callbackResult.expiresAt) { - req.session.set("tokenExpiresAt", callbackResult.expiresAt); + req.session.set("onboarding_tokenExpiresAt", callbackResult.expiresAt); } else { - req.session.delete("tokenExpiresAt"); + req.session.delete("onboarding_tokenExpiresAt"); } + + reply.redirect(POST_LOGIN_REDIRECT + callbackResult.postLoginRedirectRoute); } catch (error) { if (error instanceof AuthenticationError) { req.log.error("AuthenticationError during OIDC callback: %s", error); @@ -46,21 +48,18 @@ async function authPlugin(fastify) { throw error; } } - - reply.redirect(POST_LOGIN_REDIRECT); }); - fastify.get("/auth/me", async (req, reply) => { - const accessToken = req.session.get("accessToken"); - const userInfo = req.session.get("userInfo"); + fastify.get("/auth/onboarding/me", async (req, reply) => { + const accessToken = req.session.get("onboarding_accessToken"); + const userInfo = req.session.get("onboarding_userInfo"); const isAuthenticated = Boolean(accessToken); const user = isAuthenticated ? userInfo : null; reply.send({ isAuthenticated, user }); }); - fastify.post("/auth/logout", async (req, reply) => { // TODO: Idp sign out flow req.session.destroy(); diff --git a/src/App.tsx b/src/App.tsx index 06e8f725..8bdcca23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,13 @@ import AppRouter from './AppRouter'; -import { useAuth } from './spaces/onboarding/auth/AuthContext.tsx'; +import { useAuthOnboarding } from './spaces/onboarding/auth/AuthContextOnboarding.tsx'; import '@ui5/webcomponents-icons/dist/AllIcons.d.ts'; import { useEffect } from 'react'; import { useFrontendConfig } from './context/FrontendConfigContext.tsx'; -import { useTranslation } from 'react-i18next'; import LoginView from './views/Login.tsx'; +import { BusyIndicator } from '@ui5/webcomponents-react'; function App() { - const auth = useAuth(); - const { t } = useTranslation(); + const auth = useAuthOnboarding(); const frontendConfig = useFrontendConfig(); useEffect(() => { @@ -18,7 +17,7 @@ function App() { }, []); if (auth.isLoading) { - return
{t('App.loading')}
; + return ; } if (!auth.isAuthenticated) { diff --git a/src/common/auth/AuthCallbackHandler.tsx b/src/common/auth/AuthCallbackHandler.tsx new file mode 100644 index 00000000..9ca9fb2c --- /dev/null +++ b/src/common/auth/AuthCallbackHandler.tsx @@ -0,0 +1,72 @@ +import { ReactNode, useEffect } from 'react'; +import { BusyIndicator } from '@ui5/webcomponents-react'; + +const REDIRECT_TARGETS = { + onboarding: '/api/auth/onboarding/callback', + mcp: '/api/auth/mcp/callback', +} as const; + +type AuthFlow = keyof typeof REDIRECT_TARGETS; + +function isAuthFlow(value: unknown): value is AuthFlow { + if (typeof value !== 'string') { + return false; + } + return Object.keys(REDIRECT_TARGETS).includes(value); +} + +export const AUTH_FLOW_SESSION_KEY = 'auth:post-callback-flow'; + +function useAuthCallback() { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + const iss = params.get('iss'); + + const potentialAuthFlow = sessionStorage.getItem(AUTH_FLOW_SESSION_KEY); + + const isCallbackInProgress = !!(code && state && potentialAuthFlow); + + useEffect(() => { + if (!isCallbackInProgress) { + return; + } + + if (!isAuthFlow(potentialAuthFlow)) { + throw new Error( + `Unknown authFlow '${potentialAuthFlow}'. Should be typeof '${Object.keys(REDIRECT_TARGETS).join(' | ')}'`, + ); + } + const redirectTarget = REDIRECT_TARGETS[potentialAuthFlow]; + + const forwardUrl = new URL(redirectTarget, window.location.origin); + forwardUrl.searchParams.append('code', code); + forwardUrl.searchParams.append('state', state); + if (iss) { + forwardUrl.searchParams.append('iss', iss); + } + + sessionStorage.removeItem(AUTH_FLOW_SESSION_KEY); + window.location.href = forwardUrl.toString(); + }, [isCallbackInProgress, potentialAuthFlow, code, state, iss]); + + return { + isLoading: isCallbackInProgress, + }; +} + +export interface AuthCallbackHandlerProps { + children?: ReactNode; +} + +/** + * This component centrally handles client-side redirects from external identity providers after user authentication. + * It forwards temporary credentials (e.g.,`code`,`state`) to the backend API endpoint, + * whose URL was previously and temporarily stored in `sessionStorage`. + */ +export function AuthCallbackHandler({ children }: AuthCallbackHandlerProps) { + const { isLoading } = useAuthCallback(); + + // The component remains clean and focused on rendering. + return <>{isLoading ? : children}; +} diff --git a/src/components/Core/ShellBar.tsx b/src/components/Core/ShellBar.tsx index df6cc3bb..6cfcd53f 100644 --- a/src/components/Core/ShellBar.tsx +++ b/src/components/Core/ShellBar.tsx @@ -11,7 +11,7 @@ import { ShellBarDomRef, Ui5CustomEvent, } from '@ui5/webcomponents-react'; -import { useAuth } from '../../spaces/onboarding/auth/AuthContext.tsx'; +import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { RefObject, useEffect, useRef, useState } from 'react'; import { ShellBarProfileClickEventDetail } from '@ui5/webcomponents-fiori/dist/ShellBar.js'; import PopoverPlacement from '@ui5/webcomponents/dist/types/PopoverPlacement.js'; @@ -21,7 +21,7 @@ import styles from './ShellBar.module.css'; import { ThemingParameters } from '@ui5/webcomponents-react-base'; export function ShellBarComponent() { - const auth = useAuth(); + const auth = useAuthOnboarding(); const profilePopoverRef = useRef(null); const betaPopoverRef = useRef(null); const [profilePopoverOpen, setProfilePopoverOpen] = useState(false); @@ -107,7 +107,7 @@ const ProfilePopover = ({ setOpen: (arg0: boolean) => void; popoverRef: RefObject; }) => { - const auth = useAuth(); + const auth = useAuthOnboarding(); const { t } = useTranslation(); return ( diff --git a/src/components/Dialogs/CreateProjectDialogContainer.tsx b/src/components/Dialogs/CreateProjectDialogContainer.tsx index e13d3bef..4dae1bd1 100644 --- a/src/components/Dialogs/CreateProjectDialogContainer.tsx +++ b/src/components/Dialogs/CreateProjectDialogContainer.tsx @@ -8,7 +8,7 @@ import { } from './CreateProjectWorkspaceDialog.tsx'; import { useToast } from '../../context/ToastContext.tsx'; -import { useAuth } from '../../spaces/onboarding/auth/AuthContext.tsx'; +import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { MemberRoles } from '../../lib/api/types/shared/members.ts'; import { useTranslation } from 'react-i18next'; @@ -46,7 +46,7 @@ export function CreateProjectDialogContainer({ }, }); const { t } = useTranslation(); - const { user } = useAuth(); + const { user } = useAuthOnboarding(); const username = user?.email; diff --git a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx index 267ead65..4849fea5 100644 --- a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx +++ b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx @@ -17,7 +17,7 @@ import { import { projectnameToNamespace } from '../../utils'; import { ListWorkspaces } from '../../lib/api/types/crate/listWorkspaces'; import { useToast } from '../../context/ToastContext.tsx'; -import { useAuth } from '../../spaces/onboarding/auth/AuthContext.tsx'; +import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { Member, MemberRoles } from '../../lib/api/types/shared/members.ts'; import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -57,7 +57,7 @@ export function CreateWorkspaceDialogContainer({ }, }); const { t } = useTranslation(); - const { user } = useAuth(); + const { user } = useAuthOnboarding(); const username = user?.email; diff --git a/src/lib/api/fetch.ts b/src/lib/api/fetch.ts index 43faca74..957bd9ec 100644 --- a/src/lib/api/fetch.ts +++ b/src/lib/api/fetch.ts @@ -49,7 +49,7 @@ export const fetchApiServer = async ( if (!res.ok) { if (res.status === 401) { // Unauthorized, redirect to the login page - window.location.href = `/api/auth/login`; + window.location.href = `/api/auth/onboarding/login`; } const error = new APIError( 'An error occurred while fetching the data.', diff --git a/src/lib/shared/McpContext.tsx b/src/lib/shared/McpContext.tsx index e30bb808..e60a7e96 100644 --- a/src/lib/shared/McpContext.tsx +++ b/src/lib/shared/McpContext.tsx @@ -3,6 +3,8 @@ import { ControlPlane as ManagedControlPlaneResource } from '../api/types/crate/ import { ApiConfigProvider } from '../../components/Shared/k8s'; import useResource from '../api/useApiResource.ts'; import { GetKubeconfig } from '../api/types/crate/getKubeconfig.ts'; +import { useAuthMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; +import { BusyIndicator } from '@ui5/webcomponents-react'; interface McpContext { project: string; @@ -80,6 +82,17 @@ export function WithinManagedControlPlane({ }: { children?: ReactNode; }) { + const auth = useAuthMcp(); + + if (auth.isLoading) { + return ; + } + + if (!auth.isAuthenticated) { + auth.login(); + return null; + } + return ( <> {children} diff --git a/src/main.tsx b/src/main.tsx index 77092524..378b9cab 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,7 +15,8 @@ import { ApolloClientProvider } from './spaces/onboarding/services/ApolloClientP import { IllustratedBanner } from './components/Ui/IllustratedBanner/IllustratedBanner.tsx'; import { useTranslation } from 'react-i18next'; import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; -import { AuthProvider } from './spaces/onboarding/auth/AuthContext.tsx'; +import { AuthProviderOnboarding } from './spaces/onboarding/auth/AuthContextOnboarding.tsx'; +import { AuthCallbackHandler } from './common/auth/AuthCallbackHandler.tsx'; const ErrorFallback = ({ error }: FallbackProps) => { const { t } = useTranslation(); @@ -35,24 +36,26 @@ export function createApp() { {}}> }> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/spaces/mcp/auth/AuthContextMcp.tsx b/src/spaces/mcp/auth/AuthContextMcp.tsx new file mode 100644 index 00000000..6488d9d3 --- /dev/null +++ b/src/spaces/mcp/auth/AuthContextMcp.tsx @@ -0,0 +1,80 @@ +import { createContext, useState, useEffect, ReactNode, use } from 'react'; +import { MeResponseSchema } from './auth.schemas'; +import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx'; + +interface AuthContextMcpType { + isLoading: boolean; + isAuthenticated: boolean; + error: Error | null; + login: () => void; +} + +const AuthContextMcp = createContext(null); + +export function AuthProviderMcp({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Check the authentication status when the component mounts + useEffect(() => { + void refreshAuthStatus(); + }, []); + + async function refreshAuthStatus() { + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/auth/mcp/me'); + if (!response.ok) { + let errorBody; + try { + errorBody = await response.json(); + } catch (_) { + /* safe to ignore */ + } + throw new Error( + errorBody?.message || + `Authentication check failed with status: ${response.status}`, + ); + } + + const body = await response.json(); + const validationResult = MeResponseSchema.safeParse(body); + if (!validationResult.success) { + throw new Error( + `Auth API response validation failed: ${validationResult.error.flatten()}`, + ); + } + + const { isAuthenticated: apiIsAuthenticated } = validationResult.data; + setIsAuthenticated(apiIsAuthenticated); + } catch (err) { + setError(err instanceof Error ? err : new Error('Authentication error.')); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + } + + const login = () => { + sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'mcp'); + + window.location.href = `/api/auth/mcp/login?redirectTo=${encodeURIComponent(window.location.hash)}`; + }; + + return ( + + {children} + + ); +} + +export const useAuthMcp = () => { + const context = use(AuthContextMcp); + if (!context) { + throw new Error('useAuthMcp must be used within an AuthProviderMcp.'); + } + return context; +}; diff --git a/src/spaces/mcp/auth/auth.schemas.ts b/src/spaces/mcp/auth/auth.schemas.ts new file mode 100644 index 00000000..683afa60 --- /dev/null +++ b/src/spaces/mcp/auth/auth.schemas.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const MeResponseSchema = z.object({ + isAuthenticated: z.boolean(), +}); diff --git a/src/spaces/onboarding/auth/AuthContext.tsx b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx similarity index 65% rename from src/spaces/onboarding/auth/AuthContext.tsx rename to src/spaces/onboarding/auth/AuthContextOnboarding.tsx index bebe71ed..f68c2f61 100644 --- a/src/spaces/onboarding/auth/AuthContext.tsx +++ b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx @@ -1,7 +1,8 @@ import { createContext, useState, useEffect, ReactNode, use } from 'react'; import { MeResponseSchema, User } from './auth.schemas'; +import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx'; -interface AuthContextType { +interface AuthContextOnboardingType { isLoading: boolean; isAuthenticated: boolean; user: User | null; @@ -10,40 +11,19 @@ interface AuthContextType { logout: () => Promise; } -const AuthContext = createContext(null); +const AuthContextOnboarding = createContext( + null, +); -export function AuthProvider({ children }: { children: ReactNode }) { +export function AuthProviderOnboarding({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Handle the redirect from the IdP - useEffect(() => { - const params = new URLSearchParams(location.search); - const code = params.get('code'); - const state = params.get('state'); - const iss = params.get('iss'); - - if (code && state) { - // Construct the URL to forward to the BFF - const forwardUrl = new URL('/api/auth/callback', window.location.origin); - forwardUrl.searchParams.append('code', code); - forwardUrl.searchParams.append('state', state); - if (iss) { - forwardUrl.searchParams.append('iss', iss); - } - window.location.href = forwardUrl.toString(); - } - }, []); - // Check the authentication status when the component mounts useEffect(() => { - // Only run checkAuthStatus if not currently handling a redirect - const params = new URLSearchParams(window.location.search); - if (!params.has('code') && !params.has('error')) { - void refreshAuthStatus(); - } + void refreshAuthStatus(); }, []); async function refreshAuthStatus() { @@ -51,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setError(null); try { - const response = await fetch('/api/auth/me'); + const response = await fetch('/api/auth/onboarding/me'); if (!response.ok) { let errorBody; try { @@ -87,7 +67,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const login = () => { - window.location.href = `/api/auth/login`; + sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding'); + + window.location.href = `/api/auth/onboarding/login?redirectTo=${encodeURIComponent(window.location.hash)}`; }; const logout = async () => { @@ -115,18 +97,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; return ( - {children} - + ); } -export const useAuth = () => { - const context = use(AuthContext); +export const useAuthOnboarding = () => { + const context = use(AuthContextOnboarding); if (!context) { - throw new Error('useAuth must be used within an AuthProvider.'); + throw new Error( + 'useAuthOnboarding must be used within an AuthProviderOnboarding.', + ); } return context; }; diff --git a/src/views/ControlPlanes/ControlPlaneView.tsx b/src/views/ControlPlanes/ControlPlaneView.tsx index 2f0a3209..8fd42679 100644 --- a/src/views/ControlPlanes/ControlPlaneView.tsx +++ b/src/views/ControlPlanes/ControlPlaneView.tsx @@ -30,6 +30,7 @@ import useResource from '../../lib/api/useApiResource'; import { YamlViewButtonWithLoader } from '../../components/Yaml/YamlViewButtonWithLoader.tsx'; import { Landscapers } from '../../components/ControlPlane/Landscapers.tsx'; +import { AuthProviderMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; export default function ControlPlaneView() { const { projectName, workspaceName, controlPlaneName, contextName } = @@ -68,128 +69,130 @@ export default function ControlPlaneView() { context: contextName!, }} > - - } - //TODO: actionBar should use Toolbar and ToolbarButton for consistent design - actionsBar={ -
- - - -
- } - /> - } - > - + + } + //TODO: actionBar should use Toolbar and ToolbarButton for consistent design + actionsBar={ +
+ + + +
+ } + /> + } > - - {t('ControlPlaneView.componentsTitle')} - - } - noAnimation + - - -
- - - {t('ControlPlaneView.crossplaneTitle')} - - } - noAnimation + + {t('ControlPlaneView.componentsTitle')} + + } + noAnimation + > + + + + -
- -
-
- -
-
- -
- -
- - - {t('ControlPlaneView.landscapersTitle')} - - } - noAnimation + + {t('ControlPlaneView.crossplaneTitle')} + + } + noAnimation + > +
+ +
+
+ +
+
+ +
+
+
+ - - - - - {t('ControlPlaneView.gitOpsTitle')} - } - noAnimation + + {t('ControlPlaneView.landscapersTitle')} + + } + noAnimation + > + + + + - - - -
-
+ {t('ControlPlaneView.gitOpsTitle')} + } + noAnimation + > + + + + + + ); } diff --git a/src/views/Login.tsx b/src/views/Login.tsx index 45c603e1..9e825d46 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -1,4 +1,4 @@ -import { useAuth } from '../spaces/onboarding/auth/AuthContext.tsx'; +import { useAuthOnboarding } from '../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { Button, Card, FlexBox, Text } from '@ui5/webcomponents-react'; import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; import './login.css'; @@ -7,7 +7,7 @@ import { useLink } from '../lib/shared/useLink.ts'; import { useTranslation } from 'react-i18next'; export default function LoginView() { - const auth = useAuth(); + const auth = useAuthOnboarding(); const { documentationHomepage } = useLink(); const { t } = useTranslation(); From 0335f98e27a5cf3d95cadd22e8073abb4a89855f Mon Sep 17 00:00:00 2001 From: Enrico Kaack Date: Thu, 26 Jun 2025 14:28:24 +0200 Subject: [PATCH 03/10] Add encryptedSession for separate auth tokens for Onboarding API and MCPs (#156) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .env.template | 1 + package-lock.json | 115 +++++++++++++++ package.json | 3 +- server/app.js | 10 +- server/config/env.js | 2 + server/encrypted-session.js | 232 +++++++++++++++++++++++++++++++ server/plugins/auth-utils.js | 14 +- server/plugins/http-proxy.js | 24 ++-- server/plugins/session.js | 23 --- server/routes/auth-mcp.js | 10 +- server/routes/auth-onboarding.js | 16 +-- 11 files changed, 392 insertions(+), 58 deletions(-) create mode 100644 server/encrypted-session.js delete mode 100644 server/plugins/session.js diff --git a/.env.template b/.env.template index 219faa18..56d79ede 100644 --- a/.env.template +++ b/.env.template @@ -16,3 +16,4 @@ API_BACKEND_URL= # Replace this value with a strong, randomly generated string (at least 32 characters). # Example for generation in Node.js: require('crypto').randomBytes(32).toString('hex') COOKIE_SECRET= +SESSION_SECRET= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c0b8f4da..45eb42d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/env": "^5.0.2", "@fastify/http-proxy": "^11.1.2", + "@fastify/secure-session": "^8.2.0", "@fastify/sensible": "^6.0.3", "@fastify/session": "^11.1.0", "@fastify/static": "^8.1.1", @@ -1419,6 +1420,30 @@ "undici": "^7.0.0" } }, + "node_modules/@fastify/secure-session": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@fastify/secure-session/-/secure-session-8.2.0.tgz", + "integrity": "sha512-E1linEHVV86c0Gt+ohujcuRsCeedhD2M3X5+a2aU9Ln0YDC0bbuA7bE6QQzf/HAacOpt9+CJqV5NqdlQr9ui0A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/cookie": "^11.0.1", + "fastify-plugin": "^5.0.0", + "sodium-native": "^4.0.10" + }, + "bin": { + "secure-session": "genkey.js" + } + }, "node_modules/@fastify/send": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.0.0.tgz", @@ -5393,6 +5418,74 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-addon-resolve": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.9.4.tgz", + "integrity": "sha512-unn6Vy/Yke6F99vg/7tcrvM2KUvIhTNniaSqDbam4AWkd4NhvDVSrQiRYVlNzUV2P7SPobkCK7JFVxrJk9btCg==", + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-module-resolve": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.11.1.tgz", + "integrity": "sha512-DCxeT9i8sTs3vUMA3w321OX/oXtNEu5EjObQOnTmCdNp5RXHBAvAaBDHvAi9ta0q/948QPz+co6SsGi6aQMYRg==", + "license": "Apache-2.0", + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-semver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.1.tgz", + "integrity": "sha512-UtggzHLiTrmFOC/ogQ+Hy7VfoKoIwrP1UFcYtTxoCUdLtsIErT8+SWtOC2DH/snT9h+xDrcBEPcwKei1mzemgg==", + "license": "Apache-2.0" + }, + "node_modules/bare-url": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.1.6.tgz", + "integrity": "sha512-FgjDeR+/yDH34By4I0qB5NxAoWv7dOTYcOXwn73kr+c93HyC2lU6tnjifqUe33LKMJcDyCYPQjEAqgOQiXkE2Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -12359,6 +12452,19 @@ "throttleit": "^1.0.0" } }, + "node_modules/require-addon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.1.0.tgz", + "integrity": "sha512-KbXAD5q2+v1GJnkzd8zzbOxchTkStSyJZ9QwoCq3QwEXAaIlG3wDYRZGzVD357jmwaGY7hr5VaoEAL0BkF0Kvg==", + "license": "Apache-2.0", + "dependencies": { + "bare-addon-resolve": "^1.3.0", + "bare-url": "^2.1.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13439,6 +13545,15 @@ "tslib": "^2.0.3" } }, + "node_modules/sodium-native": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", + "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", + "license": "MIT", + "dependencies": { + "require-addon": "^1.1.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", diff --git a/package.json b/package.json index df524fd7..7d63cb5e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@fastify/env": "^5.0.2", "@fastify/http-proxy": "^11.1.2", "@fastify/sensible": "^6.0.3", + "@fastify/secure-session": "^8.2.0", "@fastify/session": "^11.1.0", "@fastify/static": "^8.1.1", "@fastify/vite": "^8.1.3", @@ -83,4 +84,4 @@ "vite": "^6.3.4", "vitest": "^3.1.4" } -} +} \ No newline at end of file diff --git a/server/app.js b/server/app.js index ba1284a0..dcae9811 100644 --- a/server/app.js +++ b/server/app.js @@ -2,14 +2,18 @@ import path, { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import AutoLoad from "@fastify/autoload"; import envPlugin from "./config/env.js"; +import encryptedSession from "./encrypted-session.js"; export const options = {}; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -export default async function(fastify, opts) { +export default async function (fastify, opts) { await fastify.register(envPlugin); + fastify.register(encryptedSession, { + ...opts, + }); await fastify.register(AutoLoad, { dir: join(__dirname, "plugins"), @@ -20,4 +24,6 @@ export default async function(fastify, opts) { dir: join(__dirname, "routes"), options: { ...opts }, }); -} + + +} \ No newline at end of file diff --git a/server/config/env.js b/server/config/env.js index 42c7cb0f..7b023b21 100644 --- a/server/config/env.js +++ b/server/config/env.js @@ -11,6 +11,7 @@ const schema = { 'OIDC_SCOPES', 'POST_LOGIN_REDIRECT', 'COOKIE_SECRET', + 'SESSION_SECRET', 'API_BACKEND_URL', ], properties: { @@ -22,6 +23,7 @@ const schema = { OIDC_SCOPES: { type: 'string' }, POST_LOGIN_REDIRECT: { type: 'string' }, COOKIE_SECRET: { type: 'string' }, + SESSION_SECRET: { type: 'string' }, API_BACKEND_URL: { type: 'string' }, // System variables diff --git a/server/encrypted-session.js b/server/encrypted-session.js new file mode 100644 index 00000000..0a9b8034 --- /dev/null +++ b/server/encrypted-session.js @@ -0,0 +1,232 @@ +import secureSession from "@fastify/secure-session"; +import fp from "fastify-plugin"; +import fastifyCookie from "@fastify/cookie"; +import fastifySession from '@fastify/session'; +import crypto from "node:crypto" + + + +export const COOKIE_NAME_ENCRYPTION_KEY = "session_encryption_key"; +export const COOKIE_NAME_SESSION = "session-cookie"; + +export const SECURE_SESSION_NAME = "encryptedSessionInternal"; +export const UNDERLYING_SESSION_NAME = "underlyingSessionNotPerUserEncrypted"; + +// This is the key used to store the encryption key in the secure session cookie +export const SECURE_COOKIE_KEY_ENCRYPTION_KEY = "encryptionKey"; + +export const REQUEST_DECORATOR = "encryptedSession"; + +async function encryptedSession(fastify) { + const { COOKIE_SECRET, SESSION_SECRET, NODE_ENV } = fastify.config; + + await fastify.register(fastifyCookie); + + fastify.register(secureSession, { + secret: Buffer.from(COOKIE_SECRET, "hex"), + cookieName: COOKIE_NAME_ENCRYPTION_KEY, + sessionName: SECURE_SESSION_NAME, + cookie: { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 7, // 7 days + }, + }); + + + fastify.register(fastifySession, { + secret: SESSION_SECRET, + cookieName: COOKIE_NAME_SESSION, + // sessionName: UNDERLYING_SESSION_NAME, //NOT POSSIBLE to change the name it is decorated on the request object + cookie: { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 7, // 7 days + }, + }); + + fastify.addHook('onRequest', (request, _reply, next) => { + //we use secure-session cookie to get the encryption key and decrypt the store + if (!request[SECURE_SESSION_NAME].get(SECURE_COOKIE_KEY_ENCRYPTION_KEY)) { + request.log.info({ "plugin": "encrypted-session" }, "user-side encryption key not found, creating new one"); + + let newEncryptionKey = generateSecureEncryptionKey(); + request[SECURE_SESSION_NAME].set(SECURE_COOKIE_KEY_ENCRYPTION_KEY, newEncryptionKey.toString('base64')); + request[REQUEST_DECORATOR] = createStore() + newEncryptionKey = undefined + } else { + request.log.info({ "plugin": "encrypted-session" }, "user-side encryption key found, using existing one"); + + const loadedEncryptionKey = Buffer.from(request[SECURE_SESSION_NAME].get(SECURE_COOKIE_KEY_ENCRYPTION_KEY), "base64"); + + const encryptedStore = request.session.get("encryptedStore"); + if (encryptedStore) { + try { + const { cipherText, iv, tag } = encryptedStore; + + const decryptedCypherText = decryptSymetric(cipherText, iv, tag, loadedEncryptionKey); + const decryptedStore = JSON.parse(decryptedCypherText); + request[REQUEST_DECORATOR] = createStore(decryptedStore); + } catch (error) { + request.log.error({ "plugin": "encrypted-session" }, "Failed to parse encrypted session store", error); + request[REQUEST_DECORATOR] = createStore(); + } + } else { + // we could not parse the encrypted store, so we create a new one and it would overwrite the previously stored store. + request.log.info({ "plugin": "encrypted-session" }, "No encrypted store found, creating new empty store"); + request[REQUEST_DECORATOR] = createStore(); + } + } + + next() + }) + + //TODO maybe move to onResponse after res is send. Lifecycle Doc https://fastify.dev/docs/latest/Reference/Lifecycle/ + // onSend is called before the response is send. Here we take encrypt the Session object and store it in the fastify-session. + // Then we also want to make sure the unencrypted object is removed from memory + fastify.addHook('onSend', async (request, reply, _payload) => { + const encryptionKey = Buffer.from(request[SECURE_SESSION_NAME].get(SECURE_COOKIE_KEY_ENCRYPTION_KEY), "base64"); + if (!encryptionKey) { + // if no encryption key is found in the secure session, we cannot encrypt the store. This should not happen since an encrption key is generated when the request arrived + request.log.error({ "plugin": "encrypted-session" }, "No encryption key found in secure session, cannot encrypt store"); + throw new Error("No encryption key found in secure session, cannot encrypt store"); + } + + //we store everything in one value in the session, that might be problematic for future redis with expiration times per key. we might want to split this + const stringifiedData = request[REQUEST_DECORATOR].stringify(); + const { cipherText, iv, tag } = encryptSymetric(stringifiedData, encryptionKey); + + //remove unencrypted data from memory + delete request[REQUEST_DECORATOR]; + request[REQUEST_DECORATOR] = null; + + request.session.set("encryptedStore", { + cipherText, + iv, + tag, + }); + await request.session.save() + request.log.info("store encrypted and set into request.session.encryptedStore"); + }) +} + +export default fp(encryptedSession); + +// use a closure to encapsulate the session data so noone can reference it and we are the only ones keeping a reference +function createStore(previousValue) { + let unencryptedStore = {}; // Private variable + if (previousValue) { + unencryptedStore = previousValue; + } + return { + set(key, value) { + unencryptedStore[key] = value; + }, + get(key) { + return unencryptedStore[key]; + }, + delete(key) { + delete unencryptedStore[key]; + }, + stringify() { + return JSON.stringify(unencryptedStore); + }, + clear() { + unencryptedStore = {}; // Clear all data + }, + }; +} + +// generates a secure encryption key for aes-256-gcm. +// Returns a buffer of 32 bytes (256 bits). +function generateSecureEncryptionKey() { + // Generates a secure random encryption key of 32 bytes (256 bits) + return crypto.randomBytes(32); +} + +// uses authenticated symetric encryption (aes-256-gcm) to encrypt the plaintext with the key. +// If no adequate key is given, it throws an error +// The key needs to be 32bytes (256bits) as type buffer. Needs to be cryptographically secure random generated e.g. with `crypto.randomBytes(32)` +// it outputs cipherText (bas64 encoded string), the initialisation vector (iv) (hex string) and the authentication tag (hex string). +function encryptSymetric(plaintext, key) { + if (key == undefined) { + throw new Error("Key must be provided"); + } + if (key.length < 32) { + throw new Error("Key must be at least 32 byte = 256 bits long"); + } + + if (!(key instanceof Buffer)) { + throw new Error("Key must be a Buffer"); + } + + if (plaintext == undefined) { + throw new Error("Plaintext must be provided"); + } + + if (typeof plaintext !== "string") { + throw new Error("Plaintext must be a string utf8 encoded"); + } + + if (!crypto.getCiphers().includes("aes-256-gcm")) { + throw new Error("Cipher suite aes-256-gcm is not available"); + } + + // initialisation vector. Needs to be stored along the cipherText. + // MUST NOT be reused and MUST be randomly generated for EVERY encryption operation. Otherwise using the same key would be insecure. + const iv = crypto.randomBytes(12); + + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + let cipherText = cipher.update(plaintext, 'utf8', 'base64'); + cipherText += cipher.final('base64'); + + // the authentication tag is used to verify the integrity of the ciphertext (that it has not been tampered with). + // stored alongside the ciphertext and iv as it can only be changed with the secret key + const tag = cipher.getAuthTag(); + + return { + cipherText, + iv: iv.toString('base64'), + tag: tag.toString('base64'), + } +} + +// uses authenticated symetric encryption (aes-256-gcm) to decrypt the ciphertext with the key. +// requires the ciphertext, the initialisation vector (iv)(hex string), the authentication tag (tag) (hex string) and the key (buffer) to be provided. +//it thows an error if the decryption or tag verification fails +function decryptSymetric(cipherText, iv, tag, key) { + if (key == undefined) { + throw new Error("Key must be provided"); + } + if (key.length < 32) { + throw new Error("Key must be at least 32bye = 256 bits long"); + } + + if (!(key instanceof Buffer)) { + throw new Error("Key must be a Buffer"); + } + + if (cipherText == undefined) { + throw new Error("Ciphertext must be provided"); + } + + if (typeof cipherText !== "string") { + throw new Error("Ciphertext must be a string utf8 encoded"); + } + + if (!crypto.getCiphers().includes("aes-256-gcm")) { + throw new Error("Cipher suite aes-256-gcm is not available"); + } + + const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + + let decrypted = decipher.update(cipherText, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} \ No newline at end of file diff --git a/server/plugins/auth-utils.js b/server/plugins/auth-utils.js index f5fb8649..e8412645 100644 --- a/server/plugins/auth-utils.js +++ b/server/plugins/auth-utils.js @@ -77,7 +77,7 @@ async function authUtilsPlugin(fastify) { request.log.info("Preparing OIDC login redirect."); const { redirectTo } = request.query; - request.session.set("postLoginRedirectRoute", redirectTo); + request.encryptedSession.set("postLoginRedirectRoute", redirectTo); const { clientId, redirectUri, scopes } = oidcConfig; @@ -85,12 +85,12 @@ async function authUtilsPlugin(fastify) { const codeVerifier = crypto.randomBytes(32).toString("base64url"); const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url"); - request.session.set("oauthState", state); - request.session.set("codeVerifier", codeVerifier); + request.encryptedSession.set("oauthState", state); + request.encryptedSession.set("codeVerifier", codeVerifier); request.log.info({ stateSet: Boolean(state), verifierSet: Boolean(codeVerifier), - }, "OAuth state and code verifier set in session."); + }, "OAuth state and code verifier set in encryptedSession."); const url = new URL(authorizationEndpoint); url.searchParams.set("response_type", "code"); @@ -116,7 +116,7 @@ async function authUtilsPlugin(fastify) { request.log.error("Missing authorization code in callback."); throw new AuthenticationError("Missing code in callback."); } - if (state !== request.session.get("oauthState")) { + if (state !== request.encryptedSession.get("oauthState")) { request.log.error("Invalid state in callback."); throw new AuthenticationError("Invalid state in callback."); } @@ -126,7 +126,7 @@ async function authUtilsPlugin(fastify) { code, redirect_uri: redirectUri, client_id: clientId, - code_verifier: request.session.get("codeVerifier"), + code_verifier: request.encryptedSession.get("codeVerifier"), }); const response = await fetch(tokenEndpoint, { @@ -146,7 +146,7 @@ async function authUtilsPlugin(fastify) { refreshToken: tokens.refresh_token, expiresAt: null, userInfo: extractUserInfoFromIdToken(request, tokens.id_token), - postLoginRedirectRoute: request.session.get("postLoginRedirectRoute") || "", + postLoginRedirectRoute: request.encryptedSession.get("postLoginRedirectRoute") || "", }; if (tokens.expires_in && typeof tokens.expires_in === "number") { diff --git a/server/plugins/http-proxy.js b/server/plugins/http-proxy.js index 9ab3bf58..21607b87 100644 --- a/server/plugins/http-proxy.js +++ b/server/plugins/http-proxy.js @@ -19,14 +19,14 @@ function proxyPlugin(fastify) { const keyRefreshToken = useCrate ? "onboarding_refreshToken" : "mcp_refreshToken"; // Check if there is an access token - const accessToken = request.session.get(keyAccessToken); + const accessToken = request.encryptedSession.get(keyAccessToken); if (!accessToken) { request.log.error("Missing access token."); return reply.unauthorized("Missing access token."); } // Check if the access token is expired or about to expire - const expiresAt = request.session.get(keyTokenExpiresAt); + const expiresAt = request.encryptedSession.get(keyTokenExpiresAt); const now = Date.now(); const REFRESH_BUFFER_SECONDS = 20; // to allow for network latency if (!expiresAt || now < expiresAt - REFRESH_BUFFER_SECONDS) { @@ -37,10 +37,10 @@ function proxyPlugin(fastify) { request.log.info({ expiresAt: new Date(expiresAt).toISOString() }, "Access token is expired or about to expire; attempting refresh."); // Check if there is a refresh token - const refreshToken = request.session.get(keyRefreshToken); + const refreshToken = request.encryptedSession.get(keyRefreshToken); if (!refreshToken) { - request.log.error("Missing refresh token; deleting session."); - request.session.destroy(); + request.log.error("Missing refresh token; deleting encryptedSession."); + request.encryptedSession.clear();//TODO: also clear user encrpytion key? return reply.unauthorized("Session expired without token refresh capability."); } @@ -54,23 +54,23 @@ function proxyPlugin(fastify) { }, issuerConfiguration.tokenEndpoint); if (!refreshedTokenData || !refreshedTokenData.accessToken) { request.log.error("Token refresh failed (no access token); deleting session."); - request.session.destroy(); + request.encryptedSession.clear();//TODO: also clear user encrpytion key? return reply.unauthorized("Session expired and token refresh failed."); } request.log.info("Token refresh successful; updating the session."); - request.session.set(keyAccessToken, refreshedTokenData.accessToken); + request.encryptedSession.set(keyAccessToken, refreshedTokenData.accessToken); if (refreshedTokenData.refreshToken) { - request.session.set(keyRefreshToken, refreshedTokenData.refreshToken); + request.encryptedSession.set(keyRefreshToken, refreshedTokenData.refreshToken); } else { - request.session.delete(keyRefreshToken); + request.encryptedSession.delete(keyRefreshToken); } if (refreshedTokenData.expiresIn) { const newExpiresAt = Date.now() + (refreshedTokenData.expiresIn * 1000); - request.session.set(keyTokenExpiresAt, newExpiresAt); + request.encryptedSession.set(keyTokenExpiresAt, newExpiresAt); } else { - request.session.delete(keyTokenExpiresAt); + request.encryptedSession.delete(keyTokenExpiresAt); } request.log.info("Token refresh successful and session updated; continuing with the HTTP request."); @@ -86,7 +86,7 @@ function proxyPlugin(fastify) { replyOptions: { rewriteRequestHeaders: (req, headers) => { const useCrate = req.headers["x-use-crate"]; - const accessToken = useCrate ? req.session.get("onboarding_accessToken") : `${req.session.get("onboarding_accessToken")},${req.session.get("mcp_accessToken")}`; + const accessToken = useCrate ? req.encryptedSession.get("onboarding_accessToken") : `${req.encryptedSession.get("onboarding_accessToken")},${req.encryptedSession.get("mcp_accessToken")}`; return { ...headers, diff --git a/server/plugins/session.js b/server/plugins/session.js deleted file mode 100644 index 213e9a17..00000000 --- a/server/plugins/session.js +++ /dev/null @@ -1,23 +0,0 @@ -import fastifySession from "@fastify/session"; -import fp from "fastify-plugin"; -import fastifyCookie from "@fastify/cookie"; - - -async function secureSessionPlugin(fastify) { - const { COOKIE_SECRET, NODE_ENV } = fastify.config; - - await fastify.register(fastifyCookie); - - fastify.register(fastifySession, { - secret: COOKIE_SECRET, - cookie: { - path: "/", - httpOnly: true, - sameSite: "lax", - secure: NODE_ENV === "production", - maxAge: 60 * 60 * 24 * 7, // 7 days - }, - }); -} - -export default fp(secureSessionPlugin); diff --git a/server/routes/auth-mcp.js b/server/routes/auth-mcp.js index 65d245d7..67809cc3 100644 --- a/server/routes/auth-mcp.js +++ b/server/routes/auth-mcp.js @@ -27,13 +27,13 @@ async function authPlugin(fastify) { redirectUri: OIDC_REDIRECT_URI, }, mcpIssuerConfiguration.tokenEndpoint); - req.session.set("mcp_accessToken", callbackResult.accessToken); - req.session.set("mcp_refreshToken", callbackResult.refreshToken); + req.encryptedSession.set("mcp_accessToken", callbackResult.accessToken); + req.encryptedSession.set("mcp_refreshToken", callbackResult.refreshToken); if (callbackResult.expiresAt) { - req.session.set("mcp_tokenExpiresAt", callbackResult.expiresAt); + req.encryptedSession.set("mcp_tokenExpiresAt", callbackResult.expiresAt); } else { - req.session.delete("mcp_tokenExpiresAt"); + req.encryptedSession.delete("mcp_tokenExpiresAt"); } reply.redirect(POST_LOGIN_REDIRECT + callbackResult.postLoginRedirectRoute); @@ -48,7 +48,7 @@ async function authPlugin(fastify) { }); fastify.get("/auth/mcp/me", async (req, reply) => { - const accessToken = req.session.get("mcp_accessToken"); + const accessToken = req.encryptedSession.get("mcp_accessToken"); const isAuthenticated = Boolean(accessToken); reply.send({ isAuthenticated }); diff --git a/server/routes/auth-onboarding.js b/server/routes/auth-onboarding.js index 9dc668a8..378574ae 100644 --- a/server/routes/auth-onboarding.js +++ b/server/routes/auth-onboarding.js @@ -29,14 +29,14 @@ async function authPlugin(fastify) { redirectUri: OIDC_REDIRECT_URI, }, issuerConfiguration.tokenEndpoint); - req.session.set("onboarding_accessToken", callbackResult.accessToken); - req.session.set("onboarding_refreshToken", callbackResult.refreshToken); - req.session.set("onboarding_userInfo", callbackResult.userInfo); + req.encryptedSession.set("onboarding_accessToken", callbackResult.accessToken); + req.encryptedSession.set("onboarding_refreshToken", callbackResult.refreshToken); + req.encryptedSession.set("onboarding_userInfo", callbackResult.userInfo); if (callbackResult.expiresAt) { - req.session.set("onboarding_tokenExpiresAt", callbackResult.expiresAt); + req.encryptedSession.set("onboarding_tokenExpiresAt", callbackResult.expiresAt); } else { - req.session.delete("onboarding_tokenExpiresAt"); + req.encryptedSession.delete("onboarding_tokenExpiresAt"); } reply.redirect(POST_LOGIN_REDIRECT + callbackResult.postLoginRedirectRoute); @@ -52,8 +52,8 @@ async function authPlugin(fastify) { fastify.get("/auth/onboarding/me", async (req, reply) => { - const accessToken = req.session.get("onboarding_accessToken"); - const userInfo = req.session.get("onboarding_userInfo"); + const accessToken = req.encryptedSession.get("onboarding_accessToken"); + const userInfo = req.encryptedSession.get("onboarding_userInfo"); const isAuthenticated = Boolean(accessToken); const user = isAuthenticated ? userInfo : null; @@ -62,7 +62,7 @@ async function authPlugin(fastify) { fastify.post("/auth/logout", async (req, reply) => { // TODO: Idp sign out flow - req.session.destroy(); + req.encryptedSession.clear(); reply.send({ message: "Logged out" }); }); } From 3dd4201d115099471e7419867934fd366bb3b5b3 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 26 Jun 2025 14:46:33 +0200 Subject: [PATCH 04/10] solve merge conflict --- .../Wizards/CreateManagedControlPlaneWizardContainer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx index 5090fae2..0bf8561e 100644 --- a/src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx @@ -1,7 +1,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useApiResourceMutation } from '../../lib/api/useApiResource'; import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; -import { useAuth } from '../../spaces/onboarding/auth/AuthContext.tsx'; +import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { Member, MemberRoles } from '../../lib/api/types/shared/members.ts'; import type { WizardStepChangeEventDetail } from '@ui5/webcomponents-fiori/dist/Wizard.js'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -64,7 +64,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< CreateManagedControlPlaneWizardContainerProps > = ({ isOpen, setIsOpen, projectName = '', workspaceName = '' }) => { const { t } = useTranslation(); - const { user } = useAuth(); + const { user } = useAuthOnboarding(); const errorDialogRef = useRef(null); const [selectedStep, setSelectedStep] = useState('metadata'); From ba9ee8869299fdb6a9b55505f581b2092fe80ceb Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 26 Jun 2025 14:52:28 +0200 Subject: [PATCH 05/10] Fix compensation for latency --- server/plugins/http-proxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/plugins/http-proxy.js b/server/plugins/http-proxy.js index 21607b87..fc03cd28 100644 --- a/server/plugins/http-proxy.js +++ b/server/plugins/http-proxy.js @@ -28,7 +28,7 @@ function proxyPlugin(fastify) { // Check if the access token is expired or about to expire const expiresAt = request.encryptedSession.get(keyTokenExpiresAt); const now = Date.now(); - const REFRESH_BUFFER_SECONDS = 20; // to allow for network latency + const REFRESH_BUFFER_SECONDS = 20 * 1000; // to allow for network latency if (!expiresAt || now < expiresAt - REFRESH_BUFFER_SECONDS) { request.log.info("Access token is still valid; no refresh needed."); return; From 955082e1d0ac57459b4d52e88d791ea8c01d8c7e Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 26 Jun 2025 14:58:41 +0200 Subject: [PATCH 06/10] Replace setting href with .replace() --- src/common/auth/AuthCallbackHandler.tsx | 2 +- src/lib/api/fetch.ts | 2 +- src/spaces/mcp/auth/AuthContextMcp.tsx | 4 +++- src/spaces/onboarding/auth/AuthContextOnboarding.tsx | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/common/auth/AuthCallbackHandler.tsx b/src/common/auth/AuthCallbackHandler.tsx index 9ca9fb2c..975dda42 100644 --- a/src/common/auth/AuthCallbackHandler.tsx +++ b/src/common/auth/AuthCallbackHandler.tsx @@ -47,7 +47,7 @@ function useAuthCallback() { } sessionStorage.removeItem(AUTH_FLOW_SESSION_KEY); - window.location.href = forwardUrl.toString(); + window.location.replace(forwardUrl.toString()); }, [isCallbackInProgress, potentialAuthFlow, code, state, iss]); return { diff --git a/src/lib/api/fetch.ts b/src/lib/api/fetch.ts index 957bd9ec..879f0820 100644 --- a/src/lib/api/fetch.ts +++ b/src/lib/api/fetch.ts @@ -49,7 +49,7 @@ export const fetchApiServer = async ( if (!res.ok) { if (res.status === 401) { // Unauthorized, redirect to the login page - window.location.href = `/api/auth/onboarding/login`; + window.location.replace('/api/auth/onboarding/login'); } const error = new APIError( 'An error occurred while fetching the data.', diff --git a/src/spaces/mcp/auth/AuthContextMcp.tsx b/src/spaces/mcp/auth/AuthContextMcp.tsx index 6488d9d3..58759c63 100644 --- a/src/spaces/mcp/auth/AuthContextMcp.tsx +++ b/src/spaces/mcp/auth/AuthContextMcp.tsx @@ -61,7 +61,9 @@ export function AuthProviderMcp({ children }: { children: ReactNode }) { const login = () => { sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'mcp'); - window.location.href = `/api/auth/mcp/login?redirectTo=${encodeURIComponent(window.location.hash)}`; + window.location.replace( + `/api/auth/mcp/login?redirectTo=${encodeURIComponent(window.location.hash)}`, + ); }; return ( diff --git a/src/spaces/onboarding/auth/AuthContextOnboarding.tsx b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx index f68c2f61..3764ff37 100644 --- a/src/spaces/onboarding/auth/AuthContextOnboarding.tsx +++ b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx @@ -69,7 +69,9 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { const login = () => { sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding'); - window.location.href = `/api/auth/onboarding/login?redirectTo=${encodeURIComponent(window.location.hash)}`; + window.location.replace( + `/api/auth/onboarding/login?redirectTo=${encodeURIComponent(window.location.hash)}`, + ); }; const logout = async () => { From 1196a70e90a61571275d4fe40ee6a64c528b1210 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 26 Jun 2025 15:55:56 +0200 Subject: [PATCH 07/10] Validate redirect URL --- server/plugins/auth-utils.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/plugins/auth-utils.js b/server/plugins/auth-utils.js index e8412645..78b6e58b 100644 --- a/server/plugins/auth-utils.js +++ b/server/plugins/auth-utils.js @@ -20,6 +20,12 @@ async function getRemoteOpenIdConfiguration(issuerBaseUrl) { return res.json(); } +function isAllowedRedirectTo(value) { + if (!value) return true; + const first = value.charAt(0); + return first === "/" || first === "#"; +} + async function authUtilsPlugin(fastify) { fastify.decorate("discoverIssuerConfiguration", async (issuerBaseUrl) => { @@ -77,6 +83,10 @@ async function authUtilsPlugin(fastify) { request.log.info("Preparing OIDC login redirect."); const { redirectTo } = request.query; + if (!isAllowedRedirectTo(redirectTo)) { + request.log.error(`Invalid redirectTo: "${redirectTo}".`); + throw new AuthenticationError("Invalid redirectTo."); + } request.encryptedSession.set("postLoginRedirectRoute", redirectTo); const { clientId, redirectUri, scopes } = oidcConfig; From 69f466866857588aeb97b36f048e38cc7a1f6d51 Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Thu, 26 Jun 2025 15:59:52 +0200 Subject: [PATCH 08/10] Rename variable --- server/plugins/http-proxy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/plugins/http-proxy.js b/server/plugins/http-proxy.js index fc03cd28..e0700a0e 100644 --- a/server/plugins/http-proxy.js +++ b/server/plugins/http-proxy.js @@ -28,8 +28,8 @@ function proxyPlugin(fastify) { // Check if the access token is expired or about to expire const expiresAt = request.encryptedSession.get(keyTokenExpiresAt); const now = Date.now(); - const REFRESH_BUFFER_SECONDS = 20 * 1000; // to allow for network latency - if (!expiresAt || now < expiresAt - REFRESH_BUFFER_SECONDS) { + const REFRESH_BUFFER_MILLISECONDS = 20 * 1000; // to allow for network latency + if (!expiresAt || now < expiresAt - REFRESH_BUFFER_MILLISECONDS) { request.log.info("Access token is still valid; no refresh needed."); return; } From f86108a5dc54619e7e3584c344c38b67182c2026 Mon Sep 17 00:00:00 2001 From: enrico-kaack-comp Date: Mon, 30 Jun 2025 14:04:43 +0200 Subject: [PATCH 09/10] improve readability --- server/encrypted-session.js | 49 ++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/server/encrypted-session.js b/server/encrypted-session.js index 0a9b8034..8abd1290 100644 --- a/server/encrypted-session.js +++ b/server/encrypted-session.js @@ -5,17 +5,20 @@ import fastifySession from '@fastify/session'; import crypto from "node:crypto" - -export const COOKIE_NAME_ENCRYPTION_KEY = "session_encryption_key"; -export const COOKIE_NAME_SESSION = "session-cookie"; - -export const SECURE_SESSION_NAME = "encryptedSessionInternal"; -export const UNDERLYING_SESSION_NAME = "underlyingSessionNotPerUserEncrypted"; - -// This is the key used to store the encryption key in the secure session cookie -export const SECURE_COOKIE_KEY_ENCRYPTION_KEY = "encryptionKey"; - +// name of the request decorator this plugin exposes. Using request.encryptedSession can be used with set, get, clear delete +// functions and the encryption will then be handled in this plugin. export const REQUEST_DECORATOR = "encryptedSession"; +// name of the request decorator of the secure-session library that stores its session data in an encrypted cookie on user side. +export const ENCRYPTED_COOKIE_REQUEST_DECORATOR = "encryptedSessionInternal"; +// name of the request decorator of the session library that is used as underlying store for this library. +export const UNDERLYING_SESSION_NAME_REQUEST_DECORATOR = "underlyingSessionNotPerUserEncrypted"; + +// name of the secure-session cookie that stores the encryption key on user side. +export const ENCRYPTION_KEY_COOKIE_NAME = "session_encryption_key"; +// the key used to store the encryption key in the secure-session cookie on user side. +export const ENCRYPTED_COOKIE_KEY_ENCRYPTION_KEY = "encryptionKey"; +// name of the cookie that stores the session identifier on user side. +export const SESSION_COOKIE_NAME = "session-cookie"; async function encryptedSession(fastify) { const { COOKIE_SECRET, SESSION_SECRET, NODE_ENV } = fastify.config; @@ -24,8 +27,8 @@ async function encryptedSession(fastify) { fastify.register(secureSession, { secret: Buffer.from(COOKIE_SECRET, "hex"), - cookieName: COOKIE_NAME_ENCRYPTION_KEY, - sessionName: SECURE_SESSION_NAME, + cookieName: ENCRYPTION_KEY_COOKIE_NAME, + sessionName: ENCRYPTED_COOKIE_REQUEST_DECORATOR, cookie: { path: "/", httpOnly: true, @@ -34,11 +37,9 @@ async function encryptedSession(fastify) { maxAge: 60 * 60 * 24 * 7, // 7 days }, }); - - fastify.register(fastifySession, { secret: SESSION_SECRET, - cookieName: COOKIE_NAME_SESSION, + cookieName: SESSION_COOKIE_NAME, // sessionName: UNDERLYING_SESSION_NAME, //NOT POSSIBLE to change the name it is decorated on the request object cookie: { path: "/", @@ -50,18 +51,18 @@ async function encryptedSession(fastify) { }); fastify.addHook('onRequest', (request, _reply, next) => { - //we use secure-session cookie to get the encryption key and decrypt the store - if (!request[SECURE_SESSION_NAME].get(SECURE_COOKIE_KEY_ENCRYPTION_KEY)) { + const userEncryptionKey = getUserEncryptionKeyFromUserCookie(request); + if (!userEncryptionKey) { request.log.info({ "plugin": "encrypted-session" }, "user-side encryption key not found, creating new one"); let newEncryptionKey = generateSecureEncryptionKey(); - request[SECURE_SESSION_NAME].set(SECURE_COOKIE_KEY_ENCRYPTION_KEY, newEncryptionKey.toString('base64')); + setUserEncryptionKeyIntoUserCookie(request, newEncryptionKey); request[REQUEST_DECORATOR] = createStore() newEncryptionKey = undefined } else { request.log.info({ "plugin": "encrypted-session" }, "user-side encryption key found, using existing one"); - const loadedEncryptionKey = Buffer.from(request[SECURE_SESSION_NAME].get(SECURE_COOKIE_KEY_ENCRYPTION_KEY), "base64"); + const loadedEncryptionKey = Buffer.from(userEncryptionKey, "base64"); const encryptedStore = request.session.get("encryptedStore"); if (encryptedStore) { @@ -89,7 +90,7 @@ async function encryptedSession(fastify) { // onSend is called before the response is send. Here we take encrypt the Session object and store it in the fastify-session. // Then we also want to make sure the unencrypted object is removed from memory fastify.addHook('onSend', async (request, reply, _payload) => { - const encryptionKey = Buffer.from(request[SECURE_SESSION_NAME].get(SECURE_COOKIE_KEY_ENCRYPTION_KEY), "base64"); + const encryptionKey = Buffer.from(getUserEncryptionKeyFromUserCookie(request), "base64"); if (!encryptionKey) { // if no encryption key is found in the secure session, we cannot encrypt the store. This should not happen since an encrption key is generated when the request arrived request.log.error({ "plugin": "encrypted-session" }, "No encryption key found in secure session, cannot encrypt store"); @@ -112,6 +113,14 @@ async function encryptedSession(fastify) { await request.session.save() request.log.info("store encrypted and set into request.session.encryptedStore"); }) + + function getUserEncryptionKeyFromUserCookie(request) { + return request[ENCRYPTED_COOKIE_REQUEST_DECORATOR].get(ENCRYPTED_COOKIE_KEY_ENCRYPTION_KEY) + } + + function setUserEncryptionKeyIntoUserCookie(request, key) { + request[ENCRYPTED_COOKIE_REQUEST_DECORATOR].set(ENCRYPTED_COOKIE_KEY_ENCRYPTION_KEY, key.toString('base64')); + } } export default fp(encryptedSession); From 648655ab54130ebf230bd2ab0ffc1ef3055737ec Mon Sep 17 00:00:00 2001 From: Andreas Kienle Date: Wed, 2 Jul 2025 14:47:33 +0200 Subject: [PATCH 10/10] Update server/encrypted-session.js Co-authored-by: Valentin Gerlach --- server/encrypted-session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/encrypted-session.js b/server/encrypted-session.js index 8abd1290..0da849c4 100644 --- a/server/encrypted-session.js +++ b/server/encrypted-session.js @@ -212,7 +212,7 @@ function decryptSymetric(cipherText, iv, tag, key) { throw new Error("Key must be provided"); } if (key.length < 32) { - throw new Error("Key must be at least 32bye = 256 bits long"); + throw new Error("Key must be at least 32 byte = 256 bits long"); } if (!(key instanceof Buffer)) {