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:
2026-05-30 18:18:41 -04:00
parent fdb6507c6f
commit 698ecaa322
11 changed files with 392 additions and 39 deletions

View 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

View File

@@ -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,

View File

@@ -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):

View File

@@ -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