Make session refresh retries idempotent
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Preserve the successor session for legitimate refresh retries so transient failures no longer revoke the user's session family.
This commit is contained in:
Rocky
2026-08-25 13:13:41 +08:00
parent 36a926f12f
commit 4c9e5feec0
14 changed files with 409 additions and 65 deletions
@@ -96,29 +96,52 @@ class SessionService(
return createSession(account.id, now)
}
suspend fun refresh(refreshToken: String): SessionTokens {
suspend fun refresh(refreshToken: String, operationId: UUID? = null): SessionTokens {
requireValue(refreshToken, "refreshToken", MAX_REFRESH_TOKEN_LENGTH)
val now = clock.instant()
val currentTokenHash = TokenHash.sha256(refreshToken)
val replacement = tokenGenerator.newRefreshToken()
val replacementExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
val replayUntil = if (operationId == null) {
now.plusSeconds(sessionConfig.legacyRefreshReplaySeconds)
} else {
// A stable operation ID lets a crashed client recover until the successor expires.
replacementExpiresAt
}
return when (
val result = repository.rotateRefreshToken(
currentTokenHash = TokenHash.sha256(refreshToken),
newTokenHash = TokenHash.sha256(replacement),
newExpiresAt = replacementExpiresAt,
now = now,
RefreshRotationAttempt(
currentTokenHash = currentTokenHash,
newTokenHash = TokenHash.sha256(replacement),
encryptedNewToken = fieldEncryptor.encrypt(
replacement,
refreshReplayContext(currentTokenHash),
),
newExpiresAt = replacementExpiresAt,
operationId = operationId,
replayUntil = replayUntil,
now = now,
),
)
) {
RefreshRotationResult.Invalid -> throw UnauthorizedException("Refresh token is invalid or expired")
RefreshRotationResult.ReuseDetected -> throw TokenReuseException()
is RefreshRotationResult.Rotated -> {
val access = sessionJwt.issue(result.accountId, result.sessionId)
SessionTokens(
is RefreshRotationResult.Rotated -> issueSessionTokens(
accountId = result.accountId,
sessionId = result.sessionId,
refreshToken = replacement,
refreshTokenExpiresAt = replacementExpiresAt,
)
is RefreshRotationResult.Replayed -> {
val replayedRefreshToken = fieldEncryptor.decrypt(
result.encryptedRefreshToken,
refreshReplayContext(currentTokenHash),
)
issueSessionTokens(
accountId = result.accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = replacement,
refreshTokenExpiresAt = replacementExpiresAt,
sessionId = result.sessionId,
refreshToken = replayedRefreshToken,
refreshTokenExpiresAt = result.refreshTokenExpiresAt,
)
}
}
@@ -147,6 +170,22 @@ class SessionService(
)
}
private fun issueSessionTokens(
accountId: UUID,
sessionId: UUID,
refreshToken: String,
refreshTokenExpiresAt: Instant,
): SessionTokens {
val access = sessionJwt.issue(accountId, sessionId)
return SessionTokens(
accountId = accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = refreshToken,
refreshTokenExpiresAt = refreshTokenExpiresAt,
)
}
private suspend fun verifyIdentityToken(token: String, nonce: String): AppleIdentity =
try {
appleIdentityVerifier.verify(token, nonce)
@@ -185,3 +224,4 @@ class SessionService(
fun appleRefreshContext(accountId: UUID): String = "apple-refresh-token:$accountId"
fun appleSubjectContext(identityFingerprint: String): String = "apple-subject:$identityFingerprint"
fun refreshReplayContext(currentTokenHash: String): String = "session-refresh-replay:$currentTokenHash"