From 080396f908da0e2e46093d926205239f54b1f314 Mon Sep 17 00:00:00 2001 From: Alexandre Dutra Date: Thu, 25 Sep 2025 12:15:58 +0200 Subject: [PATCH] feat(oauth): Add integration tests with Ory Hydra & Authelia This PR brings integration tests with Ory Hydra and Authelia. The tests are very basic and only exercise client_credentials for now. Human-to-machine flows are hard to test, so these were left out of the scope of this change, but we can look into that later. --- .../oauth2/agent/OAuth2AgentAutheliaIT.java | 142 +++++++++++++++ .../oauth2/agent/OAuth2AgentHydraIT.java | 121 +++++++++++++ .../oauth2/agent/OAuth2AgentKeycloakIT.java | 2 +- .../src/intTest/resources/logback-test.xml | 4 + .../authmgr/oauth2/test/TestEnvironment.java | 11 +- .../oauth2/test/junit/AutheliaExtension.java | 80 +++++++++ .../oauth2/test/junit/HydraExtension.java | 82 +++++++++ .../testFixtures/resources/openssl/README.md | 7 +- .../resources/openssl/keystore.p12 | Bin 0 -> 2531 bytes .../test/container/AutheliaContainer.java | 58 +++++++ .../oauth2/test/container/HydraContainer.java | 162 ++++++++++++++++++ .../resources/authelia/authelia-config.yaml | 97 +++++++++++ .../resources/authelia/authelia-users.yaml | 25 +++ 13 files changed, 787 insertions(+), 4 deletions(-) create mode 100644 oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentAutheliaIT.java create mode 100644 oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentHydraIT.java create mode 100644 oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/AutheliaExtension.java create mode 100644 oauth2/core/src/testFixtures/java/com/dremio/iceberg/authmgr/oauth2/test/junit/HydraExtension.java create mode 100644 oauth2/core/src/testFixtures/resources/openssl/keystore.p12 create mode 100644 oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/AutheliaContainer.java create mode 100644 oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/HydraContainer.java create mode 100644 oauth2/tests/src/main/resources/authelia/authelia-config.yaml create mode 100644 oauth2/tests/src/main/resources/authelia/authelia-users.yaml 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 0000000000000000000000000000000000000000..b07cbe51d06962fc237029bcc6ee53eb06fb7e26 GIT binary patch literal 2531 zcmai$XEYm%8^&c4BW7&jR?-nGh*g&up+@aeVnkatYmdgL5v!Y1HcR@ zp}-Qr6d2`)9f_rY82?7l5(;SUhcyiZF#TBL|0Vz|>8F5jU@fuo|4Mc&3e4p<@vHs> zaOVYs?)j8x>^%eovM^!Up)CKq3WPzJuwqb{bAkcjCI}3`fVt#!6}gK#Hj8!V__cyl zn1Rt0kaP1)6Ad044f?{(ehJbef;1;y_t1sOdtshr&!+Ad5hmUg1VDUMQypzpsTf^z>9`l#nEhv|2 zZZjJuR`+<70qF7^uk=bzEj65aae??-fy#QI={mNSMeT=O3g<(p2=s~XmTTL&nK@&Z z0Q0Ei$;mZwc1i#hAAU223Oxi9{XW`uNA7Id%%Aa%M?Y8h`os`a)kQz%%W;gR5 zd3YNbD(~JmGEcH7qd&=H@zO-)6;- z*lXPyaE*aDFB%m{B5k6qOmmt5UgjF&q^-)|HT6nIyCcSn>ild<|{e z?WXf0_<*93*0r&i!0TA`OXMEq@yEbL}nTMa!(*M`1RoVLF@t}Do?fDYtM^<0%= zl3wnQ;#it~PrOp~&1@eIe;~Ft z`W%q2;T}Lz%gxh7`l3*d>hmZieY^XY3nIU-*jK6EYQJOiRZ$bgc%3)?$OF^AkuYp1 z^hmnBByO4H^ROw#t1A3yP9uD5>0qdQ{;x4TcW>$AQu?J_;){371b3eZ+oF*b&+b!H zS)@WgEn=PdNrrA^?EK?NDe2RT%70m3%Pd=vW;#iZ(KRk{h=+MrIHyjoN-RCuVR2V{ zt<0&vR48gY5q-Vc6D zT>s4o?J)i|L;G~ftiIwQfi5y3!8X7Cg(`Oy>C2h4u zUtg={e*0;*%$AGVMW81(w$_&;+R3H<-#N#k$1iSK)N}G_MCVz=ZYx$~nW@(896Fda zgI*p&rH;_PsQk48Ad+sYp{Cbl-N&nU)wI$jbJpqrYJ2 zv7->!pqN9}{*!{M^D%_4HeMVB0ZB!Cv=J)8URB0OS!Nlkyk^+W)Wex%J4aY6au3Dj z4i!?ambFmtuMO$*V%*&O(nr*1%!KwJO1VeX)P{)C0Uz3P+}B^rq1)s@zd~Uu4zZEQ zBomHb_!H7u|A7S)t$68W{%)8ia&KkO6J>r>&g$79@MS|xXVBgZ6^vSq8S~!r=!$yg zb;e4vLGYy(JhfVbAX*?nxQ%(#Lf{c-l1O{W23pe+Fd8a-wW1bDSPJcpNL@}7fR`S= zx!=l@=B-H2@?REC=TIN7;8xBqT~jWUY-;_=apjEnxsnhqZJTaW-abK5MWdwF@I6OYkv@QbM&S{Yn33VTRP*7 z<9NrE9E699z5TR3C6yX+7IzRKM%mk;BTGJMk%MeCgi z3~jRu72cBT3*>_3ZYQwQqt3#a;EIEu#Hme3~`Qy;o^mpPg$e(u+$* zYurUb$u{DbZAKcprp>H0BQ*Yu+>~9Mm@+u|gnQFF?nZ~Y46_bdyS^ruNdSz8TktpH zPn9+1HSguUpr@V1cU)Oo45xDm1l86oNrG)muX!gG=4)n9t-Bwq1#}$o8u2a^^E58Q z7rGeVF`KS>Qs9^X_HVWlbCWYQLGZ`AjNwR-BhG3gHMy_hnPCosuP?VcPaq|Ul0QKUL&)=k`J`yL zr{;v4xK2)1$f$*6pubf8H>@^R9?S9bEdl}{OkjyR*Ub8w0rSfS!jbZ6^?#^UkfflW c>o(Q2-YN>}c(38XECh!~CPh&s>OZLc8zWzMJOBUy literal 0 HcmV?d00001 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