diff --git a/.env.mini b/.env.mini index ff785c9..99ec746 100644 --- a/.env.mini +++ b/.env.mini @@ -2,6 +2,13 @@ REACT_APP_AUTH_CALLBACK_URL=https://ota-admin.mini.cloud.fiskerinc.com REACT_APP_AUTH_SERVICE_URL=https://dev-gw.cloud.fiskerinc.com/compute_auth REACT_APP_CERT_SERVICE_URL=https://dev-gw.cloud.fiskerinc.com/certificate REACT_APP_ENV=mini + +# Keycloak OIDC (set to true to enable) +REACT_APP_KEYCLOAK_ENABLED=true +REACT_APP_KEYCLOAK_URL=https://keycloak.mini.cloud.fiskerinc.com +REACT_APP_KEYCLOAK_REALM=compute-auth +REACT_APP_KEYCLOAK_CLIENT_ID=ota-portal + REACT_APP_MAGNA_PROVIDER=Magna REACT_APP_MAGNA_GROUP_ID=68273225-9da4-4fa7-aea5-38e16ec471fe REACT_APP_OTA_SERVICE_URL=https://gateway.mini.cloud.fiskerinc.com/ota_update diff --git a/k8s/values-mini.yaml b/k8s/values-mini.yaml index 1ff93ed..9139794 100644 --- a/k8s/values-mini.yaml +++ b/k8s/values-mini.yaml @@ -3,10 +3,10 @@ ingress: className: traefik image: - registry: gitea.mini.cloud.fiskerinc.com - name: admin/ota-admin-portal - tag: latest - pullPolicy: Never + registry: fiskercloud.azurecr.io + name: ota-admin-portal + tag: keycloak + pullPolicy: Always resources: requests: diff --git a/package-lock.json b/package-lock.json index e243331..977514e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,12 @@ "leaflet": "^1.8.0", "material-ui-dropzone": "^3.5.0", "moment": "^2.29.4", + "oidc-client-ts": "^3.4.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-leaflet": "^3.2.5", "react-leaflet-cluster": "^1.0.3", + "react-oidc-context": "^3.3.0", "react-router-dom": "^5.3.0", "react-router-hash-link": "^2.4.3", "react-scripts": "^5.0.1", @@ -3952,6 +3954,21 @@ "string.prototype.matchall": "^4.0.6" } }, + "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", @@ -7361,20 +7378,6 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.4.567", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.567.tgz", @@ -12215,6 +12218,27 @@ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/oidc-client-ts/node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -14170,6 +14194,19 @@ "react-leaflet": "^3.0.2" } }, + "node_modules/react-oidc-context": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", + "integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -19970,10 +20007,20 @@ "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", "requires": { - "ejs": "^3.1.6", + "ejs": "3.1.9", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" + }, + "dependencies": { + "ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "requires": { + "jake": "^10.8.5" + } + } } }, "@svgr/babel-plugin-add-jsx-attribute": { @@ -22470,14 +22517,6 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "requires": { - "jake": "^10.8.5" - } - }, "electron-to-chromium": { "version": "1.4.567", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.567.tgz", @@ -26023,6 +26062,21 @@ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, + "oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "requires": { + "jwt-decode": "^4.0.0" + }, + "dependencies": { + "jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==" + } + } + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -27253,6 +27307,12 @@ "leaflet.markercluster": "^1.4.1" } }, + "react-oidc-context": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", + "integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==", + "requires": {} + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", diff --git a/package.json b/package.json index 61b24be..69795bd 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,12 @@ "leaflet": "^1.8.0", "material-ui-dropzone": "^3.5.0", "moment": "^2.29.4", + "oidc-client-ts": "^3.4.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-leaflet": "^3.2.5", "react-leaflet-cluster": "^1.0.3", + "react-oidc-context": "^3.3.0", "react-router-dom": "^5.3.0", "react-router-hash-link": "^2.4.3", "react-scripts": "^5.0.1", diff --git a/src/components/App/index.jsx b/src/components/App/index.jsx index c312ea4..ffabdc4 100644 --- a/src/components/App/index.jsx +++ b/src/components/App/index.jsx @@ -1,13 +1,15 @@ import React from "react"; import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "react-oidc-context"; import { UserProvider } from "../Contexts/UserContext"; import { StatusProvider } from "../Contexts/StatusContext"; import { CssBaseline } from "@material-ui/core"; import MenuDrawer from "../Layouts/MenuDrawer"; import SiteRoutes from "../Routes/SiteRoutes"; import { } from "../../services/monitoring"; +import { keycloakConfig, isKeycloakEnabled } from "../../services/keycloak"; -function App() { +function AppContent() { return ( @@ -22,4 +24,17 @@ function App() { ); } +function App() { + // Only wrap with AuthProvider if Keycloak is enabled + if (isKeycloakEnabled()) { + return ( + + + + ); + } + + return ; +} + export default App; diff --git a/src/components/Contexts/UserContext.jsx b/src/components/Contexts/UserContext.jsx index f6ab06c..d433d78 100644 --- a/src/components/Contexts/UserContext.jsx +++ b/src/components/Contexts/UserContext.jsx @@ -3,6 +3,7 @@ import auth from "../../services/auth"; import getTimerWorker from "../../services/getTimerWorker"; import { parsePayload } from "../../utils/jwt"; import {getGroups, getProviders} from "../../utils/roles"; +import { isKeycloakEnabled } from "../../services/keycloak"; const UserContext = React.createContext(); @@ -12,6 +13,7 @@ export const UserProvider = ({ children }) => { const [groups, setGroups] = useState(null); const [providers, setProviders] = useState(null); const [error, setError] = useState(null); + const [authSource, setAuthSource] = useState(null); // 'cognito' or 'keycloak' let timer; useEffect(() => { @@ -19,8 +21,18 @@ export const UserProvider = ({ children }) => { if (!localStorage) return; const t = JSON.parse(localStorage.getItem("token")); if (!t) return; + + // Check if it's a Keycloak token (different structure) + if (t.authSource === 'keycloak') { + setToken(t); + setAuthSource('keycloak'); + return; + } + + // Cognito token format if (!t.idToken || !t.idToken.jwtToken) throw new Error("Invalid token"); setToken(t); + setAuthSource('cognito'); } catch (e) { document.location = signOut(); } @@ -29,12 +41,16 @@ export const UserProvider = ({ children }) => { useEffect(() => { if (!token) return; - verifyToken(); + if (authSource === 'keycloak') { + verifyKeycloakToken(); + } else { + verifyToken(); + } return () => { if (timer) timer.terminate(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); + }, [token, authSource]); const refreshTokens = async () => { if (!token || !token.refreshToken || !token.refreshToken.token) return null; @@ -42,25 +58,57 @@ export const UserProvider = ({ children }) => { }; const startSessionTimer = () => { - if (!token || !token.idToken || !token.idToken.jwtToken) { - throw new Error("No id token"); + let jwtToken; + if (authSource === 'keycloak') { + jwtToken = token.idToken; + } else { + if (!token || !token.idToken || !token.idToken.jwtToken) { + throw new Error("No id token"); + } + jwtToken = token.idToken.jwtToken; } - const payload = parsePayload(token.idToken.jwtToken); + + const payload = parsePayload(jwtToken); if (!payload || !payload.exp) throw new Error("Bad id token payload"); const duration = 1000 * payload.exp - new Date().getTime(); if (!timer) { timer = getTimerWorker(); timer.onMessage(async (e) => { if (e.data === "timeout") { - const t = await refreshTokens(); - if (t && !t.error) return; - document.location = signOut(); + if (authSource === 'keycloak') { + // For Keycloak, just sign out - OIDC library handles refresh + document.location = signOut(); + } else { + const t = await refreshTokens(); + if (t && !t.error) return; + document.location = signOut(); + } } }); } timer.start(duration); }; + const verifyKeycloakToken = async () => { + try { + const idToken = token.idToken; + const payload = parsePayload(idToken); + + // For Keycloak, we trust the token if it's not expired + if (!payload || !payload.exp) throw new Error("Invalid Keycloak token"); + if (payload.exp * 1000 < Date.now()) throw new Error("Token expired"); + + // Extract groups from Keycloak token (realm_access.roles or groups claim) + const keycloakGroups = payload.groups || payload.realm_access?.roles || []; + setGroups(keycloakGroups); + setProviders([]); + startSessionTimer(); + } catch (e) { + setError(`Keycloak verify error. ${e.message}`); + document.location = signOut(); + } + }; + const verifyToken = async () => { try { const { @@ -96,6 +144,7 @@ export const UserProvider = ({ children }) => { throw new Error(result.message); } + setAuthSource('cognito'); signedIn(result); } catch (err) { setError(`Sign in error. ${err.message}`); @@ -106,13 +155,42 @@ export const UserProvider = ({ children }) => { return result; }; + // Sign in with Keycloak OIDC token + const signInWithKeycloak = (oidcUser) => { + if (!oidcUser || !oidcUser.id_token) return; + + const keycloakToken = { + authSource: 'keycloak', + idToken: oidcUser.id_token, + accessToken: oidcUser.access_token, + refreshToken: oidcUser.refresh_token, + profile: oidcUser.profile, + }; + + setAuthSource('keycloak'); + setToken(keycloakToken); + if (localStorage) { + localStorage.setItem("token", JSON.stringify(keycloakToken)); + } + }; + const signOut = () => { setGroups(null); setProviders(null); setToken(null); + setAuthSource(null); if (localStorage) { localStorage.removeItem("token"); } + + // For Keycloak, we need to redirect to Keycloak logout + if (authSource === 'keycloak' && isKeycloakEnabled()) { + const keycloakUrl = process.env.REACT_APP_KEYCLOAK_URL || 'https://keycloak.mini.cloud.fiskerinc.com'; + const realm = process.env.REACT_APP_KEYCLOAK_REALM || 'compute-auth'; + const redirectUri = process.env.REACT_APP_AUTH_CALLBACK_URL; + return `${keycloakUrl}/realms/${realm}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`; + } + return getLogoutURL(); }; @@ -158,10 +236,12 @@ export const UserProvider = ({ children }) => { groups, providers, token, + authSource, getAuthorizeURL, getLogoutURL, setError, signIn, + signInWithKeycloak, signOut, refresh, }} diff --git a/src/components/SSOForm/index.jsx b/src/components/SSOForm/index.jsx index 41a5193..85d36d3 100644 --- a/src/components/SSOForm/index.jsx +++ b/src/components/SSOForm/index.jsx @@ -4,6 +4,7 @@ import React, { useEffect } from "react"; import { useUserContext } from "../Contexts/UserContext"; import useStyles from "../useStyles"; +import { isKeycloakEnabled } from "../../services/keycloak"; const getCode = (search) => { if (!search) return null; @@ -11,7 +12,68 @@ const getCode = (search) => { return s.get("code"); }; -export default function SignInForm() { +// Keycloak-enabled version of the form +function KeycloakSignInForm() { + const { useAuth } = require("react-oidc-context"); + const classes = useStyles(); + const { getAuthorizeURL, signIn, signInWithKeycloak, fetching } = useUserContext(); + const auth = useAuth(); + + // Handle Cognito callback + useEffect(() => { + const code = getCode(document.location.search); + // Only process if it's a Cognito code (not a Keycloak callback) + // Keycloak uses 'state' param that react-oidc-context handles + if (!code || auth.isLoading || auth.isAuthenticated) return; + signIn(code); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Handle Keycloak authentication callback + useEffect(() => { + if (auth.isAuthenticated && auth.user) { + signInWithKeycloak(auth.user); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auth.isAuthenticated, auth.user]); + + const handleKeycloakLogin = () => { + auth.signinRedirect(); + }; + + return ( +
+ + +
+ — or — +
+ + +

Note: Your email address will be used as the user id

+
+ ); +} + +// Standard Cognito-only form +function CognitoSignInForm() { const classes = useStyles(); const { getAuthorizeURL, signIn, fetching } = useUserContext(); @@ -38,3 +100,10 @@ export default function SignInForm() { ); } + +export default function SignInForm() { + if (isKeycloakEnabled()) { + return ; + } + return ; +} diff --git a/src/services/keycloak.js b/src/services/keycloak.js new file mode 100644 index 0000000..3dcbfb3 --- /dev/null +++ b/src/services/keycloak.js @@ -0,0 +1,21 @@ +// Keycloak OIDC configuration +const KEYCLOAK_URL = process.env.REACT_APP_KEYCLOAK_URL || 'https://keycloak.mini.cloud.fiskerinc.com'; +const KEYCLOAK_REALM = process.env.REACT_APP_KEYCLOAK_REALM || 'compute-auth'; +const KEYCLOAK_CLIENT_ID = process.env.REACT_APP_KEYCLOAK_CLIENT_ID || 'ota-portal'; +const CALLBACK_URL = process.env.REACT_APP_AUTH_CALLBACK_URL; + +export const keycloakConfig = { + authority: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}`, + client_id: KEYCLOAK_CLIENT_ID, + redirect_uri: CALLBACK_URL, + post_logout_redirect_uri: CALLBACK_URL, + response_type: 'code', + scope: 'openid profile email roles', + // Handle silent renew and callback automatically + automaticSilentRenew: true, + loadUserInfo: true, +}; + +export const isKeycloakEnabled = () => { + return process.env.REACT_APP_KEYCLOAK_ENABLED === 'true'; +};