diff --git a/Dockerfile b/Dockerfile index 4762bb8bc..1b4d0a8a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,9 @@ ENV cwms.dataapi.access.providers="KeyAccessManager,OpenID" ENV cwms.dataapi.access.openid.wellKnownUrl="https:///.well-known/openid-configuration" ENV cwms.dataapi.access.openid.issuer="" ENV cwms.dataapi.access.openid.timeout="604800" +# Putting default values here to easy configuration +ENV cwms.dataapi.access.openid.clientId=cwms +ENV cwms.dataapi.access.openid.idpHint=federation-eams #ENV cwms.dataapi.access.openid.altAuthUrl="https://identityc-test.cwbi.us/auth/realms/cwbi" # used to simplify redeploy in certain contexts. Update to match - in image label diff --git a/cda-gui/package-lock.json b/cda-gui/package-lock.json index bfaaff33e..af90ef956 100644 --- a/cda-gui/package-lock.json +++ b/cda-gui/package-lock.json @@ -17,7 +17,7 @@ "react-dom": "^18.2.0", "react-icons": "^5.0.1", "react-router-dom": "^7.1.2", - "swagger-ui-dist": "^5.17.7", + "swagger-ui-dist": "^5.29.5", "use-debounce": "^10.0.5" }, "devDependencies": { @@ -1490,6 +1490,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -5379,9 +5386,13 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.17.7", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.7.tgz", - "integrity": "sha512-hKnq2Dss6Nvqxzj+tToBz0IJvKXgp7FExxX0Zj0rMajXJp8CJ98yLAwbKwKu8rxQf+2iIDUTGir84SCA8AN+fQ==" + "version": "5.29.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.5.tgz", + "integrity": "sha512-2zFnjONgLXlz8gLToRKvXHKJdqXF6UGgCmv65i8T6i/UrjDNyV1fIQ7FauZA40SaivlGKEvW2tw9XDyDhfcXqQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } }, "node_modules/tabbable": { "version": "6.2.0", diff --git a/cda-gui/package.json b/cda-gui/package.json index 66ccedbcc..638f00fe7 100644 --- a/cda-gui/package.json +++ b/cda-gui/package.json @@ -19,7 +19,7 @@ "react-dom": "^18.2.0", "react-icons": "^5.0.1", "react-router-dom": "^7.1.2", - "swagger-ui-dist": "^5.17.7", + "swagger-ui-dist": "^5.29.5", "use-debounce": "^10.0.5" }, "devDependencies": { diff --git a/cda-gui/public/oauth2-redirect.html b/cda-gui/public/oauth2-redirect.html new file mode 100644 index 000000000..0ddec8cbc --- /dev/null +++ b/cda-gui/public/oauth2-redirect.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cda-gui/public/oauth2-redirect.js b/cda-gui/public/oauth2-redirect.js new file mode 100644 index 000000000..96051a2d1 --- /dev/null +++ b/cda-gui/public/oauth2-redirect.js @@ -0,0 +1,69 @@ +"use strict" +function run () { + var oauth2 = window.opener.swaggerUIRedirectOauth2 + var sentState = oauth2.state + var redirectUrl = oauth2.redirectUrl + var isValid, qp, arr + + if (/code|token|error/.test(window.location.hash)) { + qp = window.location.hash.substring(1).replace("?", "&") + } else { + qp = location.search.substring(1) + } + + arr = qp.split("&") + arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace("=", '":"') + '"' }) + qp = qp ? JSON.parse("{" + arr.join() + "}", + function (key, value) { + return key === "" ? value : decodeURIComponent(value) + } + ) : {} + + isValid = qp.state === sentState + + if (( + oauth2.auth.schema.get("flow") === "accessCode" || + oauth2.auth.schema.get("flow") === "authorizationCode" || + oauth2.auth.schema.get("flow") === "authorization_code" + ) && !oauth2.auth.code) { + if (!isValid) { + oauth2.errCb({ + authId: oauth2.auth.name, + source: "auth", + level: "warning", + message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server" + }) + } + + if (qp.code) { + delete oauth2.state + oauth2.auth.code = qp.code + oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl}) + } else { + let oauthErrorMsg + if (qp.error) { + oauthErrorMsg = "["+qp.error+"]: " + + (qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") + + (qp.error_uri ? "More info: "+qp.error_uri : "") + } + + oauth2.errCb({ + authId: oauth2.auth.name, + source: "auth", + level: "error", + message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server" + }) + } + } else { + oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl}) + } + window.close() +} + +if( document.readyState !== "loading" ) { + run() +} else { + document.addEventListener("DOMContentLoaded", function () { + run() + }) +} \ No newline at end of file diff --git a/cda-gui/src/pages/swagger-ui/index.jsx b/cda-gui/src/pages/swagger-ui/index.jsx index 686f92d22..9593d59ab 100644 --- a/cda-gui/src/pages/swagger-ui/index.jsx +++ b/cda-gui/src/pages/swagger-ui/index.jsx @@ -12,23 +12,54 @@ export default function SwaggerUI() { document.title = "CWMS Data API for Data Retrieval - Swagger UI"; // Begin Swagger UI call region // TODO: add endpoint that dynamic returns swagger generated doc - SwaggerUIBundle({ + + const ui = SwaggerUIBundle({ url: getBasePath() + "/swagger-docs", + dom_id: "#swagger-ui", deepLinking: false, presets: [SwaggerUIBundle.presets.apis], plugins: [SwaggerUIBundle.plugins.DownloadUrl], requestInterceptor: (req) => { - // Add a cache-busting query param - const sep = req.url.includes("?") ? "&" : "?"; - req.url = `${req.url}${sep}_cb=${Date.now()}`; - - // Also ask intermediaries not to serve from cache - req.headers["Cache-Control"] = "no-cache, no-store, max-age=0"; - req.headers["Pragma"] = "no-cache"; + // Add a cache-busting query param... but only if it's to our api. Some + // external systems, like keycloak, don't allow random unknown parameters. + const origin = window.location.origin; + const re = new RegExp(`^${origin}.*`) + if (re.test(req.url)) + { + const sep = req.url.includes("?") ? "&" : "?"; + req.url = `${req.url}${sep}_cb=${Date.now()}`; + // Also ask intermediaries not to serve from cache + req.headers["Cache-Control"] = "no-cache, no-store, max-age=0"; + req.headers["Pragma"] = "no-cache"; + } return req; }, + onComplete: () => { + const spec = JSON.parse(ui.spec().get("spec")); + for (const schemeName in spec.components.securitySchemes) { + const scheme = spec.components.securitySchemes[schemeName]; + if (scheme.type === "openIdConnect") { + let additionalParams = null; + let hints = scheme["x-kc_idp_hint"]; + if (hints) { + additionalParams = { + // Since getting the interface to allow users to choose + // is likely impossible, we will assume the first in the list + // is the "primary" auth system + "kc_idp_hint": hints.values[0] + }; + } + ui.initOAuth({ + clientId: scheme["x-oidc-client-id"], + usePkceWithAuthorizationCodeGrant: true, + additionalQueryStringParams: additionalParams, + }); + break; + } + } + }, }); }, []); diff --git a/cda-gui/vite.config.js b/cda-gui/vite.config.js index 5aef28e49..d03eb0026 100644 --- a/cda-gui/vite.config.js +++ b/cda-gui/vite.config.js @@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { - // const env = loadEnv(mode, process.cwd(), ""); + const env = loadEnv(mode, process.cwd(), ""); // const BASE_PATH = env?.BASE_PATH ?? "/cwms-data"; return { base: "/cwms-data", @@ -14,12 +14,22 @@ export default defineConfig(({ mode }) => { server: { proxy: { "^/cwms-data/timeseries/.*": { - target: "https://cwms-data.usace.army.mil", + target: env.CDA_API_ROOT, changeOrigin: true, secure: false, }, "^/cwms-data/catalog/.*": { - target: "https://cwms-data.usace.army.mil", + target: env.CDA_API_ROOT, + changeOrigin: true, + secure: false, + }, + "^/cwms-data/auth/.*": { + target: env.CDA_API_ROOT, + changeOrigin: true, + secure: false, + }, + "^/cwms-data/swagger-docs$": { + target: env.CDA_API_ROOT, changeOrigin: true, secure: false, }, diff --git a/compose_files/keycloak/realm.json b/compose_files/keycloak/realm.json index d0d283bee..3d579b396 100644 --- a/compose_files/keycloak/realm.json +++ b/compose_files/keycloak/realm.json @@ -663,7 +663,8 @@ "clientAuthenticatorType": "client-secret", "redirectUris": [ "https://cwms-data.test:8444/*", - "https://localhost:5010/*" + "https://localhost:5010/*", + "http://localhost:*" ], "webOrigins": [ "*" diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index c2bc13bf1..add0988ac 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -227,6 +227,7 @@ task run(type: JavaExec) { mainClass = "fixtures.TomcatServer" systemProperties += project.properties.findAll { k, v -> k.startsWith("RADAR") } systemProperties += project.properties.findAll { k, v -> k.startsWith("CDA") } + systemProperties += project.properties.findAll { k, v -> k.startsWith("cwms") } def context = project.findProperty("cda.war.context") ?: "spk-data" diff --git a/cwms-data-api/src/main/java/cwms/cda/security/OpenIDConfig.java b/cwms-data-api/src/main/java/cwms/cda/security/OpenIDConfig.java index 8bc146a23..3e967aed0 100644 --- a/cwms-data-api/src/main/java/cwms/cda/security/OpenIDConfig.java +++ b/cwms-data-api/src/main/java/cwms/cda/security/OpenIDConfig.java @@ -2,52 +2,34 @@ import java.io.IOException; import java.net.HttpURLConnection; -import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.flogger.FluentLogger; -import io.swagger.v3.oas.models.security.OAuthFlow; -import io.swagger.v3.oas.models.security.OAuthFlows; -import io.swagger.v3.oas.models.security.Scopes; import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.security.SecurityScheme.In; import io.swagger.v3.oas.models.security.SecurityScheme.Type; public class OpenIDConfig { private static final FluentLogger log = FluentLogger.forEnclosingClass(); - private static final String ALT_WELL_KNOWN = "cwms.dataapi.access.openid.useAltWellKnown"; - private static final boolean USE_ALT_WELLKNOWN; - - static { - String altWellKnownStr = System.getProperty(ALT_WELL_KNOWN,System.getenv(ALT_WELL_KNOWN)); - if (altWellKnownStr != null) { - USE_ALT_WELLKNOWN = Boolean.parseBoolean(altWellKnownStr); - } else { - USE_ALT_WELLKNOWN = false; - } - } - + private URL wellKnown; - private URL altWellKnown = null; // silly, but needed by the docker-compose setup so URLs match and work. + private String issuer; - private URL authUrl; - private URL tokenUrl; - private URL userInfoUrl; - private URL logoutUrl; + private String client_id; + private String idp_hint; // keycloak specific kc_idp_hint to direct federation + private URL jwksUrl; - private Scopes scopes = new Scopes(); - private OAuthFlows flows = new OAuthFlows(); - public OpenIDConfig(URL wellKnown, String altAuthUrl) throws IOException { + public OpenIDConfig(URL wellKnown, String client_id, String idp_hint) throws IOException { this.wellKnown = wellKnown; - if (USE_ALT_WELLKNOWN) { - this.altWellKnown = substituteBase(wellKnown, altAuthUrl); - } - + this.idp_hint = idp_hint; + this.client_id = client_id; HttpURLConnection http = null; try { @@ -60,34 +42,6 @@ public OpenIDConfig(URL wellKnown, String altAuthUrl) throws IOException { JsonNode node = mapper.readTree(http.getInputStream()); jwksUrl = new URL(node.get("jwks_uri").asText()); issuer = node.get("issuer").asText(); - tokenUrl = substituteBase(new URL(node.get("token_endpoint").asText()),altAuthUrl); - userInfoUrl = substituteBase(new URL(node.get("userinfo_endpoint").asText()),altAuthUrl); - logoutUrl = substituteBase(new URL(node.get("end_session_endpoint").asText()),altAuthUrl); - authUrl = substituteBase(new URL(node.get("authorization_endpoint").asText()),altAuthUrl); - JsonNode scopes = node.get("scopes_supported"); - for(JsonNode scope: scopes) { - this.scopes.addString(scope.asText(), ""); - } - - JsonNode grants = node.get("grant_types_supported"); - for(JsonNode grant: grants) { - OAuthFlow flow = new OAuthFlow(); - flow.setTokenUrl(tokenUrl.toString()); - flow.setAuthorizationUrl(authUrl.toString()); - flow.setScopes(this.scopes); - String grantStr = grant.asText(); - if (grantStr.equalsIgnoreCase("implicit")) { - flows.setImplicit(flow); - } else if(grantStr.equalsIgnoreCase("password")) { - flows.setPassword(flow); - } else if(grantStr.equalsIgnoreCase("authorization_code")) { - flows.setAuthorizationCode(flow); - } else if (grantStr.equalsIgnoreCase("client_credentials")) { - flows.setClientCredentials(flow); - } - } - - } else { log.atSevere().log("Unable to retrieve data from realm. Response code %d",status); } @@ -97,30 +51,30 @@ public OpenIDConfig(URL wellKnown, String altAuthUrl) throws IOException { } } } - - private URL substituteBase(URL endPoint, String altAuthUrl) throws MalformedURLException { - if (altAuthUrl == null) { - return endPoint; - } - log.atInfo().log("Changing '%s' with '%s'", endPoint.toString(), altAuthUrl); - String originalPath = endPoint.getPath(); - log.atInfo().log("New Path = %s", altAuthUrl+originalPath); - return new URL(altAuthUrl+originalPath); - } - + public URL getJwksUrl() { return jwksUrl; } public SecurityScheme getScheme() { - URL theUrl = wellKnown; - if (USE_ALT_WELLKNOWN) { - theUrl = altWellKnown; + + + SecurityScheme scheme = new SecurityScheme().type(Type.OPENIDCONNECT) + .openIdConnectUrl(wellKnown.toString()) + .scheme("openid"); + if (idp_hint != null) + { + Map hint = new HashMap<>(); + hint.put("query-parameter", "kc_idp_hint"); + ArrayList values = new ArrayList<>(); + for (String value: idp_hint.split(",")) { + values.add(value.trim()); + } + hint.put("values", values); + scheme.addExtension("x-kc_idp_hint", hint); } - return new SecurityScheme().type(Type.OPENIDCONNECT) - .openIdConnectUrl(theUrl.toString()) - .name("Authorization") - .flows(flows) - .in(In.HEADER); + + scheme.addExtension("x-oidc-client-id", client_id); + return scheme; } } diff --git a/cwms-data-api/src/main/java/cwms/cda/security/OpenIdConnectIdentitityProvider.java b/cwms-data-api/src/main/java/cwms/cda/security/OpenIdConnectIdentitityProvider.java index 2b357b574..7287c8aa8 100644 --- a/cwms-data-api/src/main/java/cwms/cda/security/OpenIdConnectIdentitityProvider.java +++ b/cwms-data-api/src/main/java/cwms/cda/security/OpenIdConnectIdentitityProvider.java @@ -45,7 +45,8 @@ public final class OpenIdConnectIdentitityProvider implements IdentityProvider { private static final FluentLogger log = FluentLogger.forEnclosingClass(); public static final String WELL_KNOWN_PROPERTY = "cwms.dataapi.access.openid.wellKnownUrl"; - public static final String ALT_AUTH_URL = "cwms.dataapi.access.openid.altAuthUrl"; + public static final String CLIENT_ID = "cwms.dataapi.access.openid.clientId"; + public static final String IDP_HINT = "cwms.dataapi.access.openid.idpHint"; public static final String ISSUER_PROPERTY = "cwms.dataapi.access.openid.issuer"; public static final String TIMEOUT_PROPERTY = "cwms.dataapi.access.openid.timeout"; public static final String AUTHORIZATION = "Authorization"; @@ -64,7 +65,8 @@ public OpenIdConnectIdentitityProvider() { String wellKnownUrl = System.getProperty(WELL_KNOWN_PROPERTY,System.getenv(WELL_KNOWN_PROPERTY)); String issuer = System.getProperty(ISSUER_PROPERTY,System.getenv(ISSUER_PROPERTY)); String timeoutStr = System.getProperty(TIMEOUT_PROPERTY,System.getenv(TIMEOUT_PROPERTY)); - String altAuthUrl = System.getProperty(ALT_AUTH_URL, System.getenv(ALT_AUTH_URL)); + String clientId = System.getProperty(CLIENT_ID, System.getenv(CLIENT_ID)); + String idpHint = System.getProperty(IDP_HINT, System.getenv(IDP_HINT)); int timeout = 3600; if (timeoutStr != null && !timeoutStr.isEmpty()) { timeout = Integer.parseInt(timeoutStr); @@ -73,7 +75,7 @@ public OpenIdConnectIdentitityProvider() { if (wellKnownUrl == null || wellKnownUrl.isEmpty()) { throw new IOException("OpenID Connect well-known URL is not set."); } - config = new OpenIDConfig(new URL(wellKnownUrl), altAuthUrl); + config = new OpenIDConfig(new URL(wellKnownUrl), clientId, idpHint); jwtParser = Jwts.parserBuilder() .requireIssuer(issuer) .setSigningKeyResolver(new UrlResolver(config.getJwksUrl(),timeout)) diff --git a/docker-compose.yml b/docker-compose.yml index f82f73a19..eb067e7d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,9 @@ services: - cwms.dataapi.access.openid.altAuthUrl=http://localhost:${APP_PORT:-8081} - cwms.dataapi.access.openid.useAltWellKnown=true - cwms.dataapi.access.openid.issuer=http://localhost:${APP_PORT:-8081}/auth/realms/cwms + - cwms.dataapi.access.openid.clientId=cwms + # values are not actually used in the local keycloak, however it does fail and leaves them in place for various testing. + - cwms.dataapi.access.openid.idpHint=federation-eams,login.gov expose: - 7000 - 5005