Skip to content

fix(auth): harden device-auth poll (atomic consume + blocked re-check)#4328

Draft
markijbema wants to merge 2 commits into
mainfrom
mark/device-auth-poll-hardening
Draft

fix(auth): harden device-auth poll (atomic consume + blocked re-check)#4328
markijbema wants to merge 2 commits into
mainfrom
mark/device-auth-poll-hardening

Conversation

@markijbema

@markijbema markijbema commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #4314 (block rotates pepper). That PR was Workstream B; this is Workstream A — server-side hardening of the device-authorization poll path in apps/web/src/lib/device-auth/device-auth.ts. It closes two findings from the device-auth review:

  • Finding 1 — non-atomic single-use consume. pollDeviceAuthRequest read the approved request, minted a token, then issued an unconditional status = 'expired' update. Two concurrent polls could each pass the read and each mint a long-lived API token from one approval. Replaced with a guarded compare-and-swap (UPDATE ... SET status='expired' WHERE code = ? AND status='approved' RETURNING kilo_user_id); the token is minted only by the caller that won the swap, and losers normalize to expired.
  • Finding 2 — token minted after the user is blocked. Authorization was only checked at approval time; the later poll minted a long-lived API token with no re-check. A user could approve while in good standing, get blocked, then poll and still receive a fresh token. The poll now re-validates the user at mint time via blocked_reason and isUserBlacklistedByDomain, returning denied instead of minting. Because the approval is consumed first, a blocked user can't retry the code into a token.

No schema change, no migration. The poll API route already maps denied -> 403 and expired -> 410, so no route change was needed.

Verification

  • apps/web/src/lib/device-auth/device-auth.test.ts (24 tests pass), including two new cases:
    • concurrent polls on one approval mint exactly one token (the other gets expired);
    • a user blocked between approval and poll gets denied with no token, and a retry returns expired.
  • Existing single-use / pending / denied / expired poll tests still pass.
  • The domain-blacklist branch shares the same if as the blocked-reason check; I did not add a dedicated test for it because getBlacklistedDomains is Redis-cached (60s) and not reliably seedable in a unit test.

Visual Changes

N/A

Reviewer Notes

  • The blocked re-check returns denied while the row is set to expired by the consume; both are terminal/non-retryable, and denied (403) is the more informative signal to the polling device.
  • Two atomic commits: Finding 1 (atomic consume) then Finding 2 (blocked/blacklist re-check).

pollDeviceAuthRequest read the approved request, minted a token, then
issued an unconditional 'expired' update — so two concurrent polls could
each mint a long-lived API token from one approval. Replace it with a
guarded compare-and-swap that flips 'approved' -> 'expired' and returns
the row only to the winning caller; losers normalize to 'expired'. The
token is minted only after the row is consumed, so at most one token is
issued per approval.
…(Finding 2)

Authorization was only checked when the user approved the device request;
the later poll minted a ~5-year API token without re-checking. A user could
approve while in good standing, get blocked, then poll and receive a fresh
token still accepted by pepper-only downstream services. Re-validate the
user at mint time (blocked_reason or isUserBlacklistedByDomain) and return
'denied' instead of minting. The approval is already consumed, so a blocked
user cannot retry the code into a token.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant