diff --git a/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentAutheliaIT.java b/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentAutheliaIT.java
new file mode 100644
index 00000000..1333979c
--- /dev/null
+++ b/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentAutheliaIT.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2025 Dremio Corporation
+ *
+ * Licensed 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.dremio.iceberg.authmgr.oauth2.agent;
+
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.AutheliaExtension.CLIENT_ID1;
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.AutheliaExtension.CLIENT_ID2;
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.AutheliaExtension.CLIENT_SECRET1;
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.AutheliaExtension.CLIENT_SECRET2;
+import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
+import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_POST;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
+
+import com.dremio.iceberg.authmgr.oauth2.flow.OAuth2Exception;
+import com.dremio.iceberg.authmgr.oauth2.flow.TokensResult;
+import com.dremio.iceberg.authmgr.oauth2.test.ImmutableTestEnvironment.Builder;
+import com.dremio.iceberg.authmgr.oauth2.test.TestEnvironment;
+import com.dremio.iceberg.authmgr.oauth2.test.junit.AutheliaExtension;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTParser;
+import com.nimbusds.oauth2.sdk.ErrorObject;
+import com.nimbusds.oauth2.sdk.GrantType;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.token.AccessToken;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.text.ParseException;
+import java.util.Objects;
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+@ExtendWith(AutheliaExtension.class)
+@ExtendWith(SoftAssertionsExtension.class)
+public class OAuth2AgentAutheliaIT {
+
+ @InjectSoftAssertions private SoftAssertions soft;
+
+ private static Path keyStorePath;
+
+ @BeforeAll
+ static void beforeAll(@TempDir Path tempDir) throws Exception {
+ keyStorePath = tempDir.resolve("keystore.p12");
+ try (InputStream is =
+ OAuth2AgentAutheliaIT.class.getResourceAsStream("/openssl/keystore.p12")) {
+ Files.copy(Objects.requireNonNull(is), keyStorePath);
+ }
+ }
+
+ @Test
+ void clientSecretBasic(Builder envBuilder) throws Exception {
+ try (TestEnvironment env =
+ envBuilder
+ .grantType(GrantType.CLIENT_CREDENTIALS)
+ .clientAuthenticationMethod(CLIENT_SECRET_BASIC)
+ .clientId(new ClientID(CLIENT_ID1))
+ .clientSecret(new Secret(CLIENT_SECRET1))
+ .sslTrustAll(false)
+ .sslTrustStorePath(keyStorePath)
+ .sslTrustStorePassword("s3cr3t")
+ .build();
+ OAuth2Agent agent = env.newAgent()) {
+ assertAgent(agent, CLIENT_ID1, env.getAuthorizationServerUrl());
+ }
+ }
+
+ @Test
+ void clientSecretPost(Builder envBuilder) throws Exception {
+ try (TestEnvironment env =
+ envBuilder
+ .grantType(GrantType.CLIENT_CREDENTIALS)
+ .clientAuthenticationMethod(CLIENT_SECRET_POST)
+ .clientId(new ClientID(CLIENT_ID2))
+ .clientSecret(new Secret(CLIENT_SECRET2))
+ .sslTrustAll(false)
+ .sslTrustStorePath(keyStorePath)
+ .sslTrustStorePassword("s3cr3t")
+ .build();
+ OAuth2Agent agent = env.newAgent()) {
+ assertAgent(agent, CLIENT_ID2, env.getAuthorizationServerUrl());
+ }
+ }
+
+ @Test
+ void unauthorizedBadClientSecret(Builder envBuilder) {
+ try (TestEnvironment env =
+ envBuilder
+ .clientSecret(new Secret("BAD SECRET"))
+ .sslTrustAll(false)
+ .sslTrustStorePath(keyStorePath)
+ .sslTrustStorePassword("s3cr3t")
+ .build();
+ OAuth2Agent agent = env.newAgent()) {
+ soft.assertThatThrownBy(agent::authenticate)
+ .asInstanceOf(type(OAuth2Exception.class))
+ .extracting(OAuth2Exception::getErrorObject)
+ .extracting(ErrorObject::getHTTPStatusCode, ErrorObject::getCode)
+ .containsExactly(401, "invalid_client");
+ }
+ }
+
+ private void assertAgent(OAuth2Agent agent, String clientId, URI issuer) throws Exception {
+ // initial grant
+ TokensResult initial = agent.authenticateInternal();
+ introspectToken(initial.getTokens().getAccessToken(), clientId, issuer);
+ soft.assertThat(initial.getTokens().getRefreshToken()).isNull();
+ // fetch new tokens
+ TokensResult renewed = agent.fetchNewTokens().toCompletableFuture().get();
+ introspectToken(renewed.getTokens().getAccessToken(), clientId, issuer);
+ soft.assertThat(renewed.getTokens().getRefreshToken()).isNull();
+ }
+
+ private void introspectToken(AccessToken accessToken, String clientId, URI issuer)
+ throws ParseException {
+ soft.assertThat(accessToken).isNotNull();
+ JWT jwt = JWTParser.parse(accessToken.getValue());
+ soft.assertThat(jwt).isNotNull();
+ String actualIssuer = jwt.getJWTClaimsSet().getIssuer();
+ String actualClientId = jwt.getJWTClaimsSet().getStringClaim("client_id");
+ soft.assertThat(actualIssuer).isEqualTo(issuer.toString());
+ soft.assertThat(actualClientId).isEqualTo(clientId);
+ }
+}
diff --git a/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentHydraIT.java b/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentHydraIT.java
new file mode 100644
index 00000000..e31d2226
--- /dev/null
+++ b/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentHydraIT.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2025 Dremio Corporation
+ *
+ * Licensed 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.dremio.iceberg.authmgr.oauth2.agent;
+
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.HydraExtension.CLIENT_ID1;
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.HydraExtension.CLIENT_ID2;
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.HydraExtension.CLIENT_SECRET1;
+import static com.dremio.iceberg.authmgr.oauth2.test.junit.HydraExtension.CLIENT_SECRET2;
+import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
+import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_POST;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
+
+import com.dremio.iceberg.authmgr.oauth2.flow.OAuth2Exception;
+import com.dremio.iceberg.authmgr.oauth2.flow.TokensResult;
+import com.dremio.iceberg.authmgr.oauth2.http.HttpClientType;
+import com.dremio.iceberg.authmgr.oauth2.test.ImmutableTestEnvironment.Builder;
+import com.dremio.iceberg.authmgr.oauth2.test.TestEnvironment;
+import com.dremio.iceberg.authmgr.oauth2.test.container.HydraContainer;
+import com.dremio.iceberg.authmgr.oauth2.test.junit.HydraExtension;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTParser;
+import com.nimbusds.oauth2.sdk.ErrorObject;
+import com.nimbusds.oauth2.sdk.GrantType;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.token.AccessToken;
+import java.text.ParseException;
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junitpioneer.jupiter.cartesian.CartesianTest;
+import org.junitpioneer.jupiter.cartesian.CartesianTest.Enum;
+
+@ExtendWith(HydraExtension.class)
+@ExtendWith(SoftAssertionsExtension.class)
+public class OAuth2AgentHydraIT {
+
+ @InjectSoftAssertions private SoftAssertions soft;
+
+ @CartesianTest
+ void clientSecretBasic(@Enum HttpClientType httpClientType, Builder envBuilder) throws Exception {
+ try (TestEnvironment env =
+ envBuilder
+ .httpClientType(httpClientType)
+ .grantType(GrantType.CLIENT_CREDENTIALS)
+ .clientAuthenticationMethod(CLIENT_SECRET_BASIC)
+ .clientId(new ClientID(CLIENT_ID1))
+ .clientSecret(new Secret(CLIENT_SECRET1))
+ .build();
+ OAuth2Agent agent = env.newAgent()) {
+ assertAgent(agent, CLIENT_ID1);
+ }
+ }
+
+ @CartesianTest
+ void clientSecretPost(@Enum HttpClientType httpClientType, Builder envBuilder) throws Exception {
+ try (TestEnvironment env =
+ envBuilder
+ .httpClientType(httpClientType)
+ .grantType(GrantType.CLIENT_CREDENTIALS)
+ .clientAuthenticationMethod(CLIENT_SECRET_POST)
+ .clientId(new ClientID(CLIENT_ID2))
+ .clientSecret(new Secret(CLIENT_SECRET2))
+ .build();
+ OAuth2Agent agent = env.newAgent()) {
+ assertAgent(agent, CLIENT_ID2);
+ }
+ }
+
+ @Test
+ void unauthorizedBadClientSecret(Builder envBuilder) {
+ try (TestEnvironment env = envBuilder.clientSecret(new Secret("BAD SECRET")).build();
+ OAuth2Agent agent = env.newAgent()) {
+ soft.assertThatThrownBy(agent::authenticate)
+ .asInstanceOf(type(OAuth2Exception.class))
+ .extracting(OAuth2Exception::getErrorObject)
+ .extracting(ErrorObject::getHTTPStatusCode, ErrorObject::getCode)
+ .containsExactly(401, "invalid_client");
+ }
+ }
+
+ private void assertAgent(OAuth2Agent agent, String clientId) throws Exception {
+ // initial grant
+ TokensResult initial = agent.authenticateInternal();
+ introspectToken(initial.getTokens().getAccessToken(), clientId);
+ soft.assertThat(initial.getTokens().getRefreshToken()).isNull();
+ // fetch new tokens
+ TokensResult renewed = agent.fetchNewTokens().toCompletableFuture().get();
+ introspectToken(renewed.getTokens().getAccessToken(), clientId);
+ soft.assertThat(renewed.getTokens().getRefreshToken()).isNull();
+ }
+
+ private void introspectToken(AccessToken accessToken, String clientId) throws ParseException {
+ soft.assertThat(accessToken).isNotNull();
+ JWT jwt = JWTParser.parse(accessToken.getValue());
+ soft.assertThat(jwt).isNotNull();
+ String actualIssuer = jwt.getJWTClaimsSet().getIssuer();
+ String actualClientId = jwt.getJWTClaimsSet().getStringClaim("client_id");
+ String actualSubject = jwt.getJWTClaimsSet().getStringClaim("sub");
+ String actualScope = jwt.getJWTClaimsSet().getStringArrayClaim("scp")[0];
+ soft.assertThat(actualIssuer).isEqualTo(HydraContainer.ISSUER_URL);
+ soft.assertThat(actualClientId).isEqualTo(clientId);
+ soft.assertThat(actualSubject).isEqualTo(clientId);
+ soft.assertThat(actualScope).isEqualTo("catalog");
+ }
+}
diff --git a/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentKeycloakIT.java b/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentKeycloakIT.java
index 69f441b2..1c0c1855 100644
--- a/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentKeycloakIT.java
+++ b/oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentKeycloakIT.java
@@ -226,7 +226,7 @@ void httpsCallback(
@EnumLike CodeChallengeMethod method, Builder envBuilder, @TempDir Path tempDir)
throws Exception {
Path keyStorePath = tempDir.resolve("keystore.p12");
- try (InputStream is = getClass().getResourceAsStream("/openssl/mockserver.p12")) {
+ try (InputStream is = getClass().getResourceAsStream("/openssl/keystore.p12")) {
Files.copy(Objects.requireNonNull(is), keyStorePath);
}
try (TestEnvironment env =
diff --git a/oauth2/core/src/intTest/resources/logback-test.xml b/oauth2/core/src/intTest/resources/logback-test.xml
index 08293eef..58a41aba 100644
--- a/oauth2/core/src/intTest/resources/logback-test.xml
+++ b/oauth2/core/src/intTest/resources/logback-test.xml
@@ -38,6 +38,10 @@ limitations under the License.
+
+
+
+
diff --git a/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/TestEnvironment.java b/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/TestEnvironment.java
index ad59e9dd..b6c694f8 100644
--- a/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/TestEnvironment.java
+++ b/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/TestEnvironment.java
@@ -655,8 +655,10 @@ public Map getHttpConfig() {
ImmutableMap.builder()
.put(HttpConfig.PREFIX + '.' + HttpConfig.CLIENT_TYPE, getHttpClientType().toString())
.put(
- HttpConfig.PREFIX + '.' + HttpConfig.SSL_TRUST_ALL,
- String.valueOf(isSslTrustAll()));
+ HttpConfig.PREFIX + '.' + HttpConfig.SSL_TRUST_ALL, String.valueOf(isSslTrustAll()))
+ .put(
+ HttpConfig.PREFIX + '.' + HttpConfig.SSL_HOSTNAME_VERIFICATION_ENABLED,
+ String.valueOf(isSslHostnameVerificationEnabled()));
getSslProtocols()
.ifPresent(v -> builder.put(HttpConfig.PREFIX + '.' + HttpConfig.SSL_PROTOCOLS, v));
getSslCipherSuites()
@@ -694,6 +696,11 @@ public boolean isSslTrustAll() {
return false;
}
+ @Value.Default
+ public boolean isSslHostnameVerificationEnabled() {
+ return true;
+ }
+
public abstract Optional getProxyHost();
public abstract OptionalInt getProxyPort();
diff --git a/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/AutheliaExtension.java b/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/AutheliaExtension.java
new file mode 100644
index 00000000..a28325fa
--- /dev/null
+++ b/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/AutheliaExtension.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2025 Dremio Corporation
+ *
+ * Licensed 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.dremio.iceberg.authmgr.oauth2.test.junit;
+
+import com.dremio.iceberg.authmgr.oauth2.http.HttpClientType;
+import com.dremio.iceberg.authmgr.oauth2.test.ImmutableTestEnvironment;
+import com.dremio.iceberg.authmgr.oauth2.test.TestEnvironment;
+import com.dremio.iceberg.authmgr.oauth2.test.TestEnvironmentExtension;
+import com.dremio.iceberg.authmgr.oauth2.test.container.AutheliaContainer;
+import com.nimbusds.oauth2.sdk.Scope;
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+public class AutheliaExtension extends TestEnvironmentExtension
+ implements BeforeAllCallback, AfterAllCallback {
+
+ // Client1 is used for client_secret_basic authentication
+ public static final String CLIENT_ID1 = "Client1";
+ public static final String CLIENT_SECRET1 = "s3cr3t";
+
+ // Client2 is used for client_secret_post authentication
+ public static final String CLIENT_ID2 = "Client2";
+ public static final String CLIENT_SECRET2 = "s3cr3t";
+
+ // Authelia's private key and certificate
+ // These are classpath resources in the testFixtures resources directory
+ public static final String PRIVATE_KEY = "openssl/rsa_private_key_pkcs8.pem";
+ public static final String CERTIFICATE = "openssl/rsa_certificate.pem";
+
+ @Override
+ public void beforeAll(ExtensionContext context) {
+ AutheliaContainer authelia = new AutheliaContainer(PRIVATE_KEY, CERTIFICATE);
+ authelia.start();
+ context
+ .getStore(ExtensionContext.Namespace.GLOBAL)
+ .put(AutheliaContainer.class.getName(), authelia);
+ }
+
+ @Override
+ public void afterAll(ExtensionContext context) {
+ AutheliaContainer authelia =
+ context
+ .getStore(ExtensionContext.Namespace.GLOBAL)
+ .remove(AutheliaContainer.class.getName(), AutheliaContainer.class);
+ if (authelia != null) {
+ authelia.close();
+ }
+ }
+
+ @Override
+ protected ImmutableTestEnvironment.Builder newTestEnvironmentBuilder(ExtensionContext context) {
+ AutheliaContainer authelia =
+ context
+ .getStore(ExtensionContext.Namespace.GLOBAL)
+ .get(AutheliaContainer.class.getName(), AutheliaContainer.class);
+ return TestEnvironment.builder()
+ .unitTest(false)
+ .discoveryEnabled(true)
+ .sslTrustAll(true)
+ .sslHostnameVerificationEnabled(false)
+ .httpClientType(HttpClientType.APACHE) // required for SSL
+ .serverRootUrl(authelia.getAutheliaUrl())
+ .authorizationServerUrl(authelia.getAutheliaUrl())
+ .scope(Scope.parse("profile"));
+ }
+}
diff --git a/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/HydraExtension.java b/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/HydraExtension.java
new file mode 100644
index 00000000..20bb52c8
--- /dev/null
+++ b/oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/HydraExtension.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2025 Dremio Corporation
+ *
+ * Licensed 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.dremio.iceberg.authmgr.oauth2.test.junit;
+
+import com.dremio.iceberg.authmgr.oauth2.test.ImmutableTestEnvironment;
+import com.dremio.iceberg.authmgr.oauth2.test.TestConstants;
+import com.dremio.iceberg.authmgr.oauth2.test.TestEnvironment;
+import com.dremio.iceberg.authmgr.oauth2.test.TestEnvironmentExtension;
+import com.dremio.iceberg.authmgr.oauth2.test.container.HydraContainer;
+import com.nimbusds.oauth2.sdk.Scope;
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+public class HydraExtension extends TestEnvironmentExtension
+ implements BeforeAllCallback, AfterAllCallback {
+
+ // Client1 is used for client_secret_basic authentication
+ public static final String CLIENT_ID1 = TestConstants.CLIENT_ID1.getValue();
+ public static final String CLIENT_SECRET1 = TestConstants.CLIENT_SECRET1.getValue();
+
+ // Client2 is used for client_secret_post authentication
+ public static final String CLIENT_ID2 = TestConstants.CLIENT_ID2.getValue();
+ public static final String CLIENT_SECRET2 = TestConstants.CLIENT_SECRET2.getValue();
+
+ // Client3 is used for public client (no authentication)
+ public static final String CLIENT_ID3 = "Client3";
+
+ public static final String SCOPE1 = TestConstants.SCOPE1.toString();
+
+ @Override
+ public void beforeAll(ExtensionContext context) {
+ HydraContainer hydra =
+ new HydraContainer()
+ .withClient(CLIENT_ID1, CLIENT_SECRET1, "client_secret_basic")
+ .withClient(CLIENT_ID2, CLIENT_SECRET2, "client_secret_post")
+ .withClient(CLIENT_ID3, null, "none");
+ hydra.start();
+ context.getStore(ExtensionContext.Namespace.GLOBAL).put(HydraContainer.class.getName(), hydra);
+ }
+
+ @Override
+ public void afterAll(ExtensionContext context) {
+ HydraContainer hydra =
+ context
+ .getStore(ExtensionContext.Namespace.GLOBAL)
+ .remove(HydraContainer.class.getName(), HydraContainer.class);
+ if (hydra != null) {
+ hydra.close();
+ }
+ }
+
+ @Override
+ protected ImmutableTestEnvironment.Builder newTestEnvironmentBuilder(ExtensionContext context) {
+ HydraContainer hydra =
+ context
+ .getStore(ExtensionContext.Namespace.GLOBAL)
+ .get(HydraContainer.class.getName(), HydraContainer.class);
+ // Note: Hydra doesn't support device code flow nor token exchange
+ // Note: cannot use metadata discovery, the URLs are internal to the container network
+ return TestEnvironment.builder()
+ .unitTest(false)
+ .discoveryEnabled(false)
+ .scope(new Scope(SCOPE1))
+ .serverRootUrl(hydra.getPublicUrl())
+ .tokenEndpoint(hydra.getTokenEndpoint())
+ .authorizationEndpoint(hydra.getAuthEndpoint());
+ }
+}
diff --git a/oauth2/core/src/testFixtures/resources/openssl/README.md b/oauth2/core/src/testFixtures/resources/openssl/README.md
index fa9275b2..63cbde9c 100644
--- a/oauth2/core/src/testFixtures/resources/openssl/README.md
+++ b/oauth2/core/src/testFixtures/resources/openssl/README.md
@@ -24,6 +24,8 @@ This directory contains the following files:
* `ecdsa_private_key.pem` - ECDSA private key (`BEGIN EC PRIVATE KEY`)
* `rsa_certificate.pem` - Self-signed certificate from RSA key with CN="test"
* `ecdsa_certificate.pem` - Self-signed certificate from ECDSA key with CN="test"
+* `keystore.p12` - Java keystore containing `rsa_certificate.pem` and `rsa_private_key_pkcs8.pem`
+ (password: `s3cr3t`)
* `mockserver.p12` - Mock Server's Java keystore containing its certificate and private key
(password: `s3cr3t`)
@@ -49,7 +51,10 @@ openssl req -new -x509 -key rsa_private_key_pkcs8.pem -out rsa_certificate.pem -
# 5. Generate long-lived self-signed certificate from ECDSA key (100 years)
openssl req -new -x509 -key ecdsa_private_key.pem -out ecdsa_certificate.pem -days 36500 -subj "/CN=test"
-# 6. Generate Java keystore from Mock Server's certificate and private key
+# 6. Generate Java keystore from RSA certificate and private key
+openssl pkcs12 -export -in rsa_certificate.pem -inkey rsa_private_key_pkcs8.pem -out keystore.p12 -password pass:s3cr3t
+
+# 7. Generate Java keystore from Mock Server's certificate and private key
wget https://raw.githubusercontent.com/mock-server/mockserver/refs/heads/master/mockserver-core/src/main/resources/org/mockserver/socket/CertificateAuthorityCertificate.pem -O mockserver.pem
wget https://raw.githubusercontent.com/mock-server/mockserver/refs/heads/master/mockserver-core/src/main/resources/org/mockserver/socket/CertificateAuthorityPrivateKey.pem -O mockserver.key
openssl pkcs12 -export -in mockserver.pem -inkey mockserver.key -out mockserver.p12 -password pass:s3cr3t
diff --git a/oauth2/core/src/testFixtures/resources/openssl/keystore.p12 b/oauth2/core/src/testFixtures/resources/openssl/keystore.p12
new file mode 100644
index 00000000..b07cbe51
Binary files /dev/null and b/oauth2/core/src/testFixtures/resources/openssl/keystore.p12 differ
diff --git a/oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/AutheliaContainer.java b/oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/AutheliaContainer.java
new file mode 100644
index 00000000..9464761b
--- /dev/null
+++ b/oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/AutheliaContainer.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2025 Dremio Corporation
+ *
+ * Licensed 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.dremio.iceberg.authmgr.oauth2.test.container;
+
+import java.net.URI;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+public class AutheliaContainer extends GenericContainer {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(AutheliaContainer.class);
+
+ @SuppressWarnings("resource")
+ public AutheliaContainer(
+ String privateKeyClasspathResource, String certificateClasspathResource) {
+ super("authelia/authelia:4.39.10");
+ withExposedPorts(9091);
+ withEnv("X_AUTHELIA_CONFIG_FILTERS", "template");
+ withEnv("AUTHMGR_LOG_LEVEL", getLogLevel());
+
+ withClasspathResourceMapping(
+ "authelia/authelia-config.yaml", "/config/configuration.yml", BindMode.READ_ONLY);
+ withClasspathResourceMapping(
+ "authelia/authelia-users.yaml", "/config/users.yml", BindMode.READ_ONLY);
+ withClasspathResourceMapping(
+ privateKeyClasspathResource, "/config/key.pem", BindMode.READ_ONLY);
+ withClasspathResourceMapping(
+ certificateClasspathResource, "/config/cert.pem", BindMode.READ_ONLY);
+
+ waitingFor(Wait.forListeningPort());
+ }
+
+ public URI getAutheliaUrl() {
+ return URI.create("https://localhost:" + getMappedPort(9091));
+ }
+
+ private static String getLogLevel() {
+ return LOGGER.isDebugEnabled()
+ ? "debug"
+ : LOGGER.isInfoEnabled() ? "info" : LOGGER.isWarnEnabled() ? "warn" : "error";
+ }
+}
diff --git a/oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/HydraContainer.java b/oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/HydraContainer.java
new file mode 100644
index 00000000..a9c021b3
--- /dev/null
+++ b/oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/HydraContainer.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2025 Dremio Corporation
+ *
+ * Licensed 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.dremio.iceberg.authmgr.oauth2.test.container;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+public class HydraContainer extends GenericContainer {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(HydraContainer.class);
+
+ private static final int PUBLIC_PORT = 4444;
+ private static final int ADMIN_PORT = 4445;
+
+ public static final String ISSUER_URL = "http://localhost:" + PUBLIC_PORT + "/";
+
+ private final List clients = new ArrayList<>();
+
+ private URI publicUrl;
+ private URI adminUrl;
+ private URI tokenEndpoint;
+ private URI authEndpoint;
+
+ @SuppressWarnings("resource")
+ public HydraContainer() {
+ super("oryd/hydra:v2.3.0");
+
+ withNetworkAliases("hydra");
+ withLogConsumer(new Slf4jLogConsumer(LOGGER));
+ withExposedPorts(PUBLIC_PORT, ADMIN_PORT);
+ waitingFor(Wait.forHttp("/health/ready").forPort(PUBLIC_PORT));
+
+ withEnv("DSN", "memory"); // Use in-memory database
+ withEnv("URLS_SELF_ISSUER", ISSUER_URL);
+ withEnv("SECRETS_SYSTEM", "this-is-a-test-secret-only-for-testing");
+ withEnv("OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES", "public");
+ withEnv("OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT", "test-salt");
+ withEnv("STRATEGIES_ACCESS_TOKEN", "jwt");
+
+ withEnv("LOG_LEVEL", getLogLevel());
+ withEnv("LOG_FORMAT", "text");
+ withEnv("LOG_LEAK_SENSITIVE_VALUES", "true"); // Enable sensitive value logging for debugging
+
+ withCommand("serve", "all", "--dev");
+ }
+
+ @CanIgnoreReturnValue
+ public HydraContainer withClient(String clientId, String clientSecret, String authMethod) {
+ clients.add(new HydraClient(clientId, clientSecret, authMethod));
+ return this;
+ }
+
+ @Override
+ public void start() {
+ if (getContainerId() != null) {
+ return;
+ }
+ super.start();
+ publicUrl = URI.create("http://localhost:" + getMappedPort(PUBLIC_PORT));
+ adminUrl = URI.create("http://localhost:" + getMappedPort(ADMIN_PORT));
+ tokenEndpoint = publicUrl.resolve("/oauth2/token");
+ authEndpoint = publicUrl.resolve("/oauth2/auth");
+ LOGGER.info("Hydra URLs configured: public={}, admin={}", publicUrl, adminUrl);
+ try (Client httpClient = ClientBuilder.newBuilder().build()) {
+ for (HydraClient client : clients) {
+ createClient(client, httpClient);
+ }
+ }
+ }
+
+ public URI getPublicUrl() {
+ return publicUrl;
+ }
+
+ public URI getAdminUrl() {
+ return adminUrl;
+ }
+
+ public URI getTokenEndpoint() {
+ return tokenEndpoint;
+ }
+
+ public URI getAuthEndpoint() {
+ return authEndpoint;
+ }
+
+ private void createClient(HydraClient hydraClient, Client httpClient) {
+ StringBuilder builder = new StringBuilder();
+ builder.append("{");
+ builder.append("\"client_id\":\"").append(hydraClient.clientId).append("\",");
+ builder.append("\"client_name\":\"").append(hydraClient.clientId).append("\",");
+ if (hydraClient.clientSecret != null) {
+ builder.append("\"client_secret\":\"").append(hydraClient.clientSecret).append("\",");
+ }
+ builder.append("\"grant_types\":[");
+ if (hydraClient.authMethod.equals("none")) {
+ builder.append("\"client_credentials\"");
+ } else {
+ builder.append(
+ "\"client_credentials\", \"password\", \"authorization_code\", \"refresh_token\"");
+ }
+ builder.append("],");
+ builder.append("\"response_types\":[\"code\",\"token\",\"id_token\"],");
+ builder.append("\"scope\":\"catalog openid offline\",");
+ builder.append("\"redirect_uris\":[");
+ builder.append("\"http://localhost:63000").append("/*\"");
+ builder.append("],");
+ builder.append("\"token_endpoint_auth_method\":\"").append(hydraClient.authMethod).append("\"");
+ builder.append("}");
+
+ String json = builder.toString();
+
+ try (Response response =
+ httpClient.target(getAdminUrl()).path("/admin/clients").request().post(Entity.json(json))) {
+ if (response.getStatus() != Response.Status.CREATED.getStatusCode()) {
+ throw new RuntimeException("Failed to create client: " + response.readEntity(String.class));
+ }
+ }
+ }
+
+ private static class HydraClient {
+ final String clientId;
+ final String clientSecret;
+ final String authMethod;
+
+ HydraClient(String clientId, String clientSecret, String authMethod) {
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.authMethod = authMethod;
+ }
+ }
+
+ private static String getLogLevel() {
+ return LOGGER.isDebugEnabled()
+ ? "debug"
+ : LOGGER.isInfoEnabled() ? "info" : LOGGER.isWarnEnabled() ? "warn" : "error";
+ }
+}
diff --git a/oauth2/tests/src/main/resources/authelia/authelia-config.yaml b/oauth2/tests/src/main/resources/authelia/authelia-config.yaml
new file mode 100644
index 00000000..5a39e7ac
--- /dev/null
+++ b/oauth2/tests/src/main/resources/authelia/authelia-config.yaml
@@ -0,0 +1,97 @@
+##
+## Copyright (C) 2025 Dremio Corporation
+##
+## Licensed 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
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## 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.
+##
+---
+###############################################################
+# Authelia configuration #
+###############################################################
+
+server:
+ address: 'tcp://:9091'
+ tls:
+ key: /config/key.pem
+ certificate: /config/cert.pem
+
+log:
+ level: '{{ env "AUTHMGR_LOG_LEVEL" }}'
+
+totp:
+ issuer: 'authelia.com'
+
+identity_validation:
+ reset_password:
+ jwt_secret: 'a_very_important_secret'
+
+authentication_backend:
+ file:
+ path: '/config/users.yml'
+
+access_control:
+ default_policy: 'one_factor'
+
+ntp:
+ disable_startup_check: true
+
+session:
+ secret: 'insecure_session_secret'
+ cookies:
+ - name: 'authelia_session'
+ domain: '127.0.0.1'
+ authelia_url: 'https://127.0.0.1/whatever'
+
+storage:
+ encryption_key: 'you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this'
+ local:
+ path: '/config/db.sqlite3'
+
+notifier:
+ filesystem:
+ filename: '/config/notification.txt'
+
+identity_providers:
+ oidc:
+ enable_client_debug_messages: true
+ enforce_pkce: always
+ jwks:
+ - key: {{ secret "/config/key.pem" | mindent 10 "|" | msquote }}
+ clients:
+ - client_id: 'Client1'
+ client_name: 'Client1'
+ # The digest of 's3cr3t'.
+ client_secret: "$pbkdf2-sha512$310000$ZCRTSGdGSazSFWHVaQSMtw$WZbWXoBTaluYaZviGFzOSvlexw1yLtd7qsHa0xR/5I.ZX/qeYdm008j4Vnadfy8RmOYOiAjh.UPELqrM3tKo7A"
+ public: false
+ scopes:
+ - 'email'
+ - 'profile'
+ grant_types:
+ - 'client_credentials'
+ response_types:
+ - 'code'
+ token_endpoint_auth_method: 'client_secret_basic'
+ access_token_signed_response_alg: 'RS256'
+ - client_id: 'Client2'
+ client_name: 'Client2'
+ # The digest of 's3cr3t'.
+ client_secret: "$pbkdf2-sha512$310000$ZCRTSGdGSazSFWHVaQSMtw$WZbWXoBTaluYaZviGFzOSvlexw1yLtd7qsHa0xR/5I.ZX/qeYdm008j4Vnadfy8RmOYOiAjh.UPELqrM3tKo7A"
+ public: false
+ scopes:
+ - 'email'
+ - 'profile'
+ grant_types:
+ - 'client_credentials'
+ response_types:
+ - 'code'
+ token_endpoint_auth_method: 'client_secret_post'
+ access_token_signed_response_alg: 'RS256'
diff --git a/oauth2/tests/src/main/resources/authelia/authelia-users.yaml b/oauth2/tests/src/main/resources/authelia/authelia-users.yaml
new file mode 100644
index 00000000..0c0ab9c4
--- /dev/null
+++ b/oauth2/tests/src/main/resources/authelia/authelia-users.yaml
@@ -0,0 +1,25 @@
+##
+## Copyright (C) 2025 Dremio Corporation
+##
+## Licensed 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
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## 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.
+##
+users:
+ Alice:
+ disabled: false
+ displayname: "Alice"
+ # s3cr3t
+ password: "$pbkdf2-sha512$310000$ZCRTSGdGSazSFWHVaQSMtw$WZbWXoBTaluYaZviGFzOSvlexw1yLtd7qsHa0xR/5I.ZX/qeYdm008j4Vnadfy8RmOYOiAjh.UPELqrM3tKo7A"
+ email: alice@example.com
+ groups:
+ - admins
+ - dev