Skip to content

feat(auth): rotate refresh tokens, rate limit auth, and plan the chess features - #198

Open
Utzig26 wants to merge 2 commits into
masterfrom
feat/refresh-token-rotation
Open

feat(auth): rotate refresh tokens, rate limit auth, and plan the chess features#198
Utzig26 wants to merge 2 commits into
masterfrom
feat/refresh-token-rotation

Conversation

@Utzig26

@Utzig26 Utzig26 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes the last authentication debt the legacy documentation listed as pending, and writes down how the chess features get built.

Refresh token rotation

Sessions were a single access token with no way to end them. A refresh token is now a JWT signed with its own secret, carrying a family id and a unique jti, and only its SHA-256 hash reaches the database.

Presenting one rotates it: the old record is revoked, points at its replacement, and a new token is issued into the same family. Presenting a token that was already revoked is treated as theft and revokes the entire family, so a stolen token costs the attacker the session instead of buying silent access.

The token travels in a cookie that is httpOnly, sameSite=strict, scoped to /auth and marked secure outside development. The access token stays in the Authorization header, which keeps the API usable by clients that are not browsers.

New routes:

Route Effect
POST /auth/refresh rotates the pair
POST /auth/signout revokes the current family
POST /auth/signout-all revokes every session of the account

Expired records are reaped by a TTL index rather than a job.

Rate limiting

A dedicated guard applies a stricter budget to the auth routes than the global one, and each route keeps its own counter. Both limits come from configuration.

Two defects the tests caught

The jti is not decoration. Without it, two rotations inside the same second produce byte-identical tokens, since the payload and the iat and exp claims all match. That defeats reuse detection and collides with the unique index on the hash.

ThrottlerGuard declares injections on its first two constructor positions. A subclass that decorates only some of its own parameters inherits the rest by index, which silently delivers the storage where the reflector is expected. Every parameter is now decorated explicitly.

RFCs

Seven documents in docs/rfcs/, written against docs/legacy-api.md as the behavioural spec. Each records what the old implementation got right and is worth keeping, what it got wrong and must not come back, and what is still undecided.

The decisions that close the open questions from the legacy write-up:

  • Engine: official chess.js behind an adapter, dropping the personal fork. Position persists as FEN + PGN + SAN history, because threefold repetition and the fifty move rule depend on the history of positions, not the current one. Replaying the PGN lets the engine reconstruct that instead of us deriving it by hand.
  • Reads stop writing. The lazy clock is kept, since it is stateless and scales without coordination, but the projection becomes a pure function of now. A timed out game is only persisted as finished on the next mutation, while every read already reports it correctly.
  • Playing yourself is a supported feature, documented, rather than the oversight it was.
  • The stream stays a single instance EventEmitter behind an interface, with the limitation written down and a revision required before a second instance is deployed.

Implementation order is 001 → 002 → 003 → 004 → 005 → 007 → 006. The stream comes last on purpose: it only transports what the others produce, and writing it earlier would hide state bugs behind it.

Verification

114 tests, all green, plus a clean strict typecheck and lint.

  • 56 unit, 25 of them over rotation, revocation and token forgery
  • 58 end-to-end, covering the cookie attributes, the rotation chain, the replay that kills a family, sign out, sign out everywhere, session independence and the 429

The full cycle was also exercised against a running instance:

signup           -> 201  Set-Cookie: refresh_token=...; Path=/auth; HttpOnly; SameSite=Strict
refresh with A   -> 200  new cookie, different
replay A         -> 401  "Refresh token already used"
refresh with B   -> 401  <- family revoked
signout          -> 200  Expires=Thu, 01 Jan 1970
refresh after    -> 401

Known gaps, written down rather than fixed

A game in WP that nobody joins is orphaned forever, there is no cap on simultaneous games per user, and a timeout produces no stream event, which is the accepted consequence of a clock with no scheduler.

Utzig26 added 2 commits July 31, 2026 14:31
Sessions were a single access token with no way to end them. This adds the
refresh half, with rotation and reuse detection, and closes the last debt
the legacy documentation listed as pending.

A refresh token is a JWT signed with its own secret, carrying a family id
and a unique jti, and only its SHA-256 hash reaches the database. Presenting
one rotates it: the old record is revoked, points at its replacement, and a
new token is issued into the same family. Presenting a token that was
already revoked is treated as theft and revokes the entire family, so a
stolen token costs the attacker the session instead of buying them silent
access.

The jti matters more than it looks. Without it two rotations inside the same
second produce byte-identical tokens, since the payload and the iat and exp
claims would all match, which both defeats reuse detection and collides with
the unique index on the hash.

The token travels in a cookie that is httpOnly, sameSite strict, scoped to
/auth and marked secure outside development, so scripts cannot read it. The
access token stays in the Authorization header, which keeps the API usable
by clients that are not browsers.

New routes: POST /auth/refresh rotates, POST /auth/signout revokes the
current family, POST /auth/signout-all revokes every session of the account.
Expired records are reaped by a TTL index rather than a job.

Rate limiting arrives with the auth routes, since they are the ones worth
guessing against. A dedicated guard applies a stricter budget than the
global one, and each route keeps its own counter. Its constructor decorates
every parameter explicitly: ThrottlerGuard declares injections on the first
two constructor positions, and a subclass that decorates only some of them
inherits the rest by index, which silently delivers the storage where the
reflector is expected.

Adds 25 unit tests over rotation, revocation and token forgery, and 22
end-to-end tests covering the cookie attributes, the rotation chain, the
replay that kills a family, sign out, sign out everywhere, session
independence and the 429.
Seven RFCs covering everything the rewrite still owes, written against
docs/legacy-api.md as the behavioural spec. Each one records what the old
implementation got right and is worth keeping, what it got wrong and must
not come back, and what is still undecided.

The decisions worth calling out:

The engine is the official chess.js behind an adapter, dropping the personal
fork the old code needed. Position is persisted as FEN plus PGN plus SAN
history, because threefold repetition and the fifty move rule depend on the
history of positions, not the current one, and replaying the PGN lets the
engine reconstruct that instead of us deriving it by hand.

Reads stop writing. The lazy clock is kept, since it is stateless and scales
without coordination, but the projection becomes a pure function of now, so
a GET no longer saves the document. The cost is that a timed out game is
only persisted as finished on the next mutation, while every read already
reports it correctly.

Playing yourself is a supported feature rather than the oversight it was.

Moves get optimistic concurrency on the document version, which the old code
had no protection for at all, and the SAN regex is dropped: the engine is
the single source of legality.

The stream stays a single instance EventEmitter behind an interface, with
the limitation written down and a revision required before a second
instance is deployed. It also fixes the missing await that let the old code
open a stream for a game that did not exist, the same class of defect that
took down authentication here.
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