Skip to content

Commit 879434f

Browse files
authored
Authenticate using User JWT-Token (#293)
* initial commit * fix rst * `set_token` test * update `user_token` docstring * new: `set_token` for `JwtSuperuserConnection` * add `set_token` example in rst * fix: set_token * update docstring * update error msg * update `test_auth_jwt_expiry` * fix isort
1 parent 9ca3538 commit 879434f

File tree

5 files changed

+165
-24
lines changed

5 files changed

+165
-24
lines changed

arango/client.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ def db(
171171
password: str = "",
172172
verify: bool = False,
173173
auth_method: str = "basic",
174+
user_token: Optional[str] = None,
174175
superuser_token: Optional[str] = None,
175176
verify_certificate: bool = True,
176177
) -> StandardDatabase:
@@ -189,9 +190,17 @@ def db(
189190
refreshed automatically using ArangoDB username and password. This
190191
assumes that the clocks of the server and client are synchronized.
191192
:type auth_method: str
193+
:param user_token: User generated token for user access.
194+
If set, parameters **username**, **password** and **auth_method**
195+
are ignored. This token is not refreshed automatically. If automatic
196+
token refresh is required, consider setting **auth_method** to "jwt"
197+
and using the **username** and **password** parameters instead. Token
198+
expiry will be checked.
199+
:type user_token: str
192200
:param superuser_token: User generated token for superuser access.
193201
If set, parameters **username**, **password** and **auth_method**
194-
are ignored. This token is not refreshed automatically.
202+
are ignored. This token is not refreshed automatically. Token
203+
expiry will not be checked.
195204
:type superuser_token: str
196205
:param verify_certificate: Verify TLS certificates.
197206
:type verify_certificate: bool
@@ -213,6 +222,17 @@ def db(
213222
deserializer=self._deserializer,
214223
superuser_token=superuser_token,
215224
)
225+
elif user_token is not None:
226+
connection = JwtConnection(
227+
hosts=self._hosts,
228+
host_resolver=self._host_resolver,
229+
sessions=self._sessions,
230+
db_name=name,
231+
http_client=self._http,
232+
serializer=self._serializer,
233+
deserializer=self._deserializer,
234+
user_token=user_token,
235+
)
216236
elif auth_method.lower() == "basic":
217237
connection = BasicConnection(
218238
hosts=self._hosts,

arango/connection.py

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313
from typing import Any, Callable, Optional, Sequence, Set, Tuple, Union
1414

1515
import jwt
16+
from jwt.exceptions import ExpiredSignatureError
1617
from requests import ConnectionError, Session
1718
from requests_toolbelt import MultipartEncoder
1819

19-
from arango.exceptions import JWTAuthError, ServerConnectionError
20+
from arango.exceptions import (
21+
JWTAuthError,
22+
JWTExpiredError,
23+
JWTRefreshError,
24+
ServerConnectionError,
25+
)
2026
from arango.http import HTTPClient
2127
from arango.request import Request
2228
from arango.resolver import HostResolver
@@ -203,7 +209,7 @@ def ping(self) -> int:
203209
request = Request(method="get", endpoint="/_api/collection")
204210
resp = self.send_request(request)
205211
if resp.status_code in {401, 403}:
206-
raise ServerConnectionError("bad username and/or password")
212+
raise ServerConnectionError("bad username/password or token is expired")
207213
if not resp.is_success: # pragma: no cover
208214
raise ServerConnectionError(resp.error_message or "bad server response")
209215
return resp.status_code
@@ -300,11 +306,12 @@ def __init__(
300306
host_resolver: HostResolver,
301307
sessions: Sequence[Session],
302308
db_name: str,
303-
username: str,
304-
password: str,
305309
http_client: HTTPClient,
306310
serializer: Callable[..., str],
307311
deserializer: Callable[[str], Any],
312+
username: Optional[str] = None,
313+
password: Optional[str] = None,
314+
user_token: Optional[str] = None,
308315
) -> None:
309316
super().__init__(
310317
hosts,
@@ -323,7 +330,13 @@ def __init__(
323330
self._token: Optional[str] = None
324331
self._token_exp: int = sys.maxsize
325332

326-
self.refresh_token()
333+
if user_token is not None:
334+
self.set_token(user_token)
335+
elif username is not None and password is not None:
336+
self.refresh_token()
337+
else:
338+
m = "Either **user_token** or **username** & **password** must be set"
339+
raise ValueError(m)
327340

328341
def send_request(self, request: Request) -> Response:
329342
"""Send an HTTP request to ArangoDB server.
@@ -360,7 +373,12 @@ def refresh_token(self) -> None:
360373
361374
:return: JWT token.
362375
:rtype: str
376+
:raise arango.exceptions.JWTRefreshError: If missing username & password.
377+
:raise arango.exceptions.JWTAuthError: If token retrieval fails.
363378
"""
379+
if self._username is None or self._password is None:
380+
raise JWTRefreshError("username and password must be set")
381+
364382
request = Request(
365383
method="post",
366384
endpoint="/_open/auth",
@@ -374,21 +392,34 @@ def refresh_token(self) -> None:
374392
if not resp.is_success:
375393
raise JWTAuthError(resp, request)
376394

377-
self._token = resp.body["jwt"]
378-
assert self._token is not None
379-
380-
jwt_payload = jwt.decode(
381-
self._token,
382-
issuer="arangodb",
383-
algorithms=["HS256"],
384-
options={
385-
"require_exp": True,
386-
"require_iat": True,
387-
"verify_iat": True,
388-
"verify_exp": True,
389-
"verify_signature": False,
390-
},
391-
)
395+
self.set_token(resp.body["jwt"])
396+
397+
def set_token(self, token: str) -> None:
398+
"""Set the JWT token.
399+
400+
:param token: JWT token.
401+
:type token: str
402+
:raise arango.exceptions.JWTExpiredError: If the token is expired.
403+
"""
404+
assert token is not None
405+
406+
try:
407+
jwt_payload = jwt.decode(
408+
token,
409+
issuer="arangodb",
410+
algorithms=["HS256"],
411+
options={
412+
"require_exp": True,
413+
"require_iat": True,
414+
"verify_iat": True,
415+
"verify_exp": True,
416+
"verify_signature": False,
417+
},
418+
)
419+
except ExpiredSignatureError:
420+
raise JWTExpiredError("JWT token is expired")
421+
422+
self._token = token
392423
self._token_exp = jwt_payload["exp"]
393424
self._auth_header = f"bearer {self._token}"
394425

@@ -444,3 +475,30 @@ def send_request(self, request: Request) -> Response:
444475
request.headers["Authorization"] = self._auth_header
445476

446477
return self.process_request(host_index, request)
478+
479+
def set_token(self, token: str) -> None:
480+
"""Set the JWT token.
481+
482+
:param token: JWT token.
483+
:type token: str
484+
:raise arango.exceptions.JWTExpiredError: If the token is expired.
485+
"""
486+
assert token is not None
487+
488+
try:
489+
jwt.decode(
490+
token,
491+
issuer="arangodb",
492+
algorithms=["HS256"],
493+
options={
494+
"require_exp": True,
495+
"require_iat": True,
496+
"verify_iat": True,
497+
"verify_exp": True,
498+
"verify_signature": False,
499+
},
500+
)
501+
except ExpiredSignatureError:
502+
raise JWTExpiredError("JWT token is expired")
503+
504+
self._auth_header = f"bearer {token}"

arango/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,3 +1014,11 @@ class JWTSecretListError(ArangoServerError):
10141014

10151015
class JWTSecretReloadError(ArangoServerError):
10161016
"""Failed to reload JWT secrets."""
1017+
1018+
1019+
class JWTRefreshError(ArangoClientError):
1020+
"""Failed to refresh JWT token."""
1021+
1022+
1023+
class JWTExpiredError(ArangoClientError):
1024+
"""JWT token has expired."""

docs/auth.rst

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ to work correctly.
5959
# compensate for out-of-sync clocks between the client and server.
6060
db.conn.ext_leeway = 2
6161

62-
User generated JWT token can be used for superuser access.
62+
User generated JWT token can be used for user and superuser access.
6363

6464
**Example:**
6565

@@ -89,3 +89,29 @@ User generated JWT token can be used for superuser access.
8989
9090
# Connect to "test" database as superuser using the token.
9191
db = client.db('test', superuser_token=token)
92+
93+
# Connect to "test" database as user using the token.
94+
db = client.db('test', user_token=token)
95+
96+
User and superuser tokens can be set on the connection object as well.
97+
98+
**Example:**
99+
100+
.. code-block:: python
101+
102+
from arango import ArangoClient
103+
104+
# Initialize the ArangoDB client.
105+
client = ArangoClient()
106+
107+
# Connect to "test" database as superuser using the token.
108+
db = client.db('test', user_token='token')
109+
110+
# Set the user token on the connection object.
111+
db.conn.set_token('new token')
112+
113+
# Connect to "test" database as superuser using the token.
114+
db = client.db('test', superuser_token='superuser token')
115+
116+
# Set the user token on the connection object.
117+
db.conn.set_token('new superuser token')

tests/test_auth.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
from arango.errno import FORBIDDEN, HTTP_UNAUTHORIZED
33
from arango.exceptions import (
44
JWTAuthError,
5+
JWTExpiredError,
56
JWTSecretListError,
67
JWTSecretReloadError,
8+
ServerConnectionError,
79
ServerEncryptionError,
810
ServerTLSError,
911
ServerTLSReloadError,
@@ -37,7 +39,8 @@ def test_auth_basic(client, db_name, username, password):
3739
assert isinstance(db.properties(), dict)
3840

3941

40-
def test_auth_jwt(client, db_name, username, password):
42+
def test_auth_jwt(client, db_name, username, password, secret):
43+
# Test JWT authentication with username and password.
4144
db = client.db(
4245
name=db_name,
4346
username=username,
@@ -54,6 +57,13 @@ def test_auth_jwt(client, db_name, username, password):
5457
client.db(db_name, username, bad_password, auth_method="jwt")
5558
assert err.value.error_code == HTTP_UNAUTHORIZED
5659

60+
# Test JWT authentication with user token.
61+
token = generate_jwt(secret)
62+
db = client.db("_system", user_token=token)
63+
assert isinstance(db.conn, JwtConnection)
64+
assert isinstance(db.version(), str)
65+
assert isinstance(db.properties(), dict)
66+
5767

5868
# TODO re-examine commented out code
5969
def test_auth_superuser_token(client, db_name, root_password, secret):
@@ -116,13 +126,32 @@ def test_auth_superuser_token(client, db_name, root_password, secret):
116126
def test_auth_jwt_expiry(client, db_name, root_password, secret):
117127
# Test automatic token refresh on expired token.
118128
db = client.db("_system", "root", root_password, auth_method="jwt")
129+
valid_token = generate_jwt(secret)
119130
expired_token = generate_jwt(secret, exp=-1000)
120131
db.conn._token = expired_token
121132
db.conn._auth_header = f"bearer {expired_token}"
122133
assert isinstance(db.version(), str)
123134

124-
# Test correct error on token expiry.
135+
# Test expiry error on db instantiation (superuser)
136+
with assert_raises(ServerConnectionError) as err:
137+
client.db("_system", superuser_token=expired_token, verify=True)
138+
139+
# Test expiry error on db version (superuser)
125140
db = client.db("_system", superuser_token=expired_token)
126141
with assert_raises(ServerVersionError) as err:
127142
db.version()
128143
assert err.value.error_code == FORBIDDEN
144+
145+
# Test expiry error on set_token (superuser).
146+
db = client.db("_system", superuser_token=valid_token)
147+
with assert_raises(JWTExpiredError) as err:
148+
db.conn.set_token(expired_token)
149+
150+
# Test expiry error on db instantiation (user)
151+
with assert_raises(JWTExpiredError) as err:
152+
db = client.db("_system", user_token=expired_token)
153+
154+
# Test expiry error on set_token (user).
155+
db = client.db("_system", user_token=valid_token)
156+
with assert_raises(JWTExpiredError) as err:
157+
db.conn.set_token(expired_token)

0 commit comments

Comments
 (0)