From 3cc71ad0e26d653ae9098ea7beb43030dc8cfc2e Mon Sep 17 00:00:00 2001 From: Max Chopart Date: Mon, 29 Apr 2024 17:10:28 +0200 Subject: [PATCH] [New #126] Add oidc authentication --- .env.development.keycloak-auth | 9 ++ deploy/.docker/config.js.template | 3 + deploy/.docker/docker-entrypoint.sh | 2 +- deploy/keycloak-auth/.env | 15 +++ deploy/keycloak-auth/docker-compose.yml | 132 ++++++++++++++++++++++++ deploy/keycloak-auth/nginx/nginx.conf | 92 +++++++++++++++++ package-lock.json | 70 ++++++++++++- package.json | 3 +- src/App.tsx | 12 ++- src/components/routes/AppRoutes.tsx | 5 + src/misc/oidc/IfInternalAuth.tsx | 16 +++ src/misc/oidc/OidcAuthWrapper.tsx | 103 ++++++++++++++++++ src/misc/oidc/OidcSignInCallback.tsx | 21 ++++ src/misc/oidc/OidcSilentCallback.tsx | 12 +++ src/utils/OidcUtils.ts | 76 ++++++++++++++ src/utils/constants.tsx | 10 ++ tsconfig.json | 3 +- vite.config.js | 1 + 18 files changed, 577 insertions(+), 8 deletions(-) create mode 100644 .env.development.keycloak-auth create mode 100644 deploy/keycloak-auth/.env create mode 100644 deploy/keycloak-auth/docker-compose.yml create mode 100644 deploy/keycloak-auth/nginx/nginx.conf create mode 100644 src/misc/oidc/IfInternalAuth.tsx create mode 100644 src/misc/oidc/OidcAuthWrapper.tsx create mode 100644 src/misc/oidc/OidcSignInCallback.tsx create mode 100644 src/misc/oidc/OidcSilentCallback.tsx create mode 100644 src/utils/OidcUtils.ts diff --git a/.env.development.keycloak-auth b/.env.development.keycloak-auth new file mode 100644 index 00000000..302187da --- /dev/null +++ b/.env.development.keycloak-auth @@ -0,0 +1,9 @@ +FTA_FMEA_BASENAME='' +FTA_FMEA_API_URL=http://localhost:1235/services/fta-fmea-server +FTA_FMEA_ADMIN_REGISTRATION_ONLY=false +FTA_FMEA_TITLE='Development FTA/FMEA Tool' +FTA_FMEA_AUTHENTICATION: oidc + +// TODO: Define auth env variables +# FTA_FMEA_AUTH_SERVER_URL: +# FTA_FMEA_AUTH_CLIENT_ID: diff --git a/deploy/.docker/config.js.template b/deploy/.docker/config.js.template index 23081aeb..e99c14d9 100644 --- a/deploy/.docker/config.js.template +++ b/deploy/.docker/config.js.template @@ -4,4 +4,7 @@ window.__config__ = { FTA_FMEA_API_URL:'${FTA_FMEA_API_URL}', FTA_FMEA_ADMIN_REGISTRATION_ONLY:'${FTA_FMEA_ADMIN_REGISTRATION_ONLY}', FTA_FMEA_TITLE:'${FTA_FMEA_TITLE}' + FTA_FMEA_AUTHENTICATION:'${FTA_FMEA_AUTHENTICATION}' + FTA_FMEA_AUTH_SERVER_URL:'${FTA_FMEA_AUTH_SERVER_URL}' + FTA_FMEA_AUTH_CLIENT_ID:'${FTA_FMEA_AUTH_CLIENT_ID}' } diff --git a/deploy/.docker/docker-entrypoint.sh b/deploy/.docker/docker-entrypoint.sh index 5ad1aa2f..e35282cc 100644 --- a/deploy/.docker/docker-entrypoint.sh +++ b/deploy/.docker/docker-entrypoint.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh set -eu -envsubst '${FTA_FMEA_BASENAME} ${FTA_FMEA_API_URL} ${FTA_FMEA_ADMIN_REGISTRATION_ONLY} ${FTA_FMEA_TITLE}' < /etc/nginx/config.js.template > /usr/share/nginx/html/config.js +envsubst '${FTA_FMEA_BASENAME} ${FTA_FMEA_API_URL} ${FTA_FMEA_ADMIN_REGISTRATION_ONLY} ${FTA_FMEA_TITLE} ${FTA_FMEA_AUTHENTICATION} ${FTA_FMEA_AUTH_SERVER_URL} ${FTA_FMEA_AUTH_CLIENT_ID}' < /etc/nginx/config.js.template > /usr/share/nginx/html/config.js exec "$@" diff --git a/deploy/keycloak-auth/.env b/deploy/keycloak-auth/.env new file mode 100644 index 00000000..59087ed5 --- /dev/null +++ b/deploy/keycloak-auth/.env @@ -0,0 +1,15 @@ +# Prefix for name of all docker containers. By default it is set to "ff". +RECORD_SET_NAME=ff-iauth-demo + +# Host machine port that provides main entrypoint for the application. The application will be locally accessible at http://localhost:$INTERNAL_HOST_PORT/$FTA_FMEA_PATH (by default it is set to "1235") +INTERNAL_HOST_PORT=1235 + +# Public origin of URL where FTA/FMEA tool UI will run, e.g. https://kbss.fel.cvut.cz, https://kbss.fel.cvut.cz:8080, http://localhost. ! This option can be used only with running reverse proxy pointing to http://localhost:$INTERNAL_HOST_PORT ! +#PUBLIC_ORIGIN=http://localhost + +# Root path for all applications and services, e.g., "" or "/my-company". By default it is set to "". MUST start with slash and MUST NOT end with slash. +#APP_ROOT_PATH=/ff-demo + +# Relative path for root FTA/FMEA tool application starting from APP_ROOT_PATH (by default it is set to "/fta-fmea"). MUST start with slash and MUST NOT end with slash. +#FTA_FMEA_PATH=/fta-fmea-demo + diff --git a/deploy/keycloak-auth/docker-compose.yml b/deploy/keycloak-auth/docker-compose.yml new file mode 100644 index 00000000..10c32538 --- /dev/null +++ b/deploy/keycloak-auth/docker-compose.yml @@ -0,0 +1,132 @@ +version: "3.9" + +# Provide access to fta-fmea-ui that runs locally in dev mode +x-access-for-local-development: &local-dev-env + cors.allowedOrigins: "http://localhost:4173,http://localhost:5173" + +# Provide logging to Java application (e.g. fta-fmea-server) +x-logging-java-application: &logging-java + LOGGING_LEVEL_ROOT: "debug" + +# Expose port to access db-server directly, bypassing nginx +x-access-db-server-development-port: &db-server-dev-port + ports: + - "127.0.0.1:${DB_SERVER_DEV_PORT:-7205}:7200" + +services: + nginx: + image: nginx:latest + container_name: ${RECORD_SET_NAME:-ff}-nginx + ports: + - "127.0.0.1:${INTERNAL_HOST_PORT:-1235}:80" + restart: always + depends_on: + - fta-fmea + - fta-fmea-server + - db-server + environment: + NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx + APP_ORIGIN: "${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}" + APP_ROOT_PATH: "${APP_ROOT_PATH:-}" + FTA_FMEA_PATH: "${FTA_FMEA_PATH:-/fta-fmea}" + volumes: + - ./nginx/nginx.conf:/etc/nginx/templates/nginx.conf.template:ro + - ../shared/nginx/error.html:/usr/share/nginx/html/error.html:ro + + fta-fmea: + image: ghcr.io/kbss-cvut/fta-fmea-ui:latest + container_name: ${RECORD_SET_NAME:-ff}-fta-fmea + expose: + - "80" + depends_on: + - fta-fmea-server + environment: + FTA_FMEA_API_URL: "${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}${APP_ROOT_PATH:-}/services/fta-fmea-server" + FTA_FMEA_BASENAME: "${APP_ROOT_PATH:-}${FTA_FMEA_PATH:-/fta-fmea}" + FTA_FMEA_ADMIN_REGISTRATION_ONLY: ${ADMIN_REGISTRATION_ONLY:-false} + FTA_FMEA_AUTHENTICATION: "oidc" + FTA_FMEA_AUTH_SERVER_URL: "${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}${APP_ROOT_PATH:-}/services/auth/realms/fta-fmea" + FTA_FMEA_AUTH_CLIENT_ID: "fta-fmea" + + fta-fmea-server: + image: ghcr.io/kbss-cvut/fta-fmea:latest + container_name: ${RECORD_SET_NAME:-ff}-fta-fmea-server + expose: + - "9999" + depends_on: + - db-server + restart: always + environment: + <<: *local-dev-env + REPOSITORY_URL: ${REPOSITORY_URL:-http://db-server:7200/repositories/fta-fmea} + server.servlet.context-path: "/fta-fmea" + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI: "${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}${APP_ROOT_PATH:-}/services/auth/realms/fta-fmea" + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWKSETURI: "http://auth-server:8080/realms/fta-fmea/protocol/openid-connect/certs" + SERVER_MAXHTTPREQUESTHEADERSIZE: "40KB" + + db-server: + <<: *db-server-dev-port + image: ${RECORD_SET_NAME:-ff}-db-server + container_name: ${RECORD_SET_NAME:-ff}-db-server + build: + context: ../shared/db-server + environment: + GDB_JAVA_OPTS: "-Dgraphdb.external-url=${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}${APP_ROOT_PATH:-}/services/db-server" + expose: + - "7200" + restart: always + volumes: + - ../shared/db-server/init-data:/root/graphdb-import:ro + - db-server:/opt/graphdb/home + + auth-server-db: + image: postgres:13 + container_name: ${RECORD_SET_NAME:-rm}-auth-server-db + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + volumes: + - auth-server-db:/var/lib/postgresql/data + + auth-server: + image: ghcr.io/kbss-cvut/keycloak-graphdb-user-replicator/keycloak-graphdb:latest + container_name: ${RECORD_SET_NAME:-rm}-auth-server + command: + - start --import-realm --features="token-exchange,admin-fine-grained-authz" + environment: + KC_IMPORT: realm-export.json + KC_HOSTNAME_URL: "${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}${APP_ROOT_PATH:-}/services/auth/" + KC_HOSTNAME_ADMIN_URL: "${PUBLIC_ORIGIN:-http://localhost:${INTERNAL_HOST_PORT:-1235}}${APP_ROOT_PATH:-}/services/auth/" + KC_HOSTNAME_STRICT_BACKCHANNEL: false + KC_HTTP_ENABLED: true + KEYCLOAK_ADMIN: ${KC_ADMIN_USER} + KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD} + DB_VENDOR: POSTGRES + DB_ADDR: auth-server-db + DB_DATABASE: keycloak + DB_USER: keycloak + DB_PASSWORD: keycloak + DB_SCHEMA: "public" + DB_SERVER_URL: "http://db-server:7200" + DB_SERVER_REPOSITORY_ID: "record-manager-app" + REPOSITORY_LANGUAGE: "en" + VOCABULARY_USER_TYPE: "http://onto.fel.cvut.cz/ontologies/record-manager/user" + VOCABULARY_USER_FIRST_NAME: "http://xmlns.com/foaf/0.1/firstName" + VOCABULARY_USER_LAST_NAME: "http://xmlns.com/foaf/0.1/lastName" + VOCABULARY_USER_USERNAME: "http://xmlns.com/foaf/0.1/accountName" + VOCABULARY_USER_EMAIL: "http://xmlns.com/foaf/0.1/mbox" + ADD_ACCOUNTS: false + REALM_ID: "fta-fmea-tool" + expose: + - "8080" + volumes: + - auth-server:/opt/keycloak/data + - ./keycloak:/opt/keycloak/data/import + depends_on: + - auth-server-db + +volumes: + db-server: + auth-server: + auth-server-db: diff --git a/deploy/keycloak-auth/nginx/nginx.conf b/deploy/keycloak-auth/nginx/nginx.conf new file mode 100644 index 00000000..1bc48448 --- /dev/null +++ b/deploy/keycloak-auth/nginx/nginx.conf @@ -0,0 +1,92 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + + client_max_body_size 100M; + + include mime.types; + default_type application/octet-stream; + + map $status $status_text { + 400 'Bad Request'; + 401 'Unauthorized'; + 403 'Forbidden'; + 404 'Not Found'; + 405 'Method Not Allowed'; + 406 'Not Acceptable'; + 413 'Payload Too Large'; + 414 'URI Too Long'; + 431 'Request Header Fields Too Large'; + 500 'Internal Server Error'; + 501 'Not Implemented'; + 502 'Bad Gateway'; + 503 'Service Unavailable'; + 504 'Gateway Timeout'; + } + + server { + listen 80; + server_name localhost; + + error_page 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 + 415 416 417 418 421 422 423 424 426 428 429 431 451 500 501 502 503 + 504 505 506 507 508 510 511 /error.html; + + location = /error.html { + ssi on; + internal; + root /usr/share/nginx/html; + } + + location = ${FTA_FMEA_PATH} { + return 302 ${APP_ORIGIN}${APP_ROOT_PATH}${FTA_FMEA_PATH}/; + } + + location ${FTA_FMEA_PATH}/ { + proxy_pass http://fta-fmea/; # keep the trailing slash to cut off matched prefix + } + + location /services/fta-fmea-server/ { + proxy_pass http://fta-fmea-server:9999/fta-fmea/; # keep the trailing slash to cut off matched prefix + proxy_cookie_path /fta-fmea ${APP_ROOT_PATH}/services; + } + + location = /services/db-server { + return 302 ${APP_ORIGIN}${APP_ROOT_PATH}/services/db-server/; + } + + location /services/db-server/ { + proxy_pass http://db-server:7200/; # keep the trailing slash to cut off matched prefix + } + + location = /services/auth { + return 302 ${APP_ORIGIN}${APP_ROOT_PATH}/services/auth/; + } + + location /services/auth/ { + proxy_pass http://auth-server:8080/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Port $http_x_forwarded_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Increase buffer sizes to handle large headers sent by Keycloak and its clients + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + location /health-check { + return 200; + access_log off; + } + } +} diff --git a/package-lock.json b/package-lock.json index 9d1cee95..35ffccf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "jquery": "^3.7.0", "jsonld": "8.3.2", "lodash": "^4.17.21", + "oidc-client": "^1.11.5", "prop-types": "^15.8.1", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -2788,6 +2789,25 @@ "dev": true, "peer": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -3077,6 +3097,16 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, + "node_modules/core-js": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.0.tgz", + "integrity": "sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -3133,6 +3163,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -5811,6 +5846,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-client": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", + "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==", + "dependencies": { + "acorn": "^7.4.1", + "base64-js": "^1.5.1", + "core-js": "^3.8.3", + "crypto-js": "^4.0.0", + "serialize-javascript": "^4.0.0" + } + }, + "node_modules/oidc-client/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/oidc-client/node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6182,7 +6248,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -6631,8 +6696,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/safer-buffer": { "version": "2.1.2", diff --git a/package.json b/package.json index 27784e5d..a0b455d2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "jquery": "^3.7.0", "jsonld": "8.3.2", "lodash": "^4.17.21", + "oidc-client": "^1.11.5", "prop-types": "^15.8.1", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", @@ -69,7 +70,7 @@ "prettier": "3.2.5", "ts-loader": "^9.4.4", "vite-tsconfig-paths": "^4.3.1", - "vitest":"^1.4.0", + "vitest": "^1.4.0", "jsdom": "^24.0.0" }, "lint-staged": { diff --git a/src/App.tsx b/src/App.tsx index 57a5ff3f..b70a8a51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useEffect } from "react"; -import { ThemeProvider, Theme, StyledEngineProvider } from "@mui/material/styles"; +import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles"; import AppRoutes from "@components/routes/AppRoutes"; import { appTheme } from "@styles/App.styles"; import { SnackbarProvider } from "@hooks/useSnackbar"; @@ -8,6 +8,8 @@ import { ConfirmDialogProvider } from "@hooks/useConfirmDialog"; import { ENVVariable, SELECTED_LANGUAGE_KEY, PRIMARY_LANGUAGE } from "@utils/constants"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; +import { isUsingOidcAuth } from "@utils/OidcUtils"; +import OidcAuthWrapper from "@oidc/OidcAuthWrapper"; declare module "@mui/material/styles" { // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -36,7 +38,13 @@ const App = () => { - + {isUsingOidcAuth() ? ( + + + + ) : ( + + )} diff --git a/src/components/routes/AppRoutes.tsx b/src/components/routes/AppRoutes.tsx index 3916c4d3..67ea92cd 100644 --- a/src/components/routes/AppRoutes.tsx +++ b/src/components/routes/AppRoutes.tsx @@ -18,6 +18,8 @@ import SystemsOverview from "../systems/SystemsOverview"; import FtaOverview from "../fta/FtaOverview"; import FmeaOverview from "../fmea/FmeaOverview"; import FhaOverview from "../fha/FhaOverview"; +import OidcSignInCallback from "@oidc/OidcSignInCallback"; +import OidcSilentCallback from "@oidc/OidcSilentCallback"; export const appHistory = createHashHistory(); @@ -135,6 +137,9 @@ const AppRoutes = () => { } /> + } /> + } /> + } /> diff --git a/src/misc/oidc/IfInternalAuth.tsx b/src/misc/oidc/IfInternalAuth.tsx new file mode 100644 index 00000000..a14acc07 --- /dev/null +++ b/src/misc/oidc/IfInternalAuth.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { isUsingOidcAuth } from "../../utils/OidcUtils.js"; + +const IfInternalAuth = ({ children }) => { + if (isUsingOidcAuth()) { + return null; + } + return <>{children}; +}; + +IfInternalAuth.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default IfInternalAuth; diff --git a/src/misc/oidc/OidcAuthWrapper.tsx b/src/misc/oidc/OidcAuthWrapper.tsx new file mode 100644 index 00000000..d78bf82d --- /dev/null +++ b/src/misc/oidc/OidcAuthWrapper.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { generateRedirectUri, getUserManager } from "@utils/OidcUtils"; + +const useThrow = () => { + const [, setState] = useState(); + return useCallback( + (error) => + setState(() => { + throw error; + }), + [setState], + ); +}; + +/** + * Context provider for user data and logout action trigger + */ +export const AuthContext = React.createContext(null); + +const OidcAuthWrapper = ({ children, location = window.location }) => { + const userManager = getUserManager(); + const throwError = useThrow(); + const [user, setUser] = useState(null); + + useEffect(() => { + userManager + .getUser() + .then((u) => { + if (u && u.access_token && !u.expired) { + // User authenticated + // NOTE: the oidc-client-js library never returns null if the user is not authenticated + // Checking for existence of BOTH access_token and expired field seems OK + // Checking only for expired field is not enough + setUser(u); + } else { + // User not authenticated -> trigger auth flow + return userManager.signinRedirect({ + redirect_uri: generateRedirectUri(location.href), + }); + } + }) + .catch((error) => { + throwError(error); + }); + }, [location, throwError, setUser, userManager]); + + useEffect(() => { + // Refreshing react state when new state is available in e.g. session storage + const updateUserData = async () => { + try { + const user = await userManager.getUser(); + setUser(user); + } catch (error) { + throwError(error); + } + }; + + userManager.events.addUserLoaded(updateUserData); + + // Unsubscribe on component unmount + return () => userManager.events.removeUserLoaded(updateUserData); + }, [throwError, setUser, userManager]); + + useEffect(() => { + // Force log in if session cannot be renewed on background + const handleSilentRenewError = async () => { + try { + await userManager.signinRedirect({ + redirect_uri: generateRedirectUri(location.href), + }); + } catch (error) { + throwError(error); + } + }; + + userManager.events.addSilentRenewError(handleSilentRenewError); + + // Unsubscribe on component unmount + return () => userManager.events.removeSilentRenewError(handleSilentRenewError); + }, [location, throwError, setUser, userManager]); + + const logout = useCallback(() => { + const handleLogout = async () => { + const args = {}; + await userManager.signoutRedirect(args); + }; + handleLogout(); + }, [userManager]); + + if (!user) { + return null; + } + + return {children}; +}; + +OidcAuthWrapper.propTypes = { + children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), + location: PropTypes.object, +}; + +export default OidcAuthWrapper; diff --git a/src/misc/oidc/OidcSignInCallback.tsx b/src/misc/oidc/OidcSignInCallback.tsx new file mode 100644 index 00000000..0f4a2c54 --- /dev/null +++ b/src/misc/oidc/OidcSignInCallback.tsx @@ -0,0 +1,21 @@ +import React, { useEffect } from "react"; +import { getUserManager } from "@utils/OidcUtils"; + +const OidcSignInCallback = () => { + useEffect(() => { + getUserManager() + .signinRedirectCallback() + .then(() => { + const searchParams = new URLSearchParams(location.search); + if (!searchParams.has("forward_uri")) { + throw Error("Missing parameter forward_uri"); + } + const forwardUri = window.atob(searchParams.get("forward_uri")); + window.location.replace(forwardUri); + }); + }, []); + + return

Redirecting...

; +}; + +export default OidcSignInCallback; diff --git a/src/misc/oidc/OidcSilentCallback.tsx b/src/misc/oidc/OidcSilentCallback.tsx new file mode 100644 index 00000000..a6269e07 --- /dev/null +++ b/src/misc/oidc/OidcSilentCallback.tsx @@ -0,0 +1,12 @@ +import React, { useEffect } from "react"; +import { getUserManager } from "@utils/OidcUtils"; + +const OidcSilentCallback = () => { + useEffect(() => { + getUserManager().signinSilentCallback(); + }, []); + + return

; +}; + +export default OidcSilentCallback; diff --git a/src/utils/OidcUtils.ts b/src/utils/OidcUtils.ts new file mode 100644 index 00000000..9d89759d --- /dev/null +++ b/src/utils/OidcUtils.ts @@ -0,0 +1,76 @@ +// Taken from https://github.com/datagov-cz/assembly-line-shared but using a different config processing mechanism +import { UserManager } from "oidc-client"; +import { ENVVariable } from "./constants"; + +// Singleton UserManager instance +let userManager: UserManager; +export const getUserManager = () => { + if (!userManager) { + userManager = new UserManager(getOidcConfig()); + } + return userManager; +}; + +/** + * Base64 encoding helper + */ +const encodeBase64 = (uri: string) => { + return window.btoa(uri); +}; + +/** + * Forward URI encoding helper + */ +const encodeForwardUri = (uri: string) => { + // Since base64 produces equal signs on the end, it needs to be further encoded + return encodeURI(encodeBase64(uri)); +}; + +export const getOidcConfig = () => { + const clientId = ENVVariable.AUTH_CLIENT_ID; + const baseUrl = resolveUrl(); + return { + authority: ENVVariable.AUTH_SERVER_URL, + client_id: clientId, + redirect_uri: `${baseUrl}/oidc-signin-callback?forward_uri=${encodeForwardUri(baseUrl)}`, + silent_redirect_uri: `${baseUrl}/oidc-silent-callback`, + post_logout_redirect_uri: `${baseUrl}`, + response_type: "code", + loadUserInfo: true, + automaticSilentRenew: true, + revokeAccessTokenOnSignout: true, + }; +}; + +function resolveUrl() { + const loc = window.location; + let url = loc.protocol + "//" + loc.host; + const basename = ENVVariable.BASENAME; + if (basename !== "/" && basename !== "./") { + url += basename; + } + return url; +} + +export const userProfileLink = () => { + return `${ENVVariable.AUTH_SERVER_URL}/account`; +}; + +/** + * Helper to generate redirect Uri + */ +export const generateRedirectUri = (forwardUri: string) => { + return `${resolveUrl()}/oidc-signin-callback?forward_uri=${encodeForwardUri(forwardUri)}`; +}; + +/** + * OIDC Session storage key name + */ +export const getOidcIdentityStorageKey = () => { + const oidcConfig = getOidcConfig(); + return `oidc.user:${oidcConfig.authority}:${oidcConfig.client_id}`; +}; + +export function isUsingOidcAuth() { + return ENVVariable.AUTHENTICATION === "oidc"; +} diff --git a/src/utils/constants.tsx b/src/utils/constants.tsx index 661501ca..62174cd0 100644 --- a/src/utils/constants.tsx +++ b/src/utils/constants.tsx @@ -23,6 +23,9 @@ export const ENVVariable = { BASENAME: getEnv("FTA_FMEA_BASENAME", ""), ADMIN_REGISTRATION_ONLY: getEnv("FTA_FMEA_ADMIN_REGISTRATION_ONLY", "false"), TITLE: getEnv("FTA_FMEA_TITLE", "FTA/FMEA"), + AUTHENTICATION: getEnv("FTA_FMEA_AUTHENTICATION", "internal"), + AUTH_SERVER_URL: getEnv("FTA_FMEA_AUTH_SERVER_URL", ""), + AUTH_CLIENT_ID: getEnv("FTA_FMEA_AUTH_CLIENT_ID", ""), }; export const JSONLD = "application/ld+json"; @@ -36,6 +39,8 @@ export const ROUTES = { ADMINISTRATION: "/administration", LOGIN: "/login", LOGOUT: "/logout", + OIDC_SIGNIN_CALLBACK: "/oidc-signin-callback", + OIDC_SILENT_CALLBACK: "/oidc-silent-callback", DASHBOARD: "/", SYSTEMS: "/systems", @@ -68,3 +73,8 @@ export const PRIMARY_LANGUAGE = "en"; export const SECONDARY_LANGUAGE = "cs"; export const SELECTED_SYSTEM = "fta-selected-system"; export const SELECTED_VIEW = "fta-selected-view"; + +export const ROLE = { + ADMIN: "Admin", + REGULAR_USER: "Regular User", +}; diff --git a/tsconfig.json b/tsconfig.json index 62dcbbeb..a00de184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "@services/*": ["./src/services/*"], "@styles/*": ["./src/styles/*"], "@utils/*": ["./src/utils/*"], - "@hooks/*": ["./src/hooks/*"] + "@hooks/*": ["./src/hooks/*"], + "@oidc/*": ["./src/misc/oidc/*"] }, "lib": ["es2017", "dom", "dom.iterable"], "types": ["vite/client"] diff --git a/vite.config.js b/vite.config.js index f29f2d3d..84327d70 100644 --- a/vite.config.js +++ b/vite.config.js @@ -20,6 +20,7 @@ export default defineConfig({ "@utils": "/src/utils", "@services": "/src/services", "@models": "/src/models", + "@oidc": "/src/misc/oidc", }, }, test: {