feat(auth): persist opaque remembered sessions
This commit is contained in:
@@ -0,0 +1,767 @@
|
|||||||
|
import { createHash, hkdfSync, randomBytes } from "node:crypto";
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
closeSync,
|
||||||
|
constants,
|
||||||
|
fchmodSync,
|
||||||
|
fstatSync,
|
||||||
|
fsyncSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
|
readSync,
|
||||||
|
readdirSync,
|
||||||
|
realpathSync,
|
||||||
|
renameSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import type { Stats } from "node:fs";
|
||||||
|
import { dirname, isAbsolute, join, normalize } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { PrincipalContext } from "./principal.js";
|
||||||
|
import type { AuthSessionRecord, OidcStateRecord, Permission, Role } from "./types.js";
|
||||||
|
|
||||||
|
const TOKEN_BYTES = 32;
|
||||||
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||||
|
const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.json$/;
|
||||||
|
const PRIVATE_DIRECTORY_MODE = 0o700;
|
||||||
|
const PRIVATE_FILE_MODE = 0o600;
|
||||||
|
const MAX_SESSION_RECORD_BYTES = 16 * 1024;
|
||||||
|
const MAX_OIDC_STATE_RECORD_BYTES = 8 * 1024;
|
||||||
|
const MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
|
const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
|
||||||
|
const EMPTY_HKDF_SALT = Buffer.alloc(0);
|
||||||
|
const ROLES = ["user", "admin"] as const;
|
||||||
|
const PERMISSIONS = [
|
||||||
|
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||||
|
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||||
|
] as const satisfies readonly Permission[];
|
||||||
|
|
||||||
|
const invalid = (): Error => new Error("auth_session_store_invalid");
|
||||||
|
|
||||||
|
export interface SessionCreateInput {
|
||||||
|
principal: PrincipalContext;
|
||||||
|
method: "local" | "oidc" | "upstream";
|
||||||
|
remembered: boolean;
|
||||||
|
userAuthRevision?: number;
|
||||||
|
authConfigRevision: string;
|
||||||
|
idleTtlMs: number;
|
||||||
|
absoluteTtlMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedAuthSession {
|
||||||
|
token: string;
|
||||||
|
csrfToken: string;
|
||||||
|
record: AuthSessionRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcStateCreateInput {
|
||||||
|
nonce: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
returnTo: "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedOidcState {
|
||||||
|
state: string;
|
||||||
|
record: OidcStateRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalSessionUser {
|
||||||
|
enabled: boolean;
|
||||||
|
authRevision: number;
|
||||||
|
roles: readonly Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route layer supplies the current installation revision and local-registry lookup.
|
||||||
|
* Supplying this hook makes every resolve an authorization-generation check.
|
||||||
|
*/
|
||||||
|
export interface AuthSessionValidity {
|
||||||
|
currentAuthConfigRevision(): string | Promise<string>;
|
||||||
|
findLocalUser(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSessionStore {
|
||||||
|
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
|
||||||
|
resolve(token: string, now?: Date): Promise<AuthSessionRecord | undefined>;
|
||||||
|
touch(token: string, now?: Date): Promise<void>;
|
||||||
|
revoke(token: string): Promise<void>;
|
||||||
|
prune(now?: Date): Promise<number>;
|
||||||
|
createOidcState(input: OidcStateCreateInput, now?: Date): Promise<CreatedOidcState>;
|
||||||
|
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileIdentity {
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryIdentity {
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
mode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrustedFile<T> {
|
||||||
|
value: T;
|
||||||
|
identity: FileIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageDirectories {
|
||||||
|
root: string;
|
||||||
|
sessions: string;
|
||||||
|
oidc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
|
||||||
|
const timestamp = z.string().length(24).refine((value) => {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
||||||
|
});
|
||||||
|
const role = z.enum(ROLES);
|
||||||
|
const permission = z.enum(PERMISSIONS);
|
||||||
|
const distinct = <T>(items: readonly T[]): boolean => new Set(items).size === items.length;
|
||||||
|
const sessionRecordSchema = z.strictObject({
|
||||||
|
version: z.literal(1),
|
||||||
|
issuer: text,
|
||||||
|
subject: text,
|
||||||
|
displayName: text.optional(),
|
||||||
|
method: z.enum(["local", "oidc", "upstream"]),
|
||||||
|
roles: z.array(role).max(ROLES.length).refine(distinct),
|
||||||
|
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
|
||||||
|
userAuthRevision: z.number().int().positive().safe().optional(),
|
||||||
|
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
remembered: z.boolean(),
|
||||||
|
createdAt: timestamp,
|
||||||
|
lastSeenAt: timestamp,
|
||||||
|
idleExpiresAt: timestamp,
|
||||||
|
absoluteExpiresAt: timestamp,
|
||||||
|
}).superRefine((record, context) => {
|
||||||
|
const createdAt = Date.parse(record.createdAt);
|
||||||
|
const lastSeenAt = Date.parse(record.lastSeenAt);
|
||||||
|
const idleExpiresAt = Date.parse(record.idleExpiresAt);
|
||||||
|
const absoluteExpiresAt = Date.parse(record.absoluteExpiresAt);
|
||||||
|
if (lastSeenAt < createdAt || idleExpiresAt < lastSeenAt || idleExpiresAt > absoluteExpiresAt
|
||||||
|
|| absoluteExpiresAt < createdAt || absoluteExpiresAt - createdAt > MAX_TTL_MS) {
|
||||||
|
context.addIssue({ code: "custom", message: "invalid session lifetime" });
|
||||||
|
}
|
||||||
|
if (record.method === "local" && record.userAuthRevision === undefined) {
|
||||||
|
context.addIssue({ code: "custom", message: "local revision is required" });
|
||||||
|
}
|
||||||
|
if (record.method !== "local" && record.userAuthRevision !== undefined) {
|
||||||
|
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const oidcStateRecordSchema = z.strictObject({
|
||||||
|
version: z.literal(1),
|
||||||
|
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
|
||||||
|
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
|
||||||
|
returnTo: z.literal("/"),
|
||||||
|
createdAt: timestamp,
|
||||||
|
expiresAt: timestamp,
|
||||||
|
}).superRefine((record, context) => {
|
||||||
|
const lifetime = Date.parse(record.expiresAt) - Date.parse(record.createdAt);
|
||||||
|
if (lifetime <= 0 || lifetime > OIDC_STATE_TTL_MS) {
|
||||||
|
context.addIssue({ code: "custom", message: "invalid OIDC state lifetime" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sessionInputSchema = z.strictObject({
|
||||||
|
principal: z.strictObject({
|
||||||
|
issuer: text,
|
||||||
|
subject: text,
|
||||||
|
displayName: text.optional(),
|
||||||
|
roles: z.array(role).max(ROLES.length).refine(distinct),
|
||||||
|
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
|
||||||
|
isAdmin: z.boolean(),
|
||||||
|
}),
|
||||||
|
method: z.enum(["local", "oidc", "upstream"]),
|
||||||
|
remembered: z.boolean(),
|
||||||
|
userAuthRevision: z.number().int().positive().safe().optional(),
|
||||||
|
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
idleTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
|
||||||
|
absoluteTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
|
||||||
|
}).superRefine((input, context) => {
|
||||||
|
if (input.principal.isAdmin !== input.principal.roles.includes("admin")) {
|
||||||
|
context.addIssue({ code: "custom", message: "principal roles disagree" });
|
||||||
|
}
|
||||||
|
if (input.method === "local" && input.userAuthRevision === undefined) {
|
||||||
|
context.addIssue({ code: "custom", message: "local revision is required" });
|
||||||
|
}
|
||||||
|
if (input.method !== "local" && input.userAuthRevision !== undefined) {
|
||||||
|
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const oidcStateInputSchema = z.strictObject({
|
||||||
|
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
|
||||||
|
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
|
||||||
|
returnTo: z.literal("/"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||||
|
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
||||||
|
&& left.mtimeMs === right.mtimeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
||||||
|
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFound(error: unknown): boolean {
|
||||||
|
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalRawValue(value: string): boolean {
|
||||||
|
if (typeof value !== "string" || !TOKEN_PATTERN.test(value)) return false;
|
||||||
|
try {
|
||||||
|
const bytes = Buffer.from(value, "base64url");
|
||||||
|
return bytes.length === TOKEN_BYTES && bytes.toString("base64url") === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestFilename(rawValue: string): string {
|
||||||
|
return `${createHash("sha256").update(rawValue).digest("hex")}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFilename(filename: string): void {
|
||||||
|
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePath(directory: string, filename: string): string {
|
||||||
|
assertFilename(filename);
|
||||||
|
const path = join(directory, filename);
|
||||||
|
if (dirname(path) !== directory) throw invalid();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIdentity(info: Stats): FileIdentity {
|
||||||
|
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
||||||
|
|| (info.mode & 0o7777) !== PRIVATE_FILE_MODE || info.size < 0) {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryIdentity(path: string): DirectoryIdentity {
|
||||||
|
const info = lstatSync(path) as Stats;
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path) throw invalid();
|
||||||
|
if (process.platform !== "win32" && (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
|
||||||
|
return {
|
||||||
|
dev: info.dev,
|
||||||
|
ino: info.ino,
|
||||||
|
...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateDirectory(path: string): void {
|
||||||
|
let created = false;
|
||||||
|
try {
|
||||||
|
lstatSync(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFound(error)) throw invalid();
|
||||||
|
try {
|
||||||
|
mkdirSync(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
||||||
|
chmodSync(path, PRIVATE_DIRECTORY_MODE);
|
||||||
|
created = true;
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
directoryIdentity(path);
|
||||||
|
} catch {
|
||||||
|
if (!created) throw invalid();
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageDirectories(root: string): StorageDirectories {
|
||||||
|
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|
||||||
|
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
|
||||||
|
privateDirectory(root);
|
||||||
|
const sessions = join(root, "sessions");
|
||||||
|
const oidc = join(root, "oidc");
|
||||||
|
privateDirectory(sessions);
|
||||||
|
privateDirectory(oidc);
|
||||||
|
return { root, sessions, oidc };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDirectory(directory: string): number | undefined {
|
||||||
|
if (process.platform === "win32") return undefined;
|
||||||
|
return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
|
||||||
|
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(directory: string): void {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
descriptor = openDirectory(directory);
|
||||||
|
if (descriptor === undefined) throw invalid();
|
||||||
|
fsyncSync(descriptor);
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) {
|
||||||
|
try { closeSync(descriptor); } catch { /* converted to a sanitized failure above */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFully(descriptor: number, contents: Buffer): void {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < contents.length) {
|
||||||
|
const written = writeSync(descriptor, contents, offset, contents.length - offset);
|
||||||
|
if (written <= 0) throw invalid();
|
||||||
|
offset += written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTrusted<T>(
|
||||||
|
directory: string,
|
||||||
|
filename: string,
|
||||||
|
maximumBytes: number,
|
||||||
|
parse: (source: string) => T,
|
||||||
|
): TrustedFile<T> | undefined {
|
||||||
|
const path = filePath(directory, filename);
|
||||||
|
let directoryDescriptor: number | undefined;
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
const beforeDirectory = directoryIdentity(directory);
|
||||||
|
const beforePath = lstatSync(path) as Stats;
|
||||||
|
const before = fileIdentity(beforePath);
|
||||||
|
if (before.size > maximumBytes) throw invalid();
|
||||||
|
|
||||||
|
directoryDescriptor = openDirectory(directory);
|
||||||
|
const openedDirectory = directoryDescriptor === undefined
|
||||||
|
? beforeDirectory
|
||||||
|
: directoryIdentityFromDescriptor(directoryDescriptor);
|
||||||
|
if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid();
|
||||||
|
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
||||||
|
const opened = fileIdentity(fstatSync(descriptor) as Stats);
|
||||||
|
if (!sameFileIdentity(before, opened) || opened.size > maximumBytes) throw invalid();
|
||||||
|
|
||||||
|
const contents = Buffer.allocUnsafe(maximumBytes + 1);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < contents.length) {
|
||||||
|
const read = readSync(descriptor, contents, offset, contents.length - offset, null);
|
||||||
|
if (read === 0) break;
|
||||||
|
offset += read;
|
||||||
|
}
|
||||||
|
if (offset > maximumBytes) throw invalid();
|
||||||
|
|
||||||
|
const after = fileIdentity(fstatSync(descriptor) as Stats);
|
||||||
|
const afterPath = fileIdentity(lstatSync(path) as Stats);
|
||||||
|
const afterDirectory = directoryIdentity(directory);
|
||||||
|
const afterOpenedDirectory = directoryDescriptor === undefined
|
||||||
|
? afterDirectory
|
||||||
|
: directoryIdentityFromDescriptor(directoryDescriptor);
|
||||||
|
if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath)
|
||||||
|
|| !sameDirectoryIdentity(beforeDirectory, afterDirectory)
|
||||||
|
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
|
||||||
|
|
||||||
|
const source = new TextDecoder("utf-8", { fatal: true }).decode(contents.subarray(0, offset));
|
||||||
|
return { value: parse(source), identity: after };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotFound(error)) return undefined;
|
||||||
|
throw invalid();
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) {
|
||||||
|
try { closeSync(descriptor); } catch { /* descriptor is no longer trusted */ }
|
||||||
|
}
|
||||||
|
if (directoryDescriptor !== undefined) {
|
||||||
|
try { closeSync(directoryDescriptor); } catch { /* descriptor is no longer trusted */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryIdentityFromDescriptor(descriptor: number): DirectoryIdentity {
|
||||||
|
const info = fstatSync(descriptor) as Stats;
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink()) throw invalid();
|
||||||
|
if (process.platform !== "win32" && (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
|
||||||
|
return {
|
||||||
|
dev: info.dev,
|
||||||
|
ino: info.ino,
|
||||||
|
...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExclusive(directory: string, filename: string, contents: Buffer): boolean {
|
||||||
|
const path = filePath(directory, filename);
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
descriptor = openSync(
|
||||||
|
path,
|
||||||
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
|
||||||
|
PRIVATE_FILE_MODE,
|
||||||
|
);
|
||||||
|
fchmodSync(descriptor, PRIVATE_FILE_MODE);
|
||||||
|
writeFully(descriptor, contents);
|
||||||
|
fsyncSync(descriptor);
|
||||||
|
const written = fileIdentity(fstatSync(descriptor) as Stats);
|
||||||
|
if (written.size !== contents.length) throw invalid();
|
||||||
|
closeSync(descriptor);
|
||||||
|
descriptor = undefined;
|
||||||
|
syncDirectory(directory);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotFound(error)) throw invalid();
|
||||||
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") return false;
|
||||||
|
throw invalid();
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) {
|
||||||
|
try { closeSync(descriptor); } catch { /* best effort only */ }
|
||||||
|
try { unlinkSync(path); } catch { /* only our exclusive temporary record can remain */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTrusted(
|
||||||
|
directory: string,
|
||||||
|
filename: string,
|
||||||
|
expected: FileIdentity,
|
||||||
|
contents: Buffer,
|
||||||
|
): void {
|
||||||
|
const path = filePath(directory, filename);
|
||||||
|
const temporary = join(directory, `.${filename}.${randomBytes(12).toString("hex")}.tmp`);
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
descriptor = openSync(
|
||||||
|
temporary,
|
||||||
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
|
||||||
|
PRIVATE_FILE_MODE,
|
||||||
|
);
|
||||||
|
fchmodSync(descriptor, PRIVATE_FILE_MODE);
|
||||||
|
writeFully(descriptor, contents);
|
||||||
|
fsyncSync(descriptor);
|
||||||
|
const temporaryInfo = fileIdentity(fstatSync(descriptor) as Stats);
|
||||||
|
if (temporaryInfo.size !== contents.length) throw invalid();
|
||||||
|
closeSync(descriptor);
|
||||||
|
descriptor = undefined;
|
||||||
|
|
||||||
|
const current = fileIdentity(lstatSync(path) as Stats);
|
||||||
|
if (!sameFileIdentity(expected, current)) throw invalid();
|
||||||
|
renameSync(temporary, path);
|
||||||
|
syncDirectory(directory);
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) {
|
||||||
|
try { closeSync(descriptor); } catch { /* the failing write remains untrusted */ }
|
||||||
|
}
|
||||||
|
try { unlinkSync(temporary); } catch { /* rename or no creation: nothing to remove */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTrusted(directory: string, filename: string, expected?: FileIdentity): boolean {
|
||||||
|
const path = filePath(directory, filename);
|
||||||
|
try {
|
||||||
|
const current = fileIdentity(lstatSync(path) as Stats);
|
||||||
|
if (expected && !sameFileIdentity(expected, current)) throw invalid();
|
||||||
|
unlinkSync(path);
|
||||||
|
syncDirectory(directory);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotFound(error)) return false;
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSessionRecord(source: string): AuthSessionRecord {
|
||||||
|
try {
|
||||||
|
return sessionRecordSchema.parse(JSON.parse(source)) as AuthSessionRecord;
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOidcStateRecord(source: string): OidcStateRecord {
|
||||||
|
try {
|
||||||
|
return oidcStateRecordSchema.parse(JSON.parse(source)) as OidcStateRecord;
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(record: AuthSessionRecord | OidcStateRecord, maximumBytes: number): Buffer {
|
||||||
|
const contents = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
||||||
|
if (contents.length > maximumBytes) throw invalid();
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateMilliseconds(now: Date): number {
|
||||||
|
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw invalid();
|
||||||
|
return now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoAt(milliseconds: number): string {
|
||||||
|
if (!Number.isSafeInteger(milliseconds) || !Number.isFinite(milliseconds)) throw invalid();
|
||||||
|
try {
|
||||||
|
return new Date(milliseconds).toISOString();
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionExpired(record: AuthSessionRecord, nowMs: number): boolean {
|
||||||
|
return nowMs >= Date.parse(record.idleExpiresAt) || nowMs >= Date.parse(record.absoluteExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oidcStateExpired(record: OidcStateRecord, nowMs: number): boolean {
|
||||||
|
return nowMs >= Date.parse(record.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function equalRoleSets(left: readonly Role[], right: readonly Role[]): boolean {
|
||||||
|
if (!distinct(left) || !distinct(right) || left.length !== right.length) return false;
|
||||||
|
if (!left.every((value) => ROLES.includes(value)) || !right.every((value) => ROLES.includes(value))) return false;
|
||||||
|
return [...left].sort().every((value, index) => value === [...right].sort()[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validLocalUser(user: LocalSessionUser | undefined, record: AuthSessionRecord): boolean {
|
||||||
|
return user !== undefined && user.enabled === true && Number.isSafeInteger(user.authRevision)
|
||||||
|
&& user.authRevision > 0 && user.authRevision === record.userAuthRevision
|
||||||
|
&& equalRoleSets(user.roles, record.roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionValidity | undefined): Promise<boolean> {
|
||||||
|
if (!validity) return true;
|
||||||
|
const revision = await validity.currentAuthConfigRevision();
|
||||||
|
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
|
||||||
|
if (record.method !== "local") return true;
|
||||||
|
return validLocalUser(await validity.findLocalUser(record.subject), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async function withLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = locks.get(key) ?? Promise.resolve();
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const current = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
locks.set(key, current);
|
||||||
|
await previous;
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
release?.();
|
||||||
|
if (locks.get(key) === current) locks.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockKey(root: string, directory: "sessions" | "oidc", filename: string): string {
|
||||||
|
return `${root}\0${directory}\0${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a one-way, domain-separated 256-bit CSRF value without persisting it. */
|
||||||
|
export function deriveCsrfToken(sessionToken: string): string {
|
||||||
|
if (!canonicalRawValue(sessionToken)) throw invalid();
|
||||||
|
try {
|
||||||
|
const sessionBytes = Buffer.from(sessionToken, "base64url");
|
||||||
|
return Buffer.from(hkdfSync("sha256", sessionBytes, EMPTY_HKDF_SALT, CSRF_CONTEXT, TOKEN_BYTES))
|
||||||
|
.toString("base64url");
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileAuthSessionStore(root: string, validity?: AuthSessionValidity): AuthSessionStore {
|
||||||
|
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
|
||||||
|
const nowMs = dateMilliseconds(now);
|
||||||
|
let validated: z.infer<typeof sessionInputSchema>;
|
||||||
|
try {
|
||||||
|
validated = sessionInputSchema.parse(input);
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
const absoluteExpiresMs = nowMs + validated.absoluteTtlMs;
|
||||||
|
const idleExpiresMs = Math.min(nowMs + validated.idleTtlMs, absoluteExpiresMs);
|
||||||
|
if (!Number.isSafeInteger(absoluteExpiresMs) || !Number.isSafeInteger(idleExpiresMs)) throw invalid();
|
||||||
|
const record: AuthSessionRecord = {
|
||||||
|
version: 1,
|
||||||
|
issuer: validated.principal.issuer,
|
||||||
|
subject: validated.principal.subject,
|
||||||
|
...(validated.principal.displayName === undefined ? {} : { displayName: validated.principal.displayName }),
|
||||||
|
method: validated.method,
|
||||||
|
roles: [...validated.principal.roles],
|
||||||
|
permissions: [...validated.principal.permissions],
|
||||||
|
...(validated.userAuthRevision === undefined ? {} : { userAuthRevision: validated.userAuthRevision }),
|
||||||
|
authConfigRevision: validated.authConfigRevision,
|
||||||
|
remembered: validated.remembered,
|
||||||
|
createdAt: isoAt(nowMs),
|
||||||
|
lastSeenAt: isoAt(nowMs),
|
||||||
|
idleExpiresAt: isoAt(idleExpiresMs),
|
||||||
|
absoluteExpiresAt: isoAt(absoluteExpiresMs),
|
||||||
|
};
|
||||||
|
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
const token = randomBytes(TOKEN_BYTES).toString("base64url");
|
||||||
|
const filename = digestFilename(token);
|
||||||
|
if (writeExclusive(directories.sessions, filename, contents)) {
|
||||||
|
return { token, csrfToken: deriveCsrfToken(token), record };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSession(token: string, now = new Date()): Promise<AuthSessionRecord | undefined> {
|
||||||
|
if (!canonicalRawValue(token)) return undefined;
|
||||||
|
const nowMs = dateMilliseconds(now);
|
||||||
|
const filename = digestFilename(token);
|
||||||
|
return withLock(lockKey(root, "sessions", filename), async () => {
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||||
|
if (!trusted) return undefined;
|
||||||
|
if (sessionExpired(trusted.value, nowMs)) {
|
||||||
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (await recordIsCurrent(trusted.value, validity)) return trusted.value;
|
||||||
|
} catch {
|
||||||
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function touchSession(token: string, now = new Date()): Promise<void> {
|
||||||
|
if (!canonicalRawValue(token)) return;
|
||||||
|
const nowMs = dateMilliseconds(now);
|
||||||
|
const filename = digestFilename(token);
|
||||||
|
await withLock(lockKey(root, "sessions", filename), async () => {
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||||
|
if (!trusted) return;
|
||||||
|
if (sessionExpired(trusted.value, nowMs)) {
|
||||||
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastSeenMs = Date.parse(trusted.value.lastSeenAt);
|
||||||
|
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
|
||||||
|
const idleWindowMs = Date.parse(trusted.value.idleExpiresAt) - lastSeenMs;
|
||||||
|
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
|
||||||
|
const touched: AuthSessionRecord = {
|
||||||
|
...trusted.value,
|
||||||
|
lastSeenAt: isoAt(nowMs),
|
||||||
|
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(trusted.value.absoluteExpiresAt))),
|
||||||
|
};
|
||||||
|
replaceTrusted(
|
||||||
|
directories.sessions,
|
||||||
|
filename,
|
||||||
|
trusted.identity,
|
||||||
|
serialize(touched, MAX_SESSION_RECORD_BYTES),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSession(token: string): Promise<void> {
|
||||||
|
if (!canonicalRawValue(token)) return;
|
||||||
|
const filename = digestFilename(token);
|
||||||
|
await withLock(lockKey(root, "sessions", filename), async () => {
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
removeTrusted(directories.sessions, filename);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOidcState(input: OidcStateCreateInput, now = new Date()): Promise<CreatedOidcState> {
|
||||||
|
const nowMs = dateMilliseconds(now);
|
||||||
|
let validated: z.infer<typeof oidcStateInputSchema>;
|
||||||
|
try {
|
||||||
|
validated = oidcStateInputSchema.parse(input);
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
const expiresMs = nowMs + OIDC_STATE_TTL_MS;
|
||||||
|
if (!Number.isSafeInteger(expiresMs)) throw invalid();
|
||||||
|
const record: OidcStateRecord = {
|
||||||
|
version: 1,
|
||||||
|
nonce: validated.nonce,
|
||||||
|
codeVerifier: validated.codeVerifier,
|
||||||
|
returnTo: validated.returnTo,
|
||||||
|
createdAt: isoAt(nowMs),
|
||||||
|
expiresAt: isoAt(expiresMs),
|
||||||
|
};
|
||||||
|
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
const state = randomBytes(TOKEN_BYTES).toString("base64url");
|
||||||
|
if (writeExclusive(directories.oidc, digestFilename(state), contents)) return { state, record };
|
||||||
|
}
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeOidcState(state: string, now = new Date()): Promise<OidcStateRecord | undefined> {
|
||||||
|
if (!canonicalRawValue(state)) return undefined;
|
||||||
|
const nowMs = dateMilliseconds(now);
|
||||||
|
const filename = digestFilename(state);
|
||||||
|
return withLock(lockKey(root, "oidc", filename), async () => {
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
const trusted = readTrusted(directories.oidc, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
||||||
|
if (!trusted) return undefined;
|
||||||
|
removeTrusted(directories.oidc, filename, trusted.identity);
|
||||||
|
return oidcStateExpired(trusted.value, nowMs) ? undefined : trusted.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prune(now = new Date()): Promise<number> {
|
||||||
|
const nowMs = dateMilliseconds(now);
|
||||||
|
const directories = storageDirectories(root);
|
||||||
|
let removed = 0;
|
||||||
|
const pruneDirectory = async (
|
||||||
|
directory: string,
|
||||||
|
kind: "sessions" | "oidc",
|
||||||
|
maximumBytes: number,
|
||||||
|
parse: (source: string) => AuthSessionRecord | OidcStateRecord,
|
||||||
|
expired: (record: AuthSessionRecord | OidcStateRecord, timestamp: number) => boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(directory);
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
for (const filename of entries) {
|
||||||
|
if (!DIGEST_FILENAME_PATTERN.test(filename)) continue;
|
||||||
|
await withLock(lockKey(root, kind, filename), async () => {
|
||||||
|
const trusted = readTrusted(directory, filename, maximumBytes, parse);
|
||||||
|
if (trusted && expired(trusted.value, nowMs)) {
|
||||||
|
if (removeTrusted(directory, filename, trusted.identity)) removed += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await pruneDirectory(
|
||||||
|
directories.sessions,
|
||||||
|
"sessions",
|
||||||
|
MAX_SESSION_RECORD_BYTES,
|
||||||
|
parseSessionRecord,
|
||||||
|
(record, timestamp) => sessionExpired(record as AuthSessionRecord, timestamp),
|
||||||
|
);
|
||||||
|
await pruneDirectory(
|
||||||
|
directories.oidc,
|
||||||
|
"oidc",
|
||||||
|
MAX_OIDC_STATE_RECORD_BYTES,
|
||||||
|
parseOidcStateRecord,
|
||||||
|
(record, timestamp) => oidcStateExpired(record as OidcStateRecord, timestamp),
|
||||||
|
);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: createSession,
|
||||||
|
resolve: resolveSession,
|
||||||
|
touch: touchSession,
|
||||||
|
revoke: revokeSession,
|
||||||
|
prune,
|
||||||
|
createOidcState,
|
||||||
|
consumeOidcState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -54,3 +54,31 @@ export interface LoadedAuthConfig {
|
|||||||
export interface AuthenticationConfigProvider {
|
export interface AuthenticationConfigProvider {
|
||||||
current(): LoadedAuthConfig;
|
current(): LoadedAuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Durable, server-side representation of an opaque browser session. */
|
||||||
|
export interface AuthSessionRecord {
|
||||||
|
version: 1;
|
||||||
|
issuer: string;
|
||||||
|
subject: string;
|
||||||
|
displayName?: string;
|
||||||
|
method: "local" | "oidc" | "upstream";
|
||||||
|
roles: readonly Role[];
|
||||||
|
permissions: readonly Permission[];
|
||||||
|
userAuthRevision?: number;
|
||||||
|
authConfigRevision: string;
|
||||||
|
remembered: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
idleExpiresAt: string;
|
||||||
|
absoluteExpiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-side OIDC callback material keyed by a separately generated opaque state value. */
|
||||||
|
export interface OidcStateRecord {
|
||||||
|
version: 1;
|
||||||
|
nonce: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
returnTo: "/";
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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