🌐 AI搜索 & 代理 主页
Skip to content

Commit eb4922a

Browse files
authored
Access Tokens (#75)
* Adding support for access tokens * Docs fix
1 parent e4ade55 commit eb4922a

File tree

8 files changed

+256
-5
lines changed

8 files changed

+256
-5
lines changed

arangoasync/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ class Auth:
2020
encoding (str): Encoding for the password (default: utf-8)
2121
"""
2222

23-
username: str
24-
password: str
23+
username: str = ""
24+
password: str = ""
2525
encoding: str = "utf-8"
2626

2727

arangoasync/client.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async def db(
147147
self,
148148
name: str,
149149
auth_method: str = "basic",
150-
auth: Optional[Auth] = None,
150+
auth: Optional[Auth | str] = None,
151151
token: Optional[JwtToken] = None,
152152
verify: bool = False,
153153
compression: Optional[CompressionManager] = None,
@@ -169,7 +169,8 @@ async def db(
169169
and client are synchronized.
170170
- "superuser": Superuser JWT authentication.
171171
The `token` parameter is required. The `auth` parameter is ignored.
172-
auth (Auth | None): Login information.
172+
auth (Auth | None): Login information (username and password) or
173+
access token.
173174
token (JwtToken | None): JWT token.
174175
verify (bool): Verify the connection by sending a test request.
175176
compression (CompressionManager | None): If set, supersedes the
@@ -188,6 +189,9 @@ async def db(
188189
"""
189190
connection: Connection
190191

192+
if isinstance(auth, str):
193+
auth = Auth(password=auth)
194+
191195
if auth_method == "basic":
192196
if auth is None:
193197
raise ValueError("Basic authentication requires the `auth` parameter")

arangoasync/database.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
from arangoasync.connection import Connection
1818
from arangoasync.errno import HTTP_FORBIDDEN, HTTP_NOT_FOUND
1919
from arangoasync.exceptions import (
20+
AccessTokenCreateError,
21+
AccessTokenDeleteError,
22+
AccessTokenListError,
2023
AnalyzerCreateError,
2124
AnalyzerDeleteError,
2225
AnalyzerGetError,
@@ -107,6 +110,7 @@
107110
from arangoasync.result import Result
108111
from arangoasync.serialization import Deserializer, Serializer
109112
from arangoasync.typings import (
113+
AccessToken,
110114
CollectionInfo,
111115
CollectionType,
112116
DatabaseProperties,
@@ -2130,6 +2134,96 @@ def response_handler(resp: Response) -> Json:
21302134

21312135
return await self._executor.execute(request, response_handler)
21322136

2137+
async def create_access_token(
2138+
self,
2139+
user: str,
2140+
name: str,
2141+
valid_until: int,
2142+
) -> Result[AccessToken]:
2143+
"""Create an access token for the given user.
2144+
2145+
Args:
2146+
user (str): The name of the user.
2147+
name (str): A name for the access token to make identification easier,
2148+
like a short description.
2149+
valid_until (int): A Unix timestamp in seconds to set the expiration date and time.
2150+
2151+
Returns:
2152+
AccessToken: Information about the created access token, including the token itself.
2153+
2154+
Raises:
2155+
AccessTokenCreateError: If the operation fails.
2156+
2157+
References:
2158+
- `create-an-access-token <https://docs.arango.ai/arangodb/stable/develop/http-api/authentication/#create-an-access-token>`__
2159+
""" # noqa: E501
2160+
data: Json = {
2161+
"name": name,
2162+
"valid_until": valid_until,
2163+
}
2164+
2165+
request = Request(
2166+
method=Method.POST,
2167+
endpoint=f"/_api/token/{user}",
2168+
data=self.serializer.dumps(data),
2169+
)
2170+
2171+
def response_handler(resp: Response) -> AccessToken:
2172+
if not resp.is_success:
2173+
raise AccessTokenCreateError(resp, request)
2174+
result: Json = self.deserializer.loads(resp.raw_body)
2175+
return AccessToken(result)
2176+
2177+
return await self._executor.execute(request, response_handler)
2178+
2179+
async def delete_access_token(self, user: str, token_id: int) -> None:
2180+
"""List all access tokens for the given user.
2181+
2182+
Args:
2183+
user (str): The name of the user.
2184+
token_id (int): The ID of the access token to delete.
2185+
2186+
Raises:
2187+
AccessTokenDeleteError: If the operation fails.
2188+
2189+
References:
2190+
- `delete-an-access-token <https://docs.arango.ai/arangodb/stable/develop/http-api/authentication/#delete-an-access-token>`__
2191+
""" # noqa: E501
2192+
request = Request(
2193+
method=Method.DELETE, endpoint=f"/_api/token/{user}/{token_id}"
2194+
)
2195+
2196+
def response_handler(resp: Response) -> None:
2197+
if not resp.is_success:
2198+
raise AccessTokenDeleteError(resp, request)
2199+
2200+
await self._executor.execute(request, response_handler)
2201+
2202+
async def list_access_tokens(self, user: str) -> Result[Jsons]:
2203+
"""List all access tokens for the given user.
2204+
2205+
Args:
2206+
user (str): The name of the user.
2207+
2208+
Returns:
2209+
list: List of access tokens for the user.
2210+
2211+
Raises:
2212+
AccessTokenListError: If the operation fails.
2213+
2214+
References:
2215+
- `list-all-access-tokens <https://docs.arango.ai/arangodb/stable/develop/http-api/authentication/#list-all-access-tokens>`__
2216+
""" # noqa: E501
2217+
request = Request(method=Method.GET, endpoint=f"/_api/token/{user}")
2218+
2219+
def response_handler(resp: Response) -> Jsons:
2220+
if not resp.is_success:
2221+
raise AccessTokenListError(resp, request)
2222+
result: Json = self.deserializer.loads(resp.raw_body)
2223+
return cast(Jsons, result["tokens"])
2224+
2225+
return await self._executor.execute(request, response_handler)
2226+
21332227
async def tls(self) -> Result[Json]:
21342228
"""Return TLS data (keyfile, clientCA).
21352229

arangoasync/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ class AQLQueryValidateError(ArangoServerError):
139139
"""Failed to parse and validate query."""
140140

141141

142+
class AccessTokenCreateError(ArangoServerError):
143+
"""Failed to create an access token."""
144+
145+
146+
class AccessTokenDeleteError(ArangoServerError):
147+
"""Failed to delete an access token."""
148+
149+
150+
class AccessTokenListError(ArangoServerError):
151+
"""Failed to retrieve access tokens."""
152+
153+
142154
class AnalyzerCreateError(ArangoServerError):
143155
"""Failed to create analyzer."""
144156

arangoasync/typings.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2024,3 +2024,55 @@ def __init__(
20242024
@property
20252025
def satellites(self) -> Optional[List[str]]:
20262026
return cast(Optional[List[str]], self._data.get("satellites"))
2027+
2028+
2029+
class AccessToken(JsonWrapper):
2030+
"""User access token.
2031+
2032+
Example:
2033+
.. code-block:: json
2034+
2035+
{
2036+
"id" : 1,
2037+
"name" : "Token for Service A",
2038+
"valid_until" : 1782864000,
2039+
"created_at" : 1765543306,
2040+
"fingerprint" : "v1...71227d",
2041+
"active" : true,
2042+
"token" : "v1.7b2265223a3137471227d"
2043+
}
2044+
2045+
References:
2046+
- `create-an-access-token <https://docs.arango.ai/arangodb/stable/develop/http-api/authentication/#create-an-access-token>`__
2047+
""" # noqa: E501
2048+
2049+
def __init__(self, data: Json) -> None:
2050+
super().__init__(data)
2051+
2052+
@property
2053+
def active(self) -> bool:
2054+
return cast(bool, self._data["active"])
2055+
2056+
@property
2057+
def created_at(self) -> int:
2058+
return cast(int, self._data["created_at"])
2059+
2060+
@property
2061+
def fingerprint(self) -> str:
2062+
return cast(str, self._data["fingerprint"])
2063+
2064+
@property
2065+
def id(self) -> int:
2066+
return cast(int, self._data["id"])
2067+
2068+
@property
2069+
def name(self) -> str:
2070+
return cast(str, self._data["name"])
2071+
2072+
@property
2073+
def token(self) -> str:
2074+
return cast(str, self._data["token"])
2075+
2076+
@property
2077+
def valid_until(self) -> int:
2078+
return cast(int, self._data["valid_until"])

tests/helpers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,12 @@ def generate_service_mount():
8989
str: Random service name.
9090
"""
9191
return f"/test_{uuid4().hex}"
92+
93+
94+
def generate_token_name():
95+
"""Generate and return a random token name.
96+
97+
Returns:
98+
str: Random token name.
99+
"""
100+
return f"test_token_{uuid4().hex}"

tests/test_client.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
import time
2+
13
import pytest
24

35
from arangoasync.auth import JwtToken
46
from arangoasync.client import ArangoClient
57
from arangoasync.compression import DefaultCompressionManager
6-
from arangoasync.exceptions import ServerEncryptionError
8+
from arangoasync.exceptions import (
9+
AccessTokenCreateError,
10+
AccessTokenDeleteError,
11+
AccessTokenListError,
12+
ServerEncryptionError,
13+
)
714
from arangoasync.http import DefaultHTTPClient
815
from arangoasync.resolver import DefaultHostResolver, RoundRobinHostResolver
916
from arangoasync.version import __version__
17+
from tests.helpers import generate_token_name
1018

1119

1220
@pytest.mark.asyncio
@@ -152,3 +160,49 @@ async def test_client_jwt_superuser_auth(
152160
await client.db(
153161
sys_db_name, auth_method="superuser", auth=basic_auth_root, verify=True
154162
)
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_client_access_token(url, sys_db_name, basic_auth_root, bad_db):
167+
username = basic_auth_root.username
168+
169+
async with ArangoClient(hosts=url) as client:
170+
# First login with basic auth
171+
db_auth_basic = await client.db(
172+
sys_db_name,
173+
auth_method="basic",
174+
auth=basic_auth_root,
175+
verify=True,
176+
)
177+
178+
# Create an access token
179+
token_name = generate_token_name()
180+
token = await db_auth_basic.create_access_token(
181+
user=username, name=token_name, valid_until=int(time.time() + 3600)
182+
)
183+
assert token.active is True
184+
185+
# Cannot create a token with the same name
186+
with pytest.raises(AccessTokenCreateError):
187+
await db_auth_basic.create_access_token(
188+
user=username, name=token_name, valid_until=int(time.time() + 3600)
189+
)
190+
191+
# Authenticate with the created token
192+
access_token_db = await client.db(
193+
sys_db_name,
194+
auth_method="basic",
195+
auth=token.token,
196+
verify=True,
197+
)
198+
199+
# List access tokens
200+
tokens = await access_token_db.list_access_tokens(username)
201+
assert isinstance(tokens, list)
202+
with pytest.raises(AccessTokenListError):
203+
await bad_db.list_access_tokens(username)
204+
205+
# Clean up - delete the created token
206+
await access_token_db.delete_access_token(username, token.id)
207+
with pytest.raises(AccessTokenDeleteError):
208+
await access_token_db.delete_access_token(username, token.id)

tests/test_typings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
from arangoasync.typings import (
4+
AccessToken,
45
CollectionInfo,
56
CollectionStatistics,
67
CollectionStatus,
@@ -446,3 +447,28 @@ def test_CollectionStatistics():
446447
assert stats.key_options["type"] == "traditional"
447448
assert stats.computed_values is None
448449
assert stats.object_id == "69124"
450+
451+
452+
def test_AccessToken():
453+
data = {
454+
"active": True,
455+
"created_at": 1720000000,
456+
"fingerprint": "abc123fingerprint",
457+
"id": 42,
458+
"name": "ci-token",
459+
"token": "v2.local.eyJhbGciOi...",
460+
"valid_until": 1720003600,
461+
}
462+
463+
access_token = AccessToken(data)
464+
465+
assert access_token.active is True
466+
assert access_token.created_at == 1720000000
467+
assert access_token.fingerprint == "abc123fingerprint"
468+
assert access_token.id == 42
469+
assert access_token.name == "ci-token"
470+
assert access_token.token == "v2.local.eyJhbGciOi..."
471+
assert access_token.valid_until == 1720003600
472+
473+
# JsonWrapper behavior
474+
assert access_token.to_dict() == data

0 commit comments

Comments
 (0)