Skip to content

Commit e14bbae

Browse files
PYCBC-1715: Support JWT based authentication in Operational SDKs
Change-Id: I6b19ed9def93edaa08844fe258c9ac9c35b3c469 Reviewed-on: https://review.couchbase.org/c/couchbase-python-client/+/239443 Reviewed-by: Jared Casey <jared.casey@couchbase.com> Tested-by: Build Bot <build@couchbase.com>
1 parent 9e2ee48 commit e14bbae

7 files changed

Lines changed: 188 additions & 35 deletions

File tree

acouchbase/cluster.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
from acouchbase.management.search import SearchIndexManager
3333
from acouchbase.management.users import UserManager
3434
from acouchbase.transactions import Transactions
35-
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
35+
from couchbase.auth import (CertificateAuthenticator,
36+
JwtAuthenticator,
37+
PasswordAuthenticator)
3638
from couchbase.result import (AnalyticsResult,
3739
ClusterInfoResult,
3840
DiagnosticsResult,
@@ -129,13 +131,29 @@ async def close(self) -> None:
129131
"""
130132
await self._impl.close_connection()
131133

132-
def update_credentials(self, authenticator: Union[CertificateAuthenticator, PasswordAuthenticator]) -> None:
133-
"""Update the credentials used by this Cluster.
134+
def set_authenticator(
135+
self, authenticator: Union[CertificateAuthenticator, JwtAuthenticator, PasswordAuthenticator]
136+
) -> None:
137+
"""Replace the authenticator used by this Cluster.
138+
139+
Allows updating credentials without restarting the application.
140+
The effect on existing connections depends on the authenticator type:
141+
142+
- JwtAuthenticator: Live re-auth on existing KV connections via OAUTHBEARER SASL.
143+
HTTP requests use the new Bearer token immediately.
144+
- PasswordAuthenticator: No effect on existing KV connections (they keep old
145+
credentials). New HTTP requests use the new Basic auth header.
146+
- CertificateAuthenticator: No effect on existing connections (TLS handshake
147+
already completed). Only new connections use the new certificate.
134148
135149
Args:
136-
authenticator (Union[CertificateAuthenticator, PasswordAuthenticator]): New authenticator.
150+
authenticator: New authenticator to use. Must be the same type as the
151+
current authenticator.
152+
153+
Raises:
154+
RuntimeError: If cluster is not connected.
137155
"""
138-
req = self._impl.request_builder.build_udpate_credential_request(authenticator)
156+
req = self._impl.request_builder.build_update_credential_request(authenticator)
139157
self._impl.update_credentials(req)
140158

141159
def bucket(self, bucket_name: str) -> AsyncBucket:

acouchbase/tests/credentials_t.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,34 @@
1919
import pytest_asyncio
2020

2121
from acouchbase.cluster import Cluster as AsyncCluster
22-
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
22+
from couchbase.auth import (CertificateAuthenticator,
23+
JwtAuthenticator,
24+
PasswordAuthenticator)
2325
from couchbase.exceptions import InvalidArgumentException
2426
from couchbase.options import ClusterOptions
2527

2628

29+
class JwtAuthenticatorUnitTests:
30+
"""Unit tests for JwtAuthenticator that don't require a cluster connection."""
31+
32+
def test_jwt_authenticator_creation(self):
33+
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test.signature"
34+
auth = JwtAuthenticator(token)
35+
assert auth.as_dict() == {'jwt_token': token}
36+
37+
def test_jwt_authenticator_valid_keys(self):
38+
auth = JwtAuthenticator("test.jwt.token")
39+
assert auth.valid_keys() == ['jwt_token']
40+
41+
def test_jwt_authenticator_rejects_non_string(self):
42+
with pytest.raises(InvalidArgumentException):
43+
JwtAuthenticator(12345)
44+
45+
def test_jwt_authenticator_rejects_none(self):
46+
with pytest.raises(InvalidArgumentException):
47+
JwtAuthenticator(None)
48+
49+
2750
class AsyncCredentialsTests:
2851

2952
@pytest_asyncio.fixture(scope="class")
@@ -40,27 +63,37 @@ def __init__(self, cfg):
4063
await env.cluster.close()
4164

4265
@pytest.mark.asyncio
43-
async def test_update_credentials_async(self, cb_env):
66+
async def test_set_authenticator_reflected_in_connection_info_async(self, cb_env):
4467
cluster = cb_env.cluster
4568

69+
# capture original
70+
info_before = cluster._impl.get_connection_info()
71+
assert 'credentials' in info_before
72+
orig_creds = info_before['credentials']
73+
74+
# update to new creds; we only assert that core origin is updated
4675
new_user = f"pycbc_{uuid.uuid4().hex[:8]}"
4776
new_pass = f"pw_{uuid.uuid4().hex[:8]}"
48-
cluster.update_credentials(PasswordAuthenticator(new_user, new_pass))
77+
cluster.set_authenticator(PasswordAuthenticator(new_user, new_pass))
4978

5079
info_after = cluster._impl.get_connection_info()
5180
assert info_after['credentials']['username'] == new_user
5281
assert info_after['credentials']['password'] == new_pass
5382

83+
# restore original to avoid impacting other tests
84+
cluster.set_authenticator(PasswordAuthenticator(orig_creds['username'],
85+
orig_creds['password']))
86+
5487
@pytest.mark.asyncio
55-
async def test_update_to_certificate_auth_without_tls_fails_async(self, cb_env):
88+
async def test_set_authenticator_password_to_certificate_fails_async(self, cb_env):
5689
cluster = cb_env.cluster
57-
# Attempt to switch to certificate auth without TLS should raise
90+
# Core should reject this at validation step, surfacing as InvalidArgumentException
5891
with pytest.raises(InvalidArgumentException):
59-
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
60-
key_path='path/to/key'))
92+
cluster.set_authenticator(CertificateAuthenticator(cert_path='path/to/cert',
93+
key_path='path/to/key'))
6194

6295
@pytest.mark.asyncio
63-
async def test_update_credentials_failure_does_not_change_state_async(self, cb_env):
96+
async def test_set_authenticator_failure_does_not_change_state_async(self, cb_env):
6497
cluster = cb_env.cluster
6598

6699
# capture original
@@ -70,9 +103,18 @@ async def test_update_credentials_failure_does_not_change_state_async(self, cb_e
70103

71104
# attempt to switch to certificate auth on non-TLS connection; expect failure
72105
with pytest.raises(InvalidArgumentException):
73-
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
74-
key_path='path/to/key'))
106+
cluster.set_authenticator(CertificateAuthenticator(cert_path='path/to/cert',
107+
key_path='path/to/key'))
75108

76109
# ensure credentials are unchanged after the failed update
77110
info_after = cluster._impl.get_connection_info()
78111
assert info_after['credentials'] == orig_creds
112+
113+
@pytest.mark.asyncio
114+
async def test_set_authenticator_password_to_jwt_fails_async(self, cb_env):
115+
"""RFC: Cannot switch from PasswordAuthenticator to JwtAuthenticator."""
116+
cluster = cb_env.cluster
117+
118+
# RFC: Cannot switch authenticator types
119+
with pytest.raises(InvalidArgumentException):
120+
cluster.set_authenticator(JwtAuthenticator("some.jwt.token"))

couchbase/auth.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,40 @@ def ldap_compatible(username, # type: str
100100
return auth
101101

102102

103+
class JwtAuthenticator(Authenticator):
104+
"""
105+
JWT (JSON Web Token) authentication mechanism.
106+
107+
Uses OAUTHBEARER SASL mechanism for KV connections and Bearer token
108+
for HTTP services.
109+
110+
Args:
111+
token (str): The JWT token to use for authentication.
112+
113+
Raises:
114+
:class:`~couchbase.exceptions.InvalidArgumentException`: If token is not a string.
115+
"""
116+
117+
def __init__(self, token # type: str
118+
):
119+
"""JwtAuthenticator instance."""
120+
if not isinstance(token, str):
121+
msg = 'The token must be a str.'
122+
raise InvalidArgumentException(msg)
123+
124+
self._token = token
125+
126+
super().__init__(**self.as_dict())
127+
128+
def valid_keys(self):
129+
return ['jwt_token']
130+
131+
def as_dict(self):
132+
return {
133+
'jwt_token': self._token
134+
}
135+
136+
103137
class CertificateAuthenticator(Authenticator):
104138
"""
105139
Certificate authentication mechanism.

couchbase/cluster.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
Dict,
2222
Union)
2323

24-
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
24+
from couchbase.auth import (CertificateAuthenticator,
25+
JwtAuthenticator,
26+
PasswordAuthenticator)
2527
from couchbase.bucket import Bucket
2628
from couchbase.logic.cluster_impl import ClusterImpl
2729
from couchbase.logic.supportability import Supportability
@@ -191,13 +193,29 @@ def diagnostics(self,
191193
req = self._impl.request_builder.build_diagnostics_request(*opts, **kwargs)
192194
return self._impl.diagnostics(req)
193195

194-
def update_credentials(self, authenticator: Union[CertificateAuthenticator, PasswordAuthenticator]) -> None:
195-
"""Update the credentials used by this Cluster.
196+
def set_authenticator(
197+
self, authenticator: Union[CertificateAuthenticator, JwtAuthenticator, PasswordAuthenticator]
198+
) -> None:
199+
"""Replace the authenticator used by this Cluster.
200+
201+
Allows updating credentials without restarting the application.
202+
The effect on existing connections depends on the authenticator type:
203+
204+
- JwtAuthenticator: Live re-auth on existing KV connections via OAUTHBEARER SASL.
205+
HTTP requests use the new Bearer token immediately.
206+
- PasswordAuthenticator: No effect on existing KV connections (they keep old
207+
credentials). New HTTP requests use the new Basic auth header.
208+
- CertificateAuthenticator: No effect on existing connections (TLS handshake
209+
already completed). Only new connections use the new certificate.
196210
197211
Args:
198-
authenticator (Union[CertificateAuthenticator, PasswordAuthenticator]): New authenticator.
212+
authenticator: New authenticator to use. Must be the same type as the
213+
current authenticator.
214+
215+
Raises:
216+
RuntimeError: If cluster is not connected.
199217
"""
200-
req = self._impl.request_builder.build_udpate_credential_request(authenticator)
218+
req = self._impl.request_builder.build_update_credential_request(authenticator)
201219
self._impl.update_credentials(req)
202220

203221
def wait_until_ready(self,

couchbase/logic/cluster_req_builder.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737
SearchRequest)
3838

3939
if TYPE_CHECKING:
40-
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
40+
from couchbase.auth import (CertificateAuthenticator,
41+
JwtAuthenticator,
42+
PasswordAuthenticator)
4143

4244

4345
class ClusterRequestBuilder:
@@ -113,8 +115,9 @@ def build_search_request(self,
113115
req.num_workers = num_workers
114116
return req
115117

116-
def build_udpate_credential_request(
117-
self, authenticator: Union[CertificateAuthenticator, PasswordAuthenticator]) -> UpdateCredentialsRequest:
118+
def build_update_credential_request(
119+
self, authenticator: Union[CertificateAuthenticator, JwtAuthenticator, PasswordAuthenticator]
120+
) -> UpdateCredentialsRequest:
118121
return UpdateCredentialsRequest(authenticator.as_dict())
119122

120123
def build_wait_until_ready_request(self,

couchbase/tests/credentials_t.py

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,38 @@
1717

1818
import pytest
1919

20-
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
20+
from couchbase.auth import (CertificateAuthenticator,
21+
JwtAuthenticator,
22+
PasswordAuthenticator)
2123
from couchbase.cluster import Cluster
2224
from couchbase.exceptions import InvalidArgumentException
2325
from couchbase.options import ClusterOptions
2426

2527

28+
class JwtAuthenticatorUnitTests:
29+
"""Unit tests for JwtAuthenticator that don't require a cluster connection."""
30+
31+
def test_jwt_authenticator_creation(self):
32+
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test.signature"
33+
auth = JwtAuthenticator(token)
34+
assert auth.as_dict() == {'jwt_token': token}
35+
36+
def test_jwt_authenticator_valid_keys(self):
37+
auth = JwtAuthenticator("test.jwt.token")
38+
assert auth.valid_keys() == ['jwt_token']
39+
40+
def test_jwt_authenticator_rejects_non_string(self):
41+
with pytest.raises(InvalidArgumentException):
42+
JwtAuthenticator(12345)
43+
44+
def test_jwt_authenticator_rejects_none(self):
45+
with pytest.raises(InvalidArgumentException):
46+
JwtAuthenticator(None)
47+
48+
2649
class ClassicCredentialsTests:
2750

28-
def test_update_credentials_reflected_in_connection_info(self, couchbase_config):
51+
def test_set_authenticator_reflected_in_connection_info(self, couchbase_config):
2952
conn_string = couchbase_config.get_connection_string()
3053
username, pw = couchbase_config.get_username_and_pw()
3154

@@ -36,22 +59,22 @@ def test_update_credentials_reflected_in_connection_info(self, couchbase_config)
3659
assert 'credentials' in info_before
3760
orig_creds = info_before['credentials']
3861

39-
# update to new (likely invalid) creds; we only assert that core origin is updated
62+
# update to new creds; we only assert that core origin is updated
4063
new_user = f"pycbc_{uuid.uuid4().hex[:8]}"
4164
new_pass = f"pw_{uuid.uuid4().hex[:8]}"
42-
cluster.update_credentials(PasswordAuthenticator(new_user, new_pass))
65+
cluster.set_authenticator(PasswordAuthenticator(new_user, new_pass))
4366

4467
info_after = cluster._impl.get_connection_info()
4568
assert info_after['credentials']['username'] == new_user
4669
assert info_after['credentials']['password'] == new_pass
4770

4871
# restore original to avoid impacting other tests
49-
cluster.update_credentials(PasswordAuthenticator(orig_creds.get('username', username),
50-
orig_creds.get('password', pw)))
72+
cluster.set_authenticator(PasswordAuthenticator(orig_creds.get('username', username),
73+
orig_creds.get('password', pw)))
5174

5275
cluster.close()
5376

54-
def test_update_to_certificate_auth_without_tls_fails(self, couchbase_config):
77+
def test_set_authenticator_password_to_certificate_fails(self, couchbase_config):
5578
conn_string = couchbase_config.get_connection_string()
5679
username, pw = couchbase_config.get_username_and_pw()
5780

@@ -60,12 +83,12 @@ def test_update_to_certificate_auth_without_tls_fails(self, couchbase_config):
6083

6184
# Core should reject this at validation step, surfacing as InvalidArgumentException
6285
with pytest.raises(InvalidArgumentException):
63-
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
64-
key_path='path/to/key'))
86+
cluster.set_authenticator(CertificateAuthenticator(cert_path='path/to/cert',
87+
key_path='path/to/key'))
6588

6689
cluster.close()
6790

68-
def test_update_credentials_failure_does_not_change_state(self, couchbase_config):
91+
def test_set_authenticator_failure_does_not_change_state(self, couchbase_config):
6992
conn_string = couchbase_config.get_connection_string()
7093
username, pw = couchbase_config.get_username_and_pw()
7194

@@ -78,11 +101,24 @@ def test_update_credentials_failure_does_not_change_state(self, couchbase_config
78101

79102
# attempt to switch to certificate auth on non-TLS connection; expect failure
80103
with pytest.raises(InvalidArgumentException):
81-
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
82-
key_path='path/to/key'))
104+
cluster.set_authenticator(CertificateAuthenticator(cert_path='path/to/cert',
105+
key_path='path/to/key'))
83106

84107
# ensure credentials are unchanged after the failed update
85108
info_after = cluster._impl.get_connection_info()
86109
assert info_after['credentials'] == orig_creds
87110

88111
cluster.close()
112+
113+
def test_set_authenticator_password_to_jwt_fails(self, couchbase_config):
114+
"""RFC: Cannot switch from PasswordAuthenticator to JwtAuthenticator."""
115+
conn_string = couchbase_config.get_connection_string()
116+
username, pw = couchbase_config.get_username_and_pw()
117+
118+
cluster = Cluster.connect(conn_string, ClusterOptions(PasswordAuthenticator(username, pw)))
119+
120+
# RFC: Cannot switch authenticator types
121+
with pytest.raises(InvalidArgumentException):
122+
cluster.set_authenticator(JwtAuthenticator("some.jwt.token"))
123+
124+
cluster.close()

src/cpp_core_types.hxx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ struct py_to_cbpp_t<couchbase::core::cluster_credentials> {
159159
extract_field(pyObj, "cert_path", creds.certificate_path);
160160
extract_field(pyObj, "key_path", creds.key_path);
161161
extract_field(pyObj, "allowed_sasl_mechanisms", creds.allowed_sasl_mechanisms);
162+
extract_field(pyObj, "jwt_token", creds.jwt_token);
162163

163164
return creds;
164165
}
@@ -174,6 +175,7 @@ struct py_to_cbpp_t<couchbase::core::cluster_credentials> {
174175
add_string_field_if_not_empty(dict, "password", creds.password);
175176
add_string_field_if_not_empty(dict, "cert_path", creds.certificate_path);
176177
add_string_field_if_not_empty(dict, "key_path", creds.key_path);
178+
add_string_field_if_not_empty(dict, "jwt_token", creds.jwt_token);
177179

178180
if (creds.allowed_sasl_mechanisms.has_value() &&
179181
!creds.allowed_sasl_mechanisms.value().empty()) {

0 commit comments

Comments
 (0)