Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import type { WebConfig } from "@toolctrl/config";
import { getPublicAuthProviders, getServerAuthProviders } from "./authProviders.server";
const baseWebConfig: WebConfig = {
appName: "Test App",
nodeEnv: "production",
listen: { host: "0.0.0.0", port: 3000 },
routing: {
appBaseDomain: "example.com",
webAppHost: "app.example.com",
adminAppHost: "admin.example.com",
apiHost: "api.example.com",
protocol: "https",
},
stripe: {},
crisp: {},
captcha: {},
analytics: {},
novu: {},
mailerSend: {},
ipProviders: {},
portalServer: {},
magicLink: {
enabled: true,
tokenTtlMinutes: 15,
},
} as unknown as WebConfig;
function requestFor(host = "https://app.example.com/dashboard") {
return new Request(host);
}
describe("authProviders.server", () => {
test("reports magic-link as enabled and configured when enabled in web config", () => {
const providers = getServerAuthProviders({ request: requestFor(), webConfig: baseWebConfig });
expect(providers.magicLink).toEqual({ enabled: true, configured: true });
});
test("public providers only expose the supported magic-link method", () => {
const publicProviders = getPublicAuthProviders({ request: requestFor(), webConfig: baseWebConfig });
expect(Object.keys(publicProviders)).toEqual(["magicLink"]);
expect(publicProviders.magicLink).toEqual({ enabled: true, configured: true });
});
test("normalizes disabled magic-link to a safe disabled unconfigured provider", () => {
const webConfig = {
...baseWebConfig,
magicLink: { enabled: false, tokenTtlMinutes: undefined },
} as WebConfig;
const providers = getServerAuthProviders({ request: requestFor(), webConfig });
expect(providers.magicLink).toEqual({ enabled: false, configured: false });
});
});
@@ -0,0 +1,40 @@
import { loadRequiredWebConfig, type WebConfig } from "@toolctrl/config";
type AuthProviderRequestOptions = {
request?: Request;
webConfig?: WebConfig;
};
// EU-Sterilisation (AD/EU-Stack): social OAuth login (GitHub/Google) is removed.
// Authentication is Email+Password, Magic-Link and TOTP only. Keep this provider
// registry so additional EU-compatible methods can slot in later.
export type PublicAuthProviders = {
magicLink: {
enabled: boolean;
configured: boolean;
};
};
export type ServerAuthProviders = {
magicLink: {
enabled: boolean;
configured: boolean;
};
};
export function getPublicAuthProviders(options: AuthProviderRequestOptions): PublicAuthProviders {
const providers = getServerAuthProviders(options);
return {
magicLink: providers.magicLink,
};
}
export function getServerAuthProviders({ webConfig = loadRequiredWebConfig() }: AuthProviderRequestOptions): ServerAuthProviders {
return {
magicLink: {
enabled: webConfig.magicLink.enabled,
configured: webConfig.magicLink.enabled,
},
};
}
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSharedStore } from "@toolctrl/shared-store";
import {
checkMfaChallengeRateLimit,
mfaChallengeRateLimitKey,
recordFailedMfaChallengeAttempt,
resetMfaChallengeRateLimit,
resetMfaChallengeRateLimiterForTests,
__resetMfaChallengeRateLimitStoreForTests,
__setMfaChallengeRateLimitStoreForTests,
} from "./challenge-rate-limit.server";
beforeEach(() => {
__setMfaChallengeRateLimitStoreForTests(createSharedStore({ namespace: "test-mfa-challenge" }));
});
afterEach(async () => {
await resetMfaChallengeRateLimiterForTests();
__resetMfaChallengeRateLimitStoreForTests();
});
describe("MFA challenge rate limiter", () => {
it("keys attempts by user and client IP", () => {
const request = new Request("https://admin.example.test/mfa-challenge", {
headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1" },
});
expect(mfaChallengeRateLimitKey(request, "user_1")).toBe("user_1:203.0.113.5");
});
it("falls back from blank forwarded headers to real-ip and then unknown", () => {
expect(
mfaChallengeRateLimitKey(
new Request("https://admin.example.test/mfa-challenge", {
headers: { "x-forwarded-for": " ", "x-real-ip": "198.51.100.7" },
}),
"user_1",
),
).toBe("user_1:198.51.100.7");
expect(mfaChallengeRateLimitKey(new Request("https://admin.example.test/mfa-challenge"), "user_1")).toBe(
"user_1:unknown",
);
});
it("blocks after repeated failed attempts and can be reset", async () => {
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
for (let index = 0; index < 5; index += 1) {
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
await recordFailedMfaChallengeAttempt(key, now);
}
const blocked = await checkMfaChallengeRateLimit(key, now);
expect(blocked.allowed).toBe(false);
if (!blocked.allowed) {
expect(blocked.retryAfterSeconds).toBe(300);
}
await resetMfaChallengeRateLimit(key);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
});
it("treats non-numeric stored counters as zero remaining attempts", async () => {
const store = createSharedStore({ namespace: "test-mfa-challenge-nan" });
__setMfaChallengeRateLimitStoreForTests(store);
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
await store.set(`rl:fixed:mfa-challenge:${key}`, "NaN", { ttlSeconds: 300 });
await expect(checkMfaChallengeRateLimit(key, now)).resolves.toEqual({
allowed: true,
remaining: 5,
resetAt: new Date("2026-05-11T12:05:00.000Z"),
});
});
it("resetMfaChallengeRateLimiterForTests clears cached fixed-window keys", async () => {
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
await recordFailedMfaChallengeAttempt(key, now);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
await resetMfaChallengeRateLimiterForTests();
const freshStore = createSharedStore({ namespace: "test-mfa-challenge" });
__setMfaChallengeRateLimitStoreForTests(freshStore);
await recordFailedMfaChallengeAttempt(key, now);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
});
it("uses the redis adapter when REDIS_URL is set", () => {
const originalRedisUrl = process.env.REDIS_URL;
try {
process.env.REDIS_URL = "redis://does-not-exist:6379";
const store = createSharedStore({ namespace: "mfa-redis-selection", redisUrl: process.env.REDIS_URL });
expect(store.adapter.constructor.name).toBe("RedisAdapter");
void store.close();
} finally {
if (originalRedisUrl === undefined) delete process.env.REDIS_URL;
else process.env.REDIS_URL = originalRedisUrl;
}
});
it("uses the memory adapter when REDIS_URL is unset", () => {
const originalRedisUrl = process.env.REDIS_URL;
delete process.env.REDIS_URL;
try {
const store = createSharedStore({ namespace: "mfa-memory-selection" });
expect(store.adapter.constructor.name).toBe("MemoryAdapter");
} finally {
if (originalRedisUrl !== undefined) process.env.REDIS_URL = originalRedisUrl;
}
});
});
@@ -0,0 +1,102 @@
import {
createFixedWindowRateLimiter,
type FixedWindowRateLimiter,
type SharedStore,
} from "@toolctrl/shared-store";
import { getSharedStore } from "~/shared-store.server";
const MFA_CHALLENGE_LIMIT = 5;
const MFA_CHALLENGE_WINDOW_SECONDS = 5 * 60;
export type MfaChallengeRateLimitResult =
| { allowed: true; remaining: number; resetAt: Date }
| { allowed: false; retryAfterSeconds: number; resetAt: Date };
type Limiter = {
limiter: FixedWindowRateLimiter;
store: SharedStore;
};
let cached: Limiter | null = null;
let storeFactory: () => SharedStore = () => getSharedStore();
function getLimiter(): Limiter {
if (cached !== null) return cached;
const store = storeFactory();
cached = {
store,
limiter: createFixedWindowRateLimiter({
store,
prefix: "mfa-challenge",
windowSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
max: MFA_CHALLENGE_LIMIT,
}),
};
return cached;
}
export function mfaChallengeRateLimitKey(request: Request, userId: string) {
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const ip = forwardedFor || request.headers.get("x-real-ip")?.trim() || "unknown";
return `${userId}:${ip}`;
}
export async function checkMfaChallengeRateLimit(key: string, now: Date = new Date()): Promise<MfaChallengeRateLimitResult> {
const { store } = getLimiter();
const suffix = `rl:fixed:mfa-challenge:${key}`;
const raw = await store.get(suffix);
const count = raw === null ? 0 : Number.parseInt(raw, 10) || 0;
if (count >= MFA_CHALLENGE_LIMIT) {
return {
allowed: false,
retryAfterSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
};
}
return {
allowed: true,
remaining: MFA_CHALLENGE_LIMIT - count,
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
};
}
export async function recordFailedMfaChallengeAttempt(key: string, _now: Date = new Date()): Promise<void> {
const { limiter } = getLimiter();
await limiter.consume(key);
}
export async function resetMfaChallengeRateLimit(key: string): Promise<void> {
const { limiter } = getLimiter();
await limiter.reset(key);
}
export async function resetMfaChallengeRateLimiterForTests(): Promise<void> {
const previous = cached;
cached = null;
if (previous) {
try {
for await (const fullKey of previous.store.adapter.scan(
`shared-store:${previous.store.namespace}:rl:fixed:mfa-challenge:*`,
)) {
const prefix = `shared-store:${previous.store.namespace}:`;
if (fullKey.startsWith(prefix)) {
await previous.store.del(fullKey.slice(prefix.length));
}
}
} catch {
// Best-effort; isolated tests inject a fresh store via the seam.
}
}
}
/** Test seam: inject a custom SharedStore. */
export function __setMfaChallengeRateLimitStoreForTests(store: SharedStore): void {
cached = null;
storeFactory = () => store;
}
/** Test seam: revert to the default web shared-store. */
export function __resetMfaChallengeRateLimitStoreForTests(): void {
cached = null;
storeFactory = () => getSharedStore();
}
@@ -0,0 +1,197 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import type { UserTotpStatus } from "~/utils/db/users.db.server";
import type { MfaStepUpCheckResult } from "./step-up.server";
import {
ADMIN_MFA_CHALLENGE_PATH,
enforceAdminMfa,
type AdminMfaEnforcementResult,
} from "./enforcement.server";
const enabledTotp: UserTotpStatus = {
enabled: true,
pending: false,
enabledAt: new Date("2026-05-08T14:00:00.000Z"),
pendingCreatedAt: null,
};
const pendingOnlyTotp: UserTotpStatus = {
enabled: false,
pending: true,
enabledAt: null,
pendingCreatedAt: new Date("2026-05-08T14:00:00.000Z"),
};
const freshStepUp: MfaStepUpCheckResult = {
verified: true,
state: { userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:01:00.000Z" },
ageMs: 1_000,
};
function enforce(overrides: Partial<Parameters<typeof enforceAdminMfa>[0]> = {}): AdminMfaEnforcementResult {
return enforceAdminMfa({
requestUrl: "https://admin.example.com/users?tenant=tenant-1#section",
userId: "admin-1",
isAdmin: true,
totpStatus: enabledTotp,
stepUp: freshStepUp,
...overrides,
});
}
describe("enforceAdminMfa", () => {
test("allows admin users with enabled TOTP and fresh step-up", () => {
expect(enforce()).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
});
test("allows admins without active TOTP so the profile enrollment path is opt-in and non-blocking", () => {
expect(enforce({ totpStatus: null })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(enforce({ totpStatus: { enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null } })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(enforce({ totpStatus: pendingOnlyTotp })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
});
test("requires challenge when step-up is missing", () => {
expect(enforce({ stepUp: { verified: false, reason: "missing" } })).toEqual({
allowed: false,
reason: "step-up-missing",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
});
});
test("accepts URL objects when deriving safe redirects and challenge targets", () => {
expect(enforce({ requestUrl: new URL("https://admin.example.com/users?tenant=tenant-1#section") })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(
enforce({
requestUrl: new URL("https://admin.example.com/users?tenant=tenant-1#section"),
stepUp: { verified: false, reason: "missing" },
}),
).toEqual({
allowed: false,
reason: "step-up-missing",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
});
});
test("allows the admin MFA challenge path when requestUrl is already a URL object", () => {
expect(
enforce({
requestUrl: new URL("https://admin.example.com/mfa-challenge?redirectTo=%2Fusers"),
stepUp: { verified: false, reason: "missing" },
}),
).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/mfa-challenge?redirectTo=%2Fusers",
});
});
test("requires challenge when step-up is stale", () => {
expect(enforce({ stepUp: { verified: false, reason: "stale" } })).toEqual({
allowed: false,
reason: "step-up-stale",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
});
});
test("uses canonical admin redirects on the admin subdomain", () => {
expect(enforce({ stepUp: { verified: false, reason: "missing" }, redirectMode: "host-aware" })).toEqual({
allowed: false,
reason: "step-up-missing",
redirectTo: "/mfa-challenge?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section",
});
expect(enforce({ redirectMode: "host-aware" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(enforce({ totpStatus: null, redirectMode: "host-aware" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
});
test("allows the admin MFA enrollment route before TOTP is enabled", () => {
expect(enforce({ requestUrl: "https://admin.example.com/settings/profile?mfa=setup", totpStatus: pendingOnlyTotp, redirectMode: "host-aware" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/settings/profile?mfa=setup",
});
});
test("allows the admin MFA challenge route without existing step-up", () => {
expect(enforce({ requestUrl: "https://admin.example.com/mfa-challenge?redirectTo=%2Fusers", stepUp: { verified: false, reason: "missing" } })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/mfa-challenge?redirectTo=%2Fusers",
});
});
test("denies non-admin and anonymous requests before challenge bypass is possible", () => {
expect(enforce({ isAdmin: false, stepUp: freshStepUp })).toEqual({
allowed: false,
reason: "missing-admin",
redirectTo: "/",
});
expect(enforce({ userId: null, isAdmin: true, stepUp: freshStepUp })).toEqual({
allowed: false,
reason: "missing-user",
redirectTo: "/login",
});
});
test("sanitizes redirect targets derived from external and malformed request URLs", () => {
expect(enforce({ requestUrl: "https://evil.example/phish?next=https://admin.example.com/settings" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/phish?next=https://admin.example.com/settings",
});
expect(enforce({ requestUrl: "not a url", stepUp: { verified: false, reason: "malformed" } })).toEqual({
allowed: false,
reason: "step-up-malformed",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2F`,
});
});
test("maps malformed and wrong-user step-up states to fail-closed reasons", () => {
expect(enforce({ stepUp: { verified: false, reason: "malformed" } })).toMatchObject({
allowed: false,
reason: "step-up-malformed",
});
expect(enforce({ stepUp: { verified: false, reason: "wrong-user" } })).toMatchObject({
allowed: false,
reason: "step-up-wrong-user",
});
});
test("result payloads contain no TOTP secrets, recovery codes, hashes, cookies, tokens, or passwords", () => {
const result = enforce({
requestUrl: "https://admin.example.com/dashboard?tab=secrets",
stepUp: { verified: false, reason: "missing" },
});
expect(JSON.stringify(result)).not.toMatch(/totpSecret|recoveryCode|hash|cookie|token|password|encrypted/i);
});
});
@@ -0,0 +1,87 @@
import type { UserTotpStatus } from "~/utils/db/users.db.server";
import type { MfaStepUpCheckReason, MfaStepUpCheckResult } from "./step-up.server";
export { ADMIN_MFA_CHALLENGE_PATH, ADMIN_MFA_DEFAULT_REDIRECT_PATH, ADMIN_MFA_ENROLLMENT_PATH } from "./paths";
import { ADMIN_MFA_CHALLENGE_PATH, ADMIN_MFA_DEFAULT_REDIRECT_PATH } from "./paths";
export type AdminMfaEnforcementFailureReason =
| "missing-user"
| "missing-admin"
| "missing-totp-status"
| "totp-not-enabled"
| "step-up-missing"
| "step-up-malformed"
| "step-up-wrong-user"
| "step-up-stale";
export type AdminMfaEnforcementReason = "allowed" | AdminMfaEnforcementFailureReason;
export type AdminMfaEnforcementResult =
| { allowed: true; reason: "allowed"; redirectTo: string }
| { allowed: false; reason: AdminMfaEnforcementFailureReason; redirectTo: string };
export type AdminMfaEnforcementInput = {
requestUrl: string | URL;
userId?: string | null;
isAdmin?: boolean | null;
totpStatus?: UserTotpStatus | null;
stepUp: MfaStepUpCheckResult;
redirectMode?: "host-aware";
appFallbackPath?: string;
};
function isAdminMfaChallengePath(requestUrl: string | URL): boolean {
try {
const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl);
return url.pathname === ADMIN_MFA_CHALLENGE_PATH;
} catch {
return false;
}
}
function safePathFromRequestUrl(requestUrl: string | URL): string {
try {
const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return ADMIN_MFA_DEFAULT_REDIRECT_PATH;
}
}
function challengeRedirectTo(requestUrl: string | URL): string {
const redirectTo = safePathFromRequestUrl(requestUrl);
const params = new URLSearchParams({ redirectTo });
return `${ADMIN_MFA_CHALLENGE_PATH}?${params.toString()}`;
}
function stepUpReason(reason: MfaStepUpCheckReason): AdminMfaEnforcementFailureReason {
switch (reason) {
case "missing":
return "step-up-missing";
case "malformed":
return "step-up-malformed";
case "wrong-user":
return "step-up-wrong-user";
case "stale":
return "step-up-stale";
}
}
export function enforceAdminMfa(input: AdminMfaEnforcementInput): AdminMfaEnforcementResult {
if (!input.userId) {
return { allowed: false, reason: "missing-user", redirectTo: "/login" };
}
if (input.isAdmin !== true) {
return { allowed: false, reason: "missing-admin", redirectTo: input.appFallbackPath ?? "/" };
}
if (!input.totpStatus || !input.totpStatus.enabled) {
return { allowed: true, reason: "allowed", redirectTo: safePathFromRequestUrl(input.requestUrl) };
}
if (!input.stepUp.verified && !isAdminMfaChallengePath(input.requestUrl)) {
return { allowed: false, reason: stepUpReason(input.stepUp.reason), redirectTo: challengeRedirectTo(input.requestUrl) };
}
return { allowed: true, reason: "allowed", redirectTo: safePathFromRequestUrl(input.requestUrl) };
}
@@ -0,0 +1,5 @@
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export const ADMIN_MFA_ENROLLMENT_PATH = adminPath("settings/profile", { mode: "host-aware" });
export const ADMIN_MFA_CHALLENGE_PATH = adminPath("mfa-challenge", { mode: "host-aware" });
export const ADMIN_MFA_DEFAULT_REDIRECT_PATH = adminPath(undefined, { mode: "host-aware" });
@@ -0,0 +1,158 @@
// @vitest-environment node
import { beforeEach, describe, expect, test } from "vitest";
const codePattern = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}$/;
describe("recovery-codes.server", () => {
beforeEach(() => {
process.env.CRYPTO_SECRET = "test-recovery-code-secret";
});
test("generates the default count of unique human-readable codes with hash-only storage entries", async () => {
const { DEFAULT_RECOVERY_CODE_COUNT, generateRecoveryCodes } = await import("./recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const result = generateRecoveryCodes({ now: generatedAt });
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
expect(result.codes).toEqual(Array.from({ length: DEFAULT_RECOVERY_CODE_COUNT }, () => expect.stringMatching(codePattern)));
expect(result.generatedAt).toBe(generatedAt);
expect(result.entries).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
for (const [index, entry] of result.entries.entries()) {
expect(entry).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/), usedAt: null, createdAt: generatedAt });
expect(entry.hash).not.toBe(result.codes[index]);
expect(JSON.stringify(entry)).not.toContain(result.codes[index]);
}
});
test("normalizes code input and verifies hashes with constant-length safe comparison", async () => {
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
const code = "2345-6789-ABCD";
const hash = hashRecoveryCode(code);
expect(normalizeRecoveryCode(" 2345 6789 abcd ")).toBe(code);
expect(hash).toMatch(/^[a-f0-9]{64}$/);
expect(verifyRecoveryCodeHash("23456789abcd", hash!)).toBe(true);
expect(verifyRecoveryCodeHash("2345-6789-ABCE", hash!)).toBe(false);
expect(verifyRecoveryCodeHash("2345-6789-ABCD", "abcd")).toBe(false);
});
test("rejects malformed input before hashing or verification", async () => {
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
expect(normalizeRecoveryCode("")).toBeNull();
expect(normalizeRecoveryCode("1234-5678-ABCD")).toBeNull();
expect(normalizeRecoveryCode("2345-6789-ABC")).toBeNull();
expect(normalizeRecoveryCode("2345-6789-ABCI")).toBeNull();
expect(hashRecoveryCode("2345-6789-ABCI")).toBeNull();
expect(verifyRecoveryCodeHash("2345-6789-ABCI", "00".repeat(32))).toBe(false);
});
test("consumes a valid recovery code once and rejects replay", async () => {
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
const generated = generateRecoveryCodes({ count: 2, now: new Date("2026-05-08T14:00:00.000Z") });
const firstUseAt = new Date("2026-05-08T14:05:00.000Z");
const replayAt = new Date("2026-05-08T14:06:00.000Z");
const firstUse = consumeRecoveryCode(generated.entries, generated.codes[0], { now: firstUseAt });
const replay = consumeRecoveryCode(firstUse.entries, generated.codes[0], { now: replayAt });
const wrongCode = consumeRecoveryCode(firstUse.entries, "2345-6789-ABCD", { now: replayAt });
expect(firstUse.matched).toBe(true);
expect(firstUse.usedAt).toBe(firstUseAt);
expect(firstUse.entries[0].usedAt).toBe(firstUseAt);
expect(firstUse.entries[1].usedAt).toBeNull();
expect(replay).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
expect(wrongCode).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
});
test("generation does not loop indefinitely: succeeds on the first attempt under healthy entropy", async () => {
const { generateRecoveryCodes, DEFAULT_RECOVERY_CODE_COUNT } = await import("./recovery-codes.server");
const result = generateRecoveryCodes();
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
});
test("generation completes in bounded time even when asked for the maximum count (no rejection-sampling loop)", async () => {
const { generateRecoveryCodes } = await import("./recovery-codes.server");
// The legacy implementation rebuilt a `Set` with the codes and looped
// until uniqueness — pathological under bad entropy. We assert a strict
// wall-clock bound to catch any regression that re-introduces unbounded
// looping.
const start = Date.now();
const result = generateRecoveryCodes({ count: 50 });
const elapsed = Date.now() - start;
expect(result.codes).toHaveLength(50);
expect(new Set(result.codes).size).toBe(50);
expect(elapsed).toBeLessThan(500);
});
test("status helpers redact raw codes and hashes when serialized", async () => {
const { consumeRecoveryCode, generateRecoveryCodes, getRecoveryCodeStatus } = await import("./recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const usedAt = new Date("2026-05-08T14:10:00.000Z");
const generated = generateRecoveryCodes({ count: 3, now: generatedAt });
const consumed = consumeRecoveryCode(generated.entries, generated.codes[1], { now: usedAt });
const status = getRecoveryCodeStatus(consumed.entries);
const serialized = JSON.stringify(status);
expect(status).toEqual({
configured: true,
totalCount: 3,
remainingCount: 2,
usedCount: 1,
generatedAt,
lastUsedAt: usedAt,
});
for (const code of generated.codes) {
expect(serialized).not.toContain(code);
}
for (const entry of generated.entries) {
expect(serialized).not.toContain(entry.hash);
}
expect(serialized).not.toMatch(/hash|code|secret/i);
});
test("status helper reports an unconfigured state for null or undefined entries", async () => {
const { getRecoveryCodeStatus } = await import("./recovery-codes.server");
expect(getRecoveryCodeStatus(null)).toEqual({
configured: false,
totalCount: 0,
remainingCount: 0,
usedCount: 0,
generatedAt: null,
lastUsedAt: null,
});
expect(getRecoveryCodeStatus(undefined, { generatedAt: null, lastUsedAt: null })).toEqual({
configured: false,
totalCount: 0,
remainingCount: 0,
usedCount: 0,
generatedAt: null,
lastUsedAt: null,
});
});
test("consumeRecoveryCode rejects malformed codes and uses the current time when no now option is supplied", async () => {
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
const generated = generateRecoveryCodes({ count: 1, now: new Date("2026-05-08T14:00:00.000Z") });
expect(consumeRecoveryCode(generated.entries, "bad-code")).toEqual({
matched: false,
entries: generated.entries,
usedAt: null,
});
const consumed = consumeRecoveryCode(generated.entries, generated.codes[0]);
expect(consumed.matched).toBe(true);
expect(consumed.usedAt).toBeInstanceOf(Date);
expect(consumed.entries[0].usedAt).toBe(consumed.usedAt);
});
});
@@ -0,0 +1,206 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { loadRequiredWebConfig } from "@toolctrl/config";
export const DEFAULT_RECOVERY_CODE_COUNT = 10;
export const DEFAULT_RECOVERY_CODE_SEGMENTS = 3;
export const DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH = 4;
export const RECOVERY_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
export type RecoveryCodeHashEntry = {
hash: string;
usedAt: Date | null;
createdAt: Date;
};
export type RecoveryCodeGeneration = {
codes: string[];
entries: RecoveryCodeHashEntry[];
generatedAt: Date;
};
export type RecoveryCodeStatus = {
configured: boolean;
totalCount: number;
remainingCount: number;
usedCount: number;
generatedAt: Date | null;
lastUsedAt: Date | null;
};
type GenerateRecoveryCodesOptions = {
count?: number;
segments?: number;
segmentLength?: number;
now?: Date;
};
type NormalizeOptions = {
segments?: number;
segmentLength?: number;
};
function assertPositiveInteger(value: number, name: string) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
}
function getRecoveryCodeSecret() {
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
const secret = cryptoSecret || sessionSecret;
if (!secret) {
throw new Error("CRYPTO_SECRET or SESSION_SECRET env variable not set");
}
return secret;
}
function randomAlphabetCharacter() {
const byteLimit = Math.floor(256 / RECOVERY_CODE_ALPHABET.length) * RECOVERY_CODE_ALPHABET.length;
while (true) {
const value = randomBytes(1)[0];
if (value < byteLimit) {
return RECOVERY_CODE_ALPHABET[value % RECOVERY_CODE_ALPHABET.length];
}
}
}
export function normalizeRecoveryCode(input: string, options: NormalizeOptions = {}) {
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
assertPositiveInteger(segments, "segments");
assertPositiveInteger(segmentLength, "segmentLength");
const compact = input.trim().toUpperCase().replace(/[\s-]/g, "");
const expectedLength = segments * segmentLength;
if (compact.length !== expectedLength || [...compact].some((character) => !RECOVERY_CODE_ALPHABET.includes(character))) {
return null;
}
return Array.from({ length: segments }, (_, index) =>
compact.slice(index * segmentLength, (index + 1) * segmentLength)
).join("-");
}
export function generateRecoveryCode(options: NormalizeOptions = {}) {
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
assertPositiveInteger(segments, "segments");
assertPositiveInteger(segmentLength, "segmentLength");
return Array.from({ length: segments }, () =>
Array.from({ length: segmentLength }, () => randomAlphabetCharacter()).join("")
).join("-");
}
export function hashRecoveryCode(code: string) {
const normalized = normalizeRecoveryCode(code);
if (!normalized) {
return null;
}
return createHmac("sha256", getRecoveryCodeSecret()).update(normalized).digest("hex");
}
function hashMatches(candidateHash: string, storedHash: string) {
const candidateBuffer = Buffer.from(candidateHash, "hex");
const storedBuffer = Buffer.from(storedHash, "hex");
if (candidateBuffer.length !== storedBuffer.length) {
return false;
}
return timingSafeEqual(candidateBuffer, storedBuffer);
}
export function verifyRecoveryCodeHash(code: string, storedHash: string) {
const candidateHash = hashRecoveryCode(code);
if (!candidateHash) {
return false;
}
return hashMatches(candidateHash, storedHash);
}
export function generateRecoveryCodes(options: GenerateRecoveryCodesOptions = {}): RecoveryCodeGeneration {
const count = options.count ?? DEFAULT_RECOVERY_CODE_COUNT;
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
assertPositiveInteger(count, "count");
assertPositiveInteger(segments, "segments");
assertPositiveInteger(segmentLength, "segmentLength");
const generatedAt = options.now ?? new Date();
// 32^12 (~10^18) code space means in-batch collisions are astronomically
// unlikely. Generate `count` codes upfront and only retry once if we ever
// see a duplicate (instead of looping forever on every call).
const MAX_RETRIES = 2;
let codeList: string[] = [];
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
const candidates = Array.from({ length: count }, () => generateRecoveryCode({ segments, segmentLength }));
if (new Set(candidates).size === count) {
codeList = candidates;
break;
}
}
if (codeList.length !== count) {
throw new Error("Failed to generate unique recovery codes after retry; suspected entropy source failure.");
}
return {
codes: codeList,
entries: codeList.map((code) => ({
hash: hashRecoveryCode(code)!,
usedAt: null,
createdAt: generatedAt,
})),
generatedAt,
};
}
export function consumeRecoveryCode(
entries: RecoveryCodeHashEntry[],
code: string,
options: { now?: Date } = {}
): { matched: boolean; entries: RecoveryCodeHashEntry[]; usedAt: Date | null } {
const candidateHash = hashRecoveryCode(code);
if (!candidateHash) {
return { matched: false, entries, usedAt: null };
}
const usedAt = options.now ?? new Date();
let matched = false;
const nextEntries = entries.map((entry) => {
if (entry.usedAt || matched || !hashMatches(candidateHash, entry.hash)) {
return entry;
}
matched = true;
return { ...entry, usedAt };
});
return { matched, entries: matched ? nextEntries : entries, usedAt: matched ? usedAt : null };
}
export function getRecoveryCodeStatus(
entries: RecoveryCodeHashEntry[] | null | undefined,
options: { generatedAt?: Date | null; lastUsedAt?: Date | null } = {}
): RecoveryCodeStatus {
const configured = Boolean(entries?.length);
const totalCount = entries?.length ?? 0;
const usedEntries = entries?.filter((entry) => entry.usedAt) ?? [];
const remainingCount = entries?.filter((entry) => !entry.usedAt).length ?? 0;
const lastEntryUsedAt = usedEntries.reduce<Date | null>((latest, entry) => {
if (!entry.usedAt) {
return latest;
}
if (!latest || entry.usedAt.getTime() > latest.getTime()) {
return entry.usedAt;
}
return latest;
}, null);
return {
configured,
totalCount,
remainingCount,
usedCount: usedEntries.length,
generatedAt: options.generatedAt ?? (configured ? entries?.[0]?.createdAt ?? null : null),
lastUsedAt: options.lastUsedAt ?? lastEntryUsedAt,
};
}
@@ -0,0 +1,150 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/utils/session.server", async () => {
const { createCookieSessionStorage } = await import("react-router");
return {
storage: createCookieSessionStorage({
cookie: { name: "test_session", secrets: ["test-session-secret"], path: "/", httpOnly: true, sameSite: "lax" },
}),
};
});
import { storage } from "~/utils/session.server";
import {
checkMfaStepUpState,
clearMfaStepUpState,
getMfaStepUpState,
MFA_STEP_UP_MAX_AGE_MS,
MFA_STEP_UP_SESSION_KEY,
setMfaStepUpState,
} from "./step-up.server";
async function createSession() {
return storage.getSession();
}
describe("step-up.server", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("records and verifies fresh MFA step-up state for the same user", async () => {
const session = await createSession();
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
const state = setMfaStepUpState(session, {
userId: "admin-1",
method: "totp",
redirectTo: "/admin?tab=security",
now: verifiedAt,
});
const result = checkMfaStepUpState(session, "admin-1", { now: new Date(verifiedAt.getTime() + 30_000) });
expect(state).toEqual({
userId: "admin-1",
method: "totp",
verifiedAt: verifiedAt.toISOString(),
redirectTo: "/admin?tab=security",
});
expect(getMfaStepUpState(session)).toEqual(state);
expect(result).toEqual({ verified: true, state, ageMs: 30_000 });
});
test("records step-up state without optional redirect metadata", async () => {
const session = await createSession();
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
const state = setMfaStepUpState(session, {
userId: "admin-1",
method: "recovery-code",
now: verifiedAt,
});
expect(state).toEqual({
userId: "admin-1",
method: "recovery-code",
verifiedAt: verifiedAt.toISOString(),
});
expect(getMfaStepUpState(session)).toEqual(state);
});
test("fails closed for stale and future timestamps", async () => {
const staleSession = await createSession();
const futureSession = await createSession();
const now = new Date("2026-05-08T14:10:00.000Z");
setMfaStepUpState(staleSession, {
userId: "admin-1",
method: "recovery-code",
now: new Date(now.getTime() - MFA_STEP_UP_MAX_AGE_MS - 1),
});
setMfaStepUpState(futureSession, {
userId: "admin-1",
method: "totp",
now: new Date(now.getTime() + 1),
});
expect(checkMfaStepUpState(staleSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
expect(checkMfaStepUpState(futureSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
});
test("does not authorize a different user", async () => {
const session = await createSession();
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
expect(checkMfaStepUpState(session, "admin-2", { now: new Date("2026-05-08T14:01:00.000Z") })).toEqual({
verified: false,
reason: "wrong-user",
});
});
test("treats missing and malformed state as unverified without throwing", async () => {
const missingSession = await createSession();
const malformedPayloads = [
{ method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
{ userId: "", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
"not-json-object",
{ userId: "admin-1", method: "sms", verifiedAt: "2026-05-08T14:00:00.000Z" },
{ userId: "admin-1", verifiedAt: "2026-05-08T14:00:00.000Z" },
{ userId: "admin-1", method: "totp", verifiedAt: "not-a-date" },
{ userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z", redirectTo: 123 },
];
expect(checkMfaStepUpState(missingSession, "admin-1")).toEqual({ verified: false, reason: "missing" });
for (const payload of malformedPayloads) {
const session = await createSession();
session.set(MFA_STEP_UP_SESSION_KEY, payload);
expect(() => getMfaStepUpState(session)).not.toThrow();
expect(getMfaStepUpState(session)).toBeNull();
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "malformed" });
}
});
test("clears existing MFA step-up state", async () => {
const session = await createSession();
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
clearMfaStepUpState(session);
expect(getMfaStepUpState(session)).toBeNull();
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "missing" });
});
test("serialized step-up state contains no cookies, tokens, hashes, passwords, or factor secrets", async () => {
const session = await createSession();
const state = setMfaStepUpState(session, {
userId: "admin-1",
method: "recovery-code",
redirectTo: "/admin",
now: new Date("2026-05-08T14:00:00.000Z"),
});
const serialized = JSON.stringify(state);
expect(serialized).toContain("admin-1");
expect(serialized).toContain("recovery-code");
expect(serialized).not.toMatch(/cookie|csrf|token|secret|hash|password|encrypted|totpSecret|recoveryCodes/i);
});
});
@@ -0,0 +1,109 @@
import type { Session } from "react-router";
export const MFA_STEP_UP_SESSION_KEY = "adminMfaStepUp";
export const MFA_STEP_UP_MAX_AGE_MS = 10 * 60 * 1000;
export type MfaStepUpMethod = "totp" | "recovery-code";
export type MfaStepUpState = {
userId: string;
method: MfaStepUpMethod;
verifiedAt: string;
redirectTo?: string;
};
export type MfaStepUpCheckReason = "missing" | "malformed" | "wrong-user" | "stale";
export type MfaStepUpCheckResult =
| { verified: true; state: MfaStepUpState; ageMs: number }
| { verified: false; reason: MfaStepUpCheckReason };
const MFA_STEP_UP_METHODS = new Set<MfaStepUpMethod>(["totp", "recovery-code"]);
function isMfaStepUpMethod(method: unknown): method is MfaStepUpMethod {
return typeof method === "string" && MFA_STEP_UP_METHODS.has(method as MfaStepUpMethod);
}
function parseStepUpState(value: unknown): MfaStepUpState | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as Partial<Record<keyof MfaStepUpState, unknown>>;
if (typeof candidate.userId !== "string" || candidate.userId.length === 0) {
return null;
}
if (!isMfaStepUpMethod(candidate.method)) {
return null;
}
if (typeof candidate.verifiedAt !== "string" || Number.isNaN(Date.parse(candidate.verifiedAt))) {
return null;
}
if (candidate.redirectTo !== undefined && typeof candidate.redirectTo !== "string") {
return null;
}
return {
userId: candidate.userId,
method: candidate.method,
verifiedAt: candidate.verifiedAt,
...(candidate.redirectTo !== undefined ? { redirectTo: candidate.redirectTo } : {}),
};
}
export function getMfaStepUpState(session: Session): MfaStepUpState | null {
return parseStepUpState(session.get(MFA_STEP_UP_SESSION_KEY));
}
export function setMfaStepUpState(
session: Session,
input: {
userId: string;
method: MfaStepUpMethod;
redirectTo?: string;
now?: Date;
},
): MfaStepUpState {
const state: MfaStepUpState = {
userId: input.userId,
method: input.method,
verifiedAt: (input.now ?? new Date()).toISOString(),
...(input.redirectTo !== undefined ? { redirectTo: input.redirectTo } : {}),
};
session.set(MFA_STEP_UP_SESSION_KEY, state);
return state;
}
export function clearMfaStepUpState(session: Session) {
session.unset(MFA_STEP_UP_SESSION_KEY);
}
export function checkMfaStepUpState(
session: Session,
userId: string,
options: { now?: Date; maxAgeMs?: number } = {},
): MfaStepUpCheckResult {
const rawValue = session.get(MFA_STEP_UP_SESSION_KEY);
if (rawValue === undefined || rawValue === null) {
return { verified: false, reason: "missing" };
}
const state = parseStepUpState(rawValue);
if (!state) {
return { verified: false, reason: "malformed" };
}
if (state.userId !== userId) {
return { verified: false, reason: "wrong-user" };
}
const nowMs = (options.now ?? new Date()).getTime();
const ageMs = nowMs - Date.parse(state.verifiedAt);
const maxAgeMs = options.maxAgeMs ?? MFA_STEP_UP_MAX_AGE_MS;
if (ageMs < 0 || ageMs > maxAgeMs) {
return { verified: false, reason: "stale" };
}
return { verified: true, state, ageMs };
}
@@ -0,0 +1,154 @@
/**
* 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";
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({ nodeEnv: "test", sessionSecret: "test-session-secret-please-change" }),
};
});
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" }),
}));
import { db } from "~/utils/db.server";
import {
__resetTrustedDeviceCookieForTests,
clearTrustedDeviceCookie,
forgetCurrentTrustedDevice,
isTrustedDevice,
rememberDeviceForUser,
revokeAllTrustedDevices,
revokeTrustedDevice,
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();
__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);
// 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("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);
});
});
@@ -0,0 +1,150 @@
import { createHash, randomBytes } from "node:crypto";
import { createCookie } from "react-router";
import { loadRequiredWebConfig } from "@toolctrl/config";
import { db } from "~/utils/db.server";
import { buildSessionDeviceContext } from "~/services/auth/sessionLifecycle.server";
// D1 (Trust-Device) — "diesem Geraet vertrauen" beim 2FA-Login. Der Marker UEBERLEBT den Logout
// (deshalb NICHT die per-Session `UserSession.trustedAt`), ist aber WIDERRUFBAR: ein eigenes, langlebiges
// Cookie (`RJ_trusted_device`) traegt einen opaken Zufalls-Token, persistiert wird nur dessen SHA-256-Hash
// in der Tabelle `TrustedDevice`. Autoritaet ist die Tabelle (Revoke einzeln / "ueberall vergessen" ueber
// Settings > Sicherheit) -> erfuellt den DoD-Screen "Trust-Device-Revoke" (R-P2a). Das Cookie ist nur der
// Traeger; ohne passende, aktive Zeile gilt das Geraet als NICHT vertrauenswuerdig.
export const TRUSTED_DEVICE_COOKIE_NAME = "RJ_trusted_device";
export const TRUSTED_DEVICE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 Tage
const TRUSTED_DEVICE_TOKEN_BYTES = 32;
const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastUsedAt hoechstens alle 5min fortschreiben
export type TrustedDeviceRow = Awaited<ReturnType<typeof db.trustedDevice.create>>;
let cachedCookie: ReturnType<typeof createCookie> | null = null;
function getTrustedDeviceCookie() {
if (cachedCookie) {
return cachedCookie;
}
const webConfig = loadRequiredWebConfig();
cachedCookie = createCookie(TRUSTED_DEVICE_COOKIE_NAME, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: webConfig.nodeEnv === "production",
maxAge: TRUSTED_DEVICE_MAX_AGE_SECONDS,
secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [],
});
return cachedCookie;
}
function generateTrustedDeviceToken(): string {
return randomBytes(TRUSTED_DEVICE_TOKEN_BYTES).toString("base64url");
}
function hashTrustedDeviceToken(rawToken: string): string {
return createHash("sha256").update(rawToken).digest("hex");
}
function isActive(row: Pick<TrustedDeviceRow, "revokedAt" | "expiresAt"> | null, now = Date.now()): boolean {
return Boolean(row && row.revokedAt === null && row.expiresAt.getTime() > now);
}
async function readRawTokenFromCookie(request: Request): Promise<string | null> {
try {
const parsed = await getTrustedDeviceCookie().parse(request.headers.get("Cookie"));
return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
} catch {
return null;
}
}
/**
* Markiert das aktuelle Geraet fuer `userId` als vertrauenswuerdig: legt die Tabellen-Zeile mit
* (pseudonymisiertem) Geraete-Kontext an und gibt den Set-Cookie-String mit dem rohen Token zurueck.
*/
export async function rememberDeviceForUser({ request, userId, now = new Date() }: { request: Request; userId: string; now?: Date }): Promise<string> {
const rawToken = generateTrustedDeviceToken();
const context = await buildSessionDeviceContext(request);
await db.trustedDevice.create({
data: {
userId,
tokenHash: hashTrustedDeviceToken(rawToken),
expiresAt: new Date(now.getTime() + TRUSTED_DEVICE_MAX_AGE_SECONDS * 1000),
ipHash: context.ipHash,
userAgent: context.userAgent,
approxLocation: context.approxLocation,
},
});
return getTrustedDeviceCookie().serialize(rawToken);
}
/**
* Prueft, ob der Request ein vertrauenswuerdiges Geraet fuer `userId` traegt: Cookie-Token -> Hash ->
* aktive Tabellen-Zeile mit passender userId. Best-effort: jeder Fehler gilt als "nicht vertraut".
* Schreibt lastUsedAt gedrosselt fort.
*/
export async function isTrustedDevice(request: Request, userId: string, now: Date = new Date()): Promise<boolean> {
try {
const rawToken = await readRawTokenFromCookie(request);
if (!rawToken) {
return false;
}
const row = await db.trustedDevice.findUnique({ where: { tokenHash: hashTrustedDeviceToken(rawToken) } });
if (!row || row.userId !== userId || !isActive(row, now.getTime())) {
return false;
}
if (now.getTime() - row.lastUsedAt.getTime() >= TOUCH_THROTTLE_MS) {
await db.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: now } }).catch(() => undefined);
}
return true;
} catch {
return false;
}
}
/** Aktive vertrauenswuerdige Geraete eines Nutzers (Geraete-Liste in Settings > Sicherheit). */
export async function listTrustedDevices(userId: string): Promise<TrustedDeviceRow[]> {
return db.trustedDevice.findMany({
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
orderBy: { lastUsedAt: "desc" },
});
}
/** Widerruft genau ein eigenes vertrauenswuerdiges Geraet (Eigentuemerschaft ueber userId gesichert). */
export async function revokeTrustedDevice({ userId, deviceId }: { userId: string; deviceId: string }): Promise<boolean> {
const result = await db.trustedDevice.updateMany({
where: { id: deviceId, userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count > 0;
}
/** "Alle Geraete vergessen": widerruft alle aktiven Trust-Marker des Nutzers. */
export async function revokeAllTrustedDevices(userId: string): Promise<number> {
const result = await db.trustedDevice.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
/**
* Vergisst das aktuelle Geraet: widerruft die zum Cookie gehoerende Zeile (falls vorhanden) und gibt
* den Set-Cookie-String zurueck, der den Trust-Marker im Browser loescht.
*/
export async function forgetCurrentTrustedDevice(request: Request): Promise<string> {
const rawToken = await readRawTokenFromCookie(request);
if (rawToken) {
await db.trustedDevice.updateMany({ where: { tokenHash: hashTrustedDeviceToken(rawToken), revokedAt: null }, data: { revokedAt: new Date() } }).catch(() => undefined);
}
return clearTrustedDeviceCookie();
}
/** Set-Cookie-String, der den Trust-Marker im Browser loescht (ohne DB-Zugriff). */
export async function clearTrustedDeviceCookie(): Promise<string> {
return getTrustedDeviceCookie().serialize("", { maxAge: 0, expires: new Date(0) });
}
/** Test-Naht: erzwingt eine Neuauflage des Cookies (z.B. nach geaenderter Config im Test). */
export function __resetTrustedDeviceCookieForTests() {
cachedCookie = null;
}
@@ -0,0 +1,89 @@
// @vitest-environment node
import argon2 from "argon2";
import bcryptjs from "bcryptjs";
import { describe, expect, test, vi } from "vitest";
import { hashPassword, isKnownHashFormat, verifyPassword } from "./passwordHasher.server.js";
const PLAINTEXT = "correct-horse-battery-staple";
const WRONG = "incorrect-zebra-cell-paperclip";
describe("passwordHasher.server", () => {
test("hashPassword produces an argon2id hash that round-trips", async () => {
const hash = await hashPassword(PLAINTEXT);
expect(hash.startsWith("$argon2id$")).toBe(true);
expect(await argon2.verify(hash, PLAINTEXT)).toBe(true);
});
test("verifyPassword accepts a valid argon2 hash without proposing a rehash", async () => {
const hash = await hashPassword(PLAINTEXT);
const result = await verifyPassword(PLAINTEXT, hash);
expect(result.valid).toBe(true);
expect(result.rehashedHash).toBeUndefined();
});
test("verifyPassword rejects a wrong password against an argon2 hash", async () => {
const hash = await hashPassword(PLAINTEXT);
const result = await verifyPassword(WRONG, hash);
expect(result.valid).toBe(false);
expect(result.rehashedHash).toBeUndefined();
});
test("verifyPassword accepts a valid bcrypt hash AND proposes an argon2 rehash", async () => {
const legacyHash = await bcryptjs.hash(PLAINTEXT, 4);
expect(legacyHash.startsWith("$2")).toBe(true);
const result = await verifyPassword(PLAINTEXT, legacyHash);
expect(result.valid).toBe(true);
expect(result.rehashedHash).toBeDefined();
expect(result.rehashedHash!.startsWith("$argon2id$")).toBe(true);
// The proposed rehash must itself verify the same plaintext.
expect(await argon2.verify(result.rehashedHash!, PLAINTEXT)).toBe(true);
});
test("verifyPassword rejects a wrong password against a bcrypt hash and does NOT propose a rehash", async () => {
const legacyHash = await bcryptjs.hash(PLAINTEXT, 4);
const result = await verifyPassword(WRONG, legacyHash);
expect(result.valid).toBe(false);
expect(result.rehashedHash).toBeUndefined();
});
test("verifyPassword rejects unknown / empty hashes without throwing", async () => {
await expect(verifyPassword(PLAINTEXT, "")).resolves.toEqual({ valid: false });
await expect(verifyPassword(PLAINTEXT, "garbage")).resolves.toEqual({ valid: false });
await expect(verifyPassword(PLAINTEXT, "$pbkdf2-sha256$i=29000,l=32$abc$def")).resolves.toEqual({ valid: false });
});
test("verifyPassword swallows decryption errors and reports invalid", async () => {
// An argon2-prefixed but structurally invalid hash makes argon2.verify throw.
const result = await verifyPassword(PLAINTEXT, "$argon2id$v=19$m=19456,t=2,p=1$THISisNOTaValidSalt$norIsThisAValidHash");
expect(result.valid).toBe(false);
});
test("verifyPassword reports invalid when argon2 verification throws", async () => {
const verifySpy = vi.spyOn(argon2, "verify").mockRejectedValueOnce(new Error("argon2 unavailable"));
await expect(verifyPassword(PLAINTEXT, "$argon2id$valid-looking-prefix")).resolves.toEqual({ valid: false });
expect(verifySpy).toHaveBeenCalled();
verifySpy.mockRestore();
});
test("verifyPassword reports invalid when legacy bcrypt comparison throws", async () => {
const compareSpy = vi.spyOn(bcryptjs, "compare").mockRejectedValueOnce(new Error("bcrypt unavailable"));
const legacyHash = "$2b$10$abcdefghijklmnopqrstuu5sBKjRarAkPvaemc5yQMk3QHsOJXxUy";
await expect(verifyPassword(PLAINTEXT, legacyHash)).resolves.toEqual({ valid: false });
expect(compareSpy).toHaveBeenCalledWith(PLAINTEXT, legacyHash);
compareSpy.mockRestore();
});
test("isKnownHashFormat recognises argon2, bcrypt, and unknown shapes", async () => {
expect(isKnownHashFormat(await hashPassword(PLAINTEXT))).toBe("argon2");
expect(isKnownHashFormat(await bcryptjs.hash(PLAINTEXT, 4))).toBe("bcrypt");
expect(isKnownHashFormat("$2a$10$abc")).toBe("bcrypt");
expect(isKnownHashFormat("$2y$12$abc")).toBe("bcrypt");
expect(isKnownHashFormat("$argon2i$v=19$...")).toBe("argon2");
expect(isKnownHashFormat("plaintext")).toBe("unknown");
expect(isKnownHashFormat("")).toBe("unknown");
});
});
@@ -0,0 +1,87 @@
// Password hashing + verification. Argon2id is the only path for new hashes;
// bcrypt remains as a verify-only fallback for legacy rows. A successful
// bcrypt verify produces an argon2 rehash that the caller is responsible for
// persisting — that's how we drain the bcrypt corpus without a one-shot
// bulk-rehash (which isn't possible since bcrypt is one-way).
import argon2 from "argon2";
import bcryptjs from "bcryptjs";
// OWASP 2024 recommendation for argon2id at modest server cost.
// memoryCost is in KiB (19456 = 19 MiB).
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
} as const;
const ARGON2_PREFIX_RE = /^\$argon2(?:i|d|id)\$/;
const BCRYPT_PREFIX_RE = /^\$2[abxy]\$/;
export type VerifyPasswordResult = {
valid: boolean;
/**
* Present only when the input was successfully verified against a bcrypt
* legacy hash. The caller MUST persist this value back into the user row
* (or whatever store the hash came from) so the next login takes the
* argon2 path. Absent for argon2 hits and for any failed verify.
*/
rehashedHash?: string;
};
export async function hashPassword(plain: string): Promise<string> {
return await argon2.hash(plain, ARGON2_OPTIONS);
}
/**
* Verify `plain` against an existing hash. Detects bcrypt vs argon2 by hash
* prefix; on a successful bcrypt verify, also computes an argon2 rehash for
* the caller to persist.
*
* Always rejects unknown hash formats. Treats decryption errors (e.g.
* corrupted hashes) as a failed verify rather than surfacing the underlying
* exception, so the call site can show a generic "invalid credentials"
* message without leaking format details.
*/
export async function verifyPassword(plain: string, hash: string | null | undefined): Promise<VerifyPasswordResult> {
// Phase 01 / #20 — `User.passwordHash` is nullable for magic-link-only
// accounts. Callers that compare a submitted password against the row
// hash now pass `string | null`; treat null/empty as "no comparable
// hash" → invalid, never throw.
if (typeof hash !== "string" || hash.length === 0) {
return { valid: false };
}
if (ARGON2_PREFIX_RE.test(hash)) {
try {
const valid = await argon2.verify(hash, plain);
return { valid };
} catch {
return { valid: false };
}
}
if (BCRYPT_PREFIX_RE.test(hash)) {
let valid = false;
try {
valid = await bcryptjs.compare(plain, hash);
} catch {
return { valid: false };
}
if (!valid) {
return { valid: false };
}
const rehashedHash = await hashPassword(plain);
return { valid: true, rehashedHash };
}
return { valid: false };
}
/**
* True iff the hash format is something verifyPassword understands. Useful
* for migration scripts that want to identify legacy bcrypt rows.
*/
export function isKnownHashFormat(hash: string): "argon2" | "bcrypt" | "unknown" {
if (ARGON2_PREFIX_RE.test(hash)) return "argon2";
if (BCRYPT_PREFIX_RE.test(hash)) return "bcrypt";
return "unknown";
}