fix(security): consume magic-link tokens atomically

Validation and marking-used were two separate steps, so two concurrent
requests for the same registration token could both pass validation before
either marked it used — a replay window.

Add an atomic consume() at the repository (conditional UPDATE ... WHERE
used = 0 AND not expired, gated on rowcount) and service layers, and switch
the /register handler to consume() instead of validate()+mark_used().

Refs: porchlight-ur7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-04 10:46:38 +02:00
parent 91a2277664
commit e4eb539e3f
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
6 changed files with 80 additions and 3 deletions

View file

@ -102,6 +102,22 @@ async def test_validate_expired_token(service: MagicLinkService, repo: SQLiteMag
assert result is None
async def test_consume_validates_and_is_single_use(service: MagicLinkService) -> None:
link = await service.create(username="alice")
consumed = await service.consume(link.token)
assert consumed is not None
assert consumed.username == "alice"
assert consumed.token == link.token # raw token re-attached
# A second consume of the same token fails.
assert await service.consume(link.token) is None
async def test_consume_expired_returns_none(service: MagicLinkService, repo: SQLiteMagicLinkRepository) -> None:
expired_service = MagicLinkService(repo=repo, ttl=-1)
link = await expired_service.create(username="alice")
assert await service.consume(link.token) is None
async def test_mark_used_returns_true(service: MagicLinkService) -> None:
link = await service.create(username="alice")
result = await service.mark_used(link.token)