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

@@ -2,11 +2,12 @@
"""User CRUD."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from decnet.web.db.models import User
from decnet.web.db.models import RevokedToken, User
from decnet.web.db.sqlmodel_repo._helpers import _MixinBase
@@ -75,3 +76,29 @@ class AuthMixin(_MixinBase):
update(User).where(User.uuid == uuid).values(role=role)
)
await session.commit()
async def revoke_token(self, jti: str, user_uuid: str, expires_at: datetime) -> None:
async with self._session() as session:
# Opportunistic prune — the denylist only needs unexpired tokens, so
# purge stale rows on every insert instead of a separate vacuum job.
await session.execute(
delete(RevokedToken).where(
RevokedToken.expires_at < datetime.now(timezone.utc)
)
)
if await session.get(RevokedToken, jti) is None:
session.add(
RevokedToken(jti=jti, user_uuid=user_uuid, expires_at=expires_at)
)
await session.commit()
async def is_token_revoked(self, jti: str) -> bool:
async with self._session() as session:
return await session.get(RevokedToken, jti) is not None
async def set_tokens_valid_from(self, user_uuid: str, ts: datetime) -> None:
async with self._session() as session:
await session.execute(
update(User).where(User.uuid == user_uuid).values(tokens_valid_from=ts)
)
await session.commit()