Files
ThothII/backend/test/auth-session-store.test.ts
T

251 lines
10 KiB
TypeScript

import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
linkSync,
lstatSync,
mkdtempSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
createFileAuthSessionStore,
deriveCsrfToken,
type AuthSessionStore,
type SessionCreateInput,
} from "../src/auth/session-store.js";
const roots: string[] = [];
const base = new Date("2030-01-02T03:04:05.000Z");
const revision = "a".repeat(64);
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function root(): string {
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-session-"));
chmodSync(path, 0o700);
roots.push(path);
return path;
}
function digestPath(rootPath: string, directory: "sessions" | "oidc", rawValue: string): string {
return join(rootPath, directory, `${createHash("sha256").update(rawValue).digest("hex")}.json`);
}
async function create(
store: AuthSessionStore,
overrides: Partial<SessionCreateInput> = {},
now = base,
) {
return store.create({
principal: {
issuer: "local",
subject: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8",
displayName: "Local administrator",
roles: ["admin"],
permissions: ["session.use", "settings.manage"],
isAdmin: true,
},
method: "local",
remembered: true,
userAuthRevision: 7,
authConfigRevision: revision,
idleTtlMs: 10 * 60_000,
absoluteTtlMs: 30 * 60_000,
...overrides,
}, now);
}
async function expectStoreInvalid(operation: Promise<unknown>): Promise<void> {
await expect(operation).rejects.toThrow("auth_session_store_invalid");
}
describe("file-backed auth session store", () => {
test("creates 256-bit opaque tokens, digest-only files, and derived CSRF values", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const first = await create(store);
const second = await create(store);
const path = digestPath(storageRoot, "sessions", first.token);
const contents = readFileSync(path, "utf8");
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(Buffer.from(first.token, "base64url")).toHaveLength(32);
expect(second.token).not.toBe(first.token);
expect(lstatSync(path).isFile()).toBe(true);
expect(path).toMatch(/[a-f0-9]{64}\.json$/);
expect(contents).not.toContain(first.token);
expect(contents).not.toContain(first.csrfToken);
expect(deriveCsrfToken(first.token)).toBe(first.csrfToken);
expect(first.csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(first.csrfToken).not.toBe(first.token);
if (process.platform !== "win32") {
expect(lstatSync(storageRoot).mode & 0o7777).toBe(0o700);
expect(lstatSync(join(storageRoot, "sessions")).mode & 0o7777).toBe(0o700);
expect(lstatSync(join(storageRoot, "oidc")).mode & 0o7777).toBe(0o700);
expect(lstatSync(path).mode & 0o7777).toBe(0o600);
}
});
test("survives a backend restart and respects idle and absolute expiry", async () => {
const storageRoot = root();
const firstStore = createFileAuthSessionStore(storageRoot);
const created = await create(firstStore);
const restartedStore = createFileAuthSessionStore(storageRoot);
await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ subject: created.record.subject, remembered: true });
await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 10 * 60_000)))
.resolves.toBeUndefined();
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
const absolute = await create(restartedStore, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 10 * 60_000 });
await expect(restartedStore.resolve(absolute.token, new Date(base.getTime() + 10 * 60_000)))
.resolves.toBeUndefined();
});
test("touches at most once per five minutes and never extends absolute expiry", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const before = readFileSync(path, "utf8");
await store.touch(created.token, new Date(base.getTime() + 4 * 60_000));
expect(readFileSync(path, "utf8")).toBe(before);
await store.touch(created.token, new Date(base.getTime() + 5 * 60_000));
await expect(store.resolve(created.token, new Date(base.getTime() + 5 * 60_000))).resolves.toMatchObject({
lastSeenAt: "2030-01-02T03:09:05.000Z",
idleExpiresAt: "2030-01-02T03:19:05.000Z",
absoluteExpiresAt: "2030-01-02T03:34:05.000Z",
});
});
test("revokes sessions and prunes expired session and OIDC-state records", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const revoked = await create(store);
const expired = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
await store.revoke(revoked.token);
await expect(store.resolve(revoked.token)).resolves.toBeUndefined();
expect(existsSync(digestPath(storageRoot, "sessions", revoked.token))).toBe(false);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(existsSync(digestPath(storageRoot, "sessions", expired.token))).toBe(false);
expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false);
});
test("creates bounded OIDC state records that expire and are single-use", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const created = await store.createOidcState({
nonce: "n".repeat(43),
codeVerifier: "v".repeat(43),
returnTo: "/",
}, base);
const path = digestPath(storageRoot, "oidc", created.state);
expect(created.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(readFileSync(path, "utf8")).not.toContain(created.state);
await expect(store.consumeOidcState(created.state, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" });
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
const expired = await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base);
await expect(store.consumeOidcState(expired.state, new Date(base.getTime() + 10 * 60_000)))
.resolves.toBeUndefined();
});
test.skipIf(process.platform === "win32")("refuses symlinked and hard-linked session records", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const symlinked = await create(store);
const symlinkPath = digestPath(storageRoot, "sessions", symlinked.token);
const target = `${symlinkPath}.target`;
renameSync(symlinkPath, target);
symlinkSync(target, symlinkPath);
await expectStoreInvalid(store.resolve(symlinked.token));
const hardLinked = await create(store);
const hardLinkPath = digestPath(storageRoot, "sessions", hardLinked.token);
linkSync(hardLinkPath, `${hardLinkPath}.link`);
await expectStoreInvalid(store.resolve(hardLinked.token));
});
test("refuses malformed and oversized session records without disclosing their contents", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const malformed = await create(store);
const malformedPath = digestPath(storageRoot, "sessions", malformed.token);
writeFileSync(malformedPath, "{}", { encoding: "utf8", mode: 0o600 });
chmodSync(malformedPath, 0o600);
await expectStoreInvalid(store.resolve(malformed.token));
const oversized = await create(store);
const oversizedPath = digestPath(storageRoot, "sessions", oversized.token);
writeFileSync(oversizedPath, "#".repeat(20_000), { encoding: "utf8", mode: 0o600 });
chmodSync(oversizedPath, 0o600);
await expectStoreInvalid(store.resolve(oversized.token));
});
test("revokes on config, local-user, revision, enabled, or role mismatch before returning", async () => {
const storageRoot = root();
let currentRevision = revision;
let localUser: { enabled: boolean; authRevision: number; roles: readonly ("user" | "admin")[] } | undefined = {
enabled: true,
authRevision: 7,
roles: ["admin"],
};
const store = createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => currentRevision,
findLocalUser: async () => localUser,
});
const configChanged = await create(store);
currentRevision = "b".repeat(64);
await expect(store.resolve(configChanged.token)).resolves.toBeUndefined();
expect(existsSync(digestPath(storageRoot, "sessions", configChanged.token))).toBe(false);
currentRevision = revision;
for (const mismatch of [
undefined,
{ enabled: false, authRevision: 7, roles: ["admin"] as const },
{ enabled: true, authRevision: 8, roles: ["admin"] as const },
{ enabled: true, authRevision: 7, roles: ["user"] as const },
]) {
localUser = mismatch;
const session = await create(store);
await expect(store.resolve(session.token)).resolves.toBeUndefined();
expect(existsSync(digestPath(storageRoot, "sessions", session.token))).toBe(false);
}
});
test("serializes concurrent resolve and revoke without resurrecting a record", async () => {
const storageRoot = root();
const firstStore = createFileAuthSessionStore(storageRoot);
const secondStore = createFileAuthSessionStore(storageRoot);
const created = await create(firstStore);
await Promise.all([
...Array.from({ length: 8 }, () => firstStore.resolve(created.token)),
...Array.from({ length: 8 }, () => secondStore.revoke(created.token)),
]);
await expect(firstStore.resolve(created.token)).resolves.toBeUndefined();
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
});
});