mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
184 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
});
|