mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Rotation refused a replayed token under a spoofed device id (M-4) but still rotated a *live* one presented from a different device, and then inherited the caller's id onto the new row — putting the resulting family outside the victim device's containment scope. Refuse the mismatch outright when both ids are present; an absent id on either side stays unattributable and rotates as before, so a client that sends no X-Device-Id is not locked out. The expiry refusal had no coverage at all: the standing test presented a token that was never issued, so it passed on not_found and stayed green with the expiry branch deleted. Replace it with the real secret plus a just-inside-the-window companion, and cover the device binding.
578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mock prisma + the underlying issueApiToken so we can assert flow
|
|
// without spinning up a database.
|
|
const dbState: {
|
|
refreshTokens: Array<{
|
|
id: string;
|
|
userId: string;
|
|
tokenHash: string;
|
|
deviceId: string | null;
|
|
accessTokenHash: string | null;
|
|
expiresAt: Date;
|
|
revokedAt: Date | null;
|
|
usedAt: Date | null;
|
|
replacedById: string | null;
|
|
userAgent: string | null;
|
|
ipAddress: string | null;
|
|
createdAt: Date;
|
|
}>;
|
|
apiTokens: Array<{ tokenHash: string; revoked: boolean }>;
|
|
} = { refreshTokens: [], apiTokens: [] };
|
|
|
|
let issuedCounter = 0;
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
refreshToken: {
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
const row = {
|
|
id: `rt_${++issuedCounter}`,
|
|
createdAt: new Date(),
|
|
revokedAt: null,
|
|
usedAt: null,
|
|
replacedById: null,
|
|
userAgent: null,
|
|
ipAddress: null,
|
|
deviceId: null,
|
|
accessTokenHash: null,
|
|
...data,
|
|
} as (typeof dbState.refreshTokens)[number];
|
|
dbState.refreshTokens.push(row);
|
|
return row;
|
|
}),
|
|
findUnique: vi.fn(async ({ where }: { where: { tokenHash: string } }) => {
|
|
return (
|
|
dbState.refreshTokens.find((r) => r.tokenHash === where.tokenHash) ??
|
|
null
|
|
);
|
|
}),
|
|
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
|
return dbState.refreshTokens.filter((r) =>
|
|
Object.entries(where).every(
|
|
([k, v]) => (r as unknown as Record<string, unknown>)[k] === v,
|
|
),
|
|
);
|
|
}),
|
|
updateMany: vi.fn(
|
|
async ({
|
|
where,
|
|
data,
|
|
}: {
|
|
where: Record<string, unknown>;
|
|
data: Record<string, unknown>;
|
|
}) => {
|
|
let count = 0;
|
|
for (const row of dbState.refreshTokens) {
|
|
const matches = Object.entries(where).every(
|
|
([k, v]) => (row as unknown as Record<string, unknown>)[k] === v,
|
|
);
|
|
if (matches) {
|
|
Object.assign(row, data);
|
|
count++;
|
|
}
|
|
}
|
|
return { count };
|
|
},
|
|
),
|
|
},
|
|
apiToken: {
|
|
updateMany: vi.fn(
|
|
async ({
|
|
where,
|
|
data,
|
|
}: {
|
|
where: Record<string, unknown>;
|
|
data: Record<string, unknown>;
|
|
}) => {
|
|
let count = 0;
|
|
for (const row of dbState.apiTokens) {
|
|
const tokenHashFilter = where.tokenHash as
|
|
string | { in: string[] } | undefined;
|
|
const matches =
|
|
!tokenHashFilter ||
|
|
(typeof tokenHashFilter === "string"
|
|
? row.tokenHash === tokenHashFilter
|
|
: tokenHashFilter.in.includes(row.tokenHash));
|
|
if (matches) {
|
|
Object.assign(row, data);
|
|
count++;
|
|
}
|
|
}
|
|
return { count };
|
|
},
|
|
),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/issue-token", () => ({
|
|
issueApiToken: vi.fn(
|
|
async (opts: { userId: string; expiresInDays?: number }) => {
|
|
issuedCounter++;
|
|
const token = `hlk_token_${issuedCounter}`;
|
|
dbState.apiTokens.push({
|
|
tokenHash: `hash:${token}`,
|
|
revoked: false,
|
|
});
|
|
return {
|
|
token,
|
|
expiresAt: new Date(Date.now() + (opts.expiresInDays ?? 1) * 86400000),
|
|
tokenId: `t_${issuedCounter}`,
|
|
name: "test",
|
|
};
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/hmac", () => ({
|
|
hashToken: (raw: string) => `hash:${raw}`,
|
|
}));
|
|
|
|
import {
|
|
issueAccessAndRefresh,
|
|
rotateRefreshToken,
|
|
revokeRefreshToken,
|
|
revokeRefreshTokenByHash,
|
|
revokeBearerAccessToken,
|
|
} from "../refresh-token";
|
|
import type { TokenPolicyDecision } from "../native-client";
|
|
|
|
const NATIVE_POLICY: TokenPolicyDecision = {
|
|
policy: "native",
|
|
accessTokenDays: 1,
|
|
refreshTokenDays: 60,
|
|
tokenLabel: "native",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
dbState.refreshTokens = [];
|
|
dbState.apiTokens = [];
|
|
issuedCounter = 0;
|
|
});
|
|
|
|
describe("issueAccessAndRefresh", () => {
|
|
it("creates an ApiToken + RefreshToken row paired by hash", async () => {
|
|
const bundle = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
});
|
|
expect(bundle.accessToken).toMatch(/^hlk_token_/);
|
|
expect(bundle.refreshToken).toMatch(/^hlr_/);
|
|
expect(dbState.refreshTokens).toHaveLength(1);
|
|
expect(dbState.refreshTokens[0].accessTokenHash).toBe(
|
|
`hash:${bundle.accessToken}`,
|
|
);
|
|
});
|
|
|
|
it("throws if called for web policy (no refresh token)", async () => {
|
|
await expect(
|
|
issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: { ...NATIVE_POLICY, refreshTokenDays: null, policy: "web" },
|
|
source: "login.password",
|
|
}),
|
|
).rejects.toThrow(/web policy/);
|
|
});
|
|
});
|
|
|
|
describe("rotateRefreshToken", () => {
|
|
it("rotates: marks old used, issues new pair, revokes old access token", async () => {
|
|
const initial = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
});
|
|
const oldHash = `hash:${initial.accessToken}`;
|
|
expect(
|
|
dbState.apiTokens.find((a) => a.tokenHash === oldHash)?.revoked,
|
|
).toBe(false);
|
|
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: initial.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
const oldRow = dbState.refreshTokens[0];
|
|
expect(oldRow.usedAt).not.toBeNull();
|
|
expect(oldRow.replacedById).not.toBeNull();
|
|
expect(
|
|
dbState.apiTokens.find((a) => a.tokenHash === oldHash)?.revoked,
|
|
).toBe(true);
|
|
expect(dbState.refreshTokens).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects + revokes per-device family on reuse of consumed refresh token", async () => {
|
|
const initial = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
const first = await rotateRefreshToken({
|
|
refreshToken: initial.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
|
|
// Replay the original token — must be rejected as `already_used` AND
|
|
// the device's replacement family must be revoked too (defence in
|
|
// depth). Per-device scope means dev-1 rows get revoked.
|
|
const replay = await rotateRefreshToken({
|
|
refreshToken: initial.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
expect(replay.ok).toBe(false);
|
|
if (replay.ok) return;
|
|
expect(replay.reason).toBe("already_used");
|
|
// Every dev-1 refresh row should now be revoked
|
|
const liveDev1 = dbState.refreshTokens.filter(
|
|
(r) => r.revokedAt === null && r.deviceId === "dev-1",
|
|
);
|
|
expect(liveDev1).toHaveLength(0);
|
|
});
|
|
|
|
it("v1.4.23 — per-device replay does NOT revoke another device's tokens", async () => {
|
|
// Two-device legitimate scenario: an iPad and an iPhone both
|
|
// refresh independently. A replay attempt on dev-1 must NOT sign
|
|
// dev-2 out — that was the v1.4.22 behaviour the W4 brief
|
|
// explicitly called out as broken.
|
|
const dev1 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
const dev2 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-2",
|
|
});
|
|
|
|
// dev-1 rotates legitimately, then someone replays its stale token.
|
|
await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
const replay = await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
expect(replay.ok).toBe(false);
|
|
|
|
// dev-2's refresh token must still be live.
|
|
const dev2Row = dbState.refreshTokens.find(
|
|
(r) => r.tokenHash === `hash:${dev2.refreshToken}`,
|
|
);
|
|
expect(dev2Row?.revokedAt).toBeNull();
|
|
|
|
// dev-1's family is dead.
|
|
const liveDev1 = dbState.refreshTokens.filter(
|
|
(r) => r.revokedAt === null && r.deviceId === "dev-1",
|
|
);
|
|
expect(liveDev1).toHaveLength(0);
|
|
});
|
|
|
|
it("v1.4.23 — legacy null-deviceId replay still revokes ALL user tokens", async () => {
|
|
// Tokens issued before v1.4.23 carry deviceId === null. We can't
|
|
// safely scope the blast radius — fall back to the conservative
|
|
// user-wide revoke so a stolen pre-1.4.23 token can't bleed across
|
|
// every device the user owns.
|
|
const legacy = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
// deviceId omitted → null in DB row
|
|
});
|
|
const dev2 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-2",
|
|
});
|
|
|
|
// legacy rotates once, then someone replays it.
|
|
await rotateRefreshToken({
|
|
refreshToken: legacy.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
const replay = await rotateRefreshToken({
|
|
refreshToken: legacy.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
expect(replay.ok).toBe(false);
|
|
|
|
// dev-2 should ALSO be revoked here — null-deviceId triggers the
|
|
// wide blast radius (safety hatch).
|
|
void dev2;
|
|
const live = dbState.refreshTokens.filter((r) => r.revokedAt === null);
|
|
expect(live).toHaveLength(0);
|
|
});
|
|
|
|
it("M-4 — present-but-mismatched deviceId on replay escalates to a USER-WIDE revoke", async () => {
|
|
// Attacker steals dev-1's token and replays it under a fabricated
|
|
// X-Device-Id. The stored row's deviceId (dev-1) must NOT confine the
|
|
// revoke to the attacker's id — escalate to the whole user family so a
|
|
// victim's other device (dev-2) is also revoked.
|
|
const dev1 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
const dev2 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-2",
|
|
});
|
|
|
|
// dev-1 rotates legitimately (same deviceId), then the stale token is
|
|
// replayed under a spoofed deviceId.
|
|
await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
const replay = await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "attacker-fabricated-id",
|
|
});
|
|
expect(replay.ok).toBe(false);
|
|
if (replay.ok) return;
|
|
expect(replay.reason).toBe("already_used");
|
|
|
|
void dev2;
|
|
// Mismatch → wide revoke: NO live refresh row survives for the user.
|
|
const live = dbState.refreshTokens.filter((r) => r.revokedAt === null);
|
|
expect(live).toHaveLength(0);
|
|
});
|
|
|
|
it("M-4 — matching presented deviceId keeps the per-device scope (dev-2 survives)", async () => {
|
|
const dev1 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
const dev2 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-2",
|
|
});
|
|
|
|
await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
const replay = await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
expect(replay.ok).toBe(false);
|
|
|
|
// dev-2 still live — matched deviceId keeps the narrow scope.
|
|
const dev2Row = dbState.refreshTokens.find(
|
|
(r) => r.tokenHash === `hash:${dev2.refreshToken}`,
|
|
);
|
|
expect(dev2Row?.revokedAt).toBeNull();
|
|
const liveDev1 = dbState.refreshTokens.filter(
|
|
(r) => r.revokedAt === null && r.deviceId === "dev-1",
|
|
);
|
|
expect(liveDev1).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects an expired refresh token by its own secret", async () => {
|
|
// The token presented here is the REAL one, so the row is found and the
|
|
// expiry branch is what refuses it. Presenting an unknown string instead
|
|
// would pass on `not_found` and prove nothing about expiry at all.
|
|
const initial = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
});
|
|
dbState.refreshTokens[0].expiresAt = new Date(Date.now() - 1000);
|
|
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: initial.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("expired");
|
|
// An expired token must not mint a replacement pair.
|
|
expect(dbState.refreshTokens).toHaveLength(1);
|
|
});
|
|
|
|
it("accepts the same token one millisecond before it expires", async () => {
|
|
// Boundary companion: proves the expiry refusal above is driven by the
|
|
// clock and not by the rotation path refusing everything.
|
|
const initial = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
});
|
|
dbState.refreshTokens[0].expiresAt = new Date(Date.now() + 60_000);
|
|
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: initial.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("refuses a live token presented under a different device id", async () => {
|
|
// Device A's token replayed from device B. The token is valid and unused,
|
|
// so only the device binding can refuse it.
|
|
const dev1 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-2",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("device_mismatch");
|
|
// Refused, not consumed: the legitimate device can still rotate it.
|
|
expect(dbState.refreshTokens[0].usedAt).toBeNull();
|
|
expect(dbState.refreshTokens).toHaveLength(1);
|
|
|
|
const legitimate = await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
deviceId: "dev-1",
|
|
});
|
|
expect(legitimate.ok).toBe(true);
|
|
});
|
|
|
|
it("still rotates when the caller sends no device id at all", async () => {
|
|
// An older client that never sends `X-Device-Id` must not be locked out
|
|
// by the device binding — a presented null is unattributable, not a
|
|
// mismatch.
|
|
const dev1 = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: dev1.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects revoked refresh tokens", async () => {
|
|
const initial = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
});
|
|
await revokeRefreshToken(initial.refreshToken);
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: initial.refreshToken,
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("revoked");
|
|
});
|
|
});
|
|
|
|
describe("revokeBearerAccessToken (M-2 logout bearer revoke)", () => {
|
|
it("revokes the ApiToken and its paired refresh sibling", async () => {
|
|
const bundle = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
deviceId: "dev-1",
|
|
});
|
|
const accessHash = `hash:${bundle.accessToken}`;
|
|
expect(
|
|
dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked,
|
|
).toBe(false);
|
|
const refreshRow = dbState.refreshTokens.find(
|
|
(r) => r.accessTokenHash === accessHash,
|
|
);
|
|
expect(refreshRow?.revokedAt).toBeNull();
|
|
|
|
const ok = await revokeBearerAccessToken(bundle.accessToken);
|
|
expect(ok).toBe(true);
|
|
|
|
expect(
|
|
dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked,
|
|
).toBe(true);
|
|
expect(
|
|
dbState.refreshTokens.find((r) => r.accessTokenHash === accessHash)
|
|
?.revokedAt,
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("returns false when no live ApiToken matches the token", async () => {
|
|
const ok = await revokeBearerAccessToken("hlk_unknown_token");
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("revokeRefreshTokenByHash (native OIDC handoff replay containment)", () => {
|
|
it("revokes the refresh row + its paired access token by stored hash", async () => {
|
|
const bundle = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.oidc.native",
|
|
deviceId: "dev-1",
|
|
});
|
|
const refreshHash = `hash:${bundle.refreshToken}`;
|
|
const accessHash = `hash:${bundle.accessToken}`;
|
|
|
|
// The handoff row only ever stored the HASH of the refresh token, never
|
|
// the raw secret — so replay containment must revoke by hash.
|
|
const ok = await revokeRefreshTokenByHash(refreshHash);
|
|
expect(ok).toBe(true);
|
|
|
|
expect(
|
|
dbState.refreshTokens.find((r) => r.tokenHash === refreshHash)?.revokedAt,
|
|
).not.toBeNull();
|
|
expect(
|
|
dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("returns false for an unknown or already-revoked hash", async () => {
|
|
expect(await revokeRefreshTokenByHash("hash:hlr_unknown")).toBe(false);
|
|
|
|
const bundle = await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.oidc.native",
|
|
});
|
|
const refreshHash = `hash:${bundle.refreshToken}`;
|
|
await revokeRefreshTokenByHash(refreshHash);
|
|
// Second call is a no-op (idempotent containment).
|
|
expect(await revokeRefreshTokenByHash(refreshHash)).toBe(false);
|
|
});
|
|
});
|