Skip to content

Commit a5d5b61

Browse files
committed
MB-68388 Support JWTs as an auth method
Change-Id: I6a6a636cff8437eab0a9dba32f75fcc8083613af Reviewed-on: https://review.couchbase.org/c/couchbase-cli/+/236688 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Safian Ali <safian.ali@couchbase.com>
1 parent 0de91ec commit a5d5b61

6 files changed

Lines changed: 115 additions & 30 deletions

File tree

cbmgr.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,15 @@ def rest_initialiser(cluster_init_check=False, version_check=False, enterprise_c
193193
"""
194194
def inner(fn):
195195
def decorator(self, opts):
196-
_exit_if_errors(validate_credential_flags(opts.cluster, opts.username, opts.password, opts.client_ca,
197-
opts.client_ca_password, opts.client_pk, opts.client_pk_password,
198-
credentials_required))
196+
_exit_if_errors(validate_credential_flags(opts.cluster, opts.username, opts.password, opts.auth_token,
197+
opts.client_ca, opts.client_ca_password, opts.client_pk,
198+
opts.client_pk_password, credentials_required))
199199

200200
try:
201-
self.rest = ClusterManager(opts.cluster, opts.username, opts.password, opts.ssl, opts.ssl_verify,
202-
opts.cacert, opts.debug, client_ca=opts.client_ca,
203-
client_ca_password=opts.client_ca_password, client_pk=opts.client_pk,
204-
client_pk_password=opts.client_pk_password)
201+
self.rest = ClusterManager(opts.cluster, opts.username, opts.password, opts.auth_token,
202+
opts.ssl, opts.ssl_verify, opts.cacert, opts.debug,
203+
client_ca=opts.client_ca, client_ca_password=opts.client_ca_password,
204+
client_pk=opts.client_pk, client_pk_password=opts.client_pk_password)
205205
except X509AdapterError as error:
206206
_exit_if_errors([f"failed to setup client certificate encryption, {error}"])
207207

@@ -230,7 +230,7 @@ def decorator(self, opts):
230230
return inner
231231

232232

233-
def validate_credential_flags(host, username, password, client_ca, client_ca_password,
233+
def validate_credential_flags(host, username, password, token, client_ca, client_ca_password,
234234
client_pk, client_pk_password, credentials_required: bool = True):
235235
"""ValidateCredentialFlags - Performs validation to ensure the user has provided the flags required to connect to
236236
their cluster.
@@ -245,29 +245,40 @@ def validate_credential_flags(host, username, password, client_ca, client_ca_pas
245245
host,
246246
username,
247247
password,
248+
token,
248249
client_ca,
249250
client_ca_password,
250251
client_pk,
251252
client_pk_password)
252253

253-
if (username is None and password is None):
254+
if (username is None and password is None and token is None):
254255
if credentials_required is False:
255256
return None
256257

257-
return ["cluster credentials required, expected --username/--password or --client-cert/--client-key"]
258+
return ["cluster credentials required, expected --username/--password, --client-cert/--client-key or "
259+
"--auth-token"]
260+
261+
if token:
262+
if username is not None or password is not None:
263+
return ["expected either --username and --password or --auth-token but not both"]
264+
return None
258265

259266
if (username is None or password is None):
260267
return ["the --username/--password flags must be supplied together"]
261268

262269
return None
263270

264271

265-
def validate_certificate_flags(host, username, password, client_ca, client_ca_password, client_pk, client_pk_password):
272+
def validate_certificate_flags(host, username, password, token, client_ca, client_ca_password, client_pk,
273+
client_pk_password):
266274
"""Validate that the user is correctly using certificate authentication.
267275
"""
268276
if username is not None or password is not None:
269277
return ["expected either --username and --password or --client-cert and --client-key but not both"]
270278

279+
if token is not None:
280+
return ["expected either --auth-token or --client-cert and --client-key but not both"]
281+
271282
if not (host.startswith("https://") or host.startswith("couchbases://")):
272283
return ["certificate authentication requires a secure connection, use https:// or couchbases://"]
273284

@@ -970,6 +981,10 @@ def __init__(self, deprecate_username=False, deprecate_password=False, cluster_d
970981
action=CBNonEchoedAction, envvar='CB_REST_PASSWORD',
971982
metavar="<password>", help="The password for the Couchbase cluster")
972983

984+
group.add_argument("--auth-token", dest="auth_token",
985+
action=CBNonEchoedAction, envvar="CB_REST_AUTH_TOKEN", metavar="<auth-token>",
986+
help="The JWT to authenticate with this Couchbase cluster")
987+
973988
group.add_argument("-o", "--output", dest="output", default="standard", metavar="<output>",
974989
choices=["json", "standard"], help="The output type (json or standard)")
975990
group.add_argument("-d", "--debug", dest="debug", action="store_true",
@@ -6342,8 +6357,8 @@ def execute(self, opts):
63426357
opts.cluster = cluster
63436358

63446359
# override rest client so it uses the node to be altered
6345-
self.rest = ClusterManager(opts.cluster, opts.username, opts.password, opts.ssl, opts.ssl_verify,
6346-
opts.cacert, opts.debug)
6360+
self.rest = ClusterManager(opts.cluster, opts.username, opts.password, opts.auth_token,
6361+
opts.ssl, opts.ssl_verify, opts.cacert, opts.debug)
63476362

63486363
if opts.set:
63496364
ports, error = self._parse_ports(opts.ports)

cluster_manager.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@
4040
VERSION = '0.0.0-0000'
4141

4242

43+
class TokenAuth(requests.auth.AuthBase):
44+
def __init__(self, token):
45+
self.token = token
46+
47+
def __call__(self, r):
48+
r.headers["Authorization"] = f"Bearer {self.token}"
49+
return r
50+
51+
4352
def unexpected_403_err(message):
4453
return f"Unexpected response for error 403: {message}"
4554

@@ -86,10 +95,21 @@ def __init__(self, service):
8695
class ClusterManager(object):
8796
"""A set of REST API's for managing a Couchbase cluster"""
8897

89-
def __init__(self, hostname, username, password, ssl_flag=False, verify_cert=True, ca_cert=True, debug=False,
90-
timeout=DEFAULT_REQUEST_TIMEOUT, client_ca: Optional[Path] = None,
91-
client_ca_password: Optional[str] = None,
92-
client_pk: Optional[Path] = None, client_pk_password: Optional[str] = None):
98+
def __init__(
99+
self,
100+
hostname,
101+
username,
102+
password,
103+
token=None,
104+
ssl_flag=False,
105+
verify_cert=True,
106+
ca_cert=True,
107+
debug=False,
108+
timeout=DEFAULT_REQUEST_TIMEOUT,
109+
client_ca: Optional[Path] = None,
110+
client_ca_password: Optional[str] = None,
111+
client_pk: Optional[Path] = None,
112+
client_pk_password: Optional[str] = None):
93113
hostname = hostname.replace("couchbase://", "http://", 1)
94114
hostname = hostname.replace("couchbases://", "https://", 1)
95115

@@ -115,8 +135,13 @@ def __init__(self, hostname, username, password, ssl_flag=False, verify_cert=Tru
115135
self.verify_cert = False
116136
self.ca_cert = False
117137

118-
self.username = username.encode('utf-8').decode('latin1') if username is not None else ""
119-
self.password = password.encode('utf-8').decode('latin1') if password is not None else ""
138+
username = username.encode('utf-8').decode('latin1') if username is not None else ""
139+
password = password.encode('utf-8').decode('latin1') if password is not None else ""
140+
self.auth: Any = (username, password)
141+
142+
if token:
143+
self.auth = TokenAuth(token)
144+
120145
self.timeout = timeout
121146
self.ssl = self.hostname.startswith("https://")
122147
self.debug = debug
@@ -3002,7 +3027,7 @@ def _get(self, url, params=None):
30023027
if self.debug:
30033028
print(f'GET {url} {self._url_encode_params(params)}')
30043029

3005-
return self._handle_response(self.session.get(url, params=params, auth=(self.username, self.password),
3030+
return self._handle_response(self.session.get(url, params=params, auth=self.auth,
30063031
verify=self.ca_cert, timeout=self.timeout, headers=self.headers))
30073032

30083033
@request
@@ -3013,15 +3038,15 @@ def _post_form_encoded(self, url, params):
30133038
else:
30143039
print(f'POST {url} {self._url_encode_params(params)}')
30153040

3016-
return self._handle_response(self.session.post(url, auth=(self.username, self.password), data=params,
3041+
return self._handle_response(self.session.post(url, auth=self.auth, data=params,
30173042
verify=self.ca_cert, timeout=self.timeout, headers=self.headers))
30183043

30193044
@request
30203045
def _post_json(self, url, params):
30213046
if self.debug:
30223047
print(f'POST {url} {self._json_encode_params(params)}')
30233048

3024-
return self._handle_response(self.session.post(url, auth=(self.username, self.password), json=params,
3049+
return self._handle_response(self.session.post(url, auth=self.auth, json=params,
30253050
verify=self.ca_cert, timeout=self.timeout, headers=self.headers))
30263051

30273052
@request
@@ -3031,9 +3056,7 @@ def _patch_form_encoded(self, url, params):
30313056

30323057
return self._handle_response(
30333058
self.session.patch(
3034-
url, auth=(
3035-
self.username,
3036-
self.password),
3059+
url, auth=self.auth,
30373060
data=params,
30383061
verify=self.ca_cert,
30393062
timeout=self.timeout,
@@ -3044,7 +3067,7 @@ def _patch_json(self, url, params):
30443067
if self.debug:
30453068
print(f'PATCH {url} {self._json_encode_params(params)}')
30463069

3047-
return self._handle_response(self.session.patch(url, auth=(self.username, self.password), json=params,
3070+
return self._handle_response(self.session.patch(url, auth=self.auth, json=params,
30483071
verify=self.ca_cert, timeout=self.timeout,
30493072
headers=self.headers))
30503073

@@ -3053,23 +3076,23 @@ def _put(self, url, params):
30533076
if self.debug:
30543077
print(f'PUT {url} {self._url_encode_params(params)}')
30553078

3056-
return self._handle_response(self.session.put(url, params, auth=(self.username, self.password),
3079+
return self._handle_response(self.session.put(url, params, auth=self.auth,
30573080
verify=self.ca_cert, timeout=self.timeout, headers=self.headers))
30583081

30593082
@request
30603083
def _put_json(self, url, params):
30613084
if self.debug:
30623085
print(f'PUT {url} {self._json_encode_params(params)}')
30633086

3064-
return self._handle_response(self.session.put(url, None, auth=(self.username, self.password), json=params,
3087+
return self._handle_response(self.session.put(url, None, auth=self.auth, json=params,
30653088
verify=self.ca_cert, timeout=self.timeout, headers=self.headers))
30663089

30673090
@request
30683091
def _delete(self, url, params):
30693092
if self.debug:
30703093
print(f'DELETE {url} {self._url_encode_params(params)}')
30713094

3072-
return self._handle_response(self.session.delete(url, auth=(self.username, self.password), data=params,
3095+
return self._handle_response(self.session.delete(url, auth=self.auth, data=params,
30733096
verify=self.ca_cert, timeout=self.timeout,
30743097
headers=self.headers))
30753098

docs/modules/cli/pages/_partials/cbcli/part-common-env.adoc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ CB_REST_PASSWORD::
99
argument on the command line. It also allows the user to ensure that their
1010
password are not cached in their command line history.
1111

12+
CB_REST_AUTH_TOKEN::
13+
Specifies the JWT to authenticate with. This environment variable allows you
14+
to specify a default argument for the --auth-token argument on the command
15+
line. It also allows the user to ensure that their tokens are not cached
16+
in their command line history.
17+
1218
CB_CLIENT_CERT::
1319
The path to a client certificate used to authenticate when connecting to a
1420
cluster. May be supplied with `CB_CLIENT_KEY` as an alternative to the

docs/modules/cli/pages/_partials/cbcli/part-common-options.adoc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ include::part-common-cluster.adoc[]
1515
non-echoed stdin. You may also specify your password by using the
1616
environment variable CB_REST_PASSWORD.
1717

18+
--auth-token <auth-token>::
19+
Specifies the JWT to authenticate with. If you do not have a user account
20+
with permission to execute the command then it will fail with an unauthorized
21+
error. You may also specify your token by using the environment variable
22+
CB_REST_AUTH_TOKEN.
23+
1824
--client-cert <path>::
1925
The path to a client certificate used to authenticate when connecting to a
2026
cluster. May be supplied with `--client-key` as an alternative to the

jenkins/.aspell.en.pws

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ ipv
112112
iso
113113
JSON
114114
json
115+
JWT
115116
kek
116117
kmip
117118
kms

test/test_cli.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ def test_validate_credential_flags(self):
3737
"username": "username",
3838
"password": "password",
3939
},
40+
"ValidAuthToken": {
41+
"auth_token":
42+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
43+
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0." +
44+
"KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30",
45+
},
4046
"ValidCertAuthWithHTTPS": {
4147
"host": "https://localhost:8091",
4248
"client_ca": "/path/to/cert",
@@ -55,6 +61,22 @@ def test_validate_credential_flags(self):
5561
"password": "password",
5662
"errors": ["the --username/--password flags must be supplied together"],
5763
},
64+
"AuthTokenWithUsername": {
65+
"auth_token":
66+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
67+
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0." +
68+
"KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30",
69+
"username": "username",
70+
"errors": ["expected either --username and --password or --auth-token but not both"],
71+
},
72+
"AuthTokenWithPassword": {
73+
"auth_token":
74+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
75+
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0." +
76+
"KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30",
77+
"password": "password",
78+
"errors": ["expected either --username and --password or --auth-token but not both"],
79+
},
5880
"OnlyClientCert": {
5981
"host": "https://localhost:8091",
6082
"client_ca": "/path/to/cert",
@@ -79,6 +101,16 @@ def test_validate_credential_flags(self):
79101
"client_pk": "/path/to/key",
80102
"errors": ["expected either --username and --password or --client-cert and --client-key but not both"],
81103
},
104+
"CertAuthWithToken": {
105+
"host": "https://localhost:8091",
106+
"auth_token":
107+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
108+
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0." +
109+
"KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30",
110+
"client_ca": "/path/to/cert",
111+
"client_pk": "/path/to/key",
112+
"errors": ["expected either --auth-token or --client-cert and --client-key but not both"],
113+
},
82114
"InsecureConnectionEmpty": {
83115
"host": "",
84116
"client_ca": "/path/to/cert",
@@ -140,7 +172,8 @@ def test_validate_credential_flags(self):
140172
},
141173
"NoCredentials": {
142174
"host": "https://localhost:8091",
143-
"errors": ["cluster credentials required, expected --username/--password or --client-cert/--client-key"],
175+
"errors": ["cluster credentials required, expected --username/--password, " +
176+
"--client-cert/--client-key or --auth-token"],
144177
},
145178
"NoCredentialsAndCredentialsNotRequired": {
146179
"host": "https://localhost:8091",
@@ -157,6 +190,7 @@ def value(test, key):
157190
value(test, "host"),
158191
value(test, "username"),
159192
value(test, "password"),
193+
value(test, "auth_token"),
160194
value(test, "client_ca"),
161195
value(test, "client_ca_password"),
162196
value(test, "client_pk"),

0 commit comments

Comments
 (0)