Skip to content

[Security] high: Refresh-token grace window forks an independent, undetected parallel token chain (rotation reuse-detection bypass) #474

Description

@Kravalg

Summary

The refresh-token rotation reuse-detection is defeated by its own 60-second grace window. When an already-rotated refresh token is presented again within grace, handleGraceWindowReuse() calls RefreshTokenIssuer::issueRotatedTokens(), which mints a completely NEW, independent opaque refresh token (AuthTokenFactory::generateOpaqueToken -> new AuthRefreshToken with rotatedAt=null) instead of idempotently returning the already-issued successor. No sibling tokens are revoked and there is no per-session cap on active refresh tokens (confirmed: AuthRefreshTokenRepositoryInterface only exposes revokeBySessionId/findBySessionId, and RefreshTokenIssuer never revokes siblings). The reuse detector hasLaterRotation() only fires when a token that is ALREADY rotated is re-presented; after a fork, both the victim and the attacker each always present their own current UNROTATED token, so it is never re-evaluated and theft is never detected. The whole-session guards (AccessTokenUserResolver::validateSession / RefreshTokenContextResolver::resolveSession) only check session revoked/expired, which never happens because neither chain triggers respondToTheft().

Severity: HIGH • Category: A07:2021 Identification and Authentication Failures / refresh-token rotation
Found by an authorized automated adversarial pentest loop and confirmed by 2 independent skeptic verifiers (unanimous).

Affected code / location

src/User/Application/CommandHandler/RefreshTokenCommandHandler.php:154-175 (handleGraceWindowReuse) & 112-149 (hasLaterRotation); src/User/Application/Service/RefreshTokenIssuer.php:27-52 (issueRotatedTokens mints a brand-new opaque token); .env:36 (REFRESH_TOKEN_GRACE_WINDOW_SECONDS=60), .env:86 (AUTH_REMEMBER_ME_SESSION_TTL_SECONDS=2592000)

Exploit scenario

  1. Attacker obtains one valid refresh token R0 for a victim (via XSS on the SPA that stores the body-delivered refresh token, network capture, log leak, etc.). 2. The victim performs a normal token refresh, rotating R0->R1 (R0.rotatedAt=T0, R1 fresh/unrotated). 3. Within 60s (REFRESH_TOKEN_GRACE_WINDOW_SECONDS=60), the attacker POSTs R0 to /api/token. Flow: contextResolver->resolve (R0 not revoked/expired -> passes) -> tryHandleRotatedToken -> handleRotatedToken -> isWithinGracePeriod=true -> handleGraceWindowReuse. hasLaterRotation() is false because R1.rotatedAt is null (a client will not rotate R1 again within 60s of issuing it). consumeGraceWindow atomically sets R0.graceUsed=true and issueRotatedTokens() mints a NEW token R2 (rotatedAt=null) plus a fresh 15-min access token, returned to the attacker. 4. Now R1 (victim) and R2 (attacker) are two independent, unrevoked, unrotated refresh tokens on the SAME session. Each party rotates their own chain cleanly forever (R2->R2'->R2''...), always presenting an unrotated token, so hasLaterRotation()/respondToTheft() never fire. 5. Because the login used remember_me, the AuthSession lives 30 days (2592000s) and is never revoked, giving the attacker a 30-day persistent, invisible parallel session from a single reuse of one stolen refresh token. The correct behavior is for the grace path to replay the SAME already-issued successor token idempotently, not mint a new divergent chain.

Suggested remediation

On grace-window reuse, return the previously-issued successor token instead of minting a new one: persist a reference from the rotated token to its successor (successorTokenId/successorPlain) and have handleGraceWindowReuse return that exact token+access-token idempotently. Alternatively, on every rotation revoke all other active refresh tokens for the session (enforce a single active token per session) so a forked chain is invalidated on the next legitimate rotation, and/or run a background sweep that flags >1 active refresh token per session as theft.

Verification notes

  • Verifier 1 (CONFIRMED, exploitable=true, duplicate=false): Traced the full path in current code and could not refute it. RefreshTokenProcessor::buildResponseBody (lines 59-66) returns the plaintext refresh_token in the JSON body (only the access token is an HttpOnly __Host-auth_token cookie via AuthCookieFactory), so the token is JS-/log-/network-stealable — the theft precondition is realistic. RefreshTokenContextResolver::resolveRefreshToken (lines 60-64) rejects only expired/revoked tokens; a rotated-but-not-revoked R0 passes. In handleGraceWindowReuse (RefreshTokenCommandHandler.php:154-175) the guard hasLaterRotation/isLaterRotatedToken (lines 132-149) only fires when a sibling has getRotatedAt() > R0.rotatedAt; the victim's fresh successor R1 has rotatedAt=null, so it is not counted and no theft is detected. consumeGraceWindow atomically marks R0 grace-used once, then RefreshTokenIssuer::issueRotatedTokens (lines 27-52) unconditionally mints a NEW opaque token (AuthRefreshToken with rotatedAt=null) and never revokes siblings. AuthRefreshTokenRepositoryInterface has no per-session active-token cap and no sibling revocation (only revokeBySessionId, used in sign-out and respondToTheft). After the grace reuse, R1 (victim) and R2 (attacker) are two independent unrotated, unrevoked tokens on the same AuthSession; each advances its own chain presenting only fresh tokens, so hasLaterRotation and respondToTheft never fire and the session is never revoked. With remember-me the AuthSession lives 30 days (AUTH_REMEMBER_ME_SESSION_TTL_SECONDS=2592000). Net effect: the rotation reuse-detection control — whose entire purpose is to detect/contain a stolen refresh token — is completely and permanently bypassed by its own 60s grace window (REFRESH_TOKEN_GRACE_WINDOW_SECONDS=60), yielding a persistent, invisible parallel session. Correct behavior would be idempotent replay of the already-issued successor, not minting a divergent chain. Not a duplicate of [Security] critical: OAuth2/JWT access tokens silently escalate to ROLE_SERVICE; issuer/audience validation skipped; session-revocation bypassed #312-324 (none address refresh-token rotation grace-window forking). Severity high because it fully defeats a core auth control and grants durable undetected access; it does require prior refresh-token theft, which the body delivery makes plausible. This should reference NFR umbrella Enterprise readiness audit: close remaining gaps to reach 5/5 on all quality attributes and NFRs #348 but merits its own actionable issue.
  • Verifier 2 (CONFIRMED, exploitable=true, duplicate=false): Verified in code. RefreshTokenCommandHandler::handleGraceWindowReuse (154-175) calls tokenIssuer->issueRotatedTokens after only markGraceUsed(); RefreshTokenIssuer::issueRotatedTokens (27-52) generates a brand-new opaque token via authTokenFactory->generateOpaqueToken() and save()s a new AuthRefreshToken with rotatedAt=null, revoking NO siblings. hasLaterRotation (112-149)/isLaterRotatedToken require a sibling with rotatedAt > oldRotatedAt; the victim's fresh successor has rotatedAt=null (AuthRefreshToken line 11, set only by markAsRotated), so the detector never fires. After the fork both parties present their own unrotated token, which routes through tryHandleConcurrentRotation->markAsRotatedIfActive (normal active-rotation path) and never re-enters handleGraceWindowReuse, so respondToTheft is never triggered. No per-session active-token cap exists: AuthRefreshTokenRepositoryInterface exposes only save/find/revokeBySessionId/markAsRotatedIfActive/markGraceUsedIfEligible, and the Mongo markGraceUsedIfEligible (103-121) sets graceUsed=true on one document and revokes nothing. RefreshTokenContextResolver::resolveSession (67-78) only rejects revoked/expired sessions, which never occurs. Config confirmed: .env:36 grace=60s, .env:86 remember-me session=2592000s (30d), wired in services.yaml:344,371. Net effect: reuse of a stolen R0 within the grace window mints an independent, unrevoked parallel refresh chain that defeats rotation reuse-detection and persists for the session lifetime undetected. Note: the claimed fix (idempotent replay of the same plaintext successor) is impossible because only SHA-256 hashes are stored (ctor line 23); the true defect is the missing sibling/session revocation on grace reuse. Not a duplicate of [Security] critical: OAuth2/JWT access tokens silently escalate to ROLE_SERVICE; issuer/audience validation skipped; session-revocation bypassed #312-[Security] low: Account-lockout failure counter uses non-atomic read-modify-write (race allows extra guesses) #324 (token escalation, plaintext reset tokens, recovery codes, lockout atomicity, etc.), none of which touch refresh-rotation grace forking. Exploit requires prior theft of one refresh token and a timing race against the victim's rotation cadence, but the 60s window recurs each refresh cycle and the flaw nullifies rotation's core theft-containment guarantee.

Related

Part of the enterprise security-hardening effort — umbrella tracker #348; security NFR issues #426 (securability), #441 (vulnerability), #362 (confidentiality), #389 (integrity). Prior security wave: #312#324. Not a duplicate of the already-fixed items in that range.


Acceptance = the exploit path is closed AND a regression test (unit/Behat/Schemathesis) proves it stays closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions