251 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|