test_authenticate.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. """
  2. Unit tests for `agentpaas.api.middleware.auth.authenticate()`.
  3. audit/AUDIT_2026-06-05.md #34 baseline: the entire API-key login flow
  4. — including the legacy-hash auto-migration that writes to
  5. `api_keys.key_hash` — had ZERO direct test coverage. A typo in the
  6. hash format check or the tenant-status branch would have shipped to
  7. production with green CI.
  8. Tests cover:
  9. - missing key (empty / None)
  10. - "Bearer " prefix stripping
  11. - PBKDF2 happy path (returns TenantContext, updates last_used_at)
  12. - rejection of invalid key with no matching prefix
  13. - rejection of invalid key with matching prefix but wrong hash
  14. - SUSPENDED tenant → 403
  15. - EXPIRED key → 401
  16. - Legacy SHA-256 hash auto-migration → key still works AND the stored
  17. hash is rewritten to PBKDF2 format (this is the audit's headline
  18. SEC-01 risk that left dormant legacy hashes plaintext forever).
  19. """
  20. from __future__ import annotations
  21. import os
  22. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  23. import json
  24. import secrets
  25. import pytest
  26. from agentpaas.api.middleware.auth import (
  27. authenticate,
  28. AuthError,
  29. TenantContext,
  30. hash_key,
  31. _legacy_hash_key,
  32. )
  33. from agentpaas.db.models import Database, gen_id, now_utc
  34. import agentpaas.db.session as _session_mod
  35. # ─────────────────────────────────────────────────────────────────────────────
  36. # Fixtures
  37. # ─────────────────────────────────────────────────────────────────────────────
  38. @pytest.fixture
  39. def db():
  40. """Per-test in-memory SQLite. Same singleton-swap trick as
  41. test_api_endpoints — authenticate() calls get_db() internally."""
  42. prev = _session_mod._db
  43. _session_mod._db = Database("sqlite:///:memory:")
  44. try:
  45. yield _session_mod._db
  46. finally:
  47. _session_mod._db = prev
  48. def _insert_tenant(db, name: str = "test-tenant", status: str = "active") -> str:
  49. tid = gen_id("tn_")
  50. db.execute(
  51. "INSERT INTO tenants (id, name, plan, status, created_at) "
  52. "VALUES (?, ?, 'free', ?, ?)",
  53. (tid, name, status, now_utc()),
  54. )
  55. db.commit()
  56. return tid
  57. def _insert_user(db, tenant_id: str) -> str:
  58. uid = gen_id("usr_")
  59. db.execute(
  60. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  61. "VALUES (?, ?, '', 'admin', ?)",
  62. (uid, tenant_id, now_utc()),
  63. )
  64. db.commit()
  65. return uid
  66. def _insert_key(db, tenant_id: str, user_id: str,
  67. key_hash: str, scopes: list = None,
  68. status: str = "active",
  69. expires_at: str = "",
  70. prefix_override: str = "") -> tuple[str, str]:
  71. """Insert an api_keys row. Returns (key_id, key_prefix)."""
  72. key_id = gen_id("key_")
  73. raw = f"ap_{secrets.token_hex(16)}"
  74. db.execute(
  75. "INSERT INTO api_keys "
  76. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  77. " rate_limit, status, created_at, expires_at) "
  78. "VALUES (?, ?, ?, ?, ?, 'test-key', ?, 600, ?, ?, ?)",
  79. (key_id, tenant_id, user_id, key_hash,
  80. prefix_override or raw[:8],
  81. json.dumps(scopes or ["agents:*"]),
  82. status, now_utc(), expires_at),
  83. )
  84. db.commit()
  85. return key_id, raw
  86. # ─────────────────────────────────────────────────────────────────────────────
  87. # Missing / malformed input
  88. # ─────────────────────────────────────────────────────────────────────────────
  89. @pytest.mark.asyncio
  90. async def test_authenticate_empty_key_raises_missing(db):
  91. with pytest.raises(AuthError) as exc:
  92. await authenticate("")
  93. assert exc.value.code == 401
  94. assert "missing" in exc.value.message.lower()
  95. @pytest.mark.asyncio
  96. async def test_authenticate_strips_bearer_prefix(db):
  97. """A real client sends `Authorization: Bearer ap_xxx`; FastAPI passes
  98. the whole header string. `authenticate()` must strip the prefix."""
  99. tid = _insert_tenant(db)
  100. uid = _insert_user(db, tid)
  101. raw_key = f"ap_{secrets.token_hex(16)}"
  102. _insert_key(db, tid, uid, hash_key(raw_key), prefix_override=raw_key[:8])
  103. # Without prefix
  104. ctx = await authenticate(raw_key)
  105. assert isinstance(ctx, TenantContext)
  106. assert ctx.tenant_id == tid
  107. # With prefix — must produce the same result
  108. ctx2 = await authenticate(f"Bearer {raw_key}")
  109. assert ctx2.tenant_id == tid
  110. # ─────────────────────────────────────────────────────────────────────────────
  111. # Rejection paths
  112. # ─────────────────────────────────────────────────────────────────────────────
  113. @pytest.mark.asyncio
  114. async def test_authenticate_unknown_key_raises_401(db):
  115. with pytest.raises(AuthError) as exc:
  116. await authenticate("ap_unknown000000000000000000000000")
  117. assert exc.value.code == 401
  118. assert "invalid" in exc.value.message.lower()
  119. @pytest.mark.asyncio
  120. async def test_authenticate_matching_prefix_wrong_hash_raises_401(db):
  121. """Tenant A has ap_aaa.., tenant B uses ap_aaa.. too (same prefix
  122. by coincidence). B's key shouldn't unlock A's tenant just because
  123. the prefix lookup pulls A's row out — the verify_key_hash call must
  124. detect the mismatch and fall through. This is the silent-bypass
  125. smell SEC-01 originally flagged."""
  126. tid = _insert_tenant(db)
  127. uid = _insert_user(db, tid)
  128. real_key = "ap_aaaa11112222333344445555aaaaaaaa"
  129. _insert_key(db, tid, uid, hash_key(real_key), prefix_override="ap_aaaa")
  130. forged_key = "ap_aaaadeadbeefdeadbeefdeadbeefdead" # same prefix
  131. with pytest.raises(AuthError) as exc:
  132. await authenticate(forged_key)
  133. assert exc.value.code == 401
  134. # ─────────────────────────────────────────────────────────────────────────────
  135. # Happy path: PBKDF2 key → TenantContext
  136. # ─────────────────────────────────────────────────────────────────────────────
  137. @pytest.mark.asyncio
  138. async def test_authenticate_pbkdf2_happy_path(db):
  139. tid = _insert_tenant(db, name="acme")
  140. uid = _insert_user(db, tid)
  141. raw_key = f"ap_{secrets.token_hex(16)}"
  142. scopes = ["agents:read", "agents:write", "keys:*"]
  143. _insert_key(db, tid, uid, hash_key(raw_key), scopes=scopes,
  144. prefix_override=raw_key[:8])
  145. ctx = await authenticate(raw_key)
  146. assert ctx.tenant_id == tid
  147. assert ctx.tenant_name == "acme"
  148. assert ctx.plan == "free"
  149. assert ctx.user_id == uid
  150. # Scopes survived the round trip through JSON.
  151. assert set(ctx.scopes) == set(scopes)
  152. # ─────────────────────────────────────────────────────────────────────────────
  153. # Tenant / key status
  154. # ─────────────────────────────────────────────────────────────────────────────
  155. @pytest.mark.asyncio
  156. async def test_authenticate_suspended_tenant_raises_403(db):
  157. tid = _insert_tenant(db, status="suspended")
  158. uid = _insert_user(db, tid)
  159. raw_key = f"ap_{secrets.token_hex(16)}"
  160. _insert_key(db, tid, uid, hash_key(raw_key), prefix_override=raw_key[:8])
  161. with pytest.raises(AuthError) as exc:
  162. await authenticate(raw_key)
  163. assert exc.value.code == 403
  164. assert "suspended" in exc.value.message.lower()
  165. @pytest.mark.asyncio
  166. async def test_authenticate_expired_key_raises_401(db):
  167. tid = _insert_tenant(db)
  168. uid = _insert_user(db, tid)
  169. raw_key = f"ap_{secrets.token_hex(16)}"
  170. # Expired at 2020-01-01 — well in the past regardless of test clock.
  171. _insert_key(db, tid, uid, hash_key(raw_key),
  172. expires_at="2020-01-01T00:00:00+00:00",
  173. prefix_override=raw_key[:8])
  174. with pytest.raises(AuthError) as exc:
  175. await authenticate(raw_key)
  176. assert exc.value.code == 401
  177. assert "expired" in exc.value.message.lower()
  178. # ─────────────────────────────────────────────────────────────────────────────
  179. # audit SEC-01 headline: legacy hash auto-migration
  180. # ─────────────────────────────────────────────────────────────────────────────
  181. @pytest.mark.asyncio
  182. async def test_authenticate_legacy_hash_auto_migrates_to_pbkdf2(db):
  183. """The single most important authenticate() branch:
  184. a key stored under the legacy fixed-salt SHA-256 hash must (1) still
  185. authenticate and (2) be transparently rewritten to PBKDF2 on success.
  186. Without this test the SEC-01 'partial fix' could silently regress —
  187. a future refactor of verify_key_hash could break the legacy fallback
  188. and CI would still be green."""
  189. tid = _insert_tenant(db)
  190. uid = _insert_user(db, tid)
  191. raw_key = f"ap_{secrets.token_hex(16)}"
  192. legacy_hash = _legacy_hash_key(raw_key)
  193. key_id, _ = _insert_key(db, tid, uid, legacy_hash,
  194. prefix_override=raw_key[:8])
  195. # Sanity: directly inserting a SHA-256 hex (not pbkdf2-prefixed) is
  196. # what we wanted.
  197. pre = db.fetchone("SELECT key_hash FROM api_keys WHERE id = ?", (key_id,))
  198. assert not pre["key_hash"].startswith("pbkdf2:")
  199. ctx = await authenticate(raw_key)
  200. assert ctx.tenant_id == tid
  201. # Auto-migration: hash was rewritten in-place.
  202. post = db.fetchone("SELECT key_hash FROM api_keys WHERE id = ?", (key_id,))
  203. assert post["key_hash"].startswith("pbkdf2:"), (
  204. f"legacy hash was not auto-migrated; still {post['key_hash'][:20]}"
  205. )
  206. # Migrated row should also still authenticate on the next call (the
  207. # entire point of the upgrade).
  208. ctx2 = await authenticate(raw_key)
  209. assert ctx2.tenant_id == tid