mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Harden the native auth-token surface against four confirmed findings. - /api/auth/logout was a no-op for a bearer credential: destroySession only clears the cookie, leaving an Authorization: Bearer hlk_ token live until expiry. When the request carries a bearer, also flip the matching ApiToken.revoked and revoke its paired refresh-token sibling. Cookie path unchanged. - A browser spoofing X-Client-Type: native was handed a 60-day refresh token in the JSON body. Gate refresh-token issuance on a new isCookielessNativeCaller check (no Mozilla UA, no inbound session cookie), so the secret reaches genuine cookie-less native callers only and never alongside a browser session. The iOS native path is unaffected; a spoofing browser falls back to the short-lived access token with no refresh. - Refresh-reuse revoke trusted the stored row's deviceId, so a stolen token replayed under a fabricated X-Device-Id confined the revoke to the attacker's own id and left the victim's family live. On a present-but- mismatched deviceId, escalate to the user-wide family revoke (a real device never changes its id mid-family). The matching-id and null-id cases keep their existing scope. - Tighten the auth:refresh rate limit from 60/15min to 10/15min, aligning the high-value rotation endpoint with the passkey-verify tier. Add coverage for each: logout revokes bearer + sibling, a cookie/Mozilla caller never receives a refresh token, a device-mismatch replay triggers the wide revoke, and the cookie-less native path still gets its refresh token. (cherry picked from commit 14dcdefc5f40855305d8aaf573d073faebc61476)
466 lines
14 KiB
TypeScript
466 lines
14 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,
|
|
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 expired refresh tokens", async () => {
|
|
await issueAccessAndRefresh({
|
|
userId: "u1",
|
|
policy: NATIVE_POLICY,
|
|
source: "login.password",
|
|
});
|
|
// Manually expire
|
|
dbState.refreshTokens[0].expiresAt = new Date(Date.now() - 1000);
|
|
const result = await rotateRefreshToken({
|
|
refreshToken: "hlr_doesnt_matter",
|
|
policy: NATIVE_POLICY,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("not_found");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|