Coverage for agentos/tests/test_auth.py: 0%
341 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 12:20 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 12:20 +0800
1"""Tests for agentos.core.auth — Authenticator, RBACEngine, HS256TokenProvider."""
3import time
5import pytest
7from agentos.core.auth import (
8 DEFAULT_ROLES,
9 Algorithm,
10 ApiKeyEntry,
11 AuthContext,
12 Authenticator,
13 AuthMethod,
14 HS256TokenProvider,
15 InMemoryCredentialStore,
16 InMemoryTokenBlacklist,
17 Permission,
18 RBACEngine,
19 Role,
20 TokenClaims,
21 _b64url_decode,
22 _b64url_encode,
23 require_auth,
24)
26# ============================================================================
27# Enums & Data classes
28# ============================================================================
30class TestAuthMethod:
31 def test_values(self):
32 assert AuthMethod.NONE.value == "none"
33 assert AuthMethod.JWT.value == "jwt"
34 assert AuthMethod.API_KEY.value == "api_key"
37class TestAlgorithm:
38 def test_values(self):
39 assert Algorithm.HS256.value == "HS256"
40 assert Algorithm.RS256.value == "RS256"
43class TestPermission:
44 def test_create(self):
45 p = Permission("agent", "read", "own")
46 assert p.resource == "agent"
47 assert p.action == "read"
48 assert p.scope == "own"
50 def test_default_scope(self):
51 p = Permission("agent", "read")
52 assert p.scope == "*"
55class TestRole:
56 def test_create(self):
57 r = Role("admin", (Permission("*", "*"),))
58 assert r.name == "admin"
59 assert len(r.permissions) == 1
62class TestAuthContext:
63 def test_defaults(self):
64 ctx = AuthContext()
65 assert ctx.authenticated is False
66 assert ctx.method == AuthMethod.NONE
67 assert ctx.subject is None
69 def test_authenticated(self):
70 ctx = AuthContext(authenticated=True, method=AuthMethod.JWT, subject="u1", roles={"admin"})
71 assert ctx.authenticated
72 assert "admin" in ctx.roles
75# ============================================================================
76# TokenClaims
77# ============================================================================
79class TestTokenClaims:
80 def test_create(self):
81 tc = TokenClaims(sub="user1", roles=["admin"])
82 assert tc.sub == "user1"
83 assert tc.iss == "agentos"
84 assert len(tc.jti) == 32
86 def test_to_dict(self):
87 tc = TokenClaims(sub="u1", exp=1000.0, roles=["r1"])
88 d = tc.to_dict()
89 assert d["sub"] == "u1"
90 assert d["exp"] == 1000
91 assert d["roles"] == ["r1"]
93 def test_from_dict(self):
94 d = {"sub": "u1", "roles": ["admin"], "extra_field": "x"}
95 tc = TokenClaims.from_dict(d)
96 assert tc.sub == "u1"
97 assert tc.roles == ["admin"]
98 assert tc.extra == {"extra_field": "x"}
100 def test_roundtrip(self):
101 tc = TokenClaims(sub="u1", roles=["r1"], permissions=["agent:read"], extra={"key": "val"})
102 d = tc.to_dict()
103 tc2 = TokenClaims.from_dict(d)
104 assert tc2.sub == tc.sub
105 assert tc2.roles == tc.roles
106 assert tc2.extra == tc.extra
108 def test_from_dict_minimal(self):
109 d = {"sub": "u1"}
110 tc = TokenClaims.from_dict(d)
111 assert tc.iat is not None
114# ============================================================================
115# InMemoryTokenBlacklist
116# ============================================================================
118class TestInMemoryTokenBlacklist:
119 @pytest.mark.asyncio
120 async def test_add_and_check(self):
121 bl = InMemoryTokenBlacklist()
122 await bl.add("jti1", 60)
123 assert await bl.is_blacklisted("jti1")
125 @pytest.mark.asyncio
126 async def test_not_blacklisted(self):
127 bl = InMemoryTokenBlacklist()
128 assert not await bl.is_blacklisted("missing")
130 @pytest.mark.asyncio
131 async def test_expired_removed(self):
132 bl = InMemoryTokenBlacklist()
133 await bl.add("jti1", 0.001)
134 import asyncio
135 await asyncio.sleep(0.01)
136 assert not await bl.is_blacklisted("jti1")
138 @pytest.mark.asyncio
139 async def test_cleanup(self):
140 bl = InMemoryTokenBlacklist()
141 await bl.add("old", 0.001)
142 await bl.add("new", 60)
143 import asyncio
144 await asyncio.sleep(0.01)
145 bl._cleanup()
146 assert "old" not in bl._store
147 assert "new" in bl._store
150# ============================================================================
151# InMemoryCredentialStore
152# ============================================================================
154class TestInMemoryCredentialStore:
155 def test_add_and_lookup(self):
156 store = InMemoryCredentialStore()
157 store.add_key("raw_key_123", ApiKeyEntry(
158 key_hash="", subject="u1", name="my key", scopes=["read"], roles=["viewer"], permissions=[]
159 ))
160 entry = asyncio_run(store.lookup_by_key("raw_key_123"))
161 assert entry["subject"] == "u1"
162 assert entry["roles"] == ["viewer"]
164 def test_lookup_missing(self):
165 store = InMemoryCredentialStore()
166 assert asyncio_run(store.lookup_by_key("bad")) is None
168 def test_revoke(self):
169 store = InMemoryCredentialStore()
170 store.add_key("k1", ApiKeyEntry(key_hash="", subject="u1", name="x", scopes=[], roles=[], permissions=[]))
171 assert asyncio_run(store.revoke("k1")) is True
172 assert asyncio_run(store.lookup_by_key("k1")) is None
174 def test_revoke_missing(self):
175 store = InMemoryCredentialStore()
176 assert asyncio_run(store.revoke("bad")) is False
178 def test_expired_key(self):
179 store = InMemoryCredentialStore()
180 store.add_key("k1", ApiKeyEntry(
181 key_hash="", subject="u1", name="x", scopes=[], roles=[], permissions=[],
182 expires_at=time.time() - 60,
183 ))
184 assert asyncio_run(store.lookup_by_key("k1")) is None
187# ============================================================================
188# HS256TokenProvider
189# ============================================================================
191class TestHS256TokenProvider:
192 @pytest.mark.asyncio
193 async def test_create_and_validate(self):
194 p = HS256TokenProvider("secret")
195 tc = TokenClaims(sub="u1")
196 token = await p.create_token(tc)
197 claims = await p.validate_token(token)
198 assert claims.sub == "u1"
200 @pytest.mark.asyncio
201 async def test_validate_invalid_token(self):
202 p = HS256TokenProvider("secret")
203 assert await p.validate_token("bad.token.here") is None
205 @pytest.mark.asyncio
206 async def test_validate_wrong_secret(self):
207 p1 = HS256TokenProvider("s1")
208 p2 = HS256TokenProvider("s2")
209 token = await p1.create_token(TokenClaims(sub="u1"))
210 assert await p2.validate_token(token) is None
212 @pytest.mark.asyncio
213 async def test_key_rotation_new_token(self):
214 p = HS256TokenProvider("old_secret")
215 p.rotate_secret("new_secret")
216 tc = TokenClaims(sub="u1")
217 token = await p.create_token(tc)
218 claims = await p.validate_token(token)
219 assert claims.sub == "u1"
221 @pytest.mark.asyncio
222 async def test_expired_token(self):
223 p = HS256TokenProvider("s", default_ttl=0.001)
224 tc = TokenClaims(sub="u1")
225 tc.exp = time.time() - 10
226 token = await p.create_token(tc)
227 import asyncio
228 await asyncio.sleep(0.01)
229 assert await p.validate_token(token) is None
231 @pytest.mark.asyncio
232 async def test_default_expiry_set(self):
233 p = HS256TokenProvider("s", default_ttl=3600)
234 tc = TokenClaims(sub="u1", exp=None)
235 token = await p.create_token(tc)
236 claims = await p.validate_token(token)
237 assert claims.exp is not None
239 @pytest.mark.asyncio
240 async def test_key_rotation_grace_period(self):
241 p = HS256TokenProvider("secret")
242 token_old = await p.create_token(TokenClaims(sub="u1"))
243 p.rotate_secret("new_secret")
244 assert await p.validate_token(token_old) is not None
246 @pytest.mark.asyncio
247 async def test_validate_two_part_token(self):
248 p = HS256TokenProvider("s")
249 assert await p.validate_token("a.b") is None
252# ============================================================================
253# RBACEngine
254# ============================================================================
256class TestRBACEngine:
257 def test_register_role(self):
258 rbac = RBACEngine()
259 rbac.register_role(Role("admin", (Permission("*", "*"),)))
260 perms = rbac.get_permissions({"admin"})
261 assert len(perms) == 1
263 def test_register_roles(self):
264 rbac = RBACEngine()
265 rbac.register_roles([Role("a", (Permission("x", "y"),)), Role("b", (Permission("z", "w"),))])
266 assert len(rbac.get_permissions({"a", "b"})) == 2
268 def test_check_wildcard(self):
269 rbac = RBACEngine()
270 rbac.register_role(Role("admin", (Permission("*", "*"),)))
271 assert rbac.check({"admin"}, Permission("agent", "execute"))
273 def test_check_exact(self):
274 rbac = RBACEngine()
275 rbac.register_role(Role("dev", (Permission("agent", "read"),)))
276 assert rbac.check({"dev"}, Permission("agent", "read"))
277 assert not rbac.check({"dev"}, Permission("agent", "write"))
279 def test_check_any(self):
280 rbac = RBACEngine()
281 rbac.register_role(Role("dev", (Permission("agent", "read"),)))
282 assert rbac.check_any({"dev"}, [Permission("agent", "write"), Permission("agent", "read")])
284 def test_check_all(self):
285 rbac = RBACEngine()
286 rbac.register_role(Role("dev", (Permission("agent", "read"), Permission("agent", "execute"))))
287 assert rbac.check_all({"dev"}, [Permission("agent", "read"), Permission("agent", "execute")])
288 assert not rbac.check_all({"dev"}, [Permission("agent", "read"), Permission("agent", "delete")])
290 def test_hierarchical_scope(self):
291 rbac = RBACEngine()
292 rbac.register_role(Role("admin", (Permission("*", "*", "org:engineering"),)))
293 assert rbac.check({"admin"}, Permission("agent", "read", "org:engineering"))
294 # Narrower doesn't match broader
295 assert not rbac.check({"admin"}, Permission("agent", "read", "org"))
297 def test_get_permissions_missing_role(self):
298 rbac = RBACEngine()
299 assert rbac.get_permissions({"missing"}) == set()
301 def test_wildcard_action_in_perm(self):
302 rbac = RBACEngine()
303 rbac.register_role(Role("op", (Permission("agent", "*"),)))
304 assert rbac.check({"op"}, Permission("agent", "execute"))
307# ============================================================================
308# Authenticator
309# ============================================================================
311class TestAuthenticator:
312 @pytest.mark.asyncio
313 async def test_no_auth_header(self):
314 auth = Authenticator()
315 ctx = await auth.authenticate({})
316 assert not ctx.authenticated
318 @pytest.mark.asyncio
319 async def test_jwt_authentication(self):
320 jwt = HS256TokenProvider("secret")
321 auth = Authenticator(token_provider=jwt)
322 token = await jwt.create_token(TokenClaims(sub="u1", roles=["admin"]))
323 ctx = await auth.authenticate({"Authorization": f"Bearer {token}"})
324 assert ctx.authenticated
325 assert ctx.method == AuthMethod.JWT
326 assert ctx.subject == "u1"
327 assert "admin" in ctx.roles
329 @pytest.mark.asyncio
330 async def test_jwt_invalid_token(self):
331 jwt = HS256TokenProvider("secret")
332 auth = Authenticator(token_provider=jwt)
333 ctx = await auth.authenticate({"Authorization": "Bearer bad.token.here"})
334 assert not ctx.authenticated
336 @pytest.mark.asyncio
337 async def test_api_key_authentication(self):
338 store = InMemoryCredentialStore()
339 store.add_key("secret_key", ApiKeyEntry(
340 key_hash="", subject="u1", name="test", scopes=[], roles=["viewer"], permissions=["agent:read"]
341 ))
342 auth = Authenticator(credential_store=store)
343 ctx = await auth.authenticate({"X-API-Key": "secret_key"})
344 assert ctx.authenticated
345 assert ctx.method == AuthMethod.API_KEY
346 assert ctx.subject == "u1"
347 assert "viewer" in ctx.roles
349 @pytest.mark.asyncio
350 async def test_api_key_bad(self):
351 store = InMemoryCredentialStore()
352 auth = Authenticator(credential_store=store)
353 ctx = await auth.authenticate({"X-API-Key": "bad"})
354 assert not ctx.authenticated
356 @pytest.mark.asyncio
357 async def test_jwt_blacklisted(self):
358 jwt = HS256TokenProvider("secret")
359 bl = InMemoryTokenBlacklist()
360 token = await jwt.create_token(TokenClaims(sub="u1"))
361 tc = await jwt.validate_token(token)
362 await bl.add(tc.jti, 60)
363 auth = Authenticator(token_provider=jwt, blacklist=bl)
364 ctx = await auth.authenticate({"Authorization": f"Bearer {token}"})
365 assert not ctx.authenticated
367 @pytest.mark.asyncio
368 async def test_jwt_with_permissions(self):
369 jwt = HS256TokenProvider("secret")
370 auth = Authenticator(token_provider=jwt)
371 token = await jwt.create_token(TokenClaims(sub="u1", permissions=["agent:read:own"]))
372 ctx = await auth.authenticate({"Authorization": f"Bearer {token}"})
373 assert ctx.authenticated
374 assert len(ctx.permissions) == 1
376 @pytest.mark.asyncio
377 async def test_oauth2_stub(self):
378 auth = Authenticator()
379 ctx = await auth.authenticate({"Authorization": "Bearer oauth_token"})
380 assert not ctx.authenticated # Stub returns unauthenticated
382 @pytest.mark.asyncio
383 async def test_api_key_query_param(self):
384 store = InMemoryCredentialStore()
385 store.add_key("k1", ApiKeyEntry(key_hash="", subject="u1", name="x", scopes=[], roles=[], permissions=[]))
386 auth = Authenticator(credential_store=store)
387 ctx = await auth.authenticate({}, {"api_key": "k1"})
388 assert ctx.authenticated
391# ============================================================================
392# require_auth decorator
393# ============================================================================
395class TestRequireAuth:
396 @pytest.mark.asyncio
397 async def test_missing_auth_context(self):
398 @require_auth()
399 async def handler(*args, **kwargs):
400 return "ok"
402 with pytest.raises(PermissionError, match="auth_context"):
403 await handler()
405 @pytest.mark.asyncio
406 async def test_not_authenticated(self):
407 @require_auth()
408 async def handler(*args, **kwargs):
409 return "ok"
411 with pytest.raises(PermissionError, match="authentication"):
412 await handler(auth_context=AuthContext())
414 @pytest.mark.asyncio
415 async def test_authenticated_no_permission(self):
416 @require_auth()
417 async def handler(*args, **kwargs):
418 return "ok"
420 ctx = AuthContext(authenticated=True, method=AuthMethod.JWT, subject="u1")
421 result = await handler(auth_context=ctx)
422 assert result == "ok"
424 @pytest.mark.asyncio
425 async def test_permission_check_pass(self):
426 rbac = RBACEngine()
427 rbac.register_role(Role("admin", (Permission("*", "*"),)))
429 @require_auth(permission=Permission("agent", "execute"))
430 async def handler(*args, **kwargs):
431 return "ok"
433 ctx = AuthContext(authenticated=True, roles={"admin"})
434 result = await handler(auth_context=ctx, _rbac=rbac)
435 assert result == "ok"
437 @pytest.mark.asyncio
438 async def test_permission_check_fail(self):
439 rbac = RBACEngine()
440 rbac.register_role(Role("viewer", (Permission("agent", "read"),)))
442 @require_auth(permission=Permission("agent", "delete"))
443 async def handler(*args, **kwargs):
444 return "ok"
446 ctx = AuthContext(authenticated=True, roles={"viewer"})
447 with pytest.raises(PermissionError, match="missing permissions"):
448 await handler(auth_context=ctx, _rbac=rbac)
451# ============================================================================
452# _b64url helpers
453# ============================================================================
455class TestB64:
456 def test_roundtrip(self):
457 data = b"hello world"
458 assert _b64url_decode(_b64url_encode(data)) == data
460 def test_encode_no_padding(self):
461 enc = _b64url_encode(b"test")
462 assert "=" not in enc
465# ============================================================================
466# DEFAULT_ROLES
467# ============================================================================
469class TestDefaultRoles:
470 def test_four_roles(self):
471 assert len(DEFAULT_ROLES) == 4
472 names = {r.name for r in DEFAULT_ROLES}
473 assert names == {"admin", "developer", "viewer", "operator"}
475 def test_admin_has_all(self):
476 admin = next(r for r in DEFAULT_ROLES if r.name == "admin")
477 assert any(p.resource == "*" and p.action == "*" for p in admin.permissions)
480# ============================================================================
481# helper
482# ============================================================================
484def asyncio_run(coro):
485 import asyncio
486 try:
487 _ = asyncio.get_running_loop()
488 except RuntimeError:
489 return asyncio.run(coro)
490 import concurrent.futures
491 with concurrent.futures.ThreadPoolExecutor() as ex:
492 return ex.submit(asyncio.run, coro).result()