|
|
@@ -0,0 +1,257 @@
|
|
|
+"""
|
|
|
+Unit tests for `agentpaas.api.middleware.auth.authenticate()`.
|
|
|
+
|
|
|
+audit/AUDIT_2026-06-05.md #34 baseline: the entire API-key login flow
|
|
|
+— including the legacy-hash auto-migration that writes to
|
|
|
+`api_keys.key_hash` — had ZERO direct test coverage. A typo in the
|
|
|
+hash format check or the tenant-status branch would have shipped to
|
|
|
+production with green CI.
|
|
|
+
|
|
|
+Tests cover:
|
|
|
+ - missing key (empty / None)
|
|
|
+ - "Bearer " prefix stripping
|
|
|
+ - PBKDF2 happy path (returns TenantContext, updates last_used_at)
|
|
|
+ - rejection of invalid key with no matching prefix
|
|
|
+ - rejection of invalid key with matching prefix but wrong hash
|
|
|
+ - SUSPENDED tenant → 403
|
|
|
+ - EXPIRED key → 401
|
|
|
+ - Legacy SHA-256 hash auto-migration → key still works AND the stored
|
|
|
+ hash is rewritten to PBKDF2 format (this is the audit's headline
|
|
|
+ SEC-01 risk that left dormant legacy hashes plaintext forever).
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import os
|
|
|
+os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
|
|
|
+
|
|
|
+import json
|
|
|
+import secrets
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from agentpaas.api.middleware.auth import (
|
|
|
+ authenticate,
|
|
|
+ AuthError,
|
|
|
+ TenantContext,
|
|
|
+ hash_key,
|
|
|
+ _legacy_hash_key,
|
|
|
+)
|
|
|
+from agentpaas.db.models import Database, gen_id, now_utc
|
|
|
+import agentpaas.db.session as _session_mod
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# Fixtures
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def db():
|
|
|
+ """Per-test in-memory SQLite. Same singleton-swap trick as
|
|
|
+ test_api_endpoints — authenticate() calls get_db() internally."""
|
|
|
+ prev = _session_mod._db
|
|
|
+ _session_mod._db = Database("sqlite:///:memory:")
|
|
|
+ try:
|
|
|
+ yield _session_mod._db
|
|
|
+ finally:
|
|
|
+ _session_mod._db = prev
|
|
|
+
|
|
|
+
|
|
|
+def _insert_tenant(db, name: str = "test-tenant", status: str = "active") -> str:
|
|
|
+ tid = gen_id("tn_")
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO tenants (id, name, plan, status, created_at) "
|
|
|
+ "VALUES (?, ?, 'free', ?, ?)",
|
|
|
+ (tid, name, status, now_utc()),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return tid
|
|
|
+
|
|
|
+
|
|
|
+def _insert_user(db, tenant_id: str) -> str:
|
|
|
+ uid = gen_id("usr_")
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO users (id, tenant_id, email, role, created_at) "
|
|
|
+ "VALUES (?, ?, '', 'admin', ?)",
|
|
|
+ (uid, tenant_id, now_utc()),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return uid
|
|
|
+
|
|
|
+
|
|
|
+def _insert_key(db, tenant_id: str, user_id: str,
|
|
|
+ key_hash: str, scopes: list = None,
|
|
|
+ status: str = "active",
|
|
|
+ expires_at: str = "",
|
|
|
+ prefix_override: str = "") -> tuple[str, str]:
|
|
|
+ """Insert an api_keys row. Returns (key_id, key_prefix)."""
|
|
|
+ key_id = gen_id("key_")
|
|
|
+ raw = f"ap_{secrets.token_hex(16)}"
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO api_keys "
|
|
|
+ "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
|
|
|
+ " rate_limit, status, created_at, expires_at) "
|
|
|
+ "VALUES (?, ?, ?, ?, ?, 'test-key', ?, 600, ?, ?, ?)",
|
|
|
+ (key_id, tenant_id, user_id, key_hash,
|
|
|
+ prefix_override or raw[:8],
|
|
|
+ json.dumps(scopes or ["agents:*"]),
|
|
|
+ status, now_utc(), expires_at),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return key_id, raw
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# Missing / malformed input
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_empty_key_raises_missing(db):
|
|
|
+ with pytest.raises(AuthError) as exc:
|
|
|
+ await authenticate("")
|
|
|
+ assert exc.value.code == 401
|
|
|
+ assert "missing" in exc.value.message.lower()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_strips_bearer_prefix(db):
|
|
|
+ """A real client sends `Authorization: Bearer ap_xxx`; FastAPI passes
|
|
|
+ the whole header string. `authenticate()` must strip the prefix."""
|
|
|
+ tid = _insert_tenant(db)
|
|
|
+ uid = _insert_user(db, tid)
|
|
|
+ raw_key = f"ap_{secrets.token_hex(16)}"
|
|
|
+ _insert_key(db, tid, uid, hash_key(raw_key), prefix_override=raw_key[:8])
|
|
|
+
|
|
|
+ # Without prefix
|
|
|
+ ctx = await authenticate(raw_key)
|
|
|
+ assert isinstance(ctx, TenantContext)
|
|
|
+ assert ctx.tenant_id == tid
|
|
|
+
|
|
|
+ # With prefix — must produce the same result
|
|
|
+ ctx2 = await authenticate(f"Bearer {raw_key}")
|
|
|
+ assert ctx2.tenant_id == tid
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# Rejection paths
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_unknown_key_raises_401(db):
|
|
|
+ with pytest.raises(AuthError) as exc:
|
|
|
+ await authenticate("ap_unknown000000000000000000000000")
|
|
|
+ assert exc.value.code == 401
|
|
|
+ assert "invalid" in exc.value.message.lower()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_matching_prefix_wrong_hash_raises_401(db):
|
|
|
+ """Tenant A has ap_aaa.., tenant B uses ap_aaa.. too (same prefix
|
|
|
+ by coincidence). B's key shouldn't unlock A's tenant just because
|
|
|
+ the prefix lookup pulls A's row out — the verify_key_hash call must
|
|
|
+ detect the mismatch and fall through. This is the silent-bypass
|
|
|
+ smell SEC-01 originally flagged."""
|
|
|
+ tid = _insert_tenant(db)
|
|
|
+ uid = _insert_user(db, tid)
|
|
|
+ real_key = "ap_aaaa11112222333344445555aaaaaaaa"
|
|
|
+ _insert_key(db, tid, uid, hash_key(real_key), prefix_override="ap_aaaa")
|
|
|
+
|
|
|
+ forged_key = "ap_aaaadeadbeefdeadbeefdeadbeefdead" # same prefix
|
|
|
+ with pytest.raises(AuthError) as exc:
|
|
|
+ await authenticate(forged_key)
|
|
|
+ assert exc.value.code == 401
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# Happy path: PBKDF2 key → TenantContext
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_pbkdf2_happy_path(db):
|
|
|
+ tid = _insert_tenant(db, name="acme")
|
|
|
+ uid = _insert_user(db, tid)
|
|
|
+ raw_key = f"ap_{secrets.token_hex(16)}"
|
|
|
+ scopes = ["agents:read", "agents:write", "keys:*"]
|
|
|
+ _insert_key(db, tid, uid, hash_key(raw_key), scopes=scopes,
|
|
|
+ prefix_override=raw_key[:8])
|
|
|
+
|
|
|
+ ctx = await authenticate(raw_key)
|
|
|
+ assert ctx.tenant_id == tid
|
|
|
+ assert ctx.tenant_name == "acme"
|
|
|
+ assert ctx.plan == "free"
|
|
|
+ assert ctx.user_id == uid
|
|
|
+ # Scopes survived the round trip through JSON.
|
|
|
+ assert set(ctx.scopes) == set(scopes)
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# Tenant / key status
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_suspended_tenant_raises_403(db):
|
|
|
+ tid = _insert_tenant(db, status="suspended")
|
|
|
+ uid = _insert_user(db, tid)
|
|
|
+ raw_key = f"ap_{secrets.token_hex(16)}"
|
|
|
+ _insert_key(db, tid, uid, hash_key(raw_key), prefix_override=raw_key[:8])
|
|
|
+
|
|
|
+ with pytest.raises(AuthError) as exc:
|
|
|
+ await authenticate(raw_key)
|
|
|
+ assert exc.value.code == 403
|
|
|
+ assert "suspended" in exc.value.message.lower()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_expired_key_raises_401(db):
|
|
|
+ tid = _insert_tenant(db)
|
|
|
+ uid = _insert_user(db, tid)
|
|
|
+ raw_key = f"ap_{secrets.token_hex(16)}"
|
|
|
+ # Expired at 2020-01-01 — well in the past regardless of test clock.
|
|
|
+ _insert_key(db, tid, uid, hash_key(raw_key),
|
|
|
+ expires_at="2020-01-01T00:00:00+00:00",
|
|
|
+ prefix_override=raw_key[:8])
|
|
|
+
|
|
|
+ with pytest.raises(AuthError) as exc:
|
|
|
+ await authenticate(raw_key)
|
|
|
+ assert exc.value.code == 401
|
|
|
+ assert "expired" in exc.value.message.lower()
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# audit SEC-01 headline: legacy hash auto-migration
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_authenticate_legacy_hash_auto_migrates_to_pbkdf2(db):
|
|
|
+ """The single most important authenticate() branch:
|
|
|
+ a key stored under the legacy fixed-salt SHA-256 hash must (1) still
|
|
|
+ authenticate and (2) be transparently rewritten to PBKDF2 on success.
|
|
|
+
|
|
|
+ Without this test the SEC-01 'partial fix' could silently regress —
|
|
|
+ a future refactor of verify_key_hash could break the legacy fallback
|
|
|
+ and CI would still be green."""
|
|
|
+ tid = _insert_tenant(db)
|
|
|
+ uid = _insert_user(db, tid)
|
|
|
+ raw_key = f"ap_{secrets.token_hex(16)}"
|
|
|
+
|
|
|
+ legacy_hash = _legacy_hash_key(raw_key)
|
|
|
+ key_id, _ = _insert_key(db, tid, uid, legacy_hash,
|
|
|
+ prefix_override=raw_key[:8])
|
|
|
+
|
|
|
+ # Sanity: directly inserting a SHA-256 hex (not pbkdf2-prefixed) is
|
|
|
+ # what we wanted.
|
|
|
+ pre = db.fetchone("SELECT key_hash FROM api_keys WHERE id = ?", (key_id,))
|
|
|
+ assert not pre["key_hash"].startswith("pbkdf2:")
|
|
|
+
|
|
|
+ ctx = await authenticate(raw_key)
|
|
|
+ assert ctx.tenant_id == tid
|
|
|
+
|
|
|
+ # Auto-migration: hash was rewritten in-place.
|
|
|
+ post = db.fetchone("SELECT key_hash FROM api_keys WHERE id = ?", (key_id,))
|
|
|
+ assert post["key_hash"].startswith("pbkdf2:"), (
|
|
|
+ f"legacy hash was not auto-migrated; still {post['key_hash'][:20]}"
|
|
|
+ )
|
|
|
+
|
|
|
+ # Migrated row should also still authenticate on the next call (the
|
|
|
+ # entire point of the upgrade).
|
|
|
+ ctx2 = await authenticate(raw_key)
|
|
|
+ assert ctx2.tenant_id == tid
|