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
commit 0ac3eca9fb1b73707560ee0049883818f3b39a3d 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 69b57fde1..406496f18 100644 --- a/pom.xml +++ b/pom.xml @@ -259,6 +259,7 @@ <mina.version>2.2.8</mina.version> <netty.version>4.1.135.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>
