From 84deebcdd4e78d2f8eef52459dd0040a4c64c39e Mon Sep 17 00:00:00 2001 From: Adam Kolodziejczyk Date: Mon, 9 Dec 2024 14:59:52 +0100 Subject: [PATCH] SNOW-1346234 add automated external browser tests (#1983) --- .../parameters_aws_auth_tests.json.gpg | 4 + Jenkinsfile | 13 ++ ci/container/test_authentication.sh | 20 +++ ci/test_authentication.sh | 16 +++ parent-pom.xml | 1 + pom.xml | 2 + .../AuthConnectionParameters.java | 37 ++++++ .../client/authentication/AuthTest.java | 114 ++++++++++++++++++ .../authentication/ExternalBrowserIT.java | 82 +++++++++++++ .../client/authentication/IdTokenIT.java | 79 ++++++++++++ .../snowflake/client/category/TestTags.java | 1 + .../suites/AuthenticationTestSuite.java | 8 ++ .../client/suites/UnitTestSuite.java | 3 +- 13 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/parameters_aws_auth_tests.json.gpg create mode 100755 ci/container/test_authentication.sh create mode 100755 ci/test_authentication.sh create mode 100644 src/test/java/net/snowflake/client/authentication/AuthConnectionParameters.java create mode 100644 src/test/java/net/snowflake/client/authentication/AuthTest.java create mode 100644 src/test/java/net/snowflake/client/authentication/ExternalBrowserIT.java create mode 100644 src/test/java/net/snowflake/client/authentication/IdTokenIT.java create mode 100644 src/test/java/net/snowflake/client/suites/AuthenticationTestSuite.java diff --git a/.github/workflows/parameters_aws_auth_tests.json.gpg b/.github/workflows/parameters_aws_auth_tests.json.gpg new file mode 100644 index 000000000..1b448332e --- /dev/null +++ b/.github/workflows/parameters_aws_auth_tests.json.gpg @@ -0,0 +1,4 @@ +  .TQQ_鞋ftSo Ceǜ'W"#Z8\1j^?+hqGR,=Izc8K=Zp2+kU5wxƳ +RWܗ*pW\Jv½|/u^8%J@L+.e#Z~W ^Cv}ے]4pk^r' +hŚ>;X:7"@;~ZEzkv2nNS09=UwD08qz4?gj78*u-"*'{ih|/Lþ #Sb蕮iJ$LFc*ABn&q=rjbn6Ih +Rd쌔Di5X,+zhʻ9Oe<>[u>vscf?clՋ,rSrhΙ \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 261a2968b..8956bd92b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -89,6 +89,19 @@ timestamps { } jobDefinitions.put('JDBC-AIX-Unit', { build job: 'JDBC-AIX-UnitTests', parameters: [ string(name: 'BRANCH', value: scmInfo.GIT_BRANCH ) ] } ) + jobDefinitions.put('Test Authentication', { + withCredentials([ + string(credentialsId: 'sfctest0-parameters-secret', variable: 'PARAMETERS_SECRET'), + string(credentialsId: 'a791118f-a1ea-46cd-b876-56da1b9bc71c', variable: 'NEXUS_PASSWORD') + ]) { + sh '''\ + |#!/bin/bash + |set -e + |ci/test_authentication.sh + '''.stripMargin() + } + }) + stage('Test') { parallel (jobDefinitions) } diff --git a/ci/container/test_authentication.sh b/ci/container/test_authentication.sh new file mode 100755 index 000000000..9a2eddf27 --- /dev/null +++ b/ci/container/test_authentication.sh @@ -0,0 +1,20 @@ +#!/bin/bash -e + +set -o pipefail + +export WORKSPACE=${WORKSPACE:-/mnt/workspace} +export SOURCE_ROOT=${SOURCE_ROOT:-/mnt/host} +MVNW_EXE=$SOURCE_ROOT/mvnw + +AUTH_PARAMETER_FILE=./.github/workflows/parameters_aws_auth_tests.json +eval $(jq -r '.authtestparams | to_entries | map("export \(.key)=\(.value|tostring)")|.[]' $AUTH_PARAMETER_FILE) + +$MVNW_EXE -DjenkinsIT \ + -Djava.io.tmpdir=$WORKSPACE \ + -Djacoco.skip.instrument=true \ + -Dskip.unitTests=true \ + -DintegrationTestSuites=AuthenticationTestSuite \ + -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn \ + -Dnot-self-contained-jar \ + verify \ + --batch-mode --show-version diff --git a/ci/test_authentication.sh b/ci/test_authentication.sh new file mode 100755 index 000000000..c91a61738 --- /dev/null +++ b/ci/test_authentication.sh @@ -0,0 +1,16 @@ +#!/bin/bash -e + +set -o pipefail +THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +export WORKSPACE=${WORKSPACE:-/tmp} +export INTERNAL_REPO=nexus.int.snowflakecomputing.com:8086 + +source $THIS_DIR/scripts/login_internal_docker.sh +gpg --quiet --batch --yes --decrypt --passphrase="$PARAMETERS_SECRET" --output $THIS_DIR/../.github/workflows/parameters_aws_auth_tests.json "$THIS_DIR/../.github/workflows/parameters_aws_auth_tests.json.gpg" + +docker run \ + -v $(cd $THIS_DIR/.. && pwd):/mnt/host \ + -v $WORKSPACE:/mnt/workspace \ + --rm \ + nexus.int.snowflakecomputing.com:8086/docker/snowdrivers-test-external-browser-jdbc:1 \ + "/mnt/host/ci/container/test_authentication.sh" diff --git a/parent-pom.xml b/parent-pom.xml index b1742d64e..3d2b1b6b9 100644 --- a/parent-pom.xml +++ b/parent-pom.xml @@ -78,6 +78,7 @@ net/snowflake/client/jdbc/internal net.snowflake.client.jdbc.internal net_snowflake_client_jdbc_internal + false 2.0.13 5.1.4 UnitTestSuite diff --git a/pom.xml b/pom.xml index 2cfb0425e..f53cf4c51 100644 --- a/pom.xml +++ b/pom.xml @@ -1162,6 +1162,7 @@ maven-surefire-plugin + ${skip.unitTests} --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/sun.util.calendar=ALL-UNNAMED --add-exports=java.base/sun.nio.ch=ALL-UNNAMED --add-exports=jdk.unsupported/sun.misc=ALL-UNNAMED @@ -1214,6 +1215,7 @@ maven-surefire-plugin UnitTestSuite + ${skip.unitTests} diff --git a/src/test/java/net/snowflake/client/authentication/AuthConnectionParameters.java b/src/test/java/net/snowflake/client/authentication/AuthConnectionParameters.java new file mode 100644 index 000000000..b30fc25fe --- /dev/null +++ b/src/test/java/net/snowflake/client/authentication/AuthConnectionParameters.java @@ -0,0 +1,37 @@ +package net.snowflake.client.authentication; + +import static net.snowflake.client.jdbc.SnowflakeUtil.systemGetEnv; + +import java.util.Properties; + +public class AuthConnectionParameters { + + static final String SSO_USER = systemGetEnv("SNOWFLAKE_AUTH_TEST_BROWSER_USER"); + static final String HOST = systemGetEnv("SNOWFLAKE_AUTH_TEST_HOST"); + static final String SSO_PASSWORD = systemGetEnv("SNOWFLAKE_AUTH_TEST_OKTA_PASS"); + + static Properties getBaseConnectionParameters() { + Properties properties = new Properties(); + properties.put("host", HOST); + properties.put("port", systemGetEnv("SNOWFLAKE_AUTH_TEST_PORT")); + properties.put("role", systemGetEnv("SNOWFLAKE_AUTH_TEST_ROLE")); + properties.put("account", systemGetEnv("SNOWFLAKE_AUTH_TEST_ACCOUNT")); + properties.put("db", systemGetEnv("SNOWFLAKE_AUTH_TEST_DATABASE")); + properties.put("schema", systemGetEnv("SNOWFLAKE_AUTH_TEST_SCHEMA")); + properties.put("warehouse", systemGetEnv("SNOWFLAKE_AUTH_TEST_WAREHOUSE")); + return properties; + } + + static Properties getExternalBrowserConnectionParameters() { + Properties properties = getBaseConnectionParameters(); + properties.put("user", SSO_USER); + properties.put("authenticator", "externalbrowser"); + return properties; + } + + static Properties getStoreIDTokenConnectionParameters() { + Properties properties = getExternalBrowserConnectionParameters(); + properties.put("CLIENT_STORE_TEMPORARY_CREDENTIAL", true); + return properties; + } +} diff --git a/src/test/java/net/snowflake/client/authentication/AuthTest.java b/src/test/java/net/snowflake/client/authentication/AuthTest.java new file mode 100644 index 000000000..b7ad13052 --- /dev/null +++ b/src/test/java/net/snowflake/client/authentication/AuthTest.java @@ -0,0 +1,114 @@ +package net.snowflake.client.authentication; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import net.snowflake.client.core.SessionUtil; +import net.snowflake.client.jdbc.SnowflakeConnectionV1; +import net.snowflake.client.jdbc.SnowflakeSQLException; + +public class AuthTest { + + private Exception exception; + private String idToken; + private final boolean runAuthTestsManually; + + public AuthTest() { + this.runAuthTestsManually = Boolean.parseBoolean(System.getenv("RUN_AUTH_TESTS_MANUALLY")); + } + + public Thread getConnectAndExecuteSimpleQueryThread(Properties props, String sessionParameters) { + return new Thread(() -> connectAndExecuteSimpleQuery(props, sessionParameters)); + } + + public Thread getConnectAndExecuteSimpleQueryThread(Properties props) { + return new Thread(() -> connectAndExecuteSimpleQuery(props, null)); + } + + public void verifyExceptionIsThrown(String message) { + assertThat("Expected exception not thrown", this.exception.getMessage(), is(message)); + } + + public void verifyExceptionIsNotThrown() { + assertThat("Unexpected exception thrown", this.exception, nullValue()); + } + + public void connectAndProvideCredentials(Thread provideCredentialsThread, Thread connectThread) + throws InterruptedException { + if (runAuthTestsManually) { + connectThread.start(); + connectThread.join(); + } else { + provideCredentialsThread.start(); + connectThread.start(); + provideCredentialsThread.join(); + connectThread.join(); + } + } + + public void provideCredentials(String scenario, String login, String password) { + try { + String provideBrowserCredentialsPath = "/externalbrowser/provideBrowserCredentials.js"; + ProcessBuilder processBuilder = + new ProcessBuilder("node", provideBrowserCredentialsPath, scenario, login, password); + Process process = processBuilder.start(); + process.waitFor(15, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void cleanBrowserProcesses() { + if (!runAuthTestsManually) { + String cleanBrowserProcessesPath = "/externalbrowser/cleanBrowserProcesses.js"; + ProcessBuilder processBuilder = new ProcessBuilder("node", cleanBrowserProcessesPath); + try { + Process process = processBuilder.start(); + process.waitFor(15, TimeUnit.SECONDS); + } catch (InterruptedException | IOException e) { + throw new RuntimeException(e); + } + } + } + + public static void deleteIdToken() { + SessionUtil.deleteIdTokenCache( + AuthConnectionParameters.HOST, AuthConnectionParameters.SSO_USER); + } + + public void connectAndExecuteSimpleQuery(Properties props, String sessionParameters) { + String url = String.format("jdbc:snowflake://%s:%s", props.get("host"), props.get("port")); + if (sessionParameters != null) { + url += "?" + sessionParameters; + } + try (Connection con = DriverManager.getConnection(url, props); + Statement stmt = con.createStatement(); + ResultSet rs = stmt.executeQuery("select 1")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1)); + saveToken(con); + } catch (SQLException e) { + this.exception = e; + } + } + + private void saveToken(Connection con) throws SnowflakeSQLException { + SnowflakeConnectionV1 sfcon = (SnowflakeConnectionV1) con; + this.idToken = sfcon.getSfSession().getIdToken(); + } + + public String getIdToken() { + return idToken; + } +} diff --git a/src/test/java/net/snowflake/client/authentication/ExternalBrowserIT.java b/src/test/java/net/snowflake/client/authentication/ExternalBrowserIT.java new file mode 100644 index 000000000..aba5398e0 --- /dev/null +++ b/src/test/java/net/snowflake/client/authentication/ExternalBrowserIT.java @@ -0,0 +1,82 @@ +package net.snowflake.client.authentication; + +import static net.snowflake.client.authentication.AuthConnectionParameters.getExternalBrowserConnectionParameters; + +import java.io.IOException; +import java.util.Properties; +import net.snowflake.client.category.TestTags; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag(TestTags.AUTHENTICATION) +class ExternalBrowserIT { + + String login = AuthConnectionParameters.SSO_USER; + String password = AuthConnectionParameters.SSO_PASSWORD; + AuthTest authTest = new AuthTest(); + + @BeforeEach + public void setUp() throws IOException { + AuthTest.deleteIdToken(); + } + + @AfterEach + public void tearDown() { + authTest.cleanBrowserProcesses(); + AuthTest.deleteIdToken(); + } + + @Test + void shouldAuthenticateUsingExternalBrowser() throws InterruptedException { + Thread provideCredentialsThread = + new Thread(() -> authTest.provideCredentials("success", login, password)); + Thread connectThread = + authTest.getConnectAndExecuteSimpleQueryThread(getExternalBrowserConnectionParameters()); + + authTest.connectAndProvideCredentials(provideCredentialsThread, connectThread); + authTest.verifyExceptionIsNotThrown(); + } + + @Test + void shouldThrowErrorForMismatchedUsername() throws InterruptedException { + Properties properties = getExternalBrowserConnectionParameters(); + properties.put("user", "differentUsername"); + Thread provideCredentialsThread = + new Thread(() -> authTest.provideCredentials("success", login, password)); + Thread connectThread = authTest.getConnectAndExecuteSimpleQueryThread(properties); + + authTest.connectAndProvideCredentials(provideCredentialsThread, connectThread); + authTest.verifyExceptionIsThrown( + "The user you were trying to authenticate as differs from the user currently logged in at the IDP."); + } + + @Test + void shouldThrowErrorForWrongCredentials() throws InterruptedException { + String login = "itsnotanaccount.com"; + String password = "fakepassword"; + Thread provideCredentialsThread = + new Thread(() -> authTest.provideCredentials("fail", login, password)); + Thread connectThread = + authTest.getConnectAndExecuteSimpleQueryThread( + getExternalBrowserConnectionParameters(), "BROWSER_RESPONSE_TIMEOUT=10"); + + authTest.connectAndProvideCredentials(provideCredentialsThread, connectThread); + authTest.verifyExceptionIsThrown( + "JDBC driver encountered communication error. Message: External browser authentication failed within timeout of 10000 milliseconds."); + } + + @Test + void shouldThrowErrorForBrowserTimeout() throws InterruptedException { + Thread provideCredentialsThread = + new Thread(() -> authTest.provideCredentials("timeout", login, password)); + Thread connectThread = + authTest.getConnectAndExecuteSimpleQueryThread( + getExternalBrowserConnectionParameters(), "BROWSER_RESPONSE_TIMEOUT=1"); + + authTest.connectAndProvideCredentials(provideCredentialsThread, connectThread); + authTest.verifyExceptionIsThrown( + "JDBC driver encountered communication error. Message: External browser authentication failed within timeout of 1000 milliseconds."); + } +} diff --git a/src/test/java/net/snowflake/client/authentication/IdTokenIT.java b/src/test/java/net/snowflake/client/authentication/IdTokenIT.java new file mode 100644 index 000000000..61a739e58 --- /dev/null +++ b/src/test/java/net/snowflake/client/authentication/IdTokenIT.java @@ -0,0 +1,79 @@ +package net.snowflake.client.authentication; + +import static net.snowflake.client.authentication.AuthConnectionParameters.getStoreIDTokenConnectionParameters; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import net.snowflake.client.category.TestTags; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@Tag(TestTags.AUTHENTICATION) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class IdTokenIT { + + String login = AuthConnectionParameters.SSO_USER; + String password = AuthConnectionParameters.SSO_PASSWORD; + AuthTest authTest = new AuthTest(); + private static String firstToken; + + @BeforeAll + public static void globalSetUp() { + AuthTest.deleteIdToken(); + } + + @AfterEach + public void tearDown() { + authTest.cleanBrowserProcesses(); + } + + @Test + @Order(1) + void shouldAuthenticateUsingExternalBrowserAndSaveToken() throws InterruptedException { + Thread provideCredentialsThread = + new Thread(() -> authTest.provideCredentials("success", login, password)); + Thread connectThread = + authTest.getConnectAndExecuteSimpleQueryThread(getStoreIDTokenConnectionParameters()); + + authTest.connectAndProvideCredentials(provideCredentialsThread, connectThread); + authTest.verifyExceptionIsNotThrown(); + firstToken = authTest.getIdToken(); + assertThat("Id token was not saved", firstToken, notNullValue()); + } + + @Test + @Order(2) + void shouldAuthenticateUsingTokenWithoutBrowser() { + verifyFirstTokenWasSaved(); + authTest.connectAndExecuteSimpleQuery(getStoreIDTokenConnectionParameters(), null); + authTest.verifyExceptionIsNotThrown(); + } + + @Test + @Order(3) + void shouldOpenBrowserAgainWhenTokenIsDeleted() throws InterruptedException { + verifyFirstTokenWasSaved(); + AuthTest.deleteIdToken(); + Thread provideCredentialsThread = + new Thread(() -> authTest.provideCredentials("success", login, password)); + Thread connectThread = + authTest.getConnectAndExecuteSimpleQueryThread(getStoreIDTokenConnectionParameters()); + + authTest.connectAndProvideCredentials(provideCredentialsThread, connectThread); + authTest.verifyExceptionIsNotThrown(); + String secondToken = authTest.getIdToken(); + assertThat("Id token was not saved", secondToken, notNullValue()); + assertThat("Id token was not updated", secondToken, not(firstToken)); + } + + private void verifyFirstTokenWasSaved() { + assumeTrue(firstToken != null, "token was not saved, skipping test"); + } +} diff --git a/src/test/java/net/snowflake/client/category/TestTags.java b/src/test/java/net/snowflake/client/category/TestTags.java index 92cd7ce3b..14922c17b 100644 --- a/src/test/java/net/snowflake/client/category/TestTags.java +++ b/src/test/java/net/snowflake/client/category/TestTags.java @@ -14,4 +14,5 @@ private TestTags() {} public static final String OTHERS = "others"; public static final String RESULT_SET = "resultSet"; public static final String STATEMENT = "statement"; + public static final String AUTHENTICATION = "authentication"; } diff --git a/src/test/java/net/snowflake/client/suites/AuthenticationTestSuite.java b/src/test/java/net/snowflake/client/suites/AuthenticationTestSuite.java new file mode 100644 index 000000000..4a16ba174 --- /dev/null +++ b/src/test/java/net/snowflake/client/suites/AuthenticationTestSuite.java @@ -0,0 +1,8 @@ +package net.snowflake.client.suites; + +import net.snowflake.client.category.TestTags; +import org.junit.platform.suite.api.IncludeTags; + +@BaseTestSuite +@IncludeTags(TestTags.AUTHENTICATION) +public class AuthenticationTestSuite {} diff --git a/src/test/java/net/snowflake/client/suites/UnitTestSuite.java b/src/test/java/net/snowflake/client/suites/UnitTestSuite.java index 5bd5904fe..adadecf4f 100644 --- a/src/test/java/net/snowflake/client/suites/UnitTestSuite.java +++ b/src/test/java/net/snowflake/client/suites/UnitTestSuite.java @@ -17,6 +17,7 @@ TestTags.LOADER, TestTags.OTHERS, TestTags.RESULT_SET, - TestTags.STATEMENT + TestTags.STATEMENT, + TestTags.AUTHENTICATION }) public class UnitTestSuite {}