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() diff --git a/docs/auth.rst b/docs/auth.rst index 11f62985..cd6ae5a2 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,20 @@ 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. + +``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 +132,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 +140,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 +156,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. 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)