This is an automated email from the ASF dual-hosted git repository.
smolnar82 pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/knox_idf by this push:
new 33c51e855 KNOX-3355 - Add OIDCDiscoveryHelper,
JdbcTrustedOidcIssuerService, and TrustedOidcIssuerServiceFactory (#1315)
33c51e855 is described below
commit 33c51e855b8d129723bc18ac6f7761460c38e0af
Author: hsheinblatt <[email protected]>
AuthorDate: Wed Jul 22 06:25:19 2026 -0700
KNOX-3355 - Add OIDCDiscoveryHelper, JdbcTrustedOidcIssuerService, and
TrustedOidcIssuerServiceFactory (#1315)
---
gateway-server/pom.xml | 4 +
.../apache/knox/gateway/database/DatabaseType.java | 29 +-
.../gateway/services/DefaultGatewayServices.java | 2 +
.../factory/TrustedOidcIssuerServiceFactory.java | 105 +++++++
.../EmptyTrustedOidcIssuerService.java | 81 +++++
.../JdbcTrustedOidcIssuerService.java | 200 +++++++++++++
.../trustedoidcissuer/OIDCDiscoveryHelper.java | 176 +++++++++++
.../TrustedOidcIssuerDatabase.java | 101 +++++++
.../TrustedOidcIssuerServiceMessages.java | 51 ++++
...org.apache.knox.gateway.services.ServiceFactory | 1 +
.../EmptyTrustedOidcIssuerServiceTest.java | 64 ++++
.../JdbcTrustedOidcIssuerServiceTest.java | 325 +++++++++++++++++++++
.../trustedoidcissuer/OIDCDiscoveryHelperTest.java | 303 +++++++++++++++++++
.../TrustedOidcIssuerServiceFactoryTest.java | 265 +++++++++++++++++
.../TrustedOidcIssuersSchemaTest.java | 2 +
.../gateway/util/knoxidf/KnoxIDFConstants.java | 13 +
pom.xml | 6 +
17 files changed, 1720 insertions(+), 8 deletions(-)
diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml
index 3ddfb07ab..012ffe8af 100644
--- a/gateway-server/pom.xml
+++ b/gateway-server/pom.xml
@@ -417,6 +417,10 @@
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.knox</groupId>
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
index 3f627bdac..5052d5d5a 100644
---
a/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
@@ -24,7 +24,8 @@ public enum DatabaseType {
AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+ AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
),
MYSQL("mysql",
AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -32,7 +33,8 @@ public enum DatabaseType {
AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+ AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
),
MARIADB("mariadb",
AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -40,7 +42,8 @@ public enum DatabaseType {
AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+ AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
),
HSQL("hsql",
AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -48,7 +51,8 @@ public enum DatabaseType {
AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+
AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+ AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
),
DERBY("derbydb",
AbstractDataSourceFactory.DERBY_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -56,8 +60,8 @@ public enum DatabaseType {
AbstractDataSourceFactory.DERBY_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.DERBY_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-
AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
-
+
AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+
AbstractDataSourceFactory.DERBY_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
),
ORACLE("oracle",
AbstractDataSourceFactory.ORACLE_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -65,7 +69,8 @@ public enum DatabaseType {
AbstractDataSourceFactory.ORACLE_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.ORACLE_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-
AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+
AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+
AbstractDataSourceFactory.ORACLE_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
);
private final String type;
@@ -75,8 +80,11 @@ public enum DatabaseType {
private final String descriptorsTableSql;
private final String federatedIdentityTableSql;
private final String federatedIdentityAttrTableSql;
+ private final String trustedOidcIssuersTableSql;
- DatabaseType(String type, String tokensTableSql, String metadataTableSql,
String providersTableSql, String descriptorsTableSql, String
federatedIdentityTableSql, String federatedIdentityAttrTableSql) {
+ DatabaseType(String type, String tokensTableSql, String metadataTableSql,
String providersTableSql,
+ String descriptorsTableSql, String federatedIdentityTableSql, String
federatedIdentityAttrTableSql,
+ String trustedOidcIssuersTableSql) {
this.type = type;
this.tokensTableSql = tokensTableSql;
this.metadataTableSql = metadataTableSql;
@@ -84,6 +92,7 @@ public enum DatabaseType {
this.descriptorsTableSql = descriptorsTableSql;
this.federatedIdentityTableSql = federatedIdentityTableSql;
this.federatedIdentityAttrTableSql = federatedIdentityAttrTableSql;
+ this.trustedOidcIssuersTableSql = trustedOidcIssuersTableSql;
}
public String type() {
@@ -114,6 +123,10 @@ public enum DatabaseType {
return federatedIdentityAttrTableSql;
}
+ public String trustedOidcIssuersTableSql() {
+ return trustedOidcIssuersTableSql;
+ }
+
public static DatabaseType fromString(String dbType) {
for (DatabaseType dt : values()) {
if (dt.type.equalsIgnoreCase(dbType)) {
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
index 2d7d12f13..39cf0aca0 100644
---
a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
@@ -88,6 +88,8 @@ public class DefaultGatewayServices extends
AbstractGatewayServices {
addService(ServiceType.LDAP_SERVICE, gatewayServiceFactory.create(this,
ServiceType.LDAP_SERVICE, config, options));
addService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE,
gatewayServiceFactory.create(this,
ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options));
+
+ addService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE,
gatewayServiceFactory.create(this, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE,
config, options));
}
@Override
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
new file mode 100644
index 000000000..daa8d11a4
--- /dev/null
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.factory;
+
+import org.apache.knox.gateway.GatewayMessages;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.Service;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.ServiceType;
+import
org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService;
+import
org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService;
+import
org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
+
+ private static final GatewayMessages LOG =
MessagesFactory.get(GatewayMessages.class);
+ private static final String DEFAULT_IMPLEMENTATION =
EmptyTrustedOidcIssuerService.class.getName();
+
+ @Override
+ protected Service createService(GatewayServices gatewayServices, ServiceType
serviceType,
+ GatewayConfig gatewayConfig, Map<String, String> options, String
implementation)
+ throws ServiceLifecycleException {
+
+ String implementationToUse = implementation;
+ if (isEmptyDefaultImplementation(implementationToUse)) {
+ if (isKnoxIdfEnabledInAnyTopology(gatewayServices)) {
+ implementationToUse = JdbcTrustedOidcIssuerService.class.getName();
+ }
+ }
+
+ TrustedOidcIssuerService service = null;
+ if (shouldCreateService(implementationToUse)) {
+ if (matchesImplementation(implementationToUse,
EmptyTrustedOidcIssuerService.class, true)) {
+ service = new EmptyTrustedOidcIssuerService();
+ } else if (matchesImplementation(implementationToUse,
JdbcTrustedOidcIssuerService.class)) {
+ try {
+ final JdbcTrustedOidcIssuerService jdbcService = new
JdbcTrustedOidcIssuerService();
+ jdbcService.setAliasService(getAliasService(gatewayServices));
+ jdbcService.init(gatewayConfig, options);
+ service = jdbcService;
+ } catch (ServiceLifecycleException e) {
+ LOG.errorInitializingService(implementationToUse, e.getMessage(), e);
+ service = new EmptyTrustedOidcIssuerService();
+ } catch (Exception e) {
+ throw new ServiceLifecycleException(
+ "Error while creating TrustedOidcIssuerService: " + e, e);
+ }
+ }
+ if (service != null) {
+ logServiceUsage(service.getClass().getName(), serviceType);
+ }
+ }
+ return service;
+ }
+
+ /**
+ * Returns true if any deployed topology contains a service with role {@code
KNOXIDF}
+ * or {@code KNOXIDF_ADMIN}. The trusted issuer registry is activated by
either role
+ * because the admin API ({@code KNOXIDF_ADMIN}) also needs to persist
registrations.
+ */
+ private boolean isKnoxIdfEnabledInAnyTopology(GatewayServices
gatewayServices) {
+ final TopologyService topologyService =
gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE);
+ if (topologyService != null) {
+ for (Topology topology : topologyService.getTopologies()) {
+ if (topology.getServices().stream().anyMatch(
+ s -> "KNOXIDF".equals(s.getRole()) ||
"KNOXIDF_ADMIN".equals(s.getRole()))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ protected ServiceType getServiceType() {
+ return ServiceType.TRUSTED_OIDC_ISSUER_SERVICE;
+ }
+
+ @Override
+ protected Collection<String> getKnownImplementations() {
+ return List.of(DEFAULT_IMPLEMENTATION,
JdbcTrustedOidcIssuerService.class.getName());
+ }
+}
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java
new file mode 100644
index 000000000..506b31d65
--- /dev/null
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * No-op stub used when the KNOXIDF or KNOXIDF_ADMIN service role is not
deployed.
+ * Read methods return safe empty results; mutating methods throw
+ * {@link UnsupportedOperationException}.
+ */
+public class EmptyTrustedOidcIssuerService implements TrustedOidcIssuerService
{
+
+ @Override
+ public void init(GatewayConfig config, Map<String, String> options) throws
ServiceLifecycleException {
+ }
+
+ @Override
+ public void start() throws ServiceLifecycleException {
+ }
+
+ @Override
+ public void stop() throws ServiceLifecycleException {
+ }
+
+ @Override
+ public boolean isTrusted(String issuerUrl) {
+ return false;
+ }
+
+ @Override
+ public boolean isDynamicJwks(String issuerUrl) {
+ return false;
+ }
+
+ @Override
+ public Optional<String> resolveJwksUri(String issuerUrl) {
+ return Optional.empty();
+ }
+
+ @Override
+ public void refreshJwksUri(String issuerUrl) {
+ }
+
+ @Override
+ public void register(TrustedOidcIssuer issuer) {
+ throw new UnsupportedOperationException("TrustedOidcIssuerService is not
enabled; "
+ + "deploy the KNOXIDF or KNOXIDF_ADMIN service role to activate it.");
+ }
+
+ @Override
+ public void deregister(String issuerUrl) {
+ throw new UnsupportedOperationException("TrustedOidcIssuerService is not
enabled; "
+ + "deploy the KNOXIDF or KNOXIDF_ADMIN service role to activate it.");
+ }
+
+ @Override
+ public List<TrustedOidcIssuer> list() {
+ return Collections.emptyList();
+ }
+}
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
new file mode 100644
index 000000000..0b1e0a31e
--- /dev/null
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.database.DataSourceProvider;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
+
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * JDBC-backed implementation of {@link TrustedOidcIssuerService}.
+ * <p>
+ * Maintains an in-memory registry snapshot as an {@link AtomicReference} to
an immutable
+ * {@link Map}. Reads ({@link #isTrusted}, {@link #isDynamicJwks}, {@link
#list}) are
+ * lock-free and always see a consistent snapshot. Writes ({@link #register},
+ * {@link #deregister}) are synchronized: the DB is committed first, then the
snapshot is
+ * rebuilt from a fresh SELECT to guarantee the in-memory state cannot diverge
from
+ * persistent storage.
+ * <p>
+ * HA note: each Knox node maintains its own snapshot. A registration on node
A updates
+ * that node's snapshot immediately; other nodes' snapshots remain stale until
restart.
+ */
+public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService {
+
+ private static final TrustedOidcIssuerServiceMessages LOG =
+ MessagesFactory.get(TrustedOidcIssuerServiceMessages.class);
+
+ static final String MAX_TRUSTED_ISSUERS_CONFIG =
"gateway.trustedoidcissuer.max.issuers";
+ private static final int DEFAULT_MAX_TRUSTED_ISSUERS = 10_000;
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+ private final Lock initLock = new ReentrantLock(true);
+
+ private final AtomicReference<Map<String, TrustedOidcIssuer>>
registrySnapshot =
+ new AtomicReference<>(Collections.emptyMap());
+
+ private AliasService aliasService;
+ private TrustedOidcIssuerDatabase database;
+ private OIDCDiscoveryHelper discoveryHelper;
+ private int maxTrustedIssuers;
+
+ @Override
+ public void init(GatewayConfig config, Map<String, String> options) throws
ServiceLifecycleException {
+ if (!initialized.get()) {
+ initLock.lock();
+ try {
+ if (aliasService == null) {
+ throw new ServiceLifecycleException("The required AliasService
reference has not been set.");
+ }
+ try {
+ int maxIssuers = DEFAULT_MAX_TRUSTED_ISSUERS;
+ long cacheTtlSecs =
KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS;
+ int connectTimeoutMs =
KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS;
+ int readTimeoutMs =
KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS;
+
+ if (config instanceof Configuration) {
+ final Configuration conf = (Configuration) config;
+ maxIssuers = conf.getInt(MAX_TRUSTED_ISSUERS_CONFIG,
DEFAULT_MAX_TRUSTED_ISSUERS);
+ cacheTtlSecs =
conf.getLong(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS,
+
KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS);
+ connectTimeoutMs =
conf.getInt(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS,
+
KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS);
+ readTimeoutMs =
conf.getInt(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS,
+
KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS);
+ }
+
+ this.maxTrustedIssuers = maxIssuers;
+ this.database = new TrustedOidcIssuerDatabase(
+ DataSourceProvider.getDataSource(config, aliasService),
config.getDatabaseType());
+ this.discoveryHelper = new OIDCDiscoveryHelper(this, cacheTtlSecs,
+ OIDCDiscoveryHelper.buildHttpClient(connectTimeoutMs,
readTimeoutMs));
+ reloadRegistrySnapshot();
+ initialized.set(true);
+ } catch (ServiceLifecycleException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ServiceLifecycleException("Error initializing
JdbcTrustedOidcIssuerService: " + e, e);
+ }
+ } finally {
+ initLock.unlock();
+ }
+ }
+ }
+
+ @Override
+ public void start() throws ServiceLifecycleException {
+ }
+
+ @Override
+ public void stop() throws ServiceLifecycleException {
+ }
+
+ public void setAliasService(AliasService aliasService) {
+ this.aliasService = aliasService;
+ }
+
+ protected AliasService getAliasService() {
+ return aliasService;
+ }
+
+ @Override
+ public boolean isTrusted(String issuerUrl) {
+ return registrySnapshot.get().containsKey(issuerUrl);
+ }
+
+ @Override
+ public boolean isDynamicJwks(String issuerUrl) {
+ final TrustedOidcIssuer entry = registrySnapshot.get().get(issuerUrl);
+ return entry != null && entry.isDynamicJwks();
+ }
+
+ @Override
+ public Optional<String> resolveJwksUri(String issuerUrl) {
+ return discoveryHelper.discoverJwksUri(issuerUrl);
+ }
+
+ @Override
+ public synchronized void register(TrustedOidcIssuer issuer) {
+ if (registrySnapshot.get().size() >= maxTrustedIssuers) {
+ throw new IllegalStateException(
+ "Cannot register issuer: MAX_TRUSTED_ISSUERS (" + maxTrustedIssuers
+ ") reached");
+ }
+ try {
+ database.insert(issuer);
+ } catch (SQLException e) {
+ LOG.errorRegisteringIssuer(issuer.getIssuerUrl(), e.getMessage(), e);
+ throw new RuntimeException("Error registering trusted OIDC issuer: " +
issuer.getIssuerUrl(), e);
+ }
+ reloadRegistrySnapshot();
+ }
+
+ @Override
+ public synchronized void deregister(String issuerUrl) {
+ try {
+ database.delete(issuerUrl);
+ } catch (SQLException e) {
+ LOG.errorDeregisteringIssuer(issuerUrl, e.getMessage(), e);
+ throw new RuntimeException("Error deregistering trusted OIDC issuer: " +
issuerUrl, e);
+ }
+ reloadRegistrySnapshot();
+ discoveryHelper.invalidate(issuerUrl);
+ }
+
+ @Override
+ public void refreshJwksUri(String issuerUrl) {
+ if (isDynamicJwks(issuerUrl)) {
+ discoveryHelper.invalidate(issuerUrl);
+ }
+ }
+
+ @Override
+ public List<TrustedOidcIssuer> list() {
+ return new ArrayList<>(registrySnapshot.get().values());
+ }
+
+ /**
+ * Rebuilds the registry snapshot from the current DB state.
+ * Called on init, after register, and after deregister.
+ * Synchronized on this to prevent concurrent rebuilds from interleaving
with mutations.
+ */
+ private synchronized void reloadRegistrySnapshot() {
+ try {
+ final Map<String, TrustedOidcIssuer> fresh =
database.selectAll().stream()
+ .collect(Collectors.toMap(TrustedOidcIssuer::getIssuerUrl,
Function.identity()));
+ registrySnapshot.set(Collections.unmodifiableMap(fresh));
+ } catch (Exception e) {
+ LOG.errorReloadingRegistrySnapshot(e.getMessage(), e);
+ }
+ }
+}
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java
new file mode 100644
index 000000000..2c830bebb
--- /dev/null
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+
+import java.net.URI;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Fetches and caches JWKS URIs resolved from OIDC provider discovery documents
+ * (/.well-known/openid-configuration). Backed by a Caffeine time-based cache.
+ * <p>
+ * SSRF gate: {@link #discoverJwksUri(String)} returns {@link
Optional#empty()} immediately
+ * for any issuer not registered for dynamic JWKS. No HTTP call is ever made
for
+ * untrusted or static-JWKS issuers.
+ * <p>
+ * The {@link CloseableHttpClient} is injected at construction time so that
tests can
+ * supply a mock and verify the full fetch-and-parse code path without
overriding methods.
+ * Production callers use {@link #buildHttpClient(int, int)} to obtain a
properly
+ * configured long-lived client.
+ */
+class OIDCDiscoveryHelper {
+
+ private static final TrustedOidcIssuerServiceMessages LOG =
+ MessagesFactory.get(TrustedOidcIssuerServiceMessages.class);
+
+ private static final String USER_AGENT = "Apache-Knox-OIDCDiscovery/1.0";
+ private static final int HTTP_RETRY_COUNT = 2;
+ // Idle connections in the pool are closed after this duration so the next
cache-miss
+ // fetch always goes through a fresh connection rather than a potentially
stale one.
+ private static final long IDLE_EVICTION_SECONDS = 60L;
+
+ private final TrustedOidcIssuerService trustedIssuers;
+ // OIDC discovery document cache: issuerUrl → jwks_uri resolved from
discovery endpoint.
+ // Entries expire after cacheTtlSeconds and are re-fetched lazily on the
next access.
+ private final Cache<String, String> discoveryDocumentCache;
+ private final CloseableHttpClient httpClient;
+
+ /**
+ * Creates an {@code OIDCDiscoveryHelper} with the supplied HTTP client. Use
+ * {@link #buildHttpClient(int, int)} to obtain the production-configured
client.
+ */
+ OIDCDiscoveryHelper(TrustedOidcIssuerService trustedIssuers, long
cacheTtlSeconds,
+ CloseableHttpClient httpClient) {
+ this.trustedIssuers = trustedIssuers;
+ this.discoveryDocumentCache = Caffeine.newBuilder()
+ .expireAfterWrite(cacheTtlSeconds, TimeUnit.SECONDS)
+ .build();
+ this.httpClient = httpClient;
+ }
+
+ /**
+ * Builds a production-configured {@link CloseableHttpClient} for OIDC
discovery fetches.
+ * <p>
+ * {@code requestSentRetryEnabled=true}: Discovery endpoints are GET-only
(idempotent by RFC 7231
+ * §4.2.2), so retrying after the request was sent is safe and covers the
most common
+ * failure mode — connection reset mid-response.
+ * <p>
+ * {@code evictIdleConnections} + {@code evictExpiredConnections}: the
client is held for the
+ * gateway process lifetime. Without eviction, pooled connections become
stale when the remote
+ * server or a network middlebox closes them silently, causing the next
fetch to fail with a
+ * {@code NoHttpResponseException} before the retry handler can save it.
+ */
+ static CloseableHttpClient buildHttpClient(int connectTimeoutMs, int
readTimeoutMs) {
+ final RequestConfig requestConfig = RequestConfig.custom()
+ .setConnectTimeout(connectTimeoutMs)
+ .setSocketTimeout(readTimeoutMs)
+ .build();
+ return HttpClients.custom()
+ .setDefaultRequestConfig(requestConfig)
+ .setRetryHandler(new DefaultHttpRequestRetryHandler(HTTP_RETRY_COUNT,
true))
+ .evictIdleConnections(IDLE_EVICTION_SECONDS, TimeUnit.SECONDS)
+ .evictExpiredConnections()
+ .build();
+ }
+
+ /**
+ * Returns the JWKS URI for the given issuer URL, resolving it via OIDC
discovery if
+ * not already cached. Returns {@link Optional#empty()} immediately without
any HTTP
+ * call if the issuer is not registered for dynamic JWKS — this is the
primary SSRF gate.
+ * <p>
+ * {@code Cache.get(key, mappingFunction)} is atomic per key: concurrent
cache misses for the
+ * same issuer block on a single {@link #fetchJwksUri} call and share its
result. If
+ * {@link #fetchJwksUri} returns null (on any error), Caffeine does not
cache null, so the
+ * next call retries transparently.
+ */
+ Optional<String> discoverJwksUri(String issuerUrl) {
+ if (!trustedIssuers.isDynamicJwks(issuerUrl)) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(discoveryDocumentCache.get(issuerUrl,
this::fetchJwksUri));
+ }
+
+ /**
+ * Evicts the cached JWKS URI for the given issuer so the next call to
+ * {@link #discoverJwksUri(String)} re-fetches from the discovery endpoint.
+ */
+ void invalidate(String issuerUrl) {
+ discoveryDocumentCache.invalidate(issuerUrl);
+ }
+
+ /**
+ * Fetches the JWKS URI by retrieving and parsing the OIDC discovery
document for the
+ * given issuer. The discovery URL is constructed by stripping any trailing
slash from
+ * the issuer URL and appending {@code /.well-known/openid-configuration}.
+ * Returns null on any error so Caffeine does not cache the failure and the
next call retries.
+ */
+ String fetchJwksUri(String issuerUrl) {
+ final String discoveryUrl = issuerUrl.replaceAll("/$", "") +
"/.well-known/openid-configuration";
+ final String body = httpGet(issuerUrl, discoveryUrl);
+ if (body == null) {
+ return null;
+ }
+ try {
+ final URI jwksUri = OIDCProviderMetadata.parse(body).getJWKSetURI();
+ if (jwksUri == null) {
+ // Defensive: OIDC spec requires jwks_uri; Nimbus 11.x throws
ParseException if absent,
+ // but a non-compliant or future-lenient implementation could return
null here.
+ LOG.errorParsingDiscoveryDocument(issuerUrl,
+ "discovery document contains no jwks_uri", null);
+ return null;
+ }
+ return jwksUri.toString();
+ } catch (Exception e) {
+ LOG.errorParsingDiscoveryDocument(issuerUrl, e.getMessage(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Executes a GET request against the given URL and returns the response
body as a string.
+ * Logs any failure and returns null so the caller knows not to cache the
result.
+ */
+ private String httpGet(String issuerUrl, String url) {
+ final HttpGet request = new HttpGet(url);
+ request.setHeader("User-Agent", USER_AGENT);
+ try (CloseableHttpResponse response = httpClient.execute(request)) {
+ final int statusCode = response.getStatusLine().getStatusCode();
+ if (statusCode != 200) {
+ LOG.errorFetchingDiscoveryDocument(issuerUrl, url, "HTTP " +
statusCode,
+ new java.io.IOException("Non-200 status: " + statusCode));
+ return null;
+ }
+ return EntityUtils.toString(response.getEntity());
+ } catch (Exception e) {
+ LOG.errorFetchingDiscoveryDocument(issuerUrl, url, e.getMessage(), e);
+ return null;
+ }
+ }
+}
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
new file mode 100644
index 000000000..92d0fd5c5
--- /dev/null
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.database.KnoxDatabase;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * JDBC helper for the {@code TRUSTED_OIDC_ISSUERS} table.
+ * All SQL uses {@link PreparedStatement} with {@code ?} parameters only.
+ * Uses {@link ResultSet#getBoolean(String)} for the {@code dynamic_jwks}
column,
+ * which correctly maps both BOOLEAN (standard/Derby) and NUMBER(1) (Oracle)
values.
+ */
+class TrustedOidcIssuerDatabase extends KnoxDatabase {
+
+ static final String TABLE_NAME = "TRUSTED_OIDC_ISSUERS";
+
+ private static final String INSERT_SQL =
+ "INSERT INTO " + TABLE_NAME + " (issuer_url, dynamic_jwks, cluster_name,
registered_at, registered_by) VALUES (?, ?, ?, ?, ?)";
+ private static final String DELETE_SQL =
+ "DELETE FROM " + TABLE_NAME + " WHERE issuer_url = ?";
+ private static final String SELECT_ALL_SQL =
+ "SELECT issuer_url, dynamic_jwks, cluster_name, registered_at,
registered_by FROM " + TABLE_NAME;
+ private static final String COUNT_SQL =
+ "SELECT COUNT(*) FROM " + TABLE_NAME;
+
+ TrustedOidcIssuerDatabase(DataSource dataSource, String dbType) throws
Exception {
+ super(dataSource);
+ final DatabaseType databaseType = DatabaseType.fromString(dbType);
+ createTableIfNotExists(TABLE_NAME,
databaseType.trustedOidcIssuersTableSql());
+ }
+
+ void insert(TrustedOidcIssuer issuer) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement ps = connection.prepareStatement(INSERT_SQL)) {
+ ps.setString(1, issuer.getIssuerUrl());
+ ps.setBoolean(2, issuer.isDynamicJwks());
+ ps.setString(3, issuer.getClusterName());
+ ps.setTimestamp(4, Timestamp.from(issuer.getRegisteredAt()));
+ ps.setString(5, issuer.getRegisteredBy());
+ ps.executeUpdate();
+ }
+ }
+
+ void delete(String issuerUrl) throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement ps = connection.prepareStatement(DELETE_SQL)) {
+ ps.setString(1, issuerUrl);
+ ps.executeUpdate();
+ }
+ }
+
+ List<TrustedOidcIssuer> selectAll() throws SQLException {
+ final List<TrustedOidcIssuer> result = new ArrayList<>();
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement ps = connection.prepareStatement(SELECT_ALL_SQL);
+ ResultSet rs = ps.executeQuery()) {
+ while (rs.next()) {
+ result.add(new TrustedOidcIssuer(
+ rs.getString("issuer_url"),
+ rs.getBoolean("dynamic_jwks"),
+ rs.getString("cluster_name"),
+ rs.getTimestamp("registered_at").toInstant(),
+ rs.getString("registered_by")
+ ));
+ }
+ }
+ return result;
+ }
+
+ int count() throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement ps = connection.prepareStatement(COUNT_SQL);
+ ResultSet rs = ps.executeQuery()) {
+ return rs.next() ? rs.getInt(1) : 0;
+ }
+ }
+}
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java
new file mode 100644
index 000000000..1918bec47
--- /dev/null
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.i18n.messages.Message;
+import org.apache.knox.gateway.i18n.messages.MessageLevel;
+import org.apache.knox.gateway.i18n.messages.Messages;
+import org.apache.knox.gateway.i18n.messages.StackTrace;
+
+@Messages(logger = "org.apache.knox.gateway.knoxidf.trustedoidcissuer.service")
+interface TrustedOidcIssuerServiceMessages {
+
+ @Message(level = MessageLevel.ERROR,
+ text = "Failed to fetch OIDC discovery document for issuer {0} from {1}:
{2}")
+ void errorFetchingDiscoveryDocument(String issuerUrl, String discoveryUrl,
String cause,
+ @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+ @Message(level = MessageLevel.ERROR,
+ text = "Failed to parse OIDC discovery document for issuer {0}: {1}")
+ void errorParsingDiscoveryDocument(String issuerUrl, String cause,
+ @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+ @Message(level = MessageLevel.ERROR,
+ text = "Error registering trusted OIDC issuer {0}: {1}")
+ void errorRegisteringIssuer(String issuerUrl, String cause,
+ @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+ @Message(level = MessageLevel.ERROR,
+ text = "Error deregistering trusted OIDC issuer {0}: {1}")
+ void errorDeregisteringIssuer(String issuerUrl, String cause,
+ @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+ @Message(level = MessageLevel.ERROR,
+ text = "Error reloading trusted OIDC issuer registry snapshot: {0}")
+ void errorReloadingRegistrySnapshot(String cause,
+ @StackTrace(level = MessageLevel.DEBUG) Exception e);
+}
diff --git
a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
index 93bc7845a..e67206f2c 100644
---
a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
+++
b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
@@ -38,3 +38,4 @@
org.apache.knox.gateway.services.factory.TopologyServiceFactory
org.apache.knox.gateway.services.factory.LdapServiceFactory
org.apache.knox.gateway.services.factory.LDAPRolesLookupServiceFactory
org.apache.knox.gateway.services.factory.TokenServiceFactory
+org.apache.knox.gateway.services.factory.TrustedOidcIssuerServiceFactory
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java
new file mode 100644
index 000000000..c606010a5
--- /dev/null
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.junit.Test;
+
+import java.time.Instant;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class EmptyTrustedOidcIssuerServiceTest {
+
+ private final EmptyTrustedOidcIssuerService service = new
EmptyTrustedOidcIssuerService();
+
+ @Test
+ public void testIsTrustedReturnsFalse() {
+ assertFalse(service.isTrusted("https://any.issuer.com"));
+ }
+
+ @Test
+ public void testIsDynamicJwksReturnsFalse() {
+ assertFalse(service.isDynamicJwks("https://any.issuer.com"));
+ }
+
+ @Test
+ public void testResolveJwksUriReturnsEmpty() {
+ assertFalse(service.resolveJwksUri("https://any.issuer.com").isPresent());
+ }
+
+ @Test
+ public void testRefreshJwksUriIsNoOp() {
+ service.refreshJwksUri("https://any.issuer.com"); // must not throw
+ }
+
+ @Test
+ public void testListReturnsEmpty() {
+ assertTrue(service.list().isEmpty());
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void testRegisterThrows() {
+ service.register(new TrustedOidcIssuer("https://issuer.com", false, null,
Instant.now(), null));
+ }
+
+ @Test(expected = UnsupportedOperationException.class)
+ public void testDeregisterThrows() {
+ service.deregister("https://issuer.com");
+ }
+}
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
new file mode 100644
index 000000000..03bc54f76
--- /dev/null
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
@@ -0,0 +1,325 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.AbstractDataSourceFactory;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.easymock.EasyMock;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class JdbcTrustedOidcIssuerServiceTest {
+
+ private static final String DB_NAME = "trustedissuers_svc_test";
+ private static final String DERBY_CREATE_URL = "jdbc:derby:memory:" +
DB_NAME + ";create=true";
+ private static final String DERBY_URL = "jdbc:derby:memory:" + DB_NAME;
+ private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" +
DB_NAME + ";shutdown=true";
+
+ private static GatewayConfig gatewayConfig;
+ private static AliasService aliasService;
+
+ private JdbcTrustedOidcIssuerService service;
+
+ @BeforeClass
+ public static void setUpClass() throws Exception {
+ // Derby 10.14 does not recognize locales like en_001; force a standard
locale.
+ java.util.Locale.setDefault(java.util.Locale.US);
+ // Create the Derby in-memory DB so DerbyDataSourceFactory can connect to
it
+ DriverManager.getConnection(DERBY_CREATE_URL).close();
+
+ gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class);
+
EasyMock.expect(gatewayConfig.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+ EasyMock.expect(gatewayConfig.getDatabaseName()).andReturn("memory:" +
DB_NAME).anyTimes();
+ EasyMock.replay(gatewayConfig);
+
+ aliasService = EasyMock.createNiceMock(AliasService.class);
+ EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
+
AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
+ EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
+
AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
+ EasyMock.replay(aliasService);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ try {
+ DriverManager.getConnection(DERBY_SHUTDOWN_URL);
+ } catch (SQLException e) {
+ // Derby signals a successful in-memory shutdown as SQLState 08006 /
error 45000
+ if (!(e.getErrorCode() == 45000 && "08006".equals(e.getSQLState()))) {
+ throw new RuntimeException("Unexpected Derby shutdown error", e);
+ }
+ }
+ }
+
+ @Before
+ public void setUp() throws ServiceLifecycleException, SQLException {
+ // Clear table between tests
+ try (Connection conn = DriverManager.getConnection(DERBY_URL);
+ PreparedStatement ps = conn.prepareStatement("DELETE FROM
TRUSTED_OIDC_ISSUERS")) {
+ ps.executeUpdate();
+ } catch (SQLException e) {
+ // Table may not exist yet on first setUp; service.init() will create it
+ }
+
+ service = new JdbcTrustedOidcIssuerService();
+ service.setAliasService(aliasService);
+ service.init(gatewayConfig, null);
+ }
+
+ // ------------------------------------------------------------------
+ // Basic CRUD and snapshot
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testRegisterAndIsTrusted() {
+ service.register(issuer("https://issuer.example.com", false));
+
+ assertTrue(service.isTrusted("https://issuer.example.com"));
+ assertFalse(service.isTrusted("https://other.example.com"));
+ }
+
+ @Test
+ public void testDeregisterClearsSnapshot() {
+ service.register(issuer("https://issuer.example.com", false));
+ assertTrue(service.isTrusted("https://issuer.example.com"));
+
+ service.deregister("https://issuer.example.com");
+ assertFalse(service.isTrusted("https://issuer.example.com"));
+ }
+
+ @Test
+ public void testListReflectsSnapshot() {
+ final TrustedOidcIssuer a = issuer("https://a.example.com", false,
"clusterA", "admin");
+ final TrustedOidcIssuer b = issuer("https://b.example.com", true,
"clusterB", "operator");
+ service.register(a);
+ service.register(b);
+
+ final List<TrustedOidcIssuer> listed = service.list();
+ assertEquals(2, listed.size());
+ assertIssuerInList(a, listed);
+ assertIssuerInList(b, listed);
+ }
+
+ @Test
+ public void testDynamicJwksFlag() {
+ service.register(issuer("https://static.example.com", false));
+ service.register(issuer("https://dynamic.example.com", true));
+
+ assertFalse(service.isDynamicJwks("https://static.example.com"));
+ assertTrue(service.isDynamicJwks("https://dynamic.example.com"));
+ assertFalse("Unregistered issuer must return false",
+ service.isDynamicJwks("https://unknown.example.com"));
+ }
+
+ /**
+ * All fields must round-trip through the DB correctly, including nullable
ones.
+ */
+ @Test
+ public void testRegisterPersistsAllFields() {
+ final TrustedOidcIssuer issuer = issuer("https://issuer.example.com",
true, "prod-cluster", "admin");
+ service.register(issuer);
+
+ final List<TrustedOidcIssuer> listed = service.list();
+ assertEquals(1, listed.size());
+ assertIssuerEquals(issuer, listed.get(0));
+ }
+
+ @Test
+ public void testRegisterPersistsNullableFieldsAsNull() {
+ // clusterName and registeredBy may be null
+ final TrustedOidcIssuer issuer = new TrustedOidcIssuer(
+ "https://issuer.example.com", false, null, Instant.now(), null);
+ service.register(issuer);
+
+ final TrustedOidcIssuer fromList = service.list().get(0);
+ assertEquals("https://issuer.example.com", fromList.getIssuerUrl());
+ assertFalse(fromList.isDynamicJwks());
+ assertNotNull("registeredAt must always be persisted",
fromList.getRegisteredAt());
+ assertTrue("clusterName round-trips as null", fromList.getClusterName() ==
null
+ || fromList.getClusterName().isEmpty());
+ assertTrue("registeredBy round-trips as null", fromList.getRegisteredBy()
== null
+ || fromList.getRegisteredBy().isEmpty());
+ }
+
+ @Test
+ public void testRegistrySnapshotWarmOnInit() throws Exception {
+ // Pre-populate the TRUSTED_OIDC_ISSUERS table before initializing a new
service
+ final String preloadedUrl = "https://preloaded.example.com";
+ try (Connection conn = DriverManager.getConnection(DERBY_URL);
+ PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO TRUSTED_OIDC_ISSUERS (issuer_url, dynamic_jwks,
registered_at) "
+ + "VALUES (?, ?, ?)")) {
+ ps.setString(1, preloadedUrl);
+ ps.setBoolean(2, false);
+ ps.setTimestamp(3, java.sql.Timestamp.from(Instant.now()));
+ ps.executeUpdate();
+ }
+
+ // New service instance: snapshot must be loaded from DB on startup
+ final JdbcTrustedOidcIssuerService freshService = new
JdbcTrustedOidcIssuerService();
+ freshService.setAliasService(aliasService);
+ freshService.init(gatewayConfig, null);
+
+ assertTrue("Pre-populated issuer must be trusted after init",
freshService.isTrusted(preloadedUrl));
+ }
+
+ @Test(expected = RuntimeException.class)
+ public void testDuplicateRegistrationThrows() {
+ final TrustedOidcIssuer issuer = issuer("https://issuer.example.com",
false);
+ service.register(issuer);
+ service.register(issuer); // duplicate primary key → RuntimeException
+ }
+
+ @Test
+ public void testReloadAfterMutation() {
+ final String url = "https://issuer.example.com";
+
+ service.register(issuer(url, false));
+ assertTrue("Snapshot must contain issuer after register",
service.isTrusted(url));
+ assertEquals(1, service.list().size());
+
+ service.deregister(url);
+ assertFalse("Snapshot must not contain issuer after deregister",
service.isTrusted(url));
+ assertTrue(service.list().isEmpty());
+ }
+
+ @Test
+ public void testMaxTrustedIssuers() throws ServiceLifecycleException {
+ final GatewayConfigImpl limitedConfig = new GatewayConfigImpl();
+ limitedConfig.set(JdbcTrustedOidcIssuerService.MAX_TRUSTED_ISSUERS_CONFIG,
"2");
+ limitedConfig.set(GatewayConfigImpl.GATEWAY_DATABASE_TYPE,
DatabaseType.DERBY.type());
+ limitedConfig.set(GatewayConfigImpl.GATEWAY_DATABASE_NAME, "memory:" +
DB_NAME);
+
+ final JdbcTrustedOidcIssuerService limitedService = new
JdbcTrustedOidcIssuerService();
+ limitedService.setAliasService(aliasService);
+ limitedService.init(limitedConfig, null);
+
+ limitedService.register(issuer("https://a.example.com", false));
+ assertEquals("First registration must succeed", 1,
limitedService.list().size());
+
+ limitedService.register(issuer("https://b.example.com", false));
+ assertEquals("Second registration must succeed", 2,
limitedService.list().size());
+
+ try {
+ limitedService.register(issuer("https://c.example.com", false));
+ fail("Expected IllegalStateException when exceeding max issuers limit");
+ } catch (IllegalStateException e) {
+ assertEquals("Prior registrations must be unaffected by the rejected
call",
+ 2, limitedService.list().size());
+ }
+ }
+
+ @Test
+ public void testDeregisterNonExistentIsNoOp() {
+ // deregister of unknown issuer must not throw
+ service.deregister("https://nonexistent.example.com");
+ assertTrue(service.list().isEmpty());
+ }
+
+ // ------------------------------------------------------------------
+ // resolveJwksUri / refreshJwksUri delegation
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testResolveJwksUriForNonDynamicIssuerReturnsEmpty() {
+ service.register(issuer("https://static.example.com", false));
+ // Non-dynamic issuer: OIDCDiscoveryHelper.discoverJwksUri returns empty
immediately
+ // without any HTTP call (the SSRF gate inside the helper blocks it).
+
assertFalse(service.resolveJwksUri("https://static.example.com").isPresent());
+ }
+
+ @Test
+ public void testResolveJwksUriForUnregisteredIssuerReturnsEmpty() {
+
assertFalse(service.resolveJwksUri("https://unknown.example.com").isPresent());
+ }
+
+ @Test
+ public void testRefreshJwksUriForNonDynamicIsNoOp() {
+ service.register(issuer("https://static.example.com", false));
+ // refreshJwksUri checks isDynamicJwks first; for non-dynamic it is a no-op
+ service.refreshJwksUri("https://static.example.com"); // must not throw
+ }
+
+ @Test
+ public void testRefreshJwksUriForUnregisteredIsNoOp() {
+ service.refreshJwksUri("https://unknown.example.com"); // must not throw
+ }
+
+ // ------------------------------------------------------------------
+ // Init guard
+ // ------------------------------------------------------------------
+
+ @Test(expected = ServiceLifecycleException.class)
+ public void testInitFailsWithoutAliasService() throws
ServiceLifecycleException {
+ final JdbcTrustedOidcIssuerService noAliasService = new
JdbcTrustedOidcIssuerService();
+ // setAliasService NOT called
+ noAliasService.init(gatewayConfig, null);
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ private static TrustedOidcIssuer issuer(String url, boolean dynamicJwks) {
+ return new TrustedOidcIssuer(url, dynamicJwks, null, Instant.now(), null);
+ }
+
+ private static TrustedOidcIssuer issuer(String url, boolean dynamicJwks,
+ String clusterName, String registeredBy) {
+ return new TrustedOidcIssuer(url, dynamicJwks, clusterName, Instant.now(),
registeredBy);
+ }
+
+ /**
+ * Asserts that all non-generated fields of {@code expected} match {@code
actual}, and
+ * that the generated {@code registeredAt} field is non-null.
+ */
+ private static void assertIssuerEquals(TrustedOidcIssuer expected,
TrustedOidcIssuer actual) {
+ assertEquals("issuerUrl", expected.getIssuerUrl(), actual.getIssuerUrl());
+ assertEquals("dynamicJwks", expected.isDynamicJwks(),
actual.isDynamicJwks());
+ assertEquals("clusterName", expected.getClusterName(),
actual.getClusterName());
+ assertEquals("registeredBy", expected.getRegisteredBy(),
actual.getRegisteredBy());
+ assertNotNull("registeredAt must be persisted", actual.getRegisteredAt());
+ }
+
+ private static void assertIssuerInList(TrustedOidcIssuer expected,
List<TrustedOidcIssuer> list) {
+ final TrustedOidcIssuer found = list.stream()
+ .filter(i -> expected.getIssuerUrl().equals(i.getIssuerUrl()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Issuer not found in list: " +
expected.getIssuerUrl()));
+ assertIssuerEquals(expected, found);
+ }
+}
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java
new file mode 100644
index 000000000..f978a38af
--- /dev/null
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.http.StatusLine;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class OIDCDiscoveryHelperTest {
+
+ private static final String ISSUER = "https://issuer.example.com";
+ private static final String ISSUER_WITH_SLASH =
"https://issuer.example.com/";
+ private static final String JWKS_URI = "https://issuer.example.com/jwks";
+ private static final long CACHE_TTL = 600L;
+
+ // Minimal valid OIDC discovery document (all required fields per OpenID
Connect Discovery 1.0)
+ private static final String VALID_DISCOVERY_JSON = "{"
+ + "\"issuer\":\"" + ISSUER + "\","
+ + "\"authorization_endpoint\":\"https://issuer.example.com/authorize\","
+ + "\"jwks_uri\":\"" + JWKS_URI + "\","
+ + "\"response_types_supported\":[\"code\"],"
+ + "\"subject_types_supported\":[\"public\"],"
+ + "\"id_token_signing_alg_values_supported\":[\"RS256\"]"
+ + "}";
+
+ // Discovery doc where jwks_uri is absent; Nimbus throws ParseException for
this.
+ private static final String DISCOVERY_JSON_NO_JWKS_URI = "{"
+ + "\"issuer\":\"" + ISSUER + "\","
+ + "\"authorization_endpoint\":\"https://issuer.example.com/authorize\","
+ + "\"response_types_supported\":[\"code\"],"
+ + "\"subject_types_supported\":[\"public\"],"
+ + "\"id_token_signing_alg_values_supported\":[\"RS256\"]"
+ + "}";
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ /** Returns a mock TrustedOidcIssuerService with fixed isTrusted /
isDynamicJwks behavior. */
+ private static TrustedOidcIssuerService trustedDynamic() {
+ return stubService(true, true);
+ }
+
+ private static TrustedOidcIssuerService trustedStatic() {
+ return stubService(true, false);
+ }
+
+ private static TrustedOidcIssuerService untrusted() {
+ return stubService(false, false);
+ }
+
+ private static TrustedOidcIssuerService stubService(boolean trusted, boolean
dynamicJwks) {
+ return new EmptyTrustedOidcIssuerService() {
+ @Override public boolean isTrusted(String url) { return trusted; }
+ @Override public boolean isDynamicJwks(String url) { return dynamicJwks;
}
+ };
+ }
+
+ /**
+ * Returns a mock CloseableHttpResponse that yields the given status code
and body.
+ * Uses a real StringEntity so EntityUtils.toString() works without deep
mocking.
+ */
+ private static CloseableHttpResponse mockResponse(int statusCode, String
body) throws Exception {
+ final StatusLine statusLine = EasyMock.createNiceMock(StatusLine.class);
+
EasyMock.expect(statusLine.getStatusCode()).andReturn(statusCode).anyTimes();
+ EasyMock.replay(statusLine);
+
+ final CloseableHttpResponse response =
EasyMock.createNiceMock(CloseableHttpResponse.class);
+ EasyMock.expect(response.getStatusLine()).andReturn(statusLine).anyTimes();
+ if (body != null) {
+ EasyMock.expect(response.getEntity()).andReturn(new StringEntity(body,
"UTF-8")).anyTimes();
+ }
+ EasyMock.replay(response);
+ return response;
+ }
+
+ /** Returns an OIDCDiscoveryHelper backed by the given mock HttpClient. */
+ private static OIDCDiscoveryHelper helper(TrustedOidcIssuerService
trustedIssuers,
+ CloseableHttpClient client) {
+ return new OIDCDiscoveryHelper(trustedIssuers, CACHE_TTL, client);
+ }
+
+ // ------------------------------------------------------------------
+ // SSRF gate
+ // ------------------------------------------------------------------
+
+ /**
+ * SSRF prevention: discoverJwksUri must return empty immediately for an
issuer that is
+ * not registered for dynamic JWKS and must never call HttpClient.execute.
+ */
+ @Test
+ public void testNoHttpCallForUntrustedIssuer() throws Exception {
+ // Strict mock: any unexpected call to execute() fails the test
immediately.
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.replay(client);
+
+ final Optional<String> result = helper(untrusted(),
client).discoverJwksUri(ISSUER);
+
+ assertFalse("Untrusted issuer must return empty", result.isPresent());
+ EasyMock.verify(client); // verifies execute() was never called
+ }
+
+ @Test
+ public void testStaticJwksIssuerMakesNoHttpCall() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.replay(client);
+
+ final Optional<String> result = helper(trustedStatic(),
client).discoverJwksUri(ISSUER);
+
+ assertFalse("Static-JWKS issuer must return empty", result.isPresent());
+ EasyMock.verify(client);
+ }
+
+ // ------------------------------------------------------------------
+ // Happy path
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testDiscoveryReturnsJwksUri() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+ EasyMock.replay(client);
+
+ final Optional<String> result = helper(trustedDynamic(),
client).discoverJwksUri(ISSUER);
+
+ assertTrue(result.isPresent());
+ assertEquals(JWKS_URI, result.get());
+ EasyMock.verify(client);
+ }
+
+ // ------------------------------------------------------------------
+ // URL normalization
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testDiscoveryUrlTrailingSlashStripped() throws Exception {
+ final Capture<HttpUriRequest> captured = EasyMock.newCapture();
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.capture(captured)))
+ .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+ EasyMock.replay(client);
+
+ helper(trustedDynamic(), client).discoverJwksUri(ISSUER_WITH_SLASH);
+
+ assertEquals("https://issuer.example.com/.well-known/openid-configuration",
+ captured.getValue().getURI().toString());
+ }
+
+ @Test
+ public void testDiscoveryUrlNoDoubleSlashWithoutTrailingSlash() throws
Exception {
+ final Capture<HttpUriRequest> captured = EasyMock.newCapture();
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.capture(captured)))
+ .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+ EasyMock.replay(client);
+
+ helper(trustedDynamic(), client).discoverJwksUri(ISSUER);
+
+ assertEquals("https://issuer.example.com/.well-known/openid-configuration",
+ captured.getValue().getURI().toString());
+ }
+
+ // ------------------------------------------------------------------
+ // Cache behaviour
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testDiscoveryDocumentCacheHit() throws Exception {
+ // Strict mock expects exactly one execute() call; a second call would
throw.
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(200, VALID_DISCOVERY_JSON))
+ .once();
+ EasyMock.replay(client);
+
+ final OIDCDiscoveryHelper h = helper(trustedDynamic(), client);
+ h.discoverJwksUri(ISSUER); // fetch
+ h.discoverJwksUri(ISSUER); // cache hit — must NOT call execute again
+
+ EasyMock.verify(client);
+ }
+
+ @Test
+ public void testInvalidateEvictsFromCache() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(200, VALID_DISCOVERY_JSON))
+ .times(2); // must be called twice after eviction
+ EasyMock.replay(client);
+
+ final OIDCDiscoveryHelper h = helper(trustedDynamic(), client);
+ h.discoverJwksUri(ISSUER); // fetch #1
+ h.invalidate(ISSUER); // evict
+ h.discoverJwksUri(ISSUER); // fetch #2
+
+ EasyMock.verify(client);
+ }
+
+ /**
+ * When fetchJwksUri returns null (any failure), Caffeine must NOT cache the
null.
+ * The next call must trigger a fresh HTTP request.
+ */
+ @Test
+ public void testNullNotCachedAfterFailure() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ // First call: connection error → fetchJwksUri returns null
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andThrow(new IOException("connection refused"));
+ // Second call: succeeds
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+ EasyMock.replay(client);
+
+ final OIDCDiscoveryHelper h = helper(trustedDynamic(), client);
+ assertFalse(h.discoverJwksUri(ISSUER).isPresent()); // failure → empty
+ assertTrue(h.discoverJwksUri(ISSUER).isPresent()); // retry → success
+
+ EasyMock.verify(client);
+ }
+
+ // ------------------------------------------------------------------
+ // HTTP error paths
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testHttpGetNon200ReturnsEmpty() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(404, null));
+ EasyMock.replay(client);
+
+ assertFalse(helper(trustedDynamic(),
client).discoverJwksUri(ISSUER).isPresent());
+ EasyMock.verify(client);
+ }
+
+ @Test
+ public void testHttpGetConnectionExceptionReturnsEmpty() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andThrow(new IOException("connection refused"));
+ EasyMock.replay(client);
+
+ assertFalse(helper(trustedDynamic(),
client).discoverJwksUri(ISSUER).isPresent());
+ EasyMock.verify(client);
+ }
+
+ // ------------------------------------------------------------------
+ // Discovery document parse errors
+ // ------------------------------------------------------------------
+
+ @Test
+ public void testMalformedDiscoveryDocumentReturnsEmpty() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(200, "not valid json at all"));
+ EasyMock.replay(client);
+
+ assertFalse(helper(trustedDynamic(),
client).discoverJwksUri(ISSUER).isPresent());
+ EasyMock.verify(client);
+ }
+
+ /**
+ * Nimbus 11.x treats jwks_uri as required and throws ParseException when it
is absent.
+ * Verifies the catch block in fetchJwksUri handles this and returns
Optional.empty().
+ */
+ @Test
+ public void testMissingJwksUriReturnsEmpty() throws Exception {
+ final CloseableHttpClient client =
EasyMock.createMock(CloseableHttpClient.class);
+ EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+ .andReturn(mockResponse(200, DISCOVERY_JSON_NO_JWKS_URI));
+ EasyMock.replay(client);
+
+ assertFalse(helper(trustedDynamic(),
client).discoverJwksUri(ISSUER).isPresent());
+ EasyMock.verify(client);
+ }
+}
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
new file mode 100644
index 000000000..be885e1ef
--- /dev/null
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.AbstractDataSourceFactory;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import
org.apache.knox.gateway.services.factory.TrustedOidcIssuerServiceFactory;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+import org.easymock.EasyMock;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class TrustedOidcIssuerServiceFactoryTest {
+
+ private static final String DB_NAME = "trustedissuers_factory_test";
+ private static final String DERBY_CREATE_URL = "jdbc:derby:memory:" +
DB_NAME + ";create=true";
+ private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" +
DB_NAME + ";shutdown=true";
+
+ @BeforeClass
+ public static void setUpClass() throws SQLException {
+ // Derby 10.14 does not recognize locales like en_001; force a standard
locale.
+ java.util.Locale.setDefault(java.util.Locale.US);
+ DriverManager.getConnection(DERBY_CREATE_URL).close();
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ try {
+ DriverManager.getConnection(DERBY_SHUTDOWN_URL);
+ } catch (SQLException e) {
+ if (!(e.getErrorCode() == 45000 && "08006".equals(e.getSQLState()))) {
+ throw new RuntimeException("Unexpected Derby shutdown error", e);
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Empty (no KNOXIDF) cases
+ // ------------------------------------------------------------------
+
+ /** Zero topologies → no topology service returns anything → Empty. */
+ @Test
+ public void testNoTopologiesReturnsEmpty() throws Exception {
+ assertIsEmpty(createFactory(), buildEmptyGatewayServices(), emptyConfig());
+ }
+
+ /** Topologies exist but none contain KNOXIDF or KNOXIDF_ADMIN → Empty. */
+ @Test
+ public void testTopologiesWithNonKnoxIdfRolesReturnsEmpty() throws Exception
{
+ final GatewayServices gws =
buildGatewayServicesWithTopology(withRoles("HDFS", "WEBHDFS"), null);
+ assertIsEmpty(createFactory(), gws, emptyConfig());
+ }
+
+ /** TopologyService is null (not yet registered) → Empty, no NPE. */
+ @Test
+ public void testNullTopologyServiceReturnsEmpty() throws Exception {
+ final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+
EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(null).anyTimes();
+ EasyMock.replay(gws);
+ assertIsEmpty(createFactory(), gws, emptyConfig());
+ }
+
+ // ------------------------------------------------------------------
+ // JDBC cases
+ // ------------------------------------------------------------------
+
+ /** A single KNOXIDF topology → JDBC. */
+ @Test
+ public void testKnoxIdfTopologyReturnsJdbc() throws Exception {
+ final GatewayServices gws =
buildGatewayServicesWithTopology(withRoles("KNOXIDF"), derbyAlias());
+ assertIsJdbc(createFactory(), gws, derbyConfig());
+ }
+
+ /** A single KNOXIDF_ADMIN-only topology (no KNOXIDF) → JDBC. */
+ @Test
+ public void testKnoxIdfAdminOnlyTopologyReturnsJdbc() throws Exception {
+ final GatewayServices gws =
buildGatewayServicesWithTopology(withRoles("KNOXIDF_ADMIN"), derbyAlias());
+ assertIsJdbc(createFactory(), gws, derbyConfig());
+ }
+
+ /** Both KNOXIDF and KNOXIDF_ADMIN in the same topology → JDBC. */
+ @Test
+ public void testBothRolesInSameTopologyReturnsJdbc() throws Exception {
+ final GatewayServices gws = buildGatewayServicesWithTopology(
+ withRoles("KNOXIDF", "KNOXIDF_ADMIN"), derbyAlias());
+ assertIsJdbc(createFactory(), gws, derbyConfig());
+ }
+
+ /** Multiple topologies; only the second has KNOXIDF → JDBC (verifies the
loop continues). */
+ @Test
+ public void testMultipleTopologiesOneHasKnoxIdfReturnsJdbc() throws
Exception {
+ final AliasService alias = derbyAlias();
+ final GatewayServices gws = buildGatewayServicesWithMultipleTopologies(
+ withRoles("HDFS", "WEBHDFS"), withRoles("KNOXIDF"), alias);
+ assertIsJdbc(createFactory(), gws, derbyConfig());
+ }
+
+ // ------------------------------------------------------------------
+ // Error handling
+ // ------------------------------------------------------------------
+
+ /**
+ * When JDBC service initialization fails (e.g. bad DB type), the factory
must fall back
+ * to EmptyTrustedOidcIssuerService rather than propagating the exception.
+ */
+ @Test
+ public void testJdbcInitFailureFallsBackToEmpty() throws Exception {
+ final GatewayServices gws =
buildGatewayServicesWithTopology(withRoles("KNOXIDF"), derbyAlias());
+
+ final GatewayConfig brokenConfig =
EasyMock.createNiceMock(GatewayConfig.class);
+
EasyMock.expect(brokenConfig.getDatabaseType()).andReturn("invalid_db_type").anyTimes();
+ EasyMock.expect(brokenConfig.getServiceParameter(EasyMock.anyString(),
EasyMock.anyString()))
+ .andReturn("").anyTimes();
+ EasyMock.replay(brokenConfig);
+
+ assertIsEmpty(createFactory(), gws, brokenConfig);
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ private static TrustedOidcIssuerServiceFactory createFactory() {
+ return new TrustedOidcIssuerServiceFactory();
+ }
+
+ private static void assertIsEmpty(TrustedOidcIssuerServiceFactory factory,
+ GatewayServices gws, GatewayConfig config) throws Exception {
+ final org.apache.knox.gateway.services.Service result =
+ factory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config,
Map.of());
+ assertNotNull(result);
+ assertTrue("Expected EmptyTrustedOidcIssuerService but got " +
result.getClass().getSimpleName(),
+ result instanceof EmptyTrustedOidcIssuerService);
+ }
+
+ private static void assertIsJdbc(TrustedOidcIssuerServiceFactory factory,
+ GatewayServices gws, GatewayConfig config) throws Exception {
+ final org.apache.knox.gateway.services.Service result =
+ factory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config,
Map.of());
+ assertNotNull(result);
+ assertTrue("Expected JdbcTrustedOidcIssuerService but got " +
result.getClass().getSimpleName(),
+ result instanceof JdbcTrustedOidcIssuerService);
+ }
+
+ /** GatewayServices with a TopologyService returning no topologies; no
AliasService needed. */
+ private static GatewayServices buildEmptyGatewayServices() {
+ final TopologyService topologyService =
EasyMock.createNiceMock(TopologyService.class);
+
EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.emptyList()).anyTimes();
+ EasyMock.replay(topologyService);
+
+ final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+ EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE))
+ .andReturn(topologyService).anyTimes();
+ EasyMock.replay(gws);
+ return gws;
+ }
+
+ /**
+ * Builds a {@link GatewayServices} mock with one topology that has the
given service roles.
+ * {@code alias} may be null when no JDBC init will be attempted.
+ */
+ private static GatewayServices buildGatewayServicesWithTopology(
+ String[] roles, AliasService alias) throws Exception {
+ final Topology topology = topologyWithRoles(roles);
+ return buildGatewayServices(Collections.singletonList(topology), alias);
+ }
+
+ private static GatewayServices buildGatewayServicesWithMultipleTopologies(
+ String[] roles1, String[] roles2, AliasService alias) throws Exception {
+ return buildGatewayServices(
+ Arrays.asList(topologyWithRoles(roles1), topologyWithRoles(roles2)),
alias);
+ }
+
+ private static GatewayServices buildGatewayServices(
+ java.util.List<Topology> topologies, AliasService alias) throws
Exception {
+ final TopologyService topologyService =
EasyMock.createNiceMock(TopologyService.class);
+
EasyMock.expect(topologyService.getTopologies()).andReturn(topologies).anyTimes();
+ EasyMock.replay(topologyService);
+
+ final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+ EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE))
+ .andReturn(topologyService).anyTimes();
+ if (alias != null) {
+ EasyMock.expect(gws.getService(ServiceType.ALIAS_SERVICE))
+ .andReturn(alias).anyTimes();
+ }
+ EasyMock.replay(gws);
+ return gws;
+ }
+
+ private static Topology topologyWithRoles(String... roles) {
+ final Topology topology = EasyMock.createNiceMock(Topology.class);
+ final java.util.List<org.apache.knox.gateway.topology.Service> services =
new java.util.ArrayList<>();
+ for (String role : roles) {
+ final org.apache.knox.gateway.topology.Service svc =
+
EasyMock.createNiceMock(org.apache.knox.gateway.topology.Service.class);
+ EasyMock.expect(svc.getRole()).andReturn(role).anyTimes();
+ EasyMock.replay(svc);
+ services.add(svc);
+ }
+ EasyMock.expect(topology.getServices()).andReturn(services).anyTimes();
+ EasyMock.replay(topology);
+ return topology;
+ }
+
+ private static String[] withRoles(String... roles) {
+ return roles;
+ }
+
+ private static AliasService derbyAlias() throws Exception {
+ final AliasService alias = EasyMock.createNiceMock(AliasService.class);
+ EasyMock.expect(alias.getPasswordFromAliasForGateway(
+
AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
+ EasyMock.expect(alias.getPasswordFromAliasForGateway(
+
AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
+ EasyMock.replay(alias);
+ return alias;
+ }
+
+ private static GatewayConfig derbyConfig() {
+ final GatewayConfigImpl config = new GatewayConfigImpl();
+ config.set(GatewayConfigImpl.GATEWAY_DATABASE_TYPE,
DatabaseType.DERBY.type());
+ config.set(GatewayConfigImpl.GATEWAY_DATABASE_NAME, "memory:" + DB_NAME);
+ return config;
+ }
+
+ /** Config mock that returns empty string for getServiceParameter (required
for impl detection). */
+ private static GatewayConfig emptyConfig() {
+ final GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
+ EasyMock.expect(config.getServiceParameter(EasyMock.anyString(),
EasyMock.anyString()))
+ .andReturn("").anyTimes();
+ EasyMock.replay(config);
+ return config;
+ }
+}
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
index e8cb56015..8645017cc 100644
---
a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
@@ -53,6 +53,8 @@ public class TrustedOidcIssuersSchemaTest {
@BeforeClass
public static void setUp() throws SQLException {
+ // Derby 10.14 does not recognize locales like en_001; force a standard
locale.
+ java.util.Locale.setDefault(java.util.Locale.US);
derbyConn = DriverManager.getConnection(DERBY_URL);
hsqlConn = DriverManager.getConnection(HSQL_URL, HSQL_USER, HSQL_PASSWORD);
}
diff --git
a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
index 5573b90e2..99ba8b5e3 100644
---
a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
+++
b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
@@ -57,4 +57,17 @@ public interface KnoxIDFConstants {
String FEDERATED_OP_CONFIG_NAMES = FEDERATED_OP_CONFIG_PREFIX + "names";
String TOKEN_EXCHANGE_TOPOLOGY_NAME = "token.exchange.topology.name";
+
+ // TrustedOidcIssuerService gateway-level params (read from GatewayConfig
/ gateway-site.xml)
+ String TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS =
+ "gateway.trustedoidcissuer.discovery.cache.ttl.secs";
+ String TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS =
+ "gateway.trustedoidcissuer.discovery.connect.timeout.ms";
+ String TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS =
+ "gateway.trustedoidcissuer.discovery.read.timeout.ms";
+
+ // Default values for gateway-level TrustedOidcIssuerService params
+ int TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS = 600;
+ int TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS = 3000;
+ int TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS = 10000;
}
diff --git a/pom.xml b/pom.xml
index 52a60c643..99b9c803e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -259,6 +259,7 @@
<mina.version>2.2.8</mina.version>
<netty.version>4.1.127.Final</netty.version>
<nimbus-jose-jwt.version>10.9.1</nimbus-jose-jwt.version>
+ <oauth2-oidc-sdk.version>11.37.2</oauth2-oidc-sdk.version>
<nodejs.version>v22.20.0</nodejs.version>
<okhttp.version>4.12.0</okhttp.version>
<opensaml.version>5.2.2</opensaml.version>
@@ -1517,6 +1518,11 @@
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus-jose-jwt.version}</version>
</dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ <version>${oauth2-oidc-sdk.version}</version>
+ </dependency>
<dependency>
<groupId>net.minidev</groupId>