feat(auth): jti claim and token-revocation store
Stateless JWTs had no revocation path: a stolen token stayed valid for its full 24h even after the victim changed their password, and there was no logout. This lays the foundation for revoking them. - User.tokens_valid_from: per-user bulk-revocation cutoff (compared against the token's iat). RevokedToken(jti PK, exp): single-token denylist, pruned opportunistically on insert so it never outgrows live-but-revoked tokens. - login() now mints a jti; create_access_token already stamps iat/exp. - repo.revoke_token / is_token_revoked / set_tokens_valid_from (abstract + shared sqlmodel impl + DummyRepo coverage stubs). - Centralized validate path in dependencies.py: every auth dependency now resolves the user and fails closed on (1) missing jti (legacy/pre-deploy token -> one forced re-login), (2) iat before the cutoff, (3) a denylisted jti. Denylist lookups ride a 10s membership cache mirroring the user cache. - Contract/fuzz harness seeds its fixed-uuid principal under DECNET_CONTRACT_TEST so its minted token resolves to a live admin user.
This commit is contained in:
144
tests/api/auth/test_token_revocation.py
Normal file
144
tests/api/auth/test_token_revocation.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""JWT revocation foundation (WI1): jti claim, denylist, and bulk cutoff.
|
||||
|
||||
These exercise the centralized validate path in decnet.web.dependencies through
|
||||
real HTTP requests, plus the three repository primitives directly. The wiring
|
||||
into logout / password-change lives in later work items; here we drive the
|
||||
mechanism by calling the repo + cache helpers the way those endpoints will.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from decnet.web.auth import create_access_token
|
||||
from decnet.web.dependencies import (
|
||||
invalidate_token_cache,
|
||||
invalidate_user_cache,
|
||||
repo,
|
||||
)
|
||||
|
||||
PROTECTED = "/api/v1/attackers?limit=1" # auth-gated; 200 for an authed viewer/admin
|
||||
|
||||
|
||||
def _claims(token: str) -> dict:
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Token shape #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_token_carries_jti_and_iat(client, auth_token):
|
||||
claims = _claims(auth_token)
|
||||
assert claims.get("jti"), "login token must carry a jti for the denylist"
|
||||
assert "iat" in claims and "exp" in claims
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_token_is_accepted(client, auth_token):
|
||||
r = await client.get(PROTECTED, headers=_auth(auth_token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fail-closed cases #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_token_without_jti_is_rejected(client, auth_token):
|
||||
# A token minted before this feature (no jti) cannot be revoked, so it is
|
||||
# refused outright — one forced re-login on deploy.
|
||||
uuid = _claims(auth_token)["uuid"]
|
||||
legacy = create_access_token({"uuid": uuid}) # no jti
|
||||
r = await client.get(PROTECTED, headers=_auth(legacy))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_for_unknown_user_is_rejected(client):
|
||||
ghost = create_access_token({"uuid": "no-such-user", "jti": "ghost"})
|
||||
r = await client.get(PROTECTED, headers=_auth(ghost))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoked_jti_is_rejected(client, auth_token):
|
||||
claims = _claims(auth_token)
|
||||
# Sanity: works before revocation.
|
||||
assert (await client.get(PROTECTED, headers=_auth(auth_token))).status_code == 200
|
||||
# Denylist this token's jti the way logout will.
|
||||
await repo.revoke_token(
|
||||
claims["jti"], claims["uuid"],
|
||||
datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
)
|
||||
invalidate_token_cache(claims["jti"])
|
||||
r = await client.get(PROTECTED, headers=_auth(auth_token))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iat_before_cutoff_is_rejected(client, auth_token):
|
||||
claims = _claims(auth_token)
|
||||
assert (await client.get(PROTECTED, headers=_auth(auth_token))).status_code == 200
|
||||
# Move the bulk cutoff past this token's iat (what password/role change does).
|
||||
await repo.set_tokens_valid_from(
|
||||
claims["uuid"], datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
)
|
||||
invalidate_user_cache(claims["uuid"])
|
||||
r = await client.get(PROTECTED, headers=_auth(auth_token))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_issued_after_cutoff_still_works(client, auth_token):
|
||||
# A cutoff in the PAST must not revoke a token issued now.
|
||||
claims = _claims(auth_token)
|
||||
await repo.set_tokens_valid_from(
|
||||
claims["uuid"], datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
)
|
||||
invalidate_user_cache(claims["uuid"])
|
||||
r = await client.get(PROTECTED, headers=_auth(auth_token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Repository primitives #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_token_revoked_roundtrip(client):
|
||||
exp = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
assert await repo.is_token_revoked("jti-a") is False
|
||||
await repo.revoke_token("jti-a", "user-1", exp)
|
||||
assert await repo.is_token_revoked("jti-a") is True
|
||||
# Idempotent — re-revoking the same jti does not raise.
|
||||
await repo.revoke_token("jti-a", "user-1", exp)
|
||||
assert await repo.is_token_revoked("jti-a") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_token_prunes_expired_rows(client):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
await repo.revoke_token("expired-jti", "user-1", past)
|
||||
# Inserting a fresh revocation prunes the already-expired row.
|
||||
await repo.revoke_token("live-jti", "user-1", future)
|
||||
assert await repo.is_token_revoked("expired-jti") is False
|
||||
assert await repo.is_token_revoked("live-jti") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_tokens_valid_from_persists(client, auth_token):
|
||||
uuid = _claims(auth_token)["uuid"]
|
||||
ts = datetime.now(timezone.utc)
|
||||
await repo.set_tokens_valid_from(uuid, ts)
|
||||
user = await repo.get_user_by_uuid(uuid)
|
||||
assert user is not None and user["tokens_valid_from"] is not None
|
||||
@@ -47,7 +47,11 @@ pytestmark = pytest.mark.xdist_group("schemathesis")
|
||||
import decnet.web.auth
|
||||
decnet.web.auth.SECRET_KEY = TEST_SECRET
|
||||
|
||||
TEST_TOKEN = create_access_token({"uuid": "00000000-0000-0000-0000-000000000001"})
|
||||
# jti is mandatory post token-revocation; the matching user is seeded by the
|
||||
# server under DECNET_CONTRACT_TEST (sqlmodel_repo._ensure_contract_user).
|
||||
TEST_TOKEN = create_access_token(
|
||||
{"uuid": "00000000-0000-0000-0000-000000000001", "jti": "contract-test-jti"}
|
||||
)
|
||||
|
||||
ALL_CHECKS = (
|
||||
not_a_server_error,
|
||||
|
||||
@@ -72,6 +72,9 @@ class DummyRepo(BaseRepository):
|
||||
async def list_users(self): await super().list_users()
|
||||
async def delete_user(self, u): await super().delete_user(u)
|
||||
async def update_user_role(self, u, r): await super().update_user_role(u, r)
|
||||
async def revoke_token(self, j, u, e): await super().revoke_token(j, u, e)
|
||||
async def is_token_revoked(self, j): await super().is_token_revoked(j); return False
|
||||
async def set_tokens_valid_from(self, u, ts): await super().set_tokens_valid_from(u, ts)
|
||||
async def purge_logs_and_bounties(self): await super().purge_logs_and_bounties()
|
||||
async def get_attacker_artifacts(self, uuid): await super().get_attacker_artifacts(uuid)
|
||||
async def get_attacker_transcripts(self, uuid): await super().get_attacker_transcripts(uuid)
|
||||
@@ -275,6 +278,10 @@ async def test_base_repo_coverage():
|
||||
# is ``pass`` (returns None), the rest raise NotImplementedError.
|
||||
from datetime import datetime, timezone
|
||||
await dr.get_log_histogram()
|
||||
# Token-revocation surface (JWT denylist + bulk cutoff).
|
||||
await dr.revoke_token("jti-x", "user-x", datetime.now(timezone.utc))
|
||||
await dr.is_token_revoked("jti-x")
|
||||
await dr.set_tokens_valid_from("user-x", datetime.now(timezone.utc))
|
||||
with pytest.raises(NotImplementedError):
|
||||
await dr.has_observations_for_evidence("shard:x#1")
|
||||
with pytest.raises(NotImplementedError):
|
||||
|
||||
@@ -16,11 +16,22 @@ from decnet.web.auth import create_access_token
|
||||
class TestGetCurrentUser:
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_token(self):
|
||||
# Post token-revocation, get_current_user resolves the user and checks
|
||||
# the denylist, so a valid token must carry a jti, name a live user, and
|
||||
# not be revoked.
|
||||
from decnet.web import dependencies as deps
|
||||
from decnet.web.dependencies import get_current_user
|
||||
token = create_access_token({"uuid": "test-uuid-123"})
|
||||
deps._reset_user_cache()
|
||||
token = create_access_token({"uuid": "test-uuid-123", "jti": "jti-1"})
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": f"Bearer {token}"}
|
||||
result = await get_current_user(request)
|
||||
user = {
|
||||
"uuid": "test-uuid-123", "role": "viewer",
|
||||
"must_change_password": False, "tokens_valid_from": None,
|
||||
}
|
||||
with patch.object(deps.repo, "get_user_by_uuid", AsyncMock(return_value=user)), \
|
||||
patch.object(deps.repo, "is_token_revoked", AsyncMock(return_value=False)):
|
||||
result = await get_current_user(request)
|
||||
assert result == "test-uuid-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user