feat(auth): load immutable runtime projection snapshots
This commit is contained in:
@@ -228,7 +228,7 @@ function canonicalRevision(value: AuthenticationConfig): string {
|
|||||||
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAuthenticationConfig(source: string): AuthenticationConfig {
|
export function parseAuthenticationConfigSource(source: string): AuthenticationConfig {
|
||||||
try {
|
try {
|
||||||
const document = parseDocument(source, { uniqueKeys: true });
|
const document = parseDocument(source, { uniqueKeys: true });
|
||||||
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
||||||
@@ -254,7 +254,7 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig {
|
|||||||
|
|
||||||
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
|
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
|
||||||
const read = readBoundedConfig(path);
|
const read = readBoundedConfig(path);
|
||||||
const value = parseAuthenticationConfig(read.source);
|
const value = parseAuthenticationConfigSource(read.source);
|
||||||
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
|
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ function loadWindowsAuthenticationConfig(
|
|||||||
const contents = bridge.readAuthConfig(path);
|
const contents = bridge.readAuthConfig(path);
|
||||||
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(contents);
|
const source = new TextDecoder("utf-8", { fatal: true }).decode(contents);
|
||||||
const value = parseAuthenticationConfig(source);
|
const value = parseAuthenticationConfigSource(source);
|
||||||
return { value, revision: canonicalRevision(value), sourcePath: path };
|
return { value, revision: canonicalRevision(value), sourcePath: path };
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { dirname, isAbsolute, join, normalize } from "node:path";
|
|||||||
import { parseDocument } from "yaml";
|
import { parseDocument } from "yaml";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
|
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
|
||||||
import type { LoadedAuthConfig, Role } from "./types.js";
|
import type { LoadedAuthConfig, LocalUserRecord, Role } from "./types.js";
|
||||||
import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
|
import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
|
||||||
|
|
||||||
const MAX_USERS_YAML_BYTES = 1 << 20;
|
const MAX_USERS_YAML_BYTES = 1 << 20;
|
||||||
@@ -28,16 +28,7 @@ function runtimeOwner(): number {
|
|||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalUserRecord {
|
export type { LocalUserRecord } from "./types.js";
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
normalizedUsername: string;
|
|
||||||
displayName?: string;
|
|
||||||
passwordHash: string;
|
|
||||||
roles: readonly Role[];
|
|
||||||
enabled: boolean;
|
|
||||||
authRevision: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalUserRegistry {
|
export interface LocalUserRegistry {
|
||||||
/** Safe production diagnostic probe; never returns user records or hashes. */
|
/** Safe production diagnostic probe; never returns user records or hashes. */
|
||||||
@@ -193,7 +184,7 @@ function readBounded(path: string, owner: number): { source: string; identity: R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRegistry(source: string): LocalUserRecord[] {
|
export function parseLocalUserRegistrySource(source: string): readonly LocalUserRecord[] {
|
||||||
try {
|
try {
|
||||||
const document = parseDocument(source, { uniqueKeys: true });
|
const document = parseDocument(source, { uniqueKeys: true });
|
||||||
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
||||||
@@ -216,24 +207,24 @@ function parseRegistry(source: string): LocalUserRecord[] {
|
|||||||
authRevision: user.authRevision,
|
authRevision: user.authRevision,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return records;
|
return Object.freeze(records);
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } {
|
function load(path: string, owner: number): { records: readonly LocalUserRecord[]; identity: RegistryIdentity } {
|
||||||
const read = readBounded(path, owner);
|
const read = readBounded(path, owner);
|
||||||
return { records: parseRegistry(read.source), identity: read.identity };
|
return { records: parseLocalUserRegistrySource(read.source), identity: read.identity };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry {
|
export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry {
|
||||||
let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined;
|
let cached: { records: readonly LocalUserRecord[]; identity: RegistryIdentity } | undefined;
|
||||||
const windowsStorage = process.platform === "win32"
|
const windowsStorage = process.platform === "win32"
|
||||||
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
|
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
function currentPosix(): LocalUserRecord[] {
|
function currentPosix(): readonly LocalUserRecord[] {
|
||||||
try {
|
try {
|
||||||
const owner = runtimeOwner();
|
const owner = runtimeOwner();
|
||||||
const before = registryIdentity(usersPath, owner);
|
const before = registryIdentity(usersPath, owner);
|
||||||
@@ -251,19 +242,19 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg
|
|||||||
throw invalid();
|
throw invalid();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function current(): Promise<LocalUserRecord[]> {
|
async function current(): Promise<readonly LocalUserRecord[]> {
|
||||||
if (process.platform !== "win32") return currentPosix();
|
if (process.platform !== "win32") return currentPosix();
|
||||||
try {
|
try {
|
||||||
if (!windowsStorage) throw invalid();
|
if (!windowsStorage) throw invalid();
|
||||||
const contents = await windowsStorage.readLocalUsers(usersPath);
|
const contents = await windowsStorage.readLocalUsers(usersPath);
|
||||||
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_USERS_YAML_BYTES) throw invalid();
|
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_USERS_YAML_BYTES) throw invalid();
|
||||||
return parseRegistry(new TextDecoder("utf-8", { fatal: true }).decode(contents));
|
return parseLocalUserRegistrySource(new TextDecoder("utf-8", { fatal: true }).decode(contents));
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function operationalRecords(): Promise<LocalUserRecord[]> {
|
async function operationalRecords(): Promise<readonly LocalUserRecord[]> {
|
||||||
const records = await current();
|
const records = await current();
|
||||||
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
|
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
|
||||||
return records;
|
return records;
|
||||||
@@ -291,15 +282,48 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver {
|
export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver {
|
||||||
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
|
let current: { key: object | string; registry: LocalUserRegistry } | undefined;
|
||||||
return {
|
return {
|
||||||
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
|
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
|
||||||
if (loaded.value.mode !== "local") return undefined;
|
if (loaded.value.mode !== "local") return undefined;
|
||||||
|
const runtimeProjection = loaded.runtimeProjection;
|
||||||
|
const projectedUsers = runtimeProjection?.localUsers;
|
||||||
|
if (projectedUsers && runtimeProjection) {
|
||||||
|
if (current && current.key === runtimeProjection) return current.registry;
|
||||||
|
const registry = createInMemoryLocalUserRegistry(projectedUsers);
|
||||||
|
current = { key: runtimeProjection, registry };
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
|
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
|
||||||
if (current?.usersPath === usersPath) return current.registry;
|
if (current && current.key === usersPath) return current.registry;
|
||||||
const registry = createLocalUserRegistry(usersPath, options);
|
const registry = createLocalUserRegistry(usersPath, options);
|
||||||
current = { usersPath, registry };
|
current = { key: usersPath, registry };
|
||||||
return registry;
|
return registry;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createInMemoryLocalUserRegistry(records: readonly LocalUserRecord[]): LocalUserRegistry {
|
||||||
|
async function operationalRecords(): Promise<readonly LocalUserRecord[]> {
|
||||||
|
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async hasEnabledAdmin(): Promise<boolean> {
|
||||||
|
return records.some((user) => user.enabled && user.roles.includes("admin"));
|
||||||
|
},
|
||||||
|
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
|
||||||
|
return (await operationalRecords()).find((user) => user.normalizedUsername === normalizeUsername(username));
|
||||||
|
},
|
||||||
|
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
|
||||||
|
return (await operationalRecords()).find((user) => user.id === id);
|
||||||
|
},
|
||||||
|
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
|
||||||
|
if (!user || !user.enabled) {
|
||||||
|
await verifyWithDummy(password);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await verifyPassword(password, user.passwordHash);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|||||||
import type {
|
import type {
|
||||||
AuthenticationConfigProvider,
|
AuthenticationConfigProvider,
|
||||||
LoadedAuthConfig,
|
LoadedAuthConfig,
|
||||||
|
LocalUserRecord,
|
||||||
OidcAuthenticationConfig,
|
OidcAuthenticationConfig,
|
||||||
OidcStateRecord,
|
OidcStateRecord,
|
||||||
OidcTransactionTransport,
|
OidcTransactionTransport,
|
||||||
Role,
|
Role,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
import type { LocalUserRegistry } from "./local-registry.js";
|
||||||
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
|
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
|
||||||
import { rolesToPermissions } from "./config.js";
|
import { rolesToPermissions } from "./config.js";
|
||||||
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
closeSync,
|
||||||
|
constants,
|
||||||
|
fstatSync,
|
||||||
|
lstatSync,
|
||||||
|
openSync,
|
||||||
|
readSync,
|
||||||
|
readdirSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import type { Stats } from "node:fs";
|
||||||
|
import { isAbsolute, join, normalize } from "node:path";
|
||||||
|
import { parseAuthenticationConfigSource } from "./config.js";
|
||||||
|
import { parseLocalUserRegistrySource } from "./local-registry.js";
|
||||||
|
import type {
|
||||||
|
AuthenticationConfigProvider,
|
||||||
|
LoadedAuthConfig,
|
||||||
|
LocalUserRecord,
|
||||||
|
RuntimeProjectionSnapshot,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const MAX_AUTH_BYTES = 1 << 20;
|
||||||
|
const MAX_USERS_BYTES = 1 << 20;
|
||||||
|
const MAX_SELECTOR_BYTES = 4096;
|
||||||
|
const MAX_MANIFEST_BYTES = 4096;
|
||||||
|
const DIR_MODE = 0o700;
|
||||||
|
const FILE_MODE = 0o600;
|
||||||
|
const GENERATION = /^[0-9a-f]{64}$/;
|
||||||
|
const TRANSACTION = /^[0-9a-f]{32}$/;
|
||||||
|
const invalid = (): Error =>
|
||||||
|
new Error("authentication runtime projection is invalid");
|
||||||
|
|
||||||
|
interface Identity {
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
uid: number;
|
||||||
|
gid: number;
|
||||||
|
mode: number;
|
||||||
|
nlink: number;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
ctimeMs: number;
|
||||||
|
}
|
||||||
|
interface Selector {
|
||||||
|
version: 1;
|
||||||
|
state: "ready" | "blocked";
|
||||||
|
transaction: string;
|
||||||
|
generation?: string;
|
||||||
|
previousGenerations?: readonly string[];
|
||||||
|
}
|
||||||
|
interface ManifestFile {
|
||||||
|
name: "auth.yaml" | "users.yaml";
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
interface Manifest {
|
||||||
|
version: 1;
|
||||||
|
generation: string;
|
||||||
|
mode: "local" | "oidc";
|
||||||
|
canonicalRevision: string;
|
||||||
|
files: readonly ManifestFile[];
|
||||||
|
}
|
||||||
|
class CurrentReplaced extends Error {}
|
||||||
|
|
||||||
|
function runtimeOwner(): number {
|
||||||
|
if (process.platform === "win32" || typeof process.geteuid !== "function")
|
||||||
|
throw invalid();
|
||||||
|
const uid = process.geteuid();
|
||||||
|
if (!Number.isSafeInteger(uid) || uid < 0) throw invalid();
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
function meta(info: Stats): Identity {
|
||||||
|
return {
|
||||||
|
dev: info.dev,
|
||||||
|
ino: info.ino,
|
||||||
|
uid: info.uid,
|
||||||
|
gid: info.gid,
|
||||||
|
mode: info.mode & 0o7777,
|
||||||
|
nlink: info.nlink,
|
||||||
|
size: info.size,
|
||||||
|
mtimeMs: info.mtimeMs,
|
||||||
|
ctimeMs: info.ctimeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function same(a: Identity, b: Identity): boolean {
|
||||||
|
return (
|
||||||
|
a.dev === b.dev &&
|
||||||
|
a.ino === b.ino &&
|
||||||
|
a.uid === b.uid &&
|
||||||
|
a.gid === b.gid &&
|
||||||
|
a.mode === b.mode &&
|
||||||
|
a.nlink === b.nlink &&
|
||||||
|
a.size === b.size &&
|
||||||
|
a.mtimeMs === b.mtimeMs &&
|
||||||
|
a.ctimeMs === b.ctimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function directory(info: Stats, uid: number): Identity {
|
||||||
|
const value = meta(info);
|
||||||
|
if (!info.isDirectory() || value.uid !== uid || value.mode !== DIR_MODE)
|
||||||
|
throw invalid();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function regular(info: Stats, uid: number, maximum: number): Identity {
|
||||||
|
const value = meta(info);
|
||||||
|
if (
|
||||||
|
!info.isFile() ||
|
||||||
|
value.uid !== uid ||
|
||||||
|
value.mode !== FILE_MODE ||
|
||||||
|
value.nlink !== 1 ||
|
||||||
|
value.size < 0 ||
|
||||||
|
value.size > maximum
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function checkRoot(root: string): void {
|
||||||
|
if (
|
||||||
|
typeof root !== "string" ||
|
||||||
|
root.length === 0 ||
|
||||||
|
root.includes("\0") ||
|
||||||
|
!isAbsolute(root) ||
|
||||||
|
normalize(root) !== root
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
function openDirectory(
|
||||||
|
path: string,
|
||||||
|
uid: number,
|
||||||
|
): { fd: number; identity: Identity } {
|
||||||
|
let fd: number | undefined;
|
||||||
|
try {
|
||||||
|
const before = directory(lstatSync(path) as Stats, uid);
|
||||||
|
fd = openSync(
|
||||||
|
path,
|
||||||
|
constants.O_RDONLY |
|
||||||
|
(constants.O_DIRECTORY ?? 0) |
|
||||||
|
constants.O_NOFOLLOW |
|
||||||
|
constants.O_NONBLOCK,
|
||||||
|
);
|
||||||
|
const opened = directory(fstatSync(fd) as Stats, uid);
|
||||||
|
if (!same(before, opened)) throw invalid();
|
||||||
|
return { fd, identity: opened };
|
||||||
|
} catch {
|
||||||
|
if (fd !== undefined)
|
||||||
|
try {
|
||||||
|
closeSync(fd);
|
||||||
|
} catch {}
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stableDirectory(
|
||||||
|
path: string,
|
||||||
|
opened: { fd: number; identity: Identity },
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!same(opened.identity, directory(fstatSync(opened.fd) as Stats, uid)) ||
|
||||||
|
!same(opened.identity, directory(lstatSync(path) as Stats, uid))
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
function entries(path: string, uid: number, expected: readonly string[]): void {
|
||||||
|
const opened = openDirectory(path, uid);
|
||||||
|
try {
|
||||||
|
const names = readdirSync(path);
|
||||||
|
if (
|
||||||
|
names.length !== expected.length ||
|
||||||
|
new Set(names).size !== names.length ||
|
||||||
|
names.some((name) => !expected.includes(name))
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
stableDirectory(path, opened, uid);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
closeSync(opened.fd);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function replaced(before: Identity, after: Identity): boolean {
|
||||||
|
return before.dev !== after.dev || before.ino !== after.ino;
|
||||||
|
}
|
||||||
|
function readRegular(
|
||||||
|
path: string,
|
||||||
|
parentPath: string,
|
||||||
|
uid: number,
|
||||||
|
maximum: number,
|
||||||
|
retryOnReplacement = false,
|
||||||
|
): { bytes: Buffer; identity: Identity } {
|
||||||
|
let fd: number | undefined;
|
||||||
|
let parent: { fd: number; identity: Identity } | undefined;
|
||||||
|
try {
|
||||||
|
parent = openDirectory(parentPath, uid);
|
||||||
|
const before = regular(lstatSync(path) as Stats, uid, maximum);
|
||||||
|
fd = openSync(
|
||||||
|
path,
|
||||||
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||||
|
);
|
||||||
|
const opened = regular(fstatSync(fd) as Stats, uid, maximum);
|
||||||
|
if (!same(before, opened)) {
|
||||||
|
if (retryOnReplacement && replaced(before, opened))
|
||||||
|
throw new CurrentReplaced();
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
const buffer = Buffer.allocUnsafe(maximum + 1);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const count = readSync(fd, buffer, offset, buffer.length - offset, null);
|
||||||
|
if (count === 0) break;
|
||||||
|
offset += count;
|
||||||
|
}
|
||||||
|
if (offset > maximum) throw invalid();
|
||||||
|
const after = regular(fstatSync(fd) as Stats, uid, maximum);
|
||||||
|
const atPath = regular(lstatSync(path) as Stats, uid, maximum);
|
||||||
|
if (!same(opened, after) || !same(after, atPath)) {
|
||||||
|
if (retryOnReplacement && replaced(after, atPath))
|
||||||
|
throw new CurrentReplaced();
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
stableDirectory(parentPath, parent, uid);
|
||||||
|
return { bytes: buffer.subarray(0, offset), identity: after };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CurrentReplaced) throw error;
|
||||||
|
throw invalid();
|
||||||
|
} finally {
|
||||||
|
if (fd !== undefined)
|
||||||
|
try {
|
||||||
|
closeSync(fd);
|
||||||
|
} catch {}
|
||||||
|
if (parent)
|
||||||
|
try {
|
||||||
|
closeSync(parent.fd);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function text(bytes: Buffer): string {
|
||||||
|
try {
|
||||||
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function object(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||||
|
throw invalid();
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
function safeGeneration(value: unknown): string {
|
||||||
|
if (typeof value !== "string" || !GENERATION.test(value)) throw invalid();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function strictJson<T>(
|
||||||
|
contents: string,
|
||||||
|
normalizeValue: (raw: unknown) => T,
|
||||||
|
): T {
|
||||||
|
try {
|
||||||
|
const value = normalizeValue(JSON.parse(contents));
|
||||||
|
if (`${JSON.stringify(value)}\n` !== contents) throw invalid();
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selector(contents: string): Selector {
|
||||||
|
return strictJson(contents, (raw) => {
|
||||||
|
const value = object(raw);
|
||||||
|
if (
|
||||||
|
value.version !== 1 ||
|
||||||
|
typeof value.transaction !== "string" ||
|
||||||
|
!TRANSACTION.test(value.transaction)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
if (value.state === "blocked" && Object.keys(value).length === 3)
|
||||||
|
return { version: 1, state: "blocked", transaction: value.transaction };
|
||||||
|
if (
|
||||||
|
value.state !== "ready" ||
|
||||||
|
(Object.keys(value).length !== 4 && Object.keys(value).length !== 5)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
const generation = safeGeneration(value.generation);
|
||||||
|
const previousGenerations =
|
||||||
|
value.previousGenerations === undefined
|
||||||
|
? []
|
||||||
|
: Array.isArray(value.previousGenerations)
|
||||||
|
? value.previousGenerations.map(safeGeneration)
|
||||||
|
: (() => {
|
||||||
|
throw invalid();
|
||||||
|
})();
|
||||||
|
if (
|
||||||
|
previousGenerations.length > 2 ||
|
||||||
|
(previousGenerations.length === 0 && Object.keys(value).length !== 4) ||
|
||||||
|
(previousGenerations.length > 0 && Object.keys(value).length !== 5)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
if (
|
||||||
|
new Set([generation, ...previousGenerations]).size !==
|
||||||
|
previousGenerations.length + 1
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
state: "ready",
|
||||||
|
transaction: value.transaction,
|
||||||
|
generation,
|
||||||
|
...(previousGenerations.length > 0 ? { previousGenerations } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function manifest(contents: string): Manifest {
|
||||||
|
return strictJson(contents, (raw) => {
|
||||||
|
const value = object(raw);
|
||||||
|
if (
|
||||||
|
Object.keys(value).length !== 5 ||
|
||||||
|
value.version !== 1 ||
|
||||||
|
(value.mode !== "local" && value.mode !== "oidc")
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
const mode = value.mode;
|
||||||
|
const generation = safeGeneration(value.generation);
|
||||||
|
if (
|
||||||
|
value.canonicalRevision !== `sha256:${generation}` ||
|
||||||
|
!Array.isArray(value.files)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
const wanted: readonly ("auth.yaml" | "users.yaml")[] =
|
||||||
|
mode === "local" ? ["auth.yaml", "users.yaml"] : ["auth.yaml"];
|
||||||
|
if (value.files.length !== wanted.length) throw invalid();
|
||||||
|
const files: ManifestFile[] = value.files.map((candidate, index) => {
|
||||||
|
const item = object(candidate);
|
||||||
|
const name = wanted[index]!;
|
||||||
|
const maximum = name === "auth.yaml" ? MAX_AUTH_BYTES : MAX_USERS_BYTES;
|
||||||
|
if (
|
||||||
|
Object.keys(item).length !== 3 ||
|
||||||
|
item.name !== name ||
|
||||||
|
!Number.isSafeInteger(item.size) ||
|
||||||
|
(item.size as number) < 0 ||
|
||||||
|
(item.size as number) > maximum ||
|
||||||
|
typeof item.sha256 !== "string" ||
|
||||||
|
!GENERATION.test(item.sha256)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
return { name, size: item.size as number, sha256: item.sha256 };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generation,
|
||||||
|
mode,
|
||||||
|
canonicalRevision: value.canonicalRevision as string,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function digest(bytes: Buffer): string {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
function generationFor(
|
||||||
|
mode: "local" | "oidc",
|
||||||
|
auth: Buffer,
|
||||||
|
users?: Buffer,
|
||||||
|
): string {
|
||||||
|
return digest(
|
||||||
|
Buffer.from(
|
||||||
|
`thothii-auth-projection-v1\nmode=${mode}\nauth=${digest(auth)}\nusers=${mode === "local" && users ? digest(users) : "-"}\n`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function snapshot(
|
||||||
|
generation: string,
|
||||||
|
users?: readonly LocalUserRecord[],
|
||||||
|
): RuntimeProjectionSnapshot {
|
||||||
|
const localUsers =
|
||||||
|
users === undefined
|
||||||
|
? undefined
|
||||||
|
: Object.freeze(
|
||||||
|
users.map((user) =>
|
||||||
|
Object.freeze({ ...user, roles: Object.freeze([...user.roles]) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
generation,
|
||||||
|
canonicalRevision: `sha256:${generation}`,
|
||||||
|
...(localUsers ? { localUsers } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
interface ValidGeneration {
|
||||||
|
value: ReturnType<typeof parseAuthenticationConfigSource>;
|
||||||
|
users?: readonly LocalUserRecord[];
|
||||||
|
}
|
||||||
|
function validateGeneration(
|
||||||
|
generationsPath: string,
|
||||||
|
generation: string,
|
||||||
|
uid: number,
|
||||||
|
): ValidGeneration {
|
||||||
|
const selectedPath = join(generationsPath, generation);
|
||||||
|
const openedGeneration = openDirectory(selectedPath, uid);
|
||||||
|
try {
|
||||||
|
const readManifest = readRegular(
|
||||||
|
join(selectedPath, "manifest.json"),
|
||||||
|
selectedPath,
|
||||||
|
uid,
|
||||||
|
MAX_MANIFEST_BYTES,
|
||||||
|
);
|
||||||
|
const loadedManifest = manifest(text(readManifest.bytes));
|
||||||
|
if (loadedManifest.generation !== generation) throw invalid();
|
||||||
|
entries(
|
||||||
|
selectedPath,
|
||||||
|
uid,
|
||||||
|
[
|
||||||
|
...loadedManifest.files.map((item) => item.name),
|
||||||
|
"manifest.json",
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
const auth = readRegular(
|
||||||
|
join(selectedPath, "auth.yaml"),
|
||||||
|
selectedPath,
|
||||||
|
uid,
|
||||||
|
MAX_AUTH_BYTES,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
loadedManifest.files[0]?.size !== auth.bytes.length ||
|
||||||
|
loadedManifest.files[0]?.sha256 !== digest(auth.bytes)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
const value = parseAuthenticationConfigSource(text(auth.bytes));
|
||||||
|
if (value.mode !== loadedManifest.mode) throw invalid();
|
||||||
|
let users: readonly LocalUserRecord[] | undefined;
|
||||||
|
let userBytes: Buffer | undefined;
|
||||||
|
if (loadedManifest.mode === "local") {
|
||||||
|
const readUsers = readRegular(
|
||||||
|
join(selectedPath, "users.yaml"),
|
||||||
|
selectedPath,
|
||||||
|
uid,
|
||||||
|
MAX_USERS_BYTES,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
loadedManifest.files[1]?.size !== readUsers.bytes.length ||
|
||||||
|
loadedManifest.files[1]?.sha256 !== digest(readUsers.bytes)
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
userBytes = readUsers.bytes;
|
||||||
|
users = parseLocalUserRegistrySource(text(userBytes));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
generationFor(loadedManifest.mode, auth.bytes, userBytes) !== generation
|
||||||
|
)
|
||||||
|
throw invalid();
|
||||||
|
stableDirectory(selectedPath, openedGeneration, uid);
|
||||||
|
return { value, users };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
closeSync(openedGeneration.fd);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function load(root: string): LoadedAuthConfig {
|
||||||
|
const uid = runtimeOwner();
|
||||||
|
checkRoot(root);
|
||||||
|
const openedRoot = openDirectory(root, uid);
|
||||||
|
try {
|
||||||
|
entries(root, uid, ["CURRENT", "generations"]);
|
||||||
|
const currentPath = join(root, "CURRENT");
|
||||||
|
const selectedCurrent = readRegular(
|
||||||
|
currentPath,
|
||||||
|
root,
|
||||||
|
uid,
|
||||||
|
MAX_SELECTOR_BYTES,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const selected = selector(text(selectedCurrent.bytes));
|
||||||
|
if (selected.state !== "ready" || !selected.generation) throw invalid();
|
||||||
|
const generationsPath = join(root, "generations");
|
||||||
|
const openedGenerations = openDirectory(generationsPath, uid);
|
||||||
|
try {
|
||||||
|
entries(generationsPath, uid, [
|
||||||
|
selected.generation,
|
||||||
|
...(selected.previousGenerations ?? []),
|
||||||
|
]);
|
||||||
|
const selectedGeneration = validateGeneration(
|
||||||
|
generationsPath,
|
||||||
|
selected.generation,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
for (const predecessor of selected.previousGenerations ?? [])
|
||||||
|
validateGeneration(generationsPath, predecessor, uid);
|
||||||
|
stableDirectory(generationsPath, openedGenerations, uid);
|
||||||
|
const afterCurrent = regular(
|
||||||
|
lstatSync(currentPath) as Stats,
|
||||||
|
uid,
|
||||||
|
MAX_SELECTOR_BYTES,
|
||||||
|
);
|
||||||
|
if (!same(selectedCurrent.identity, afterCurrent)) {
|
||||||
|
if (replaced(selectedCurrent.identity, afterCurrent))
|
||||||
|
throw new CurrentReplaced();
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
stableDirectory(root, openedRoot, uid);
|
||||||
|
return {
|
||||||
|
value: selectedGeneration.value,
|
||||||
|
revision: `sha256:${selected.generation}`,
|
||||||
|
sourcePath: join(generationsPath, selected.generation, "auth.yaml"),
|
||||||
|
runtimeProjection: snapshot(
|
||||||
|
selected.generation,
|
||||||
|
selectedGeneration.users,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
closeSync(openedGenerations.fd);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
closeSync(openedRoot.fd);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function createProjectedAuthenticationConfigProvider(
|
||||||
|
root: string,
|
||||||
|
): AuthenticationConfigProvider {
|
||||||
|
return {
|
||||||
|
current(): LoadedAuthConfig {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return load(root);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CurrentReplaced && attempt === 0) continue;
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw invalid();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,10 +45,28 @@ export interface OidcAuthenticationConfig {
|
|||||||
|
|
||||||
export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig;
|
export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig;
|
||||||
|
|
||||||
|
export interface LocalUserRecord {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
normalizedUsername: string;
|
||||||
|
displayName?: string;
|
||||||
|
passwordHash: string;
|
||||||
|
roles: readonly Role[];
|
||||||
|
enabled: boolean;
|
||||||
|
authRevision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeProjectionSnapshot {
|
||||||
|
generation: string;
|
||||||
|
canonicalRevision: string;
|
||||||
|
localUsers?: readonly LocalUserRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoadedAuthConfig {
|
export interface LoadedAuthConfig {
|
||||||
value: AuthenticationConfig;
|
value: AuthenticationConfig;
|
||||||
revision: string;
|
revision: string;
|
||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
|
runtimeProjection?: RuntimeProjectionSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticationConfigProvider {
|
export interface AuthenticationConfigProvider {
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
chownSync,
|
||||||
|
existsSync,
|
||||||
|
linkSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { createProjectedAuthenticationConfigProvider } from "../src/auth/runtime-projection.js";
|
||||||
|
import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-registry.js";
|
||||||
|
|
||||||
|
const lstatHook = vi.hoisted(() => ({
|
||||||
|
path: undefined as string | undefined,
|
||||||
|
callback: undefined as (() => void) | undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("node:fs")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
lstatSync(path: import("node:fs").PathLike) {
|
||||||
|
const result = actual.lstatSync(path);
|
||||||
|
if (lstatHook.path === String(path)) lstatHook.callback?.();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sentinel = "$argon2id$synthetic-sentinel";
|
||||||
|
const password = "correct horse battery staple";
|
||||||
|
const passwordHash =
|
||||||
|
"$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||||
|
const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
lstatHook.path = undefined;
|
||||||
|
lstatHook.callback = undefined;
|
||||||
|
for (const root of roots.splice(0))
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ProjectionFixture {
|
||||||
|
username: string;
|
||||||
|
hash?: string;
|
||||||
|
publicUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generationFor(auth: string, users: string): string {
|
||||||
|
return sha256(
|
||||||
|
`thothii-auth-projection-v1\nmode=local\nauth=${sha256(auth)}\nusers=${sha256(users)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localProjectionFixture(
|
||||||
|
username: string,
|
||||||
|
hash = passwordHash,
|
||||||
|
): ProjectionFixture {
|
||||||
|
return { username, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectionRoot(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "tht-auth-projection-"));
|
||||||
|
chmodSync(root, 0o700);
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDocument(
|
||||||
|
generation: string,
|
||||||
|
previousGenerations: readonly string[] = [],
|
||||||
|
): string {
|
||||||
|
return `${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
state: "ready",
|
||||||
|
transaction: "a".repeat(32),
|
||||||
|
generation,
|
||||||
|
...(previousGenerations.length === 0 ? {} : { previousGenerations }),
|
||||||
|
})}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectionSources(fixture: ProjectionFixture): {
|
||||||
|
auth: string;
|
||||||
|
users: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
auth: stringify({
|
||||||
|
version: 1,
|
||||||
|
mode: "local",
|
||||||
|
publicUrl: fixture.publicUrl ?? "http://127.0.0.1:8080",
|
||||||
|
local: { usersFile: "users.yaml" },
|
||||||
|
}),
|
||||||
|
users: stringify({
|
||||||
|
version: 1,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
username: fixture.username,
|
||||||
|
passwordHash: fixture.hash ?? passwordHash,
|
||||||
|
roles: ["admin"],
|
||||||
|
enabled: true,
|
||||||
|
authRevision: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGeneration(root: string, fixture: ProjectionFixture): string {
|
||||||
|
const { auth, users } = projectionSources(fixture);
|
||||||
|
const generation = generationFor(auth, users);
|
||||||
|
const directory = join(root, "generations", generation);
|
||||||
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
|
const manifest = `${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
generation,
|
||||||
|
mode: "local",
|
||||||
|
canonicalRevision: `sha256:${generation}`,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "auth.yaml",
|
||||||
|
size: Buffer.byteLength(auth),
|
||||||
|
sha256: sha256(auth),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "users.yaml",
|
||||||
|
size: Buffer.byteLength(users),
|
||||||
|
sha256: sha256(users),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}\n`;
|
||||||
|
for (const [name, source] of [
|
||||||
|
["manifest.json", manifest],
|
||||||
|
["auth.yaml", auth],
|
||||||
|
["users.yaml", users],
|
||||||
|
] as const) {
|
||||||
|
writeFileSync(join(directory, name), source, {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(join(directory, name), 0o600);
|
||||||
|
}
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeReadyProjection(
|
||||||
|
root: string,
|
||||||
|
fixture: ProjectionFixture,
|
||||||
|
): string {
|
||||||
|
mkdirSync(join(root, "generations"), { mode: 0o700 });
|
||||||
|
chmodSync(join(root, "generations"), 0o700);
|
||||||
|
const generation = writeGeneration(root, fixture);
|
||||||
|
writeFileSync(join(root, "CURRENT"), currentDocument(generation), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(join(root, "CURRENT"), 0o600);
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectDenied(operation: () => unknown): void {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
throw new Error("operation unexpectedly succeeded");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
expect(message).toBe("authentication runtime projection is invalid");
|
||||||
|
expect(message).not.toContain(sentinel);
|
||||||
|
expect(message).not.toContain(passwordHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loads ready projection as one immutable auth and local-users snapshot", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const fixture = localProjectionFixture("synthetic-user", passwordHash);
|
||||||
|
const generation = writeReadyProjection(root, fixture);
|
||||||
|
const loaded = createProjectedAuthenticationConfigProvider(root).current();
|
||||||
|
|
||||||
|
expect(loaded.revision).toBe(`sha256:${generation}`);
|
||||||
|
expect(loaded.sourcePath).toBe(
|
||||||
|
join(root, "generations", generation, "auth.yaml"),
|
||||||
|
);
|
||||||
|
expect(loaded.runtimeProjection?.generation).toBe(generation);
|
||||||
|
expect(loaded.runtimeProjection?.canonicalRevision).toBe(
|
||||||
|
`sha256:${generation}`,
|
||||||
|
);
|
||||||
|
expect(Object.isFrozen(loaded.runtimeProjection)).toBe(true);
|
||||||
|
expect(Object.isFrozen(loaded.runtimeProjection?.localUsers)).toBe(true);
|
||||||
|
expect(Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0])).toBe(true);
|
||||||
|
expect(
|
||||||
|
Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0]?.roles),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["missing", undefined],
|
||||||
|
[
|
||||||
|
"blocked",
|
||||||
|
`${JSON.stringify({ version: 1, state: "blocked", transaction: "a".repeat(32) })}\n`,
|
||||||
|
],
|
||||||
|
["malformed", "{not-json}\n"],
|
||||||
|
[
|
||||||
|
"duplicate-field",
|
||||||
|
`{"version":1,"version":1,"state":"ready","transaction":"${"a".repeat(32)}","generation":"${"b".repeat(64)}"}\n`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"unknown-version",
|
||||||
|
`${JSON.stringify({ version: 2, state: "ready", transaction: "a".repeat(32), generation: "b".repeat(64) })}\n`,
|
||||||
|
],
|
||||||
|
])("rejects %s CURRENT without secret disclosure", (_label, contents) => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", sentinel),
|
||||||
|
);
|
||||||
|
if (contents === undefined) unlinkSync(join(root, "CURRENT"));
|
||||||
|
else
|
||||||
|
writeFileSync(join(root, "CURRENT"), contents, {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"root traversal",
|
||||||
|
"CURRENT symlink",
|
||||||
|
"CURRENT hardlink",
|
||||||
|
"permissive mode",
|
||||||
|
"unexpected root entry",
|
||||||
|
])("rejects %s without secret disclosure", (kind) => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", sentinel),
|
||||||
|
);
|
||||||
|
const current = join(root, "CURRENT");
|
||||||
|
if (kind === "root traversal") {
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(
|
||||||
|
`${root}/../${root.split("/").at(-1)!}`,
|
||||||
|
).current(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "CURRENT symlink") {
|
||||||
|
renameSync(current, join(root, "current-target"));
|
||||||
|
symlinkSync(join(root, "current-target"), current);
|
||||||
|
} else if (kind === "CURRENT hardlink") {
|
||||||
|
linkSync(current, join(root, "current-link"));
|
||||||
|
} else if (kind === "permissive mode") {
|
||||||
|
chmodSync(current, 0o640);
|
||||||
|
} else {
|
||||||
|
writeFileSync(join(root, "unexpected"), "x", {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.runIf(process.geteuid?.() === 0)(
|
||||||
|
"rejects wrong owner at every projection boundary without secret disclosure",
|
||||||
|
() => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", sentinel),
|
||||||
|
);
|
||||||
|
// Root may safely construct a synthetic foreign-owned fixture; no real account is touched.
|
||||||
|
chownSync(join(root, "CURRENT"), 1, 1);
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("rejects a symlinked runtime root", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", passwordHash),
|
||||||
|
);
|
||||||
|
const linkedRoot = `${root}-link`;
|
||||||
|
symlinkSync(root, linkedRoot, "dir");
|
||||||
|
roots.push(linkedRoot);
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(linkedRoot).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"root",
|
||||||
|
"generations",
|
||||||
|
"selected generation",
|
||||||
|
"manifest",
|
||||||
|
"auth",
|
||||||
|
"users",
|
||||||
|
])("rejects unsafe mode on %s", (boundary) => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const generation = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", passwordHash),
|
||||||
|
);
|
||||||
|
const selected = join(root, "generations", generation);
|
||||||
|
const path =
|
||||||
|
boundary === "root"
|
||||||
|
? root
|
||||||
|
: boundary === "generations"
|
||||||
|
? join(root, "generations")
|
||||||
|
: boundary === "selected generation"
|
||||||
|
? selected
|
||||||
|
: join(
|
||||||
|
selected,
|
||||||
|
boundary === "manifest" ? "manifest.json" : `${boundary}.yaml`,
|
||||||
|
);
|
||||||
|
chmodSync(
|
||||||
|
path,
|
||||||
|
boundary === "root" ||
|
||||||
|
boundary === "generations" ||
|
||||||
|
boundary === "selected generation"
|
||||||
|
? 0o750
|
||||||
|
: 0o640,
|
||||||
|
);
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["manifest", "generation", "size", "digest"])(
|
||||||
|
"rejects changed %s integrity data without secret disclosure",
|
||||||
|
(kind) => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const generation = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", sentinel),
|
||||||
|
);
|
||||||
|
const manifestPath = join(root, "generations", generation, "manifest.json");
|
||||||
|
const manifest = JSON.parse(String(readFileSync(manifestPath))) as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>;
|
||||||
|
if (kind === "manifest") manifest.unexpected = true;
|
||||||
|
if (kind === "generation") manifest.generation = "b".repeat(64);
|
||||||
|
if (kind === "size") manifest.files[0].size += 1;
|
||||||
|
if (kind === "digest") manifest.files[1].sha256 = "b".repeat(64);
|
||||||
|
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(manifestPath, 0o600);
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("switches atomically to a later complete generation", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const first = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("first", passwordHash),
|
||||||
|
);
|
||||||
|
const provider = createProjectedAuthenticationConfigProvider(root);
|
||||||
|
expect(provider.current().runtimeProjection?.generation).toBe(first);
|
||||||
|
const second = writeGeneration(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("second", passwordHash),
|
||||||
|
);
|
||||||
|
const temporary = join(root, ".current-switch.tmp");
|
||||||
|
writeFileSync(temporary, currentDocument(second, [first]), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(temporary, 0o600);
|
||||||
|
renameSync(temporary, join(root, "CURRENT"));
|
||||||
|
expect(provider.current().runtimeProjection?.generation).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries once when CURRENT is atomically replaced between lstat and open", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const first = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("first", passwordHash),
|
||||||
|
);
|
||||||
|
const second = writeGeneration(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("second", passwordHash),
|
||||||
|
);
|
||||||
|
const temporary = join(root, ".current-replacement.tmp");
|
||||||
|
lstatHook.path = join(root, "CURRENT");
|
||||||
|
lstatHook.callback = () => {
|
||||||
|
lstatHook.callback = undefined;
|
||||||
|
writeFileSync(temporary, currentDocument(second, [first]), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(temporary, 0o600);
|
||||||
|
renameSync(temporary, join(root, "CURRENT"));
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current()
|
||||||
|
.runtimeProjection?.generation,
|
||||||
|
).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a second CURRENT replacement after the one permitted retry", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const first = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("first", passwordHash),
|
||||||
|
);
|
||||||
|
const second = writeGeneration(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("second", passwordHash),
|
||||||
|
);
|
||||||
|
const third = writeGeneration(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("third", passwordHash),
|
||||||
|
);
|
||||||
|
const replacements = [
|
||||||
|
{ generation: second, previous: [first] },
|
||||||
|
{ generation: third, previous: [second, first] },
|
||||||
|
];
|
||||||
|
lstatHook.path = join(root, "CURRENT");
|
||||||
|
lstatHook.callback = () => {
|
||||||
|
const replacement = replacements.shift();
|
||||||
|
if (!replacement) return;
|
||||||
|
const temporary = join(
|
||||||
|
root,
|
||||||
|
`.current-replacement-${replacement.generation}.tmp`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
temporary,
|
||||||
|
currentDocument(replacement.generation, replacement.previous),
|
||||||
|
{ encoding: "utf8", mode: 0o600 },
|
||||||
|
);
|
||||||
|
chmodSync(temporary, 0o600);
|
||||||
|
renameSync(temporary, join(root, "CURRENT"));
|
||||||
|
};
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails deterministically when generations is replaced during a load", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const generation = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", passwordHash),
|
||||||
|
);
|
||||||
|
const replacement = mkdtempSync(
|
||||||
|
join(tmpdir(), "tht-auth-projection-replacement-"),
|
||||||
|
);
|
||||||
|
roots.push(replacement);
|
||||||
|
chmodSync(replacement, 0o700);
|
||||||
|
mkdirSync(join(replacement, generation), { recursive: true, mode: 0o700 });
|
||||||
|
chmodSync(join(replacement, generation), 0o700);
|
||||||
|
for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) {
|
||||||
|
const source = join(root, "generations", generation, name);
|
||||||
|
writeFileSync(join(replacement, generation, name), readFileSync(source), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(join(replacement, generation, name), 0o600);
|
||||||
|
}
|
||||||
|
lstatHook.path = join(root, "generations");
|
||||||
|
lstatHook.callback = () => {
|
||||||
|
lstatHook.callback = undefined;
|
||||||
|
renameSync(join(root, "generations"), join(root, "generations-retired"));
|
||||||
|
renameSync(replacement, join(root, "generations"));
|
||||||
|
};
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails deterministically when the selected generation directory is replaced during a load", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const generation = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", passwordHash),
|
||||||
|
);
|
||||||
|
const replacement = mkdtempSync(
|
||||||
|
join(tmpdir(), "tht-auth-projection-generation-replacement-"),
|
||||||
|
);
|
||||||
|
roots.push(replacement);
|
||||||
|
chmodSync(replacement, 0o700);
|
||||||
|
for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) {
|
||||||
|
const source = join(root, "generations", generation, name);
|
||||||
|
writeFileSync(join(replacement, name), readFileSync(source), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(join(replacement, name), 0o600);
|
||||||
|
}
|
||||||
|
lstatHook.path = join(root, "generations", generation);
|
||||||
|
lstatHook.callback = () => {
|
||||||
|
lstatHook.callback = undefined;
|
||||||
|
renameSync(
|
||||||
|
join(root, "generations", generation),
|
||||||
|
join(root, "generation-retired"),
|
||||||
|
);
|
||||||
|
renameSync(replacement, join(root, "generations", generation));
|
||||||
|
};
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["corrupt", "symlink"])(
|
||||||
|
"rejects a %s retained predecessor generation",
|
||||||
|
(kind) => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const first = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("first", passwordHash),
|
||||||
|
);
|
||||||
|
const second = writeGeneration(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("second", passwordHash),
|
||||||
|
);
|
||||||
|
writeFileSync(join(root, "CURRENT"), currentDocument(second, [first]), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(join(root, "CURRENT"), 0o600);
|
||||||
|
const predecessor = join(root, "generations", first);
|
||||||
|
if (kind === "corrupt") {
|
||||||
|
writeFileSync(join(predecessor, "auth.yaml"), "tampered", {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(join(predecessor, "auth.yaml"), 0o600);
|
||||||
|
} else {
|
||||||
|
const replacement = mkdtempSync(
|
||||||
|
join(tmpdir(), "tht-auth-projection-history-"),
|
||||||
|
);
|
||||||
|
roots.push(replacement);
|
||||||
|
chmodSync(replacement, 0o700);
|
||||||
|
rmSync(predecessor, { recursive: true, force: true });
|
||||||
|
symlinkSync(replacement, predecessor, "dir");
|
||||||
|
}
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("has no direct-file fallback when CURRENT is absent", () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const generation = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("synthetic-user", sentinel),
|
||||||
|
);
|
||||||
|
unlinkSync(join(root, "CURRENT"));
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "auth.yaml"),
|
||||||
|
projectionSources(localProjectionFixture("synthetic-user", sentinel)).auth,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
expect(existsSync(join(root, "generations", generation, "auth.yaml"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expectDenied(() =>
|
||||||
|
createProjectedAuthenticationConfigProvider(root).current(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => {
|
||||||
|
const root = projectionRoot();
|
||||||
|
const first = writeReadyProjection(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("first", passwordHash),
|
||||||
|
);
|
||||||
|
const provider = createProjectedAuthenticationConfigProvider(root);
|
||||||
|
const loadedA = provider.current();
|
||||||
|
const second = writeGeneration(
|
||||||
|
root,
|
||||||
|
localProjectionFixture("second", passwordHash),
|
||||||
|
);
|
||||||
|
const temporary = join(root, ".current-switch.tmp");
|
||||||
|
writeFileSync(temporary, currentDocument(second), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
chmodSync(temporary, 0o600);
|
||||||
|
renameSync(temporary, join(root, "CURRENT"));
|
||||||
|
rmSync(join(root, "generations", first), { recursive: true, force: true });
|
||||||
|
|
||||||
|
const resolver = createCurrentLocalUserRegistryResolver();
|
||||||
|
const registryA = resolver.resolve(loadedA);
|
||||||
|
const userA = await registryA?.findByUsername("FIRST");
|
||||||
|
await expect(registryA?.verify(userA, password)).resolves.toBe(true);
|
||||||
|
const loadedB = provider.current();
|
||||||
|
const registryB = resolver.resolve(loadedB);
|
||||||
|
await expect(registryB?.findByUsername("second")).resolves.toMatchObject({
|
||||||
|
username: "second",
|
||||||
|
});
|
||||||
|
await expect(registryB?.findByUsername("first")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
@@ -56,7 +56,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
|
import { createCurrentLocalUserRegistryResolver, createLocalUserRegistry } from "../src/auth/local-registry.js";
|
||||||
|
|
||||||
const password = "correct horse battery staple";
|
const password = "correct horse battery staple";
|
||||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||||
@@ -136,6 +136,44 @@ async function expectInvalid(operation: Promise<unknown>, secrets: string[] = []
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("local user registry", () => {
|
describe("local user registry", () => {
|
||||||
|
test("resolves a projected local registry from its frozen in-memory users", async () => {
|
||||||
|
const resolver = createCurrentLocalUserRegistryResolver();
|
||||||
|
const users = Object.freeze([Object.freeze({
|
||||||
|
id: adminId,
|
||||||
|
username: "ProjectedAdmin",
|
||||||
|
normalizedUsername: "projectedadmin",
|
||||||
|
passwordHash,
|
||||||
|
roles: Object.freeze(["admin"] as const),
|
||||||
|
enabled: true,
|
||||||
|
authRevision: 1,
|
||||||
|
})]);
|
||||||
|
const loaded = {
|
||||||
|
value: {
|
||||||
|
version: 1 as const,
|
||||||
|
mode: "local" as const,
|
||||||
|
publicUrl: "http://127.0.0.1:8080",
|
||||||
|
session: {
|
||||||
|
regularTtlSeconds: 1,
|
||||||
|
regularIdleSeconds: 1,
|
||||||
|
rememberTtlSeconds: 1,
|
||||||
|
rememberIdleSeconds: 1,
|
||||||
|
oidcTtlSeconds: 1,
|
||||||
|
},
|
||||||
|
local: { usersFile: "users.yaml" },
|
||||||
|
},
|
||||||
|
revision: "sha256:synthetic",
|
||||||
|
sourcePath: "/definitely/not/opened/auth.yaml",
|
||||||
|
runtimeProjection: Object.freeze({
|
||||||
|
generation: "a".repeat(64),
|
||||||
|
canonicalRevision: `sha256:${"a".repeat(64)}`,
|
||||||
|
localUsers: users,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const registry = resolver.resolve(loaded);
|
||||||
|
await expect(registry?.findByUsername("PROJECTEDADMIN")).resolves.toMatchObject({ id: adminId });
|
||||||
|
await expect(registry?.verify(await registry?.findByUsername("projectedadmin"), password)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("reads known fields, performs case-insensitive lookup, and verifies passwords", async () => {
|
test("reads known fields, performs case-insensitive lookup, and verifies passwords", async () => {
|
||||||
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Local administrator" })));
|
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Local administrator" })));
|
||||||
const registry = createLocalUserRegistry(fixture.path);
|
const registry = createLocalUserRegistry(fixture.path);
|
||||||
|
|||||||
Reference in New Issue
Block a user