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 ? (