From 023ac4a7f2b573881a02ff412e35f4c9574ad584 Mon Sep 17 00:00:00 2001 From: "Michael A. Neilson" Date: Mon, 3 Aug 2026 17:43:33 +0000 Subject: [PATCH 1/2] Allow for case of OpenID provider not yet being ready. Attempts to initially query the provider and if it's not available try every 5 minutes until it is. Or never if now wellKnown URL was provided. Additionally, added mechanism to surpress specific IdentityProviders in certain envrionments. Modified docker compose to simulate the initial missing keycloak configuration and allow better remote development by removing the need for the alt well known setup. --- Dockerfile | 1 + .../cwms/cda/spi/CdaIdentityProviders.java | 2 +- compose_files/api_entry.sh | 5 + compose_files/pki/certs/features.properties | 0 compose_files/proxy_auth.sh | 5 + .../src/main/java/cwms/cda/ApiServlet.java | 22 +-- .../cda/openapi/OpenApiSchemeProcessor.java | 50 +++++ .../java/cwms/cda/security/Authenticator.java | 11 +- .../java/cwms/cda/security/OpenIDConfig.java | 136 +++++++++++-- .../OpenIdConnectIdentitityProvider.java | 178 +++++------------- docker-compose.yml | 25 ++- 11 files changed, 268 insertions(+), 167 deletions(-) create mode 100755 compose_files/api_entry.sh create mode 100644 compose_files/pki/certs/features.properties create mode 100755 compose_files/proxy_auth.sh create mode 100644 cwms-data-api/src/main/java/cwms/cda/openapi/OpenApiSchemeProcessor.java diff --git a/Dockerfile b/Dockerfile index 61eb82a6cd..75f9d71e55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,7 @@ ENV CDA_POOL_MAX_ACTIVE="30" ENV CDA_POOL_MAX_IDLE="10" ENV CDA_POOL_MIN_IDLE="5" ENV cwms.dataapi.access.providers="KeyAccessManager,OpenID" +ENV cwms.dataapi.access.providers.surpress=CwmsAAACacAuth ENV cwms.dataapi.access.openid.wellKnownUrl="https:///.well-known/openid-configuration" ENV cwms.dataapi.access.openid.issuer="" ENV cwms.dataapi.access.openid.timeout="604800" diff --git a/access-manager-api/src/main/java/cwms/cda/spi/CdaIdentityProviders.java b/access-manager-api/src/main/java/cwms/cda/spi/CdaIdentityProviders.java index a946a579a2..b3eab7240f 100644 --- a/access-manager-api/src/main/java/cwms/cda/spi/CdaIdentityProviders.java +++ b/access-manager-api/src/main/java/cwms/cda/spi/CdaIdentityProviders.java @@ -4,7 +4,7 @@ import java.util.ServiceLoader; public class CdaIdentityProviders { - + private static final ServiceLoader loader = ServiceLoader.load(IdentityProvider.class); diff --git a/compose_files/api_entry.sh b/compose_files/api_entry.sh new file mode 100755 index 0000000000..97c9a818c1 --- /dev/null +++ b/compose_files/api_entry.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +nohup ./proxy_auth.sh 2>&1 > /dev/null & +echo "auth proxy started now executing $*" +exec $* \ No newline at end of file diff --git a/compose_files/pki/certs/features.properties b/compose_files/pki/certs/features.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/compose_files/proxy_auth.sh b/compose_files/proxy_auth.sh new file mode 100755 index 0000000000..e57c30aa89 --- /dev/null +++ b/compose_files/proxy_auth.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +mkfifo backpipe +#while true; do nc -lk -p 7100 0backpipe; done +nc -lk -p ${APP_PORT:-8081} -e nc auth ${APP_PORT:-8081} \ No newline at end of file diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index a6b5555e02..0c0e3ccc9a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -184,6 +184,7 @@ import cwms.cda.features.CdaFeatures; import cwms.cda.formatters.Formats; import cwms.cda.formatters.csv.CsvExampleGenerator; +import cwms.cda.openapi.OpenApiSchemeProcessor; import cwms.cda.security.Authenticator; import cwms.cda.security.CdaAccessManager; import cwms.cda.security.DataApiPrincipal; @@ -322,6 +323,7 @@ public class ApiServlet extends HttpServlet { JavalinServlet javalin = null; private Authenticator authenticator = new Authenticator(); + private OpenApiSchemeProcessor schemeProcessor = new OpenApiSchemeProcessor(authenticator); private String APP_CONTEXT; @Resource(name = "jdbc/CWMS3") @@ -374,6 +376,7 @@ public void init() { }) .attribute("PolicyFactory", sanitizer) .attribute("ObjectMapper", om) + .attribute("schemeProcessor", schemeProcessor) .before(authenticator) .before(ctx -> { ctx.attribute("sanitizer", sanitizer); @@ -976,32 +979,20 @@ private void getOpenApiOptions(JavalinConfig config) { String provider = CdaAccessManager.class.getSimpleName(); - - Components components = new Components(); - final ArrayList secReqs = new ArrayList<>(); - authenticator.getActiveProviders().forEach(identityProvider -> { - components.addSecuritySchemes(identityProvider.getName(),identityProvider.getScheme()); - SecurityRequirement req = new SecurityRequirement(); - if (!identityProvider.getName().equalsIgnoreCase("guestauth") - && !identityProvider.getName().equalsIgnoreCase("noauth")) { - req.addList(identityProvider.getName()); - secReqs.add(req); - } - }); - List servers = new ArrayList<>(); servers.add(new Server().url(APP_CONTEXT)); OpenApiOptions ops = new OpenApiOptions( - () -> new OpenAPI().components(components) + () -> new OpenAPI() .servers(servers) .info(applicationInfo) .addSecurityItem(new SecurityRequirement().addList(provider)) ); ops.path("/swagger-docs") .responseModifier((ctx,api) -> { + schemeProcessor.apply(ctx, api); api.getPaths().forEach((key,path) -> { - setSecurityRequirements(key,path,secReqs); + setSecurityRequirements(key,path,schemeProcessor.getSecurityRequirements()); // yeah, we really need to figure out how to update everything, this is supported as an annotation in // newer versions. if (key.startsWith("/rss")) { @@ -1066,6 +1057,7 @@ private void getOpenApiOptions(JavalinConfig config) { .activateAnnotationScanningFor("cwms.cda.api"); addEndpointExamples(ops); config.registerPlugin(new OpenApiPlugin(ops)); + } private static void setSecurityRequirements(String key, PathItem path,List secReqs) { diff --git a/cwms-data-api/src/main/java/cwms/cda/openapi/OpenApiSchemeProcessor.java b/cwms-data-api/src/main/java/cwms/cda/openapi/OpenApiSchemeProcessor.java new file mode 100644 index 0000000000..b50e2fdc18 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/openapi/OpenApiSchemeProcessor.java @@ -0,0 +1,50 @@ +package cwms.cda.openapi; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import cwms.cda.security.Authenticator; +import io.javalin.http.Context; +import io.javalin.plugin.openapi.OpenApiModelModifier; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +public class OpenApiSchemeProcessor implements OpenApiModelModifier { + + private final Authenticator authenticator; + private final ArrayList secReqs = new ArrayList<>(); + + public OpenApiSchemeProcessor(Authenticator authenticator) + { + this.authenticator = authenticator; + } + + @Override + public OpenAPI apply(Context ctx, OpenAPI api) { + var schemes = api.getComponents().getSecuritySchemes(); + if (schemes != null) + { + schemes.clear(); + } + synchronized (secReqs) { + secReqs.clear(); + authenticator.getActiveProviders().forEach(identityProvider -> { + api.getComponents().addSecuritySchemes(identityProvider.getName(),identityProvider.getScheme()); + SecurityRequirement req = new SecurityRequirement(); + if (!identityProvider.getName().equalsIgnoreCase("guestauth") + && !identityProvider.getName().equalsIgnoreCase("noauth")) { + req.addList(identityProvider.getName()); + secReqs.add(req); + } + }); + } + return api; + } + + + public List getSecurityRequirements() + { + return Collections.unmodifiableList(secReqs); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/security/Authenticator.java b/cwms-data-api/src/main/java/cwms/cda/security/Authenticator.java index 53fa72b634..956ccc516d 100644 --- a/cwms-data-api/src/main/java/cwms/cda/security/Authenticator.java +++ b/cwms-data-api/src/main/java/cwms/cda/security/Authenticator.java @@ -17,11 +17,16 @@ public final class Authenticator implements Handler { private final ArrayList providers = new ArrayList<>(); public Authenticator() { + var surpressed = System.getenv("cwms.dataapi.access.providers.surpress"); + final var supressedList = surpressed == null ? List.of() : List.of(surpressed.split(",")); + CdaIdentityProviders.providers().forEachRemaining(provider -> { - if (provider.getScheme() != null) { + if (!supressedList.contains(provider.getName()) && provider.getScheme() != null) { providers.add(provider); } else { - logger.atSevere().log("Unable to add Identity Provider %s. See earlier logs for specific error message.", provider.getName()); + logger.atSevere() + .log("Unable to add Identity Provider %s. See earlier logs for specific error message.", + provider.getName()); } }); } @@ -36,7 +41,7 @@ public void handle(Context ctx) throws Exception { } } } - + public List getActiveProviders() { return Collections.unmodifiableList(providers); } 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 44fe78f286..9a0156ffe1 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 @@ -1,9 +1,18 @@ package cwms.cda.security; import java.io.IOException; +import java.math.BigInteger; import java.net.HttpURLConnection; import java.net.URL; +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Base64; +import java.util.Base64.Decoder; import java.util.HashMap; import java.util.Map; @@ -11,49 +20,71 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.flogger.FluentLogger; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SigningKeyResolverAdapter; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme.Type; public class OpenIDConfig { private static final FluentLogger log = FluentLogger.forEnclosingClass(); - + private URL wellKnown; - + private String issuer; private String client_id; private String idp_hint; // keycloak specific kc_idp_hint to direct federation - + private JwtParser jwtParser; private URL jwksUrl; - - public OpenIDConfig(URL wellKnown, String client_id, String idp_hint) throws IOException { + + private OpenIDConfig(URL wellKnown, String client_id, String idp_hint, JwtParser jwtParser) throws IOException { this.wellKnown = wellKnown; this.idp_hint = idp_hint; this.client_id = client_id; + this.jwtParser = jwtParser; + } + + public URL getJwksUrl() { + return jwksUrl; + } + + public static OpenIDConfig from(URL wellKnown, String clientId, String idpHint, int timeout) throws IOException + { HttpURLConnection http = null; try { http = (HttpURLConnection)wellKnown.openConnection(); http.setRequestMethod("GET"); - http.setInstanceFollowRedirects(true); + http.setInstanceFollowRedirects(true); int status = http.getResponseCode(); if (status == 200) { ObjectMapper mapper = new ObjectMapper(); JsonNode node = mapper.readTree(http.getInputStream()); - jwksUrl = new URL(node.get("jwks_uri").asText()); - issuer = node.get("issuer").asText(); + URL jwksUrl = new URL(node.get("jwks_uri").asText()); + String issuer = node.get("issuer").asText(); + + JwtParser jwtParser = Jwts.parserBuilder() + .requireIssuer(issuer) + .setSigningKeyResolver(new UrlResolver(jwksUrl, timeout)) + .build(); + return new OpenIDConfig(wellKnown, clientId, idpHint, jwtParser); } else { - log.atSevere().log("Unable to retrieve data from realm. Response code %d",status); + log.atSevere().log("Unable to retrieve data from realm. Response code %d", status); } } finally { if (http != null) { http.disconnect(); } } + throw new IOException("Unable to retrieve OIDC information from provider."); } - - public URL getJwksUrl() { - return jwksUrl; + + public JwtParser getJwtParser() + { + return this.jwtParser; } static SecurityScheme buildScheme(String wellKnownUrl, String clientId, String idpHint) { @@ -76,8 +107,87 @@ static SecurityScheme buildScheme(String wellKnownUrl, String clientId, String i } public SecurityScheme getScheme() { - + SecurityScheme scheme = buildScheme(wellKnown.toString(), client_id, idp_hint); return scheme; } + + + private static class UrlResolver extends SigningKeyResolverAdapter { + private final URL jwksUrl; + private ZonedDateTime lastCheck; + private final Map realmPublicKeys = new HashMap<>(); + private final int realmPublicKeyTimeoutMinutes; + private KeyFactory keyFactory = null; + + public UrlResolver(URL jwksUrl, int keyTimeoutMinutes) { + this.jwksUrl = jwksUrl; + this.realmPublicKeyTimeoutMinutes = keyTimeoutMinutes; + try { + keyFactory = KeyFactory.getInstance("RSA"); + } catch (NoSuchAlgorithmException ex) { + log.atSevere().withCause(ex).log("Unable to initialize key factory."); + } + } + + private void updateKey() { + if (realmPublicKeys.isEmpty() || ZonedDateTime.now().isAfter(lastCheck.plusMinutes(realmPublicKeyTimeoutMinutes))) { + log.atInfo().log("Checking for new key at %s",jwksUrl); + try { + realmPublicKeys.clear(); + updateSigningKey(); + } catch (IOException ex) { + log.atSevere().withCause(ex).log("Unable to update key. Will continue to use previous key."); + } catch (InvalidKeySpecException ex) { + log.atSevere().withCause(ex).log("New Public Key was not valid. Will continue to use previous key."); + } + lastCheck = ZonedDateTime.now(); + } + } + + private void updateSigningKey() throws IOException, InvalidKeySpecException { + HttpURLConnection http = null; + try { + http = (HttpURLConnection)jwksUrl.openConnection(); + http.setRequestMethod("GET"); + http.setInstanceFollowRedirects(true); + int status = http.getResponseCode(); + if (status == 200) { + ObjectMapper mapper = new ObjectMapper(); + JsonNode keys = mapper.readTree(http.getInputStream()).get("keys"); + for (JsonNode key: keys) { + String kid = key.get("kid").textValue(); + Decoder b64 = Base64.getUrlDecoder(); // https://datatracker.ietf.org/doc/id/draft-jones-json-web-key-01.html#RFC4648 + String nStr = key.get("n").textValue(); + String eStr = key.get("e").textValue(); + log.atInfo().log("Loading Key %s with parameters (n,e) -> (%s,%s)",kid,nStr,eStr); + BigInteger n = new BigInteger(1,b64.decode(nStr)); + BigInteger e = new BigInteger(1,b64.decode(eStr)); + Key pubKey = keyFactory.generatePublic(new RSAPublicKeySpec(n, e)); + realmPublicKeys.put(kid,pubKey); + } + } else { + log.atSevere().log("Unable to retrieve actual keys. Response code %d",status); + } + } finally { + if (http != null) { + http.disconnect(); + } + } + } + + @Override + public Key resolveSigningKey(JwsHeader header, Claims claims) { + if (!header.getAlgorithm().toLowerCase().startsWith("rs")) { + log.atWarning().log("Request with invalid algorithm '%s'",header.getAlgorithm()); + return null; // we only deal with RSA keys right now. + } + updateKey(); + Key key = realmPublicKeys.get(header.getKeyId()); + if (key == null) { + log.atSevere().log("Key not found for id '%s'",header.getKeyId()); + } + return key; + } + } } 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 48d1c8940c..0f006eef7d 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 @@ -8,35 +8,21 @@ import io.javalin.http.Context; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; -import io.jsonwebtoken.JwsHeader; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SigningKeyResolverAdapter; import io.swagger.v3.oas.models.security.SecurityScheme; import java.io.IOException; -import java.math.BigInteger; -import java.net.HttpURLConnection; import java.net.URL; -import java.security.Key; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; import java.security.Principal; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.RSAPublicKeySpec; -import java.time.ZonedDateTime; -import java.util.Base64; -import java.util.Base64.Decoder; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.servlet.http.HttpServletResponse; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.flogger.FluentLogger; @@ -58,38 +44,61 @@ public final class OpenIdConnectIdentitityProvider implements IdentityProvider { private static final boolean CREATE_USERS = Boolean.parseBoolean(System.getProperty(CREATE_USERS_KEY,"true")); - private JwtParser jwtParser = null; - private OpenIDConfig config = null; + private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + private AtomicReference config = new AtomicReference<>(null); + + private final String wellKnownUrl; + private final String issuer; + private final String clientId; + private final int timeout; + private final String idpHint; public OpenIdConnectIdentitityProvider() { - String wellKnownUrl = System.getProperty(WELL_KNOWN_PROPERTY,System.getenv(WELL_KNOWN_PROPERTY)); - String issuer = System.getProperty(ISSUER_PROPERTY,System.getenv(ISSUER_PROPERTY)); + wellKnownUrl = System.getProperty(WELL_KNOWN_PROPERTY,System.getenv(WELL_KNOWN_PROPERTY)); + issuer = System.getProperty(ISSUER_PROPERTY,System.getenv(ISSUER_PROPERTY)); String timeoutStr = System.getProperty(TIMEOUT_PROPERTY,System.getenv(TIMEOUT_PROPERTY)); - String clientId = System.getProperty(CLIENT_ID, System.getenv(CLIENT_ID)); - String idpHint = System.getProperty(IDP_HINT, System.getenv(IDP_HINT)); - int timeout = 3600; + clientId = System.getProperty(CLIENT_ID, System.getenv(CLIENT_ID)); + idpHint = System.getProperty(IDP_HINT, System.getenv(IDP_HINT)); if (timeoutStr != null && !timeoutStr.isEmpty()) { timeout = Integer.parseInt(timeoutStr); + } else { + timeout = 3600; } - try { - if (wellKnownUrl == null || wellKnownUrl.isEmpty()) { - throw new IOException("OpenID Connect well-known URL is not set."); + // try it once, then every 5 minutes until we get it. + initializeProvider(); + executor.scheduleAtFixedRate(this::initializeProvider, 0, 5, TimeUnit.MINUTES); + } + + private void initializeProvider() + { + var foundConfig = config.getAndUpdate(c -> { + if (c != null) + { + return c; // already initialized, don't change it. } - config = new OpenIDConfig(new URL(wellKnownUrl), clientId, idpHint); - jwtParser = Jwts.parserBuilder() - .requireIssuer(issuer) - .setSigningKeyResolver(new UrlResolver(config.getJwksUrl(),timeout)) - .build(); - } catch (IOException ex) { - // The downstream users of this check if the Provider is valid and respond appropriate. - // To test manually have OpenIDConfig throw an IOException so config stays null and - // see the resulting explained failure in the logs. - // That said it's possible we should maybe just have the system fail completely. - log.atSevere().withCause(ex).log("Unable to initialize realm."); + try { + log.atFine().log("Attempting to initalize OIDC provider for %s", wellKnownUrl); + if (wellKnownUrl == null || wellKnownUrl.isEmpty()) { + executor.shutdown(); // it won't be found, don't keep looking + throw new IOException("OpenID Connect well-known URL is not set."); + } + URL wellKnown = new URL(wellKnownUrl); + return OpenIDConfig.from(wellKnown, clientId, idpHint, timeout); + } catch (IOException ex) { + // The downstream users of this check if the Provider is valid and respond appropriate. + // To test manually have OpenIDConfig throw an IOException so config stays null and + // see the resulting explained failure in the logs. + // That said it's possible we should maybe just have the system fail completely. + log.atSevere().withCause(ex).log("Unable to initialize realm."); + } + return c; + }); + if (foundConfig != null) { + executor.shutdown(); // we have it, don't need to keep polling } } - @Override public Principal authenticate(Context ctx) { return getUserFromToken(ctx); @@ -97,7 +106,7 @@ public Principal authenticate(Context ctx) { private DataApiPrincipal getUserFromToken(Context ctx) throws CwmsAuthException { try { - Jws token = jwtParser.parseClaimsJws(getToken(ctx)); + Jws token = config.get().getJwtParser().parseClaimsJws(getToken(ctx)); Claims claims = token.getBody(); final String issuer = claims.getIssuer(); final String subject = claims.getSubject(); @@ -139,7 +148,8 @@ private String getToken(Context ctx) { @Override public SecurityScheme getScheme() { - return config != null ? config.getScheme() : null; + var configActual = config.get(); + return configActual != null ? configActual.getScheme() : null; } @Override @@ -155,88 +165,4 @@ public boolean canAuth(Context ctx) { } return header.trim().toLowerCase().startsWith("bearer"); } - - - private static class UrlResolver extends SigningKeyResolverAdapter { - private final URL jwksUrl; - private ZonedDateTime lastCheck; - private final Map realmPublicKeys = new HashMap<>(); - private final int realmPublicKeyTimeoutMinutes; - private KeyFactory keyFactory = null; - - public UrlResolver(URL jwksUrl, int keyTimeoutMinutes) { - this.jwksUrl = jwksUrl; - this.realmPublicKeyTimeoutMinutes = keyTimeoutMinutes; - try { - keyFactory = KeyFactory.getInstance("RSA"); - } catch (NoSuchAlgorithmException ex) { - log.atSevere().withCause(ex).log("Unable to initialize key factory."); - } - } - - /** - * TODO: This needs more, some configurations may be more complex (like the - * authelia test environment) than others. - */ - private void updateKey() { - if (realmPublicKeys.isEmpty() || ZonedDateTime.now().isAfter(lastCheck.plusMinutes(realmPublicKeyTimeoutMinutes))) { - log.atInfo().log("Checking for new key at %s",jwksUrl); - try { - realmPublicKeys.clear(); - updateSigningKey(); - } catch (IOException ex) { - log.atSevere().withCause(ex).log("Unable to update key. Will continue to use previous key."); - } catch (InvalidKeySpecException ex) { - log.atSevere().withCause(ex).log("New Public Key was not valid. Will continue to use previous key."); - } - lastCheck = ZonedDateTime.now(); - } - } - - private void updateSigningKey() throws IOException, InvalidKeySpecException { - HttpURLConnection http = null; - try { - http = (HttpURLConnection)jwksUrl.openConnection(); - http.setRequestMethod("GET"); - http.setInstanceFollowRedirects(true); - int status = http.getResponseCode(); - if (status == 200) { - ObjectMapper mapper = new ObjectMapper(); - JsonNode keys = mapper.readTree(http.getInputStream()).get("keys"); - for (JsonNode key: keys) { - String kid = key.get("kid").textValue(); - Decoder b64 = Base64.getUrlDecoder(); // https://datatracker.ietf.org/doc/id/draft-jones-json-web-key-01.html#RFC4648 - String nStr = key.get("n").textValue(); - String eStr = key.get("e").textValue(); - log.atInfo().log("Loading Key %s with parameters (n,e) -> (%s,%s)",kid,nStr,eStr); - BigInteger n = new BigInteger(1,b64.decode(nStr)); - BigInteger e = new BigInteger(1,b64.decode(eStr)); - Key pubKey = keyFactory.generatePublic(new RSAPublicKeySpec(n, e)); - realmPublicKeys.put(kid,pubKey); - } - } else { - log.atSevere().log("Unable to retrieve actual keys. Response code %d",status); - } - } finally { - if (http != null) { - http.disconnect(); - } - } - } - - @Override - public Key resolveSigningKey(JwsHeader header, Claims claims) { - if (!header.getAlgorithm().toLowerCase().startsWith("rs")) { - log.atWarning().log("Request with invalid algorithm '%s'",header.getAlgorithm()); - return null; // we only deal with RSA keys right now. - } - updateKey(); - Key key = realmPublicKeys.get(header.getKeyId()); - if (key == null) { - log.atSevere().log("Key not found for id '%s'",header.getKeyId()); - } - return key; - } - } - } diff --git a/docker-compose.yml b/docker-compose.yml index e8f93c5423..663310b950 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,8 +48,11 @@ services: data-api: depends_on: - auth: - condition: service_healthy + # to test the OpenIdConnectIdentityProvider setup, we initionally + # reverse the dependency order to force the situtation of + # there not being an OpenId Connect Provider instances ready set and service startup. + #auth: + # condition: service_healthy db: condition: service_healthy db_webuser_permissions: @@ -65,12 +68,16 @@ services: target: api context: . dockerfile: Dockerfile + entrypoint: ["/api_entry.sh"] + command: ["/usr/local/tomcat/bin/catalina.sh", "run"] #command: bash -c "/conf/installcerts.sh && /usr/local/tomcat/bin/catalina.sh run" restart: unless-stopped volumes: - ./compose_files/pki/certs:/conf/ - ./compose_files/togglz/features.properties:/conf/features.properties:ro - ./compose_files/tomcat/logging.properties:/usr/local/tomcat/conf/logging.properties:ro + - ./compose_files/api_entry.sh:/api_entry.sh:ro + - ./compose_files/proxy_auth.sh:/proxy_auth.sh:ro environment: - JAVA_OPTS=-Dproperties.file=/conf/features.properties -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -Dorg.apache.tomcat.util.buf.UDecoder.ALLOW_ENCODED_SLASH=true - CDA_JDBC_DRIVER=oracle.jdbc.driver.OracleDriver @@ -82,14 +89,12 @@ services: - CDA_POOL_MAX_IDLE=5 - CDA_POOL_MIN_IDLE=2 - cwms.dataapi.access.provider=MultipleAccessManager - - cwms.dataapi.access.providers=KeyAccessManager,OpenID + - cwms.dataapi.access.providers.surpress=CwmsAAACacAuth - cwms.dataapi.access.openid.create_users=true - - cwms.dataapi.access.openid.wellKnownUrl=http://auth:${APP_PORT:-8081}/auth/realms/cwms/.well-known/openid-configuration - - cwms.dataapi.access.openid.altAuthUrl=http://localhost:${APP_PORT:-8081} - - cwms.dataapi.access.openid.useAltWellKnown=true + - cwms.dataapi.access.openid.wellKnownUrl=http://localhost:${APP_PORT:-8081}/auth/realms/cwms/.well-known/openid-configuration - 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. + # values are not actually used in the local keycloak, however it doesn't fail and leaves them in place for various testing. - cwms.dataapi.access.openid.idpHint=federation-eams,login.gov - blob.store.endpoint=http://minio:9000 - blob.store.region=docker @@ -142,12 +147,14 @@ services: depends_on: traefik: condition: service_healthy - + data-api: + condition: service_healthy + # Proxy for HTTPS for OpenID traefik: - image: "traefik:v3.6.2" + image: "traefik:v3.7.10" ports: - "${APP_PORT:-8081}:80" expose: From 72229916b7644247e04382cb8109ae6b753c77ac Mon Sep 17 00:00:00 2001 From: Charles Graham Date: Fri, 7 Aug 2026 10:30:49 -0500 Subject: [PATCH 2/2] Harden OpenID retry initialization (#1870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up hardening for #1859's OpenID discovery retry and local Compose setup. ## Changes and rationale - Treat OpenID as unable to authenticate until its configuration is ready, preventing bearer requests from dereferencing a missing parser during the retry window. - Skip retry scheduling when no well-known URL is configured and stop scheduling after successful initialization, avoiding rejected executor work and unnecessary polling. - Pass `APP_PORT` into the data-api container, keeping its local authentication proxy aligned with the discovery URL and Keycloak when a nondefault port is used. - Add regression coverage for the intentionally disabled no-URL configuration. Per review feedback, this follow-up no longer changes `Authenticator.java`. ## Validation - `./gradlew :cwms-data-api:test --tests '*OpenIDConfigTest' :cwms-data-api:checkstyleMain :cwms-data-api:checkstyleTest` - `docker compose config` with `APP_PORT=9090` - `git diff --check` The focused test and Checkstyle tasks pass. Checkstyle reports the repository's existing warning-level violations. --------- Signed-off-by: Charles Graham, SWT --- .../OpenIdConnectIdentitityProvider.java | 20 ++++++++++++------- .../cwms/cda/security/OpenIDConfigTest.java | 19 ++++++++++++++++++ docker-compose.yml | 1 + 3 files changed, 33 insertions(+), 7 deletions(-) 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 0f006eef7d..ea99b84e48 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 @@ -46,7 +46,7 @@ public final class OpenIdConnectIdentitityProvider implements IdentityProvider { private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - private AtomicReference config = new AtomicReference<>(null); + private final AtomicReference config = new AtomicReference<>(null); private final String wellKnownUrl; private final String issuer; @@ -65,9 +65,16 @@ public OpenIdConnectIdentitityProvider() { } else { timeout = 3600; } + if (wellKnownUrl == null || wellKnownUrl.isEmpty()) { + log.atInfo().log("OpenID Connect well-known URL is not set; provider will remain disabled."); + executor.shutdown(); + return; + } // try it once, then every 5 minutes until we get it. initializeProvider(); - executor.scheduleAtFixedRate(this::initializeProvider, 0, 5, TimeUnit.MINUTES); + if (config.get() == null) { + executor.scheduleAtFixedRate(this::initializeProvider, 5, 5, TimeUnit.MINUTES); + } } private void initializeProvider() @@ -79,10 +86,6 @@ private void initializeProvider() } try { log.atFine().log("Attempting to initalize OIDC provider for %s", wellKnownUrl); - if (wellKnownUrl == null || wellKnownUrl.isEmpty()) { - executor.shutdown(); // it won't be found, don't keep looking - throw new IOException("OpenID Connect well-known URL is not set."); - } URL wellKnown = new URL(wellKnownUrl); return OpenIDConfig.from(wellKnown, clientId, idpHint, timeout); } catch (IOException ex) { @@ -94,7 +97,7 @@ private void initializeProvider() } return c; }); - if (foundConfig != null) { + if (foundConfig != null || config.get() != null) { executor.shutdown(); // we have it, don't need to keep polling } } @@ -159,6 +162,9 @@ public String getName() { @Override public boolean canAuth(Context ctx) { + if (config.get() == null) { + return false; + } String header = ctx.header(AUTHORIZATION); if (header == null) { return false; diff --git a/cwms-data-api/src/test/java/cwms/cda/security/OpenIDConfigTest.java b/cwms-data-api/src/test/java/cwms/cda/security/OpenIDConfigTest.java index cdce8c5042..b7f46b2ff4 100644 --- a/cwms-data-api/src/test/java/cwms/cda/security/OpenIDConfigTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/security/OpenIDConfigTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.swagger.v3.oas.models.security.SecurityScheme; @@ -12,6 +13,24 @@ class OpenIDConfigTest { + @Test + void providerRemainsDisabledWhenWellKnownUrlIsMissing() { + String previousWellKnown = System.getProperty(OpenIdConnectIdentitityProvider.WELL_KNOWN_PROPERTY); + try { + System.setProperty(OpenIdConnectIdentitityProvider.WELL_KNOWN_PROPERTY, ""); + + OpenIdConnectIdentitityProvider provider = new OpenIdConnectIdentitityProvider(); + + assertNull(provider.getScheme()); + } finally { + if (previousWellKnown == null) { + System.clearProperty(OpenIdConnectIdentitityProvider.WELL_KNOWN_PROPERTY); + } else { + System.setProperty(OpenIdConnectIdentitityProvider.WELL_KNOWN_PROPERTY, previousWellKnown); + } + } + } + @Test void buildSchemeUsesWellKnownDiscoveryUrlWithoutHttpAuthScheme() { SecurityScheme scheme = OpenIDConfig.buildScheme( diff --git a/docker-compose.yml b/docker-compose.yml index 663310b950..dd79581ef0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,7 @@ services: - ./compose_files/api_entry.sh:/api_entry.sh:ro - ./compose_files/proxy_auth.sh:/proxy_auth.sh:ro environment: + - APP_PORT=${APP_PORT:-8081} - JAVA_OPTS=-Dproperties.file=/conf/features.properties -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -Dorg.apache.tomcat.util.buf.UDecoder.ALLOW_ENCODED_SLASH=true - CDA_JDBC_DRIVER=oracle.jdbc.driver.OracleDriver - CDA_JDBC_URL=jdbc:oracle:thin:@db/FREEPDB1