diff --git a/cda-gui/package.json b/cda-gui/package.json index 97d0854bd..761e6bd1a 100644 --- a/cda-gui/package.json +++ b/cda-gui/package.json @@ -8,6 +8,7 @@ "build": "vite build --mode production && node scripts/generate-sitemap.mjs", "build:development": "vite build --mode development && node scripts/generate-sitemap.mjs", "build:test": "vite build --mode test && node scripts/generate-sitemap.mjs", + "test": "node --test src/utils/auth-config.test.js", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "prepare": "cd .. && husky cda-gui/.husky", diff --git a/cda-gui/src/components/AppAuthProvider.jsx b/cda-gui/src/components/AppAuthProvider.jsx new file mode 100644 index 000000000..990c073a2 --- /dev/null +++ b/cda-gui/src/components/AppAuthProvider.jsx @@ -0,0 +1,121 @@ +import { + AuthProvider, + createKeycloakAuthMethod, +} from "@usace-watermanagement/groundwork-water"; +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; + +import { getBasePath } from "../utils/base"; +import { getKeycloakConfig, normalizeOpenIdConnectUrls } from "../utils/auth-config"; +import { AuthConfigurationContext } from "./auth-configuration-context"; + +function createLocalAuthMethod() { + let token; + return { + async login() { + const response = await fetch( + `${import.meta.env.VITE_AUTH_HOST}/realms/${import.meta.env.VITE_AUTH_REALM}/protocol/openid-connect/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "password", + client_id: "cwms", + username: import.meta.env.VITE_AUTH_USER, + password: import.meta.env.VITE_AUTH_PASSWORD, + }), + }, + ); + if (!response.ok) { + throw new Error(`Local Keycloak login failed (${response.status})`); + } + token = (await response.json()).access_token; + }, + async logout() { + token = undefined; + }, + async isAuth() { + return !!token; + }, + get token() { + return token; + }, + }; +} + +const unavailableAuthMethod = { + async login() {}, + async logout() {}, + async isAuth() { + return false; + }, + get token() { + return undefined; + }, +}; + +async function loadDeployedAuthMethod() { + const response = await fetch(`${getBasePath()}/swagger-docs`, { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`OpenAPI request failed with HTTP ${response.status}`); + } + + const spec = await response.json(); + normalizeOpenIdConnectUrls(spec, window.location.origin); + const config = getKeycloakConfig(spec, window.location.href); + if (!config) { + throw new Error("The OpenAPI document does not advertise a usable OpenID client."); + } + return createKeycloakAuthMethod(config); +} + +export default function AppAuthProvider({ children }) { + const [state, setState] = useState(() => + import.meta.env.MODE === "dev-cda-compose" + ? { method: createLocalAuthMethod(), error: null } + : { method: null, error: null }, + ); + + useEffect(() => { + if (state.method) return undefined; + + let cancelled = false; + loadDeployedAuthMethod() + .then((method) => { + if (!cancelled) setState({ method, error: null }); + }) + .catch((error) => { + if (!cancelled) { + setState({ + method: unavailableAuthMethod, + error: `Sign-in is unavailable: ${error?.message ?? "invalid authentication configuration"}`, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [state.method]); + + if (!state.method) { + return ( +
+ Loading authentication configuration… +
+ ); + } + + return ( + + {children} + + ); +} + +AppAuthProvider.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/cda-gui/src/components/AuthButton.jsx b/cda-gui/src/components/AuthButton.jsx index d8379a1ce..3b59c3745 100644 --- a/cda-gui/src/components/AuthButton.jsx +++ b/cda-gui/src/components/AuthButton.jsx @@ -1,8 +1,18 @@ import { useAuth } from "@usace-watermanagement/groundwork-water"; import { LoginButton } from "@usace/groundwork"; +import { useAuthConfiguration } from "./auth-configuration-context"; export default function AuthButton() { const auth = useAuth(); + const { error } = useAuthConfiguration(); + + if (error) { + return ( + + Sign-in unavailable + + ); + } return auth.isAuth ? ( + {!authConfigurationError && ( + + )} ); } diff --git a/cda-gui/src/utils/auth-config.js b/cda-gui/src/utils/auth-config.js new file mode 100644 index 000000000..524d06936 --- /dev/null +++ b/cda-gui/src/utils/auth-config.js @@ -0,0 +1,52 @@ +export function isLoopbackHost(hostname) { + return ["localhost", "127.0.0.1", "::1"].includes(hostname); +} + +export function getOpenIdConnectScheme(spec) { + const schemes = spec.components?.securitySchemes ?? {}; + return Object.values(schemes).find((scheme) => scheme.type === "openIdConnect"); +} + +export function normalizeOpenIdConnectUrls(spec, currentOrigin) { + const schemes = spec.components?.securitySchemes ?? {}; + for (const scheme of Object.values(schemes)) { + if (scheme.type === "openIdConnect" && scheme.openIdConnectUrl) { + const openIdConnectUrl = new URL(scheme.openIdConnectUrl, currentOrigin); + if (openIdConnectUrl.hostname === "auth") { + const currentUrl = new URL(currentOrigin); + openIdConnectUrl.protocol = currentUrl.protocol; + openIdConnectUrl.host = currentUrl.host; + scheme.openIdConnectUrl = openIdConnectUrl.toString(); + } + } + } +} + +export function getKeycloakConfig(spec, currentUrl) { + const scheme = getOpenIdConnectScheme(spec); + if (!scheme?.openIdConnectUrl || !scheme["x-oidc-client-id"]) { + return null; + } + + const pageUrl = new URL(currentUrl); + const openIdConnectUrl = new URL(scheme.openIdConnectUrl, pageUrl.origin); + const realmMatch = openIdConnectUrl.pathname.match(/^(.*)\/realms\/([^/]+)\//); + if (!realmMatch) { + return null; + } + + const providerHint = scheme["x-kc_idp_hint"]?.values?.[0]; + const useLocalDevCredentials = isLoopbackHost(openIdConnectUrl.hostname); + const redirectUri = `${pageUrl.origin}${pageUrl.pathname}`; + return { + host: `${openIdConnectUrl.origin}${realmMatch[1]}`, + realm: realmMatch[2], + client: scheme["x-oidc-client-id"], + flow: useLocalDevCredentials ? "direct-grant" : "authorization-code-pkce", + username: useLocalDevCredentials ? "m5hectest" : undefined, + password: useLocalDevCredentials ? "m5hectest" : undefined, + redirectUri, + postLogoutRedirectUri: redirectUri, + providerHint: useLocalDevCredentials ? undefined : providerHint, + }; +} diff --git a/cda-gui/src/utils/auth-config.test.js b/cda-gui/src/utils/auth-config.test.js new file mode 100644 index 000000000..423150911 --- /dev/null +++ b/cda-gui/src/utils/auth-config.test.js @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getKeycloakConfig, normalizeOpenIdConnectUrls } from "./auth-config.js"; + +function specWithOpenId(openIdConnectUrl, client = "cwms") { + return { + components: { + securitySchemes: { + OpenID: { + type: "openIdConnect", + openIdConnectUrl, + "x-oidc-client-id": client, + "x-kc_idp_hint": { values: ["federation-eams"] }, + }, + }, + }, + }; +} + +test("returns null when the deployment does not advertise OpenID", () => { + assert.equal(getKeycloakConfig({}, "https://water.dev.cwbi.us/cwms-data/"), null); +}); + +test("returns null when the OpenID client is missing", () => { + const spec = specWithOpenId( + "https://identity-test.cwbi.us/auth/realms/cwbi/.well-known/openid-configuration", + ); + delete spec.components.securitySchemes.OpenID["x-oidc-client-id"]; + + assert.equal(getKeycloakConfig(spec, "https://water.dev.cwbi.us/cwms-data/"), null); +}); + +test("derives deployed Keycloak configuration from the OpenAPI document", () => { + const spec = specWithOpenId( + "https://identity-test.cwbi.us/auth/realms/cwbi/.well-known/openid-configuration", + ); + + assert.deepEqual( + getKeycloakConfig( + spec, + "https://water.dev.cwbi.us/cwms-data/user-lists?office=SWT", + ), + { + host: "https://identity-test.cwbi.us/auth", + realm: "cwbi", + client: "cwms", + flow: "authorization-code-pkce", + username: undefined, + password: undefined, + redirectUri: "https://water.dev.cwbi.us/cwms-data/user-lists", + postLogoutRedirectUri: "https://water.dev.cwbi.us/cwms-data/user-lists", + providerHint: "federation-eams", + }, + ); +}); + +test("rewrites the compose-only auth hostname to the visible origin", () => { + const spec = specWithOpenId( + "http://auth:8080/auth/realms/cwms/.well-known/openid-configuration", + ); + + normalizeOpenIdConnectUrls(spec, "http://localhost:8081"); + + assert.equal( + spec.components.securitySchemes.OpenID.openIdConnectUrl, + "http://localhost:8081/auth/realms/cwms/.well-known/openid-configuration", + ); +});