From e59851f5b3548318e43fcc071520c14a7cb01b5f Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Mon, 2 Nov 2026 21:52:37 +0800 Subject: [PATCH 1/4] Document JWT renewal policy, retry limits, and application-managed token behavior --- docs/auth.rst | 75 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/docs/auth.rst b/docs/auth.rst index 11f62985..cb531729 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -31,10 +31,48 @@ This is python-arango's default authentication method. JSON Web Tokens (JWT) ===================== -Python-arango automatically obtains JSON web tokens from the server using -username and password. It also refreshes expired tokens and retries requests. -The client and server clocks must be synchronized for the automatic refresh -to work correctly. +JWTs are sent in the HTTP ``Authorization`` header as Bearer tokens. Token +renewal depends on whether the driver obtains tokens using username/password +or the application supplies them. + +Driver-managed tokens +~~~~~~~~~~~~~~~~~~~~~ + +With ``auth_method='jwt'``, the driver obtains an initial token using the +configured username and password when ``client.db()`` is called. It retains +those credentials to obtain replacement tokens. If initial acquisition fails, +``client.db()`` raises an error instead of returning a database wrapper. + +For each database request, the authentication policy is: + +1. Send the request with the current token. +2. If the response has both HTTP status 401 and ArangoDB error code 11, attempt + to obtain a replacement token using the configured credentials. +3. If renewal succeeds, retry the original request once with the replacement + token. + + * If that retry fails, the application receives the error. The driver does + not renew the token or retry the request again. + +4. If obtaining a replacement token fails, the original request is not retried. + The error from that renewal attempt is raised to the caller. + +Both the HTTP status and ArangoDB error code must match to trigger renewal. +Other responses, including HTTP 403, do not trigger JWT renewal or an +authentication retry. For example, a rejected version request raises +:class:`arango.exceptions.ServerVersionError`. + +HTTP 401/error code 11 does not prove that the token expired. The same renewal +and retry policy applies if the server rejects a locally unexpired token. +Renewal authenticates the same user and does not grant additional permissions; +if access is still denied, the retried request fails normally. + +Renewal is reactive: it happens after a qualifying server rejection, not on a +timer or proactively before expiration. The driver does not fall back to Basic +authentication when JWT authentication fails. + +Keep client and server clocks synchronized because the driver checks token +expiration when accepting a token. **Example:** @@ -55,11 +93,21 @@ to work correctly. # Manually refresh the token. db.conn.refresh_token() - # Override the token expiry compare leeway in seconds (default: 0) to - # compensate for out-of-sync clocks between the client and server. - db.conn.ext_leeway = 2 +Application-managed tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~ -User generated JWT token can be used for user and superuser access. +Use ``user_token`` or ``superuser_token`` to supply a token obtained outside +the driver. These tokens are not renewed automatically: the application is +responsible for obtaining replacements. Supplied username/password and +``auth_method`` are ignored when either token argument is used, so they cannot +provide renewal credentials for that connection. + +The credentials-based renewal policy above does not apply to these tokens. +``user_token`` is checked for expiration locally when the connection is +created. ``superuser_token`` is accepted without a local expiry check at +construction; the server can reject it on the first request. With +``verify=True``, that first request occurs during ``client.db()`` and a rejected +token causes :class:`arango.exceptions.ServerConnectionError`. **Example:** @@ -85,7 +133,7 @@ User generated JWT token can be used for user and superuser access. 'server_id': 'client' }, key='secret', - ).decode('utf-8') + ) # Connect to "test" database as superuser using the token. db = client.db('test', superuser_token=token) @@ -93,7 +141,12 @@ User generated JWT token can be used for user and superuser access. # Connect to "test" database as user using the token. db = client.db('test', user_token=token) -User and superuser tokens can be set on the connection object as well. +Replace an application-managed token using ``db.conn.set_token(new_token)``. +This changes the token used by the database wrapper and existing collection +wrappers sharing its connection. Separate connections created by other +``client.db()`` calls are unaffected. For both user and superuser connections, +``set_token()`` checks expiration locally and raises +:class:`arango.exceptions.JWTExpiredError` if the replacement has expired. **Example:** @@ -104,7 +157,7 @@ User and superuser tokens can be set on the connection object as well. # Initialize the ArangoDB client. client = ArangoClient() - # Connect to "test" database as superuser using the token. + # Connect to "test" database as a user using the token. db = client.db('test', user_token='token') # Set the user token on the connection object. From 280f96aa3394030904f9d317e1b3a85b60c74505 Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Mon, 2 Nov 2026 21:53:33 +0800 Subject: [PATCH 2/4] Fix JWT expiry test to keep recorded expiration consistent with the expired token --- tests/test_auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index 9a869512..e5f71f2a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,7 @@ import time +import jwt + from arango.connection import BasicConnection, JwtConnection, JwtSuperuserConnection from arango.errno import FORBIDDEN, HTTP_UNAUTHORIZED from arango.exceptions import ( @@ -140,6 +142,8 @@ def test_auth_jwt_expiry(client, db_name, root_password, secret): expired_token = generate_jwt(secret, exp=-1000) db.conn._token = expired_token db.conn._auth_header = f"bearer {expired_token}" + payload = jwt.decode(expired_token, options={"verify_signature": False}) + db.conn._token_exp = payload["exp"] assert isinstance(db.version(), str) # Test expiry error on db instantiation (superuser) From a0b64ecf334be32cffb124b148646ecd791950fe Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Mon, 2 Nov 2026 22:26:11 +0800 Subject: [PATCH 3/4] Fix JWT renewal after token expiration when credentials are availabl --- arango/connection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/arango/connection.py b/arango/connection.py index e65fd326..840284cb 100644 --- a/arango/connection.py +++ b/arango/connection.py @@ -407,7 +407,8 @@ def send_request(self, request: Request) -> Response: return resp now = int(time.time()) - if self._token_exp < now - self.exp_leeway: # pragma: no cover + has_credentials = self._username is not None and self._password is not None + if not has_credentials and self._token_exp < now - self.exp_leeway: return resp self.refresh_token() From ea21450eec9ec277ba9a7e3f884832264c8d42e7 Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Mon, 2 Nov 2026 22:54:00 +0800 Subject: [PATCH 4/4] doc update --- docs/auth.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/auth.rst b/docs/auth.rst index cb531729..cd6ae5a2 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -102,7 +102,6 @@ responsible for obtaining replacements. Supplied username/password and ``auth_method`` are ignored when either token argument is used, so they cannot provide renewal credentials for that connection. -The credentials-based renewal policy above does not apply to these tokens. ``user_token`` is checked for expiration locally when the connection is created. ``superuser_token`` is accepted without a local expiry check at construction; the server can reject it on the first request. With