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/package-lock.json b/package-lock.json index 796925d8..c0b8f4da 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", @@ -1419,30 +1419,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", @@ -1491,6 +1467,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", @@ -5397,74 +5393,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", @@ -12431,19 +12359,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", @@ -13524,15 +13439,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 4217a74a..df524fd7 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,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", 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 d793131f..9ab3bf58 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) { @@ -13,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) { @@ -32,11 +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("refreshToken"); + const refreshToken = request.session.get(keyRefreshToken); 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,24 +54,23 @@ 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."); } 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."); @@ -81,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/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-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 63% rename from server/routes/auth.js rename to server/routes/auth-onboarding.js index 2bfcfcef..9dc668a8 100644 --- a/server/routes/auth.js +++ b/server/routes/auth-onboarding.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"; @@ -12,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, @@ -23,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); @@ -47,25 +48,21 @@ 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) => { + 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" }); }); } 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