Files
HealthLog/tests/integration/refresh-rotation-access-sunset.test.ts
T
Marc-André Bombeck dfb5d7483f fix(auth): sunset the rotated-away access token instead of revoking it
Rotation revoked the access token paired with the consumed refresh token
the instant the rotation committed. A native client with requests already
on the wire got 401 revoked on those, read that as "re-authenticate",
rotated again with the refresh token it had just spent, and tripped reuse
detection: the device's whole token family died because the client was
busy at the boundary.

Pull the paired token's expiry in to fifteen seconds instead. It is long
enough for an in-flight request on a slow mobile link to finish, including
a retry, and short enough that the token gains nothing meaningful over the
validity it already had. bearer.ts answers a passed expiry with reason
expired, which a client treats as "refresh", not as "sign in again".

LEAST(expires_at, $1) takes the minimum inside the single UPDATE, so the
write can only ever shorten: a token already inside its last seconds keeps
its own expiry, and there is no read-then-write window. The bound value is
an ISO-8601 UTC string cast in SQL so the comparison does not shift with
the host's timezone.

Reuse detection is untouched. A replayed refresh token, or one presented
under a spoofed device id, still revokes the family and its access tokens
on the spot, and so does the logout path.
2026-09-06 15:36:35 +02:00

184 lines
6.4 KiB
TypeScript

/**
* Refresh rotation must not kill the outgoing access token mid-flight.
*
* A native client keeps several requests in flight across the access-token
* boundary. Some of them leave the device with the old access token a few
* hundred milliseconds before the rotation commits and land after it. While
* rotation revoked the paired access token instantly, those requests came back
* 401 `revoked`, the client read that as "re-authenticate", rotated again with
* the refresh token it had just consumed, tripped reuse detection, and the
* whole family died: signed out for having been busy.
*
* The contract this file pins:
* a) the outgoing access token still authenticates inside the sunset window,
* and outside it fails as `expired` — never `revoked`;
* b) reuse detection is untouched: a replayed refresh token revokes the
* family and its access tokens instantly, with no window;
* c) the sunset only ever SHORTENS — a token expiring sooner than the window
* keeps its own expiry;
* d) logout still revokes instantly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const {
ACCESS_TOKEN_SUNSET_MS,
issueAccessAndRefresh,
rotateRefreshToken,
revokeBearerAccessToken,
} = await import("@/lib/auth/refresh-token");
const { resolveBearerToken, BearerAuthError } =
await import("@/lib/auth/bearer");
const { hashToken } = await import("@/lib/auth/hmac");
const NATIVE_POLICY = {
policy: "native" as const,
accessTokenDays: 1,
refreshTokenDays: 30,
tokenLabel: "native",
};
const DEVICE_ID = "sunset-device-1";
async function seedUser(username: string) {
return getPrismaClient().user.create({
data: { username, email: `${username}@example.test` },
});
}
/** The bearer verdict as a plain string, so a failure reads as a diff. */
async function bearerVerdict(rawToken: string): Promise<string> {
try {
await resolveBearerToken(rawToken, { kind: "wildcard-only" });
} catch (error) {
if (error instanceof BearerAuthError) return error.reason;
throw error;
}
return "accepted";
}
async function rotate(refreshToken: string) {
const result = await rotateRefreshToken({
refreshToken,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
});
return result;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
vi.useRealTimers();
});
describe("refresh rotation — paired access-token sunset", () => {
it("keeps the outgoing access token alive inside the window and expires it after", async () => {
const user = await seedUser("sunset-inflight");
const first = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
const rotated = await rotate(first.refreshToken);
if (!rotated.ok) throw new Error(`rotation failed: ${rotated.reason}`);
// The in-flight request that left the device before the rotation
// committed: it must still be served.
expect(await bearerVerdict(first.accessToken)).toBe("accepted");
// Past the window the token is dead — but as an expiry, not a revoke, so
// the client treats it as "refresh", not "re-authenticate".
const afterWindow = Date.now() + ACCESS_TOKEN_SUNSET_MS + 1_000;
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(afterWindow));
try {
expect(await bearerVerdict(first.accessToken)).toBe("expired");
} finally {
vi.useRealTimers();
}
// The replacement pair is untouched by the sunset.
expect(await bearerVerdict(rotated.bundle.accessToken)).toBe("accepted");
});
it("revokes the family and its access tokens instantly on refresh-token reuse", async () => {
const user = await seedUser("sunset-reuse");
const first = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
const rotated = await rotate(first.refreshToken);
if (!rotated.ok) throw new Error(`rotation failed: ${rotated.reason}`);
// Replay of the consumed refresh token: the stolen-token defence.
const replay = await rotate(first.refreshToken);
expect(replay).toEqual({ ok: false, reason: "already_used" });
// No window here: the live pair's access token is revoked, not sunset.
expect(await bearerVerdict(rotated.bundle.accessToken)).toBe("revoked");
const revokedRow = await getPrismaClient().apiToken.findUnique({
where: { tokenHash: hashToken(rotated.bundle.accessToken) },
select: { revoked: true },
});
expect(revokedRow?.revoked).toBe(true);
const liveRefresh = await getPrismaClient().refreshToken.findMany({
where: { userId: user.id, revokedAt: null },
});
expect(liveRefresh).toHaveLength(0);
});
it("never extends an access token whose own expiry is sooner than the window", async () => {
const user = await seedUser("sunset-min-guard");
const first = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
// This token has 5 seconds left of its own — less than the sunset window.
const ownExpiry = new Date(Date.now() + 5_000);
const accessHash = hashToken(first.accessToken);
await getPrismaClient().apiToken.updateMany({
where: { tokenHash: accessHash },
data: { expiresAt: ownExpiry },
});
const rotated = await rotate(first.refreshToken);
if (!rotated.ok) throw new Error(`rotation failed: ${rotated.reason}`);
const row = await getPrismaClient().apiToken.findUnique({
where: { tokenHash: accessHash },
select: { expiresAt: true, revoked: true },
});
expect(row?.revoked).toBe(false);
expect(row?.expiresAt?.getTime()).toBe(ownExpiry.getTime());
});
it("revokes instantly at logout", async () => {
const user = await seedUser("sunset-logout");
const pair = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
expect(await revokeBearerAccessToken(pair.accessToken)).toBe(true);
expect(await bearerVerdict(pair.accessToken)).toBe("revoked");
const refreshRow = await getPrismaClient().refreshToken.findUnique({
where: { tokenHash: hashToken(pair.refreshToken) },
select: { revokedAt: true },
});
expect(refreshRow?.revokedAt).not.toBeNull();
});
});