Per-subject not-before watermark for issued access tokens (#296).
A credential change has to invalidate outstanding access tokens, and
AccessTokenDenylist cannot express that: it is add(jti) / has(jti),
and the jtis a subject currently holds are not enumerable anywhere. A
watermark inverts the problem — instead of naming every token, it names the
moment before which none of them count.
The comparison is against the token's iat, and it is deliberately
inclusive (iat <= watermark is revoked). iat is second-truncated
(generateToken floors Date.now() / 1000) and a multi-replica deployment
has independent clocks, so a token minted a few hundred milliseconds before
the reset routinely lands in the same second as the watermark. Killing a
token minted just after the reset costs the client one retry; letting one
from just before survive is the vulnerability this exists to close.
TTL contract: revokeBefore MUST be called with an expiresAt at least as
far out as the longest-lived credential the watermark has to refuse — which
is the longest-lived refresh token, not the access token, wherever the
composition forwards subjectRevocation to the refresh grant (oauthModule
does). Family revocation is the primary kill for refresh tokens and the
watermark is the backstop for the case family revocation did not complete, so
sizing the watermark to the access-token TTL retires the backstop minutes
after a cascade failure while the RT it exists to catch lives for days.
Per-subject not-before watermark for issued access tokens (#296).
A credential change has to invalidate outstanding access tokens, and
AccessTokenDenylistcannot express that: it isadd(jti)/has(jti), and the jtis a subject currently holds are not enumerable anywhere. A watermark inverts the problem — instead of naming every token, it names the moment before which none of them count.The comparison is against the token's
iat, and it is deliberately inclusive (iat <= watermarkis revoked).iatis second-truncated (generateTokenfloorsDate.now() / 1000) and a multi-replica deployment has independent clocks, so a token minted a few hundred milliseconds before the reset routinely lands in the same second as the watermark. Killing a token minted just after the reset costs the client one retry; letting one from just before survive is the vulnerability this exists to close.TTL contract:
revokeBeforeMUST be called with anexpiresAtat least as far out as the longest-lived credential the watermark has to refuse — which is the longest-lived refresh token, not the access token, wherever the composition forwardssubjectRevocationto the refresh grant (oauthModuledoes). Family revocation is the primary kill for refresh tokens and the watermark is the backstop for the case family revocation did not complete, so sizing the watermark to the access-token TTL retires the backstop minutes after a cascade failure while the RT it exists to catch lives for days.