Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/auth/mfa/trusted-device.server.test.ts
T

238 lines
9.7 KiB
TypeScript

/**
* D1 (Trust-Device) — DB-gestützter, widerrufbarer Geräte-Marker. Das Cookie trägt einen opaken,
* signierten Token; Autorität ist die TrustedDevice-Tabelle (Hash, userId, Ablauf, Widerruf).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { testWebConfig } = vi.hoisted(() => ({
testWebConfig: {
nodeEnv: "test" as string,
sessionSecret: "test-session-secret-please-change",
routing: {
appBaseDomain: "example.test",
webAppHost: "app.example.test",
adminAppHost: "admin.example.test",
apiHost: "api.example.test",
protocol: "https" as "http" | "https",
},
},
}));
const resolveTrustedDeviceRlsIdentityMock = vi.hoisted(() => vi.fn());
vi.mock("@hivegrid/config", async () => {
const actual = await vi.importActual<typeof import("@hivegrid/config")>("@hivegrid/config");
return {
...actual,
loadRequiredWebConfig: () => testWebConfig,
};
});
vi.mock("~/utils/db.server", () => ({
db: {
trustedDevice: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("~/services/auth/sessionLifecycle.server", () => ({
buildSessionDeviceContext: vi.fn().mockResolvedValue({ ipHash: null, userAgent: "UA", approxLocation: "DE" }),
}));
vi.mock("@hivegrid/db/rls-resolvers.server", () => ({
resolveTrustedDeviceRlsIdentity: resolveTrustedDeviceRlsIdentityMock,
}));
import { db } from "~/utils/db.server";
import {
__resetTrustedDeviceCookieForTests,
clearTrustedDeviceCookie,
forgetCurrentTrustedDevice,
isTrustedDevice,
listTrustedDevices,
rememberDeviceForUser,
revokeAllTrustedDevices,
revokeTrustedDevice,
LOCAL_TRUSTED_DEVICE_COOKIE_NAME,
TRUSTED_DEVICE_COOKIE_NAME,
} from "./trusted-device.server";
const create = vi.mocked(db.trustedDevice.create);
const findUnique = vi.mocked(db.trustedDevice.findUnique);
const updateMany = vi.mocked(db.trustedDevice.updateMany);
function activeRow(overrides: Record<string, unknown> = {}) {
return {
id: "td-1",
userId: "user-1",
tokenHash: "hash",
createdAt: new Date(),
lastUsedAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
revokedAt: null,
ipHash: null,
userAgent: "UA",
deviceLabel: null,
approxLocation: "DE",
...overrides,
};
}
async function rememberAndGetRequest(userId = "user-1") {
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId });
const pair = setCookie.split(";")[0];
return new Request("https://app.example.com/login", { headers: { Cookie: pair } });
}
beforeEach(() => {
vi.clearAllMocks();
resolveTrustedDeviceRlsIdentityMock.mockResolvedValue({ resourceId: "td-1", userId: "user-1" });
testWebConfig.nodeEnv = "test";
testWebConfig.routing.protocol = "https";
testWebConfig.routing.appBaseDomain = "example.test";
__resetTrustedDeviceCookieForTests();
});
afterEach(() => __resetTrustedDeviceCookieForTests());
describe("trusted-device (DB-backed)", () => {
it("remembers a device: writes a row and returns a Set-Cookie", async () => {
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId: "user-1" });
expect(create).toHaveBeenCalledTimes(1);
expect(setCookie).toContain(TRUSTED_DEVICE_COOKIE_NAME);
expect(setCookie).toContain("Secure");
expect(setCookie).toContain("Path=/");
expect(setCookie).not.toContain("Domain=");
// Nur der Hash, nie der rohe Token, wird persistiert.
const data = (create.mock.calls[0][0] as { data: { tokenHash: string } }).data;
expect(data.tokenHash).toMatch(/^[0-9a-f]{64}$/);
});
it("round-trips: a remembered device is trusted for the same user", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
});
it("touches lastUsedAt (throttled) when the marker was last used long ago", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ lastUsedAt: new Date(0) }) as never);
vi.mocked(db.trustedDevice.update).mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
expect(db.trustedDevice.update).toHaveBeenCalledTimes(1);
});
it("rejects a marker that belongs to a different user", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ userId: "user-2" }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("rejects an expired marker", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("rejects a revoked marker", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ revokedAt: new Date() }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("returns false (and skips the DB) without a cookie", async () => {
await expect(isTrustedDevice(new Request("https://app.example.com/login"), "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("rejects a tampered cookie (broken signature) without hitting the DB", async () => {
const request = await rememberAndGetRequest("user-1");
const tampered = new Request("https://app.example.com/login", { headers: { Cookie: `${request.headers.get("Cookie")}TAMPER` } });
findUnique.mockClear();
await expect(isTrustedDevice(tampered, "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("ignores a legacy parent-domain cookie even when its signed value is otherwise valid", async () => {
const validRequest = await rememberAndGetRequest("user-1");
const cookie = validRequest.headers.get("Cookie") ?? "";
const legacyCookie = cookie.replace(TRUSTED_DEVICE_COOKIE_NAME, LOCAL_TRUSTED_DEVICE_COOKIE_NAME);
const request = new Request("https://app.example.test/login", { headers: { Cookie: legacyCookie } });
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("does not let a legacy cookie shadow the host-prefixed marker", async () => {
const validRequest = await rememberAndGetRequest("user-1");
const secureCookie = validRequest.headers.get("Cookie") ?? "";
const request = new Request("https://app.example.test/login", {
headers: { Cookie: `${LOCAL_TRUSTED_DEVICE_COOKIE_NAME}=attacker; ${secureCookie}` },
});
findUnique.mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
expect(findUnique).toHaveBeenCalledTimes(1);
});
it("uses a distinct non-Secure fallback name on local HTTP", async () => {
testWebConfig.nodeEnv = "development";
testWebConfig.routing.protocol = "http";
testWebConfig.routing.appBaseDomain = "localhost:3000";
__resetTrustedDeviceCookieForTests();
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("http://localhost:3000/login-mfa"), userId: "user-1" });
expect(setCookie).toContain(`${LOCAL_TRUSTED_DEVICE_COOKIE_NAME}=`);
expect(setCookie).not.toContain(TRUSTED_DEVICE_COOKIE_NAME);
expect(setCookie).not.toContain("Secure");
expect(setCookie).not.toContain("Domain=");
});
it("lists active trusted devices scoped to the owner, newest-used first", async () => {
const rows = [activeRow({ id: "td-1" }), activeRow({ id: "td-2" })];
vi.mocked(db.trustedDevice.findMany).mockResolvedValue(rows as never);
await expect(listTrustedDevices("user-1")).resolves.toEqual(rows);
expect(db.trustedDevice.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user-1", revokedAt: null }),
orderBy: { lastUsedAt: "desc" },
})
);
});
it("treats a database failure during the trust lookup as not-trusted", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockRejectedValue(new Error("db unavailable"));
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("revokes a single device scoped to the owner", async () => {
updateMany.mockResolvedValue({ count: 1 } as never);
await expect(revokeTrustedDevice({ userId: "user-1", deviceId: "td-1" })).resolves.toBe(true);
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: "td-1", userId: "user-1", revokedAt: null }) }));
});
it("revokes all devices of a user", async () => {
updateMany.mockResolvedValue({ count: 3 } as never);
await expect(revokeAllTrustedDevices("user-1")).resolves.toBe(3);
});
it("forgets the current device: revokes its row and clears the cookie", async () => {
const request = await rememberAndGetRequest("user-1");
updateMany.mockResolvedValue({ count: 1 } as never);
const setCookie = await forgetCurrentTrustedDevice(request);
expect(updateMany).toHaveBeenCalledTimes(1);
expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
it("clears the cookie without DB access", async () => {
const cleared = await clearTrustedDeviceCookie();
expect(cleared).toContain(TRUSTED_DEVICE_COOKIE_NAME);
});
});