Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authenticate using User JWT-Token #293

Merged
merged 11 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions arango/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def db(
password: str = "",
verify: bool = False,
auth_method: str = "basic",
user_token: Optional[str] = None,
superuser_token: Optional[str] = None,
verify_certificate: bool = True,
) -> StandardDatabase:
Expand All @@ -189,6 +190,10 @@ def db(
refreshed automatically using ArangoDB username and password. This
assumes that the clocks of the server and client are synchronized.
:type auth_method: str
:param user_token: User generated token for user access.
If set, parameters **username**, **password** and **auth_method**
are ignored. This token is not refreshed automatically.
:type user_token: str
:param superuser_token: User generated token for superuser access.
If set, parameters **username**, **password** and **auth_method**
are ignored. This token is not refreshed automatically.
Expand All @@ -213,6 +218,17 @@ def db(
deserializer=self._deserializer,
superuser_token=superuser_token,
)
elif user_token is not None:
connection = JwtConnection(
hosts=self._hosts,
host_resolver=self._host_resolver,
sessions=self._sessions,
db_name=name,
http_client=self._http,
serializer=self._serializer,
deserializer=self._deserializer,
user_token=user_token,
)
aMahanna marked this conversation as resolved.
Show resolved Hide resolved
elif auth_method.lower() == "basic":
connection = BasicConnection(
hosts=self._hosts,
Expand Down
69 changes: 50 additions & 19 deletions arango/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@
from typing import Any, Callable, Optional, Sequence, Set, Tuple, Union

import jwt
from jwt.exceptions import ExpiredSignatureError
from requests import ConnectionError, Session
from requests_toolbelt import MultipartEncoder

from arango.exceptions import JWTAuthError, ServerConnectionError
from arango.exceptions import (
JWTAuthError,
JWTExpiredError,
JWTRefreshError,
ServerConnectionError,
)
from arango.http import HTTPClient
from arango.request import Request
from arango.resolver import HostResolver
Expand Down Expand Up @@ -300,11 +306,12 @@ def __init__(
host_resolver: HostResolver,
sessions: Sequence[Session],
db_name: str,
username: str,
password: str,
http_client: HTTPClient,
serializer: Callable[..., str],
deserializer: Callable[[str], Any],
username: Optional[str] = None,
password: Optional[str] = None,
user_token: Optional[str] = None,
) -> None:
super().__init__(
hosts,
Expand All @@ -323,7 +330,13 @@ def __init__(
self._token: Optional[str] = None
self._token_exp: int = sys.maxsize

self.refresh_token()
if user_token is not None:
self.set_token(user_token)
elif username is not None and password is not None:
self.refresh_token()
else:
m = "Either **user_token** or **username** & **password** must be set"
raise ValueError(m)

def send_request(self, request: Request) -> Response:
"""Send an HTTP request to ArangoDB server.
Expand Down Expand Up @@ -360,7 +373,12 @@ def refresh_token(self) -> None:

:return: JWT token.
:rtype: str
:raise arango.exceptions.JWTRefreshError: If missing username & password.
:raise arango.exceptions.JWTAuthError: If token retrieval fails.
"""
if self._username is None or self._password is None:
raise JWTRefreshError("username and password must be set")

request = Request(
method="post",
endpoint="/_open/auth",
Expand All @@ -374,21 +392,34 @@ def refresh_token(self) -> None:
if not resp.is_success:
raise JWTAuthError(resp, request)

self._token = resp.body["jwt"]
assert self._token is not None

jwt_payload = jwt.decode(
self._token,
issuer="arangodb",
algorithms=["HS256"],
options={
"require_exp": True,
"require_iat": True,
"verify_iat": True,
"verify_exp": True,
"verify_signature": False,
},
)
self.set_token(resp.body["jwt"])

def set_token(self, token: str) -> None:
"""Set the JWT token.

:param token: JWT token.
:type token: str
:raise arango.exceptions.JWTExpiredError: If the token is expired.
"""
assert token is not None

try:
jwt_payload = jwt.decode(
token,
issuer="arangodb",
algorithms=["HS256"],
options={
"require_exp": True,
"require_iat": True,
"verify_iat": True,
"verify_exp": True,
"verify_signature": False,
},
)
except ExpiredSignatureError:
raise JWTExpiredError("JWT token is expired")

self._token = token
self._token_exp = jwt_payload["exp"]
self._auth_header = f"bearer {self._token}"

Expand Down
8 changes: 8 additions & 0 deletions arango/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,3 +1014,11 @@ class JWTSecretListError(ArangoServerError):

class JWTSecretReloadError(ArangoServerError):
"""Failed to reload JWT secrets."""


class JWTRefreshError(ArangoClientError):
"""Failed to refresh JWT token."""


class JWTExpiredError(ArangoClientError):
"""JWT token has expired."""
8 changes: 7 additions & 1 deletion docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ to work correctly.
# compensate for out-of-sync clocks between the client and server.
db.conn.ext_leeway = 2

User generated JWT token can be used for superuser access.
User generated JWT token can be used for user and superuser access.

**Example:**

Expand Down Expand Up @@ -89,3 +89,9 @@ User generated JWT token can be used for superuser access.

# 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)

# Manually set the token (JwtConnection only).
db.conn.set_token('new token')
17 changes: 15 additions & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from arango.errno import FORBIDDEN, HTTP_UNAUTHORIZED
from arango.exceptions import (
JWTAuthError,
JWTExpiredError,
JWTSecretListError,
JWTSecretReloadError,
ServerEncryptionError,
Expand Down Expand Up @@ -37,7 +38,8 @@ def test_auth_basic(client, db_name, username, password):
assert isinstance(db.properties(), dict)


def test_auth_jwt(client, db_name, username, password):
def test_auth_jwt(client, db_name, username, password, secret):
# Test JWT authentication with username and password.
db = client.db(
name=db_name,
username=username,
Expand All @@ -54,6 +56,13 @@ def test_auth_jwt(client, db_name, username, password):
client.db(db_name, username, bad_password, auth_method="jwt")
assert err.value.error_code == HTTP_UNAUTHORIZED

# Test JWT authentication with user token.
token = generate_jwt(secret)
db = client.db("_system", user_token=token)
assert isinstance(db.conn, JwtConnection)
assert isinstance(db.version(), str)
assert isinstance(db.properties(), dict)


# TODO re-examine commented out code
def test_auth_superuser_token(client, db_name, root_password, secret):
Expand Down Expand Up @@ -121,8 +130,12 @@ def test_auth_jwt_expiry(client, db_name, root_password, secret):
db.conn._auth_header = f"bearer {expired_token}"
assert isinstance(db.version(), str)

# Test correct error on token expiry.
# Test correct error on token expiry (superuser).
db = client.db("_system", superuser_token=expired_token)
with assert_raises(ServerVersionError) as err:
db.version()
assert err.value.error_code == FORBIDDEN

# Test correct error on token expiry (user).
with assert_raises(JWTExpiredError) as err:
db = client.db("_system", user_token=expired_token)
aMahanna marked this conversation as resolved.
Show resolved Hide resolved
Loading