diff --git a/.github/jwks.json b/.github/jwks.json new file mode 100644 index 000000000..ba11d4653 --- /dev/null +++ b/.github/jwks.json @@ -0,0 +1,14 @@ +{ + "keys": [ + { + "e": "AQAB", + "kid": "ZoA8VvkIi_fBxd5DEx9xekutcCztcanNFIHOTQp5aDo", + "kty": "RSA", + "n": "q-qV7VPWHe0-Pj-sgmq0_szurrPPwAsq3BPTllfFNQcQq5967SdS5jiCKhpVKYeXng468e9yimOMPzLIVBoCkV9ul9ruZDDJiPBoQ-_Pp5IuQQWTFUmnJ-ONkczVdb-FdkfQz4z1NVz_blCdOVyVPmeE9gh1S1V4g3kQazznwxKgOlJM4mO23j8xOmhGIL0w1IktP09vqicZoOsq3-RgN0x6jXdiv5BnTAh1yDKIFRRqIa7idz55Bv_AGosH7hsKHYoCwfiPSCeVc4StYIhokj9HZswnMyAStELfQWen2Ev3pDuiANI4TuE34TOx0LsinVoWeEtiMaRi67mYFk5JIQ", + "use": "sig", + "x5c": [ + "MIIDYjCCAkqgAwIBAgIUH0aS9zvv7N9L9UR/HmOaA5k9l/4wDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xGDAWBgNVBAoMD015IE9yZ2FuaXphdGlvbjEVMBMGA1UEAwwMbXlkb21haW4uY29tMB4XDTI2MDkwMzIxMDgxOFoXDTI3MDkwMzIxMDgxOFowazELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xGDAWBgNVBAoMD015IE9yZ2FuaXphdGlvbjEVMBMGA1UEAwwMbXlkb21haW4uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq+qV7VPWHe0+Pj+sgmq0/szurrPPwAsq3BPTllfFNQcQq5967SdS5jiCKhpVKYeXng468e9yimOMPzLIVBoCkV9ul9ruZDDJiPBoQ+/Pp5IuQQWTFUmnJ+ONkczVdb+FdkfQz4z1NVz/blCdOVyVPmeE9gh1S1V4g3kQazznwxKgOlJM4mO23j8xOmhGIL0w1IktP09vqicZoOsq3+RgN0x6jXdiv5BnTAh1yDKIFRRqIa7idz55Bv/AGosH7hsKHYoCwfiPSCeVc4StYIhokj9HZswnMyAStELfQWen2Ev3pDuiANI4TuE34TOx0LsinVoWeEtiMaRi67mYFk5JIQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCD5HRitEaurh0QOv/sqH0eFZ5KWAWbr4gP3EU9tgEAGAo0DQz2v7ov+wey4aTplXKtXIxB7aPobXFqTBEI6yD+to2UgmC35U+0tHZTgDG+BSmDEERoj+/Guk55LuUwODvQJviFUzEM9q7I9fLANPVkhY/SJ2/hl1ftxqxNFAH8mVzUbBNb2DMDc30eicHvsJUg0bqKoB5pLciyYqFsyA9z0x8ZaVpPVbXnVOHbzilp0jnhHeyLr+JCYH2UKfH/zFjxvcMP/q3M+HHCYyHXgdhMIFGBRE7xUMZjRnir28aUyet6hB5iV4wM8Yjd2mVaAsYtPqY8GnQxISzhUJ9rugie" + ] + } + ] +} diff --git a/.github/scripts/.gitignore b/.github/scripts/.gitignore new file mode 100644 index 000000000..6c7e7b783 --- /dev/null +++ b/.github/scripts/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.venv +*.token +*.json +*.pem diff --git a/.github/scripts/generate_jwt.py b/.github/scripts/generate_jwt.py new file mode 100644 index 000000000..01dd1df58 --- /dev/null +++ b/.github/scripts/generate_jwt.py @@ -0,0 +1,60 @@ +import os +import sys +import time + +from jwcrypto.jwk import JWK +from jwcrypto.jwt import JWT + + +def get_private_key_pem(private_key_pem=None): + """Retrieve private key PEM string from parameter or JWT_PKEY environment variable.""" + pkey = private_key_pem or os.getenv("JWT_PKEY") + if pkey: + return pkey + + raise ValueError( + "Private key not found. Please set JWT_PKEY environment variable." + ) + + +def generate_jwt_token( + private_key_pem=None, + audience="ci-test-service", + issuer="mydomain.com", + subject="ci-test", + expiration_seconds=600, # Valid for 10 minutes by default + output_file=None, +): + """Generate a signed JWT token valid for a few minutes using the private key.""" + private_key_pem = get_private_key_pem(private_key_pem) + jwk_key = JWK.from_pem(private_key_pem.encode("utf-8")) + + now = int(time.time()) + payload = { + "iss": issuer, + "sub": subject, + "aud": audience, + "iat": now, + "exp": now + expiration_seconds, + } + + header = { + "alg": "RS256", + "typ": "JWT", + "kid": jwk_key.key_id, + } + + token = JWT(header=header, claims=payload) + token.make_signed_token(jwk_key) + jwt_str = token.serialize() + + if output_file: + with open(output_file, "w", encoding="utf-8") as f: + f.write(jwt_str) + + return jwt_str + + +if __name__ == "__main__": + out_file = sys.argv[1] if len(sys.argv) > 1 else "jwt.token" + generate_jwt_token(output_file=out_file) diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 000000000..dacef4771 --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,2 @@ +jwcrypto + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ccec4fb70..0c741516c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -207,7 +207,7 @@ jobs: test-with-cloud: runs-on: ubuntu-latest - needs: test-jdbc-driver + needs: compile strategy: matrix: # most recent LTS releases as well as latest stable builds @@ -271,9 +271,13 @@ jobs: env: CLICKHOUSE_CLOUD_HOST: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_HOST_SMT_PROD }} CLICKHOUSE_CLOUD_PASSWORD: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_PASSWORD_SMT_PROD }} - CLIENT_JWT: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_JWT_DESERT_VM_43 }} - JWT_TEST_HOST: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_HOST_SMT }} + JWT_PKEY: ${{ secrets.JWT_PKEY }} run: | + python3 -m pip install -r .github/scripts/requirements.txt + python3 .github/scripts/generate_jwt.py jwt.token + JWT_TOKEN="$(cat jwt.token)" + rm -f jwt.token + export JWT_TOKEN mvn --batch-mode --no-transfer-progress --projects ${{ matrix.project }} -DclickhouseVersion=${{ matrix.clickhouse }} -Dprotocol=http -Dmaven.javadoc.skip=true verify - name: Upload test results uses: actions/upload-artifact@v4 diff --git a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java index b50d3700b..1d5c48468 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java @@ -27,6 +27,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testng.Assert; +import org.testng.SkipException; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.testng.util.Strings; @@ -767,6 +768,32 @@ private void assertTableNotFoundInSession(Client client, String tableName, Query } } + @Test(groups = { "integration" }) + public void testJWTWithCloud() throws Exception { + String jwt = System.getenv("JWT_TOKEN"); + if (jwt == null || jwt.trim().isEmpty()) { + throw new SkipException("JWT_TOKEN environment variable is not set. Skipping JWT test."); + } + ClickHouseNode node = getServer(ClickHouseProtocol.HTTP); + Assert.assertFalse(jwt.contains("\n") || jwt.contains("-----"), "JWT should be single string ready for HTTP header"); + try (Client client = new Client.Builder() + .addEndpoint(Protocol.HTTP, node.getHost(), node.getPort(), isCloud()) + .compressClientRequest(false) + .setDefaultDatabase(ClickHouseServerForTest.getDatabase()) + .serverSetting(ServerSettings.WAIT_END_OF_QUERY, "1") + .useBearerTokenAuth(jwt).build()) { + try { + List response = client.queryAll("SELECT currentUser()"); + String username = response.get(0).getString(1); + Assert.assertTrue(username != null && username.matches("^JWT::.+::.+$"), + "Expected username in format JWT::::, but actual username was: '" + username + "'"); + } catch (Exception e) { + e.printStackTrace(); + throw e; + } + } + } + protected Client.Builder newClient() { ClickHouseNode node = getServer(ClickHouseProtocol.HTTP); boolean isSecure = isCloud(); diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 6f357fddc..b71435783 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -1562,33 +1562,6 @@ public void testSetCredentialsAfterClientCreation() throws Exception { } } - @Test(groups = { "integration" }) - public void testJWTWithCloud() throws Exception { - if (!isCloud()) { - return; // only for cloud - } - final String jwt = System.getenv("CLIENT_JWT"); - final String host = System.getenv("JWT_TEST_HOST"); - Assert.assertTrue(jwt != null && !jwt.trim().isEmpty(), "CLIENT_JWT is not set."); - Assert.assertTrue(host != null && !host.trim().isEmpty(), "JWT_TEST_HOST is not set"); - Assert.assertFalse(jwt.contains("\n") || jwt.contains("-----"), "JWT should be single string ready for HTTP header"); - try (Client client = new Client.Builder() - .addEndpoint(Protocol.HTTP, host, 8443, true) - .setUsername("default") - .compressClientRequest(false) - .setDefaultDatabase("default") - .serverSetting(ServerSettings.WAIT_END_OF_QUERY, "1") - .useBearerTokenAuth(jwt).build()) { - try { - List response = client.queryAll("SELECT user(), now()"); - System.out.println("response: " + response.get(0).getString(1) + " time: " + response.get(0).getString(2)); - } catch (Exception e) { - e.printStackTrace(); - throw e; - } - } - } - @Test(groups = { "integration" }) public void testWithDefaultTimeouts() { if (isCloud()) { diff --git a/docs/clickhouse-docs/client.mdx b/docs/clickhouse-docs/client.mdx index 6a2ff98b2..fb9e95131 100644 --- a/docs/clickhouse-docs/client.mdx +++ b/docs/clickhouse-docs/client.mdx @@ -85,11 +85,11 @@ Authentication by a password requires setting user name password by calling `set .build(); ``` -Authentication by an access token requires setting access token by calling `setAccessToken(String)`: +Authentication by an access token or Bearer token (such as a JWT) requires setting the token by calling `useBearerTokenAuth(String)` (or `setAccessToken(String)` for raw access tokens): ```java showLineNumbers Client client = new Client.Builder() .addEndpoint("https://clickhouse-cloud-instance:8443/") - .setAccessToken(userAccessToken) + .useBearerTokenAuth(userAccessToken) .build(); ``` @@ -377,6 +377,46 @@ try (Client client = new Client.Builder() To configure per operation, set the header on `QuerySettings`, `InsertSettings`, or `CommandSettings` instead of the client builder. +### JWT Authentication (CLOUD) {#jwt-authentication} + +[JWT Authentication](/concepts/features/security/external-authenticators/jwt) allows authenticating users with JSON Web Tokens (JWTs) in ClickHouse Cloud. Instead of pre-existing database users, ClickHouse dynamically creates **ephemeral users** derived from claims embedded in each token. + +#### Initial Configuration + +To configure initial JWT authentication on the client, use `useBearerTokenAuth(token)` when building the `Client` instance: + +```java showLineNumbers +import com.clickhouse.client.api.Client; + +public Client createJwtClient(String jwtToken) { + return new Client.Builder() + .addEndpoint("https://your-service.clickhouse.cloud:8443") + .useBearerTokenAuth(jwtToken) + .build(); +} +``` + + +`useBearerTokenAuth(token)` automatically prepends the `Bearer ` prefix to the `Authorization` HTTP header. + + +#### Updating Token at Runtime + +Because JWTs are short-lived and ephemeral users exist only for the token's lifetime, new JWT tokens should be set **before** the current token expires so no operations fail. + +You can update the bearer token on an existing `Client` instance at runtime without recreating the client: + +```java showLineNumbers +import com.clickhouse.client.api.Client; + +public void refreshJwtToken(Client client, String newJwtToken) { + // Set the new JWT before the current token expires + client.updateBearerToken(newJwtToken); +} +``` + +All subsequent requests on the `Client` instance will use the updated JWT token. + ## Common Definitions {#common-definitions} ### ClickHouseFormat {#clickhouseformat} diff --git a/docs/clickhouse-docs/jdbc.mdx b/docs/clickhouse-docs/jdbc.mdx index b70c83009..0308676fc 100644 --- a/docs/clickhouse-docs/jdbc.mdx +++ b/docs/clickhouse-docs/jdbc.mdx @@ -227,6 +227,55 @@ Connection conn = Driver.connect("jdbc:ch:https://your-service.clickhouse.cloud: // jdbc:ch:https://your-service.clickhouse.cloud:8443?http_header_X-CLICKHOUSE-REPLICA-TAG=my-app-session-1 ``` +### JWT Authentication (CLOUD) {#jwt-authentication} + +[JWT Authentication](/concepts/features/security/external-authenticators/jwt) allows authenticating with JSON Web Tokens (JWTs) in ClickHouse Cloud. ClickHouse dynamically creates **ephemeral users** derived from token claims. + +#### Initial Configuration + +To configure initial JWT authentication in JDBC, pass the `bearer_token` property in connection properties or directly in the JDBC URL: + +```java showLineNumbers +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Properties; + +public Connection createJwtConnection(String jwtToken) throws SQLException { + Properties properties = new Properties(); + properties.setProperty("bearer_token", jwtToken); + + return DriverManager.getConnection("jdbc:ch:https://your-service.clickhouse.cloud:8443", properties); +} +``` + +URL equivalent: +```plaintext +jdbc:ch:https://your-service.clickhouse.cloud:8443?bearer_token= +``` + +#### Updating Token at Runtime + +Because JWTs have a limited validity period, new JWT tokens should be set **before** the current token expires so no operations fail. + +To update the JWT token on an active JDBC connection at runtime, unwrap the connection to access the underlying `com.clickhouse.client.api.Client` instance and call `updateBearerToken`: + +```java showLineNumbers +import com.clickhouse.jdbc.ConnectionImpl; +import java.sql.Connection; +import java.sql.SQLException; + +public void refreshJwtToken(Connection connection, String newJwtToken) throws SQLException { + // Unwrap the connection to get the underlying Client instance + ConnectionImpl connImpl = connection.unwrap(ConnectionImpl.class); + + // Set the new JWT before the current token expires + connImpl.getClient().updateBearerToken(newJwtToken); +} +``` + +Subsequent statements executed on this JDBC connection will use the updated JWT token. + ## Supported data types {#supported-data-types} JDBC driver supports the same data formats as the underlying [java client](/integrations/language-clients/java/index#supported-data-types). diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java index d52adc967..686b5baff 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java @@ -1013,27 +1013,24 @@ public void testBearerTokenAuth() throws Exception { } } - private static final String SAMPLE_JWT_TOKEN_FOR_TESTS = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; - @Test(groups = { "integration" }) public void testJWTWithCloud() throws Exception { - - final String jwt = isCloud() ? System.getenv("CLIENT_JWT") : SAMPLE_JWT_TOKEN_FOR_TESTS; - final String url = isCloud() ? "jdbc:ch:https://" + System.getenv("JWT_TEST_HOST") + "/default": getEndpointString(); - Assert.assertTrue(jwt != null && !jwt.trim().isEmpty(), "CLIENT_JWT is not set."); - Assert.assertTrue(url != null && !url.trim().isEmpty(), "JWT_TEST_HOST is not set"); + String jwt = System.getenv("JWT_TOKEN"); + if (jwt == null || jwt.trim().isEmpty()) { + throw new SkipException("JWT_TOKEN environment variable is not set. Skipping JWT test."); + } + String url = getEndpointString(); Properties properties = new Properties(); properties.put(ClientConfigProperties.BEARERTOKEN_AUTH.getKey(), jwt); - properties.put(ClientConfigProperties.USER.getKey(), "default"); - try (Connection conn = new ConnectionImpl(url, properties)) { - if (isCloud()) { // else check configuration only - try (Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery("SELECT 1")) { - Assert.assertTrue(rs.next()); - } - } + try (Connection conn = new ConnectionImpl(url, properties); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT currentUser()")) { + Assert.assertTrue(rs.next()); + String username = rs.getString(1); + Assert.assertTrue(username != null && username.matches("^JWT::.+::.+$"), + "Expected username in format JWT::::, but actual username was: '" + username + "'"); } }