Skip to content

Commit 4cb0f0f

Browse files
slvrtrngenzgd
andauthored
JWT auth support (#442)
* JWT auth support * Update JWT secret env var * Add `set_access_token` method and the related tests * Bump version for release --------- Co-authored-by: Geoff Genz <[email protected]>
1 parent 51a1c05 commit 4cb0f0f

File tree

11 files changed

+216
-9
lines changed

11 files changed

+216
-9
lines changed

.github/workflows/clickhouse_ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
CLICKHOUSE_CONNECT_TEST_CLOUD: 'True'
3535
CLICKHOUSE_CONNECT_TEST_HOST: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_HOST_SMT }}
3636
CLICKHOUSE_CONNECT_TEST_PASSWORD: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_PASSWORD_SMT }}
37+
CLICKHOUSE_CONNECT_TEST_JWT_SECRET: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_JWT_PRIVATE_KEY }}
3738
run: pytest tests/integration_tests
3839

3940
- name: Run ClickHouse Container (LATEST)

.github/workflows/on_push.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ jobs:
118118
if: "${{ env.CLOUD_HOST != '' }}"
119119
run: echo "HAS_SECRETS=true" >> $GITHUB_OUTPUT
120120

121-
122121
cloud-tests:
123122
runs-on: ubuntu-latest
124123
name: Cloud Tests Py=${{ matrix.python-version }}
@@ -153,5 +152,6 @@ jobs:
153152
CLICKHOUSE_CONNECT_TEST_PORT: 8443
154153
CLICKHOUSE_CONNECT_TEST_HOST: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_HOST_SMT }}
155154
CLICKHOUSE_CONNECT_TEST_PASSWORD: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_PASSWORD_SMT }}
155+
CLICKHOUSE_CONNECT_TEST_JWT_SECRET: ${{ secrets.INTEGRATIONS_TEAM_TESTS_CLOUD_JWT_PRIVATE_KEY }}
156156
SQLALCHEMY_SILENCE_UBER_WARNING: 1
157157
run: pytest tests/integration_tests

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ release (0.9.0), unrecognized arguments/keywords for these methods of creating a
1717
instead of being passed as ClickHouse server settings. This is in conjunction with some refactoring in Client construction.
1818
The supported method of passing ClickHouse server settings is to prefix such arguments/query parameters with`ch_`.
1919

20+
## 0.8.12, 2025-01-06
21+
### Improvement
22+
- Added support for JWT authentication (ClickHouse Cloud feature).
23+
It can be set via the `access_token` client configuration option for both sync and async clients.
24+
The token can also be updated via the `set_access_token` method in the existing client instance.
25+
NB: do not mix access token and username/password credentials in the configuration;
26+
the client will throw an error if both are set.
27+
2028
## 0.8.11, 2024-12-21
2129
### Improvement
2230
- Support of ISO8601 strings for inserting values to columns with DateTime64 type was added. If the driver detects

clickhouse_connect/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version = '0.8.11'
1+
version = '0.8.12'

clickhouse_connect/driver/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def create_client(*,
1616
host: str = None,
1717
username: str = None,
1818
password: str = '',
19+
access_token: Optional[str] = None,
1920
database: str = '__default__',
2021
interface: Optional[str] = None,
2122
port: int = 0,
@@ -29,7 +30,11 @@ def create_client(*,
2930
3031
:param host: The hostname or IP address of the ClickHouse server. If not set, localhost will be used.
3132
:param username: The ClickHouse username. If not set, the default ClickHouse user will be used.
33+
Should not be set if `access_token` is used.
3234
:param password: The password for username.
35+
Should not be set if `access_token` is used.
36+
:param access_token: JWT access token (ClickHouse Cloud feature).
37+
Should not be set if `username`/`password` are used.
3338
:param database: The default database for the connection. If not set, ClickHouse Connect will use the
3439
default database for username.
3540
:param interface: Must be http or https. Defaults to http, or to https if port is set to 8443 or 443
@@ -90,6 +95,8 @@ def create_client(*,
9095
if not interface:
9196
interface = 'https' if use_tls else 'http'
9297
port = port or default_port(interface, use_tls)
98+
if access_token and (username or password != ''):
99+
raise ProgrammingError('Cannot use both access_token and username/password')
93100
if username is None and 'user' in kwargs:
94101
username = kwargs.pop('user')
95102
if username is None and 'user_name' in kwargs:
@@ -112,7 +119,8 @@ def create_client(*,
112119
if name.startswith('ch_'):
113120
name = name[3:]
114121
settings[name] = value
115-
return HttpClient(interface, host, port, username, password, database, settings=settings, **kwargs)
122+
return HttpClient(interface, host, port, username, password, database, access_token,
123+
settings=settings, **kwargs)
116124
raise ProgrammingError(f'Unrecognized client type {interface}')
117125

118126

clickhouse_connect/driver/asyncclient.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ def __init__(self, *, client: Client, executor_threads: int = 0):
3030
executor_threads = min(32, (os.cpu_count() or 1) + 4) # Mimic the default behavior
3131
self.executor = ThreadPoolExecutor(max_workers=executor_threads)
3232

33-
3433
def set_client_setting(self, key, value):
3534
"""
3635
Set a clickhouse setting for the client after initialization. If a setting is not recognized by ClickHouse,
@@ -48,6 +47,13 @@ def get_client_setting(self, key) -> Optional[str]:
4847
"""
4948
return self.client.get_client_setting(key=key)
5049

50+
def set_access_token(self, access_token: str):
51+
"""
52+
Set the ClickHouse access token for the client
53+
:param access_token: Access token string
54+
"""
55+
self.client.set_access_token(access_token)
56+
5157
def min_version(self, version_str: str) -> bool:
5258
"""
5359
Determine whether the connected server is at least the submitted version

clickhouse_connect/driver/client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ def _init_common_settings(self, apply_server_timezone:Optional[Union[str, bool]]
107107
if self.min_version('24.8') and not self.min_version('24.10'):
108108
dynamic_module.json_serialization_format = 0
109109

110-
111110
def _validate_settings(self, settings: Optional[Dict[str, Any]]) -> Dict[str, str]:
112111
"""
113112
This strips any ClickHouse settings that are not recognized or are read only.
@@ -184,6 +183,13 @@ def get_client_setting(self, key) -> Optional[str]:
184183
:return: The string value of the setting, if it exists, or None
185184
"""
186185

186+
@abstractmethod
187+
def set_access_token(self, access_token: str):
188+
"""
189+
Set the ClickHouse access token for the client
190+
:param access_token: Access token string
191+
"""
192+
187193
# pylint: disable=unused-argument,too-many-locals
188194
def query(self,
189195
query: Optional[str] = None,

clickhouse_connect/driver/httpclient.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __init__(self,
5454
username: str,
5555
password: str,
5656
database: str,
57+
access_token: Optional[str],
5758
compress: Union[bool, str] = True,
5859
query_limit: int = 0,
5960
query_retries: int = 2,
@@ -115,8 +116,11 @@ def __init__(self,
115116
else:
116117
self.http = default_pool_manager()
117118

118-
if (not client_cert or tls_mode in ('strict', 'proxy')) and username:
119+
if access_token:
120+
self.headers['Authorization'] = f'Bearer {access_token}'
121+
elif (not client_cert or tls_mode in ('strict', 'proxy')) and username:
119122
self.headers['Authorization'] = 'Basic ' + b64encode(f'{username}:{password}'.encode()).decode()
123+
120124
self.headers['User-Agent'] = common.build_client_name(client_name)
121125
self._read_format = self._write_format = 'Native'
122126
self._transform = NativeTransform()
@@ -180,6 +184,12 @@ def set_client_setting(self, key, value):
180184
def get_client_setting(self, key) -> Optional[str]:
181185
return self.params.get(key)
182186

187+
def set_access_token(self, access_token: str):
188+
auth_header = self.headers.get('Authorization')
189+
if auth_header and not auth_header.startswith('Bearer'):
190+
raise ProgrammingError('Cannot set access token when a different auth type is used')
191+
self.headers['Authorization'] = f'Bearer {access_token}'
192+
183193
def _prep_query(self, context: QueryContext):
184194
final_query = super()._prep_query(context)
185195
if context.is_insert:

tests/integration_tests/conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ def test_table_engine_fixture() -> Iterator[str]:
9494
# pylint: disable=too-many-branches
9595
@fixture(scope='session', autouse=True, name='test_client')
9696
def test_client_fixture(test_config: TestConfig, test_create_client: Callable) -> Iterator[Client]:
97-
compose_file = f'{PROJECT_ROOT_DIR}/docker-compose.yml'
9897
if test_config.docker:
98+
compose_file = f'{PROJECT_ROOT_DIR}/docker-compose.yml'
9999
run_cmd(['docker', 'compose', '-f', compose_file, 'down', '-v'])
100100
sys.stderr.write('Starting docker compose')
101101
pull_result = run_cmd(['docker', 'compose', '-f', compose_file, 'pull'])
@@ -121,7 +121,6 @@ def test_client_fixture(test_config: TestConfig, test_create_client: Callable) -
121121
client.command(f'CREATE DATABASE IF NOT EXISTS {test_config.test_database}', use_database=False)
122122
yield client
123123

124-
# client.command(f'DROP database IF EXISTS {test_db}', use_database=False)
125124
if test_config.docker:
126125
down_result = run_cmd(['docker', 'compose', '-f', compose_file, 'down', '-v'])
127126
if down_result[0]:
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from datetime import datetime, timezone, timedelta
2+
from os import environ
3+
4+
import jwt
5+
import pytest
6+
7+
from clickhouse_connect.driver import create_client, ProgrammingError, create_async_client
8+
from tests.integration_tests.conftest import TestConfig
9+
10+
11+
def test_jwt_auth_sync_client(test_config: TestConfig):
12+
if not test_config.cloud:
13+
pytest.skip('Skipping JWT test in non-Cloud mode')
14+
15+
access_token = make_access_token()
16+
client = create_client(
17+
host=test_config.host,
18+
port=test_config.port,
19+
access_token=access_token
20+
)
21+
result = client.query(query=CHECK_CLOUD_MODE_QUERY).result_set
22+
assert result == [(True,)]
23+
24+
25+
def test_jwt_auth_sync_client_set_access_token(test_config: TestConfig):
26+
if not test_config.cloud:
27+
pytest.skip('Skipping JWT test in non-Cloud mode')
28+
29+
access_token = make_access_token()
30+
client = create_client(
31+
host=test_config.host,
32+
port=test_config.port,
33+
access_token=access_token,
34+
)
35+
36+
# Should still work after the override
37+
access_token = make_access_token()
38+
client.set_access_token(access_token)
39+
40+
result = client.query(query=CHECK_CLOUD_MODE_QUERY).result_set
41+
assert result == [(True,)]
42+
43+
44+
def test_jwt_auth_sync_client_config_errors():
45+
with pytest.raises(ProgrammingError):
46+
create_client(
47+
username='bob',
48+
access_token='foobar'
49+
)
50+
with pytest.raises(ProgrammingError):
51+
create_client(
52+
username='bob',
53+
password='secret',
54+
access_token='foo'
55+
)
56+
with pytest.raises(ProgrammingError):
57+
create_client(
58+
password='secret',
59+
access_token='foo'
60+
)
61+
62+
63+
def test_jwt_auth_sync_client_set_access_token_errors(test_config: TestConfig):
64+
if not test_config.cloud:
65+
pytest.skip('Skipping JWT test in non-Cloud mode')
66+
67+
client = create_client(
68+
host=test_config.host,
69+
port=test_config.port,
70+
username=test_config.username,
71+
password=test_config.password,
72+
)
73+
74+
# Can't use JWT with username/password
75+
access_token = make_access_token()
76+
with pytest.raises(ProgrammingError):
77+
client.set_access_token(access_token)
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_jwt_auth_async_client(test_config: TestConfig):
82+
if not test_config.cloud:
83+
pytest.skip('Skipping JWT test in non-Cloud mode')
84+
85+
access_token = make_access_token()
86+
client = await create_async_client(
87+
host=test_config.host,
88+
port=test_config.port,
89+
access_token=access_token
90+
)
91+
result = (await client.query(query=CHECK_CLOUD_MODE_QUERY)).result_set
92+
assert result == [(True,)]
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_jwt_auth_async_client_set_access_token(test_config: TestConfig):
97+
if not test_config.cloud:
98+
pytest.skip('Skipping JWT test in non-Cloud mode')
99+
100+
access_token = make_access_token()
101+
client = await create_async_client(
102+
host=test_config.host,
103+
port=test_config.port,
104+
access_token=access_token,
105+
)
106+
107+
access_token = make_access_token()
108+
client.set_access_token(access_token)
109+
110+
result = (await client.query(query=CHECK_CLOUD_MODE_QUERY)).result_set
111+
assert result == [(True,)]
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_jwt_auth_async_client_config_errors():
116+
with pytest.raises(ProgrammingError):
117+
await create_async_client(
118+
username='bob',
119+
access_token='foobar'
120+
)
121+
with pytest.raises(ProgrammingError):
122+
await create_async_client(
123+
username='bob',
124+
password='secret',
125+
access_token='foo'
126+
)
127+
with pytest.raises(ProgrammingError):
128+
await create_async_client(
129+
password='secret',
130+
access_token='foo'
131+
)
132+
133+
134+
@pytest.mark.asyncio
135+
async def test_jwt_auth_async_client_set_access_token_errors(test_config: TestConfig):
136+
if not test_config.cloud:
137+
pytest.skip('Skipping JWT test in non-Cloud mode')
138+
139+
client = await create_async_client(
140+
host=test_config.host,
141+
port=test_config.port,
142+
username=test_config.username,
143+
password=test_config.password,
144+
)
145+
146+
# Can't use JWT with username/password
147+
access_token = make_access_token()
148+
with pytest.raises(ProgrammingError):
149+
client.set_access_token(access_token)
150+
151+
152+
CHECK_CLOUD_MODE_QUERY = "SELECT value='1' FROM system.settings WHERE name='cloud_mode'"
153+
JWT_SECRET_ENV_KEY = 'CLICKHOUSE_CONNECT_TEST_JWT_SECRET'
154+
155+
156+
def make_access_token():
157+
secret = environ.get(JWT_SECRET_ENV_KEY)
158+
if not secret:
159+
raise ValueError(f'{JWT_SECRET_ENV_KEY} environment variable is not set')
160+
payload = {
161+
'iss': 'ClickHouse',
162+
'sub': 'CI_Test',
163+
'aud': '1f7f78b8-da67-480b-8913-726fdd31d2fc',
164+
'clickhouse:roles': ['default'],
165+
'clickhouse:grants': [],
166+
'exp': datetime.now(tz=timezone.utc) + timedelta(minutes=15)
167+
}
168+
return jwt.encode(payload, secret, algorithm='RS256')

tests/test_requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ numpy~=1.26.0; python_version >= '3.11' and python_version <= '3.12'
1515
numpy~=2.1.0; python_version >= '3.13'
1616
pandas
1717
zstandard
18-
lz4
18+
lz4
19+
pyjwt[crypto]==2.10.1

0 commit comments

Comments
 (0)