{@link #reason()} is for logs and tests only. The HTTP body is identical for every + * reason on purpose: a caller must not be able to tell "never existed" from "already used". + */ +public class InvalidGrantException extends RuntimeException { + + public enum Reason { UNKNOWN, REVOKED, EXPIRED, REUSE_DETECTED, REUSE_WITHIN_GRACE } + + private final Reason reason; + + public InvalidGrantException(Reason reason) { + super(reason.name()); + this.reason = reason; + } + + public Reason reason() { + return reason; + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java new file mode 100644 index 0000000..0a9338f --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenService.java @@ -0,0 +1,150 @@ +package com.ankurm.refresh.token; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; + +import com.ankurm.refresh.token.InvalidGrantException.Reason; +import com.ankurm.refresh.token.RefreshTokenStore.Row; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Issues, rotates and revokes refresh tokens. + * + *
A refresh token here is NOT a JWT. It is 32 random bytes from {@link SecureRandom}, + * base64url encoded, and the database keeps only its SHA-256. A refresh token must be + * revocable and single-use, and a self-contained JWT is neither without a lookup, so the + * lookup is the design rather than a workaround. + * + *
Every login starts a family. Every rotation adds a child to the same family. A token + * presented a second time means two parties hold the same secret, and the service cannot + * tell which one is the thief, so it revokes the whole family. + */ +@Service +public class RefreshTokenService { + + private static final Logger log = LoggerFactory.getLogger(RefreshTokenService.class); + private static final SecureRandom RANDOM = new SecureRandom(); + + /** What a caller gets back. {@code rawToken} exists only here, never in the database. */ + public record Issued(String rawToken, String familyId, String username, Instant expiresAt) { } + + private final RefreshTokenStore store; + private final TransactionTemplate tx; + private final Clock clock; + private final Duration tokenTtl; + private final Duration familyMaxAge; + private final Duration reuseGrace; + + public RefreshTokenService(RefreshTokenStore store, TransactionTemplate tx, Clock clock, + @Value("${demo.refresh.token-ttl}") Duration tokenTtl, + @Value("${demo.refresh.family-max-age}") Duration familyMaxAge, + @Value("${demo.refresh.reuse-grace}") Duration reuseGrace) { + this.store = store; + this.tx = tx; + this.clock = clock; + this.tokenTtl = tokenTtl; + this.familyMaxAge = familyMaxAge; + this.reuseGrace = reuseGrace; + } + + /** First token of a brand new family. Call after the password check succeeded. */ + public Issued startFamily(String username) { + long now = clock.millis(); + return insertNew(username, UUID.randomUUID().toString(), now, now); + } + + /** + * Spend {@code rawToken} and hand out its successor, or throw {@link InvalidGrantException}. + * The order of the checks is the whole design: unknown, revoked, expired, then the + * ACTIVE / USED split that reuse detection lives in. + */ + public Issued rotate(String rawToken) { + Row row = store.findByHash(hash(rawToken)) + .orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN)); + long now = clock.millis(); + + if (RefreshTokenStore.REVOKED.equals(row.status())) { + throw new InvalidGrantException(Reason.REVOKED); + } + if (now >= row.expiresAt() || now >= row.familyStartedAt() + familyMaxAge.toMillis()) { + throw new InvalidGrantException(Reason.EXPIRED); + } + if (RefreshTokenStore.USED.equals(row.status())) { + throw reuse(row, now); + } + + // ACTIVE. Spend it and mint the child in ONE transaction, so a crash between the two + // cannot leave the user holding a spent token with no successor. + Issued issued = tx.execute(status -> { + if (!store.markUsed(row.id(), now)) { + return null; // lost a race against another request + } + return insertNew(row.username(), row.familyId(), row.familyStartedAt(), now); + }); + if (issued != null) { + return issued; + } + // Somebody else spent it between our read and our UPDATE. Re-read to see how. + Row fresh = store.findByHash(hash(rawToken)).orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN)); + if (RefreshTokenStore.REVOKED.equals(fresh.status())) { + throw new InvalidGrantException(Reason.REVOKED); + } + throw reuse(fresh, now); + } + + /** Single-device logout: revoke the family this token belongs to, whatever state it is in. */ + public void revokeFamilyOf(String rawToken) { + store.findByHash(hash(rawToken)).ifPresent(row -> store.revokeFamily(row.familyId())); + } + + /** Logout everywhere. */ + public int revokeAll(String username) { + return store.revokeAllForUser(username); + } + + private InvalidGrantException reuse(Row row, long now) { + if (row.usedAt() != null && reuseGrace.toMillis() > 0 && now - row.usedAt() <= reuseGrace.toMillis()) { + log.info("refresh token replayed {} ms after use, inside the {} ms grace window, family {} kept", + now - row.usedAt(), reuseGrace.toMillis(), row.familyId()); + return new InvalidGrantException(Reason.REUSE_WITHIN_GRACE); + } + int revoked = store.revokeFamily(row.familyId()); + log.warn("refresh token REUSE detected for user {}: family {} revoked ({} tokens)", + row.username(), row.familyId(), revoked); + return new InvalidGrantException(Reason.REUSE_DETECTED); + } + + private Issued insertNew(String username, String familyId, long familyStartedAt, long now) { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + long expiresAt = now + tokenTtl.toMillis(); + store.insert(new Row(UUID.randomUUID().toString(), hash(raw), familyId, username, + RefreshTokenStore.ACTIVE, now, expiresAt, familyStartedAt, null)); + return new Issued(raw, familyId, username, Instant.ofEpochMilli(expiresAt)); + } + + /** SHA-256 hex. Fast hashing is fine here: the input is 256 random bits, not a password. */ + public static String hash(String rawToken) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } + catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java new file mode 100644 index 0000000..9c464a6 --- /dev/null +++ b/refresh-tokens/src/main/java/com/ankurm/refresh/token/RefreshTokenStore.java @@ -0,0 +1,79 @@ +package com.ankurm.refresh.token; + +import java.util.List; +import java.util.Optional; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * The only class that touches the refresh_token table. + * + *
Two methods carry the security argument. {@link #markUsed} is a single conditional
+ * UPDATE, so when several requests present the same token only one of them can change the
+ * row from ACTIVE to USED, no matter how the requests interleave. {@link #revokeFamily}
+ * is what reuse detection pulls when it sees a token that was already spent.
+ */
+@Repository
+public class RefreshTokenStore {
+
+ public static final String ACTIVE = "ACTIVE";
+ public static final String USED = "USED";
+ public static final String REVOKED = "REVOKED";
+
+ /** One row. Times are epoch milliseconds. {@code usedAt} is null until the token is spent. */
+ public record Row(String id, String tokenHash, String familyId, String username, String status,
+ long issuedAt, long expiresAt, long familyStartedAt, Long usedAt) { }
+
+ private final JdbcClient jdbc;
+
+ public RefreshTokenStore(JdbcClient jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public void insert(Row row) {
+ jdbc.sql("""
+ insert into refresh_token
+ (id, token_hash, family_id, username, status, issued_at, expires_at, family_started_at, used_at)
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """)
+ .params(row.id(), row.tokenHash(), row.familyId(), row.username(), row.status(),
+ row.issuedAt(), row.expiresAt(), row.familyStartedAt(), row.usedAt())
+ .update();
+ }
+
+ public Optional