Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion arango/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
74 changes: 63 additions & 11 deletions docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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:**

Expand All @@ -85,15 +132,20 @@ 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)

# 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:**

Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
Loading