feat(auth): persist opaque remembered sessions
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user