Add Keycloak OIDC authentication alongside Cognito

This commit is contained in:
Chris Rai
2026-01-31 22:09:36 -05:00
parent 3bdff103aa
commit 5a682a9618
8 changed files with 291 additions and 37 deletions

View File

@@ -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

View File

@@ -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:

106
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<StatusProvider>
<UserProvider>
@@ -22,4 +24,17 @@ function App() {
);
}
function App() {
// Only wrap with AuthProvider if Keycloak is enabled
if (isKeycloakEnabled()) {
return (
<AuthProvider {...keycloakConfig}>
<AppContent />
</AuthProvider>
);
}
return <AppContent />;
}
export default App;

View File

@@ -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,
}}

View File

@@ -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 (
<div className={clsx(classes.paper, classes.textJustifyAlign)}>
<Button
type="submit"
variant="contained"
color="primary"
className={classes.submit}
href={getAuthorizeURL()}
disabled={fetching}
>
{fetching ? "Please wait..." : "Sign In with Cognito"}
</Button>
<div style={{ margin: '16px 0', textAlign: 'center', color: '#666' }}>
or
</div>
<Button
variant="outlined"
color="primary"
className={classes.submit}
onClick={handleKeycloakLogin}
disabled={fetching || auth.isLoading}
>
{auth.isLoading ? "Loading..." : "Sign In with Keycloak"}
</Button>
<p><strong>Note: Your email address will be used as the user id</strong></p>
</div>
);
}
// Standard Cognito-only form
function CognitoSignInForm() {
const classes = useStyles();
const { getAuthorizeURL, signIn, fetching } = useUserContext();
@@ -38,3 +100,10 @@ export default function SignInForm() {
</div>
);
}
export default function SignInForm() {
if (isKeycloakEnabled()) {
return <KeycloakSignInForm />;
}
return <CognitoSignInForm />;
}

21
src/services/keycloak.js Normal file
View File

@@ -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';
};