feat(auth): rotate refresh tokens, rate limit auth, and plan the chess features - #198
Open
Utzig26 wants to merge 2 commits into
Open
feat(auth): rotate refresh tokens, rate limit auth, and plan the chess features#198Utzig26 wants to merge 2 commits into
Utzig26 wants to merge 2 commits into
Conversation
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.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/authand markedsecureoutside development. The access token stays in theAuthorizationheader, which keeps the API usable by clients that are not browsers.New routes:
POST /auth/refreshPOST /auth/signoutPOST /auth/signout-allExpired 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
jtiis not decoration. Without it, two rotations inside the same second produce byte-identical tokens, since the payload and theiatandexpclaims all match. That defeats reuse detection and collides with the unique index on the hash.ThrottlerGuarddeclares 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 againstdocs/legacy-api.mdas 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:
chess.jsbehind 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.now. A timed out game is only persisted as finished on the next mutation, while every read already reports it correctly.EventEmitterbehind 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
stricttypecheck and lint.The full cycle was also exercised against a running instance:
Known gaps, written down rather than fixed
A game in
WPthat 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.