fix(auth): paginate session maintenance safely

This commit is contained in:
2026-08-17 09:05:20 +02:00
parent 3e7bb11313
commit c86f01e886
12 changed files with 1021 additions and 79 deletions
+118 -18
View File
@@ -1,6 +1,13 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js";
import type {
AuthenticationConfigProvider,
LoadedAuthConfig,
OidcAuthenticationConfig,
OidcStateRecord,
OidcTransactionTransport,
Role,
} from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
@@ -9,6 +16,7 @@ import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js";
import type { OidcProtocol } from "./oidc-client.js";
import { parseConfiguredTransportUrl } from "./url-policy.js";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000;
@@ -18,10 +26,20 @@ const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
const OIDC_TRANSACTION_COOKIE = "__Host-thothii_oidc_tx";
const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000;
const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
interface OidcTransactionCookieProfile {
transport: OidcTransactionTransport;
name: string;
secure: boolean;
}
const OIDC_TRANSACTION_COOKIE_PROFILES: Readonly<Record<OidcTransactionTransport, OidcTransactionCookieProfile>> = {
https: { transport: "https", name: "__Host-thothii_oidc_tx", secure: true },
loopback_http: { transport: "loopback_http", name: "thothii_oidc_tx", secure: false },
};
export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
authentication?: AuthenticationConfigProvider;
@@ -224,8 +242,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply);
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps);
if (!configured || !deps.sessionStore) {
clearOidcTransactionCookie(reply);
const transactionProfile = configured === undefined
? undefined
: oidcTransactionCookieProfile(configured.loaded);
if (!configured || !transactionProfile || !deps.sessionStore) {
clearOidcTransactionCookies(reply);
return unavailable(reply);
}
const nonce = randomOidcValue();
@@ -239,18 +260,20 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
authConfigRevision: configured.loaded.revision,
issuer: configured.config.oidc.issuer,
browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"),
browserTransactionTransport: transactionProfile.transport,
});
try {
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
reply.setCookie(OIDC_TRANSACTION_COOKIE, browserTransaction, oidcTransactionCookieOptions());
clearOidcTransactionCookie(reply, otherOidcTransactionCookieProfile(transactionProfile));
reply.setCookie(transactionProfile.name, browserTransaction, oidcTransactionCookieOptions(transactionProfile));
return reply.redirect(location.href);
} catch {
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
clearOidcTransactionCookie(reply);
clearOidcTransactionCookies(reply, transactionProfile);
return unavailable(reply);
}
} catch (error) {
clearOidcTransactionCookie(reply);
clearOidcTransactionCookies(reply, transactionProfile);
if (error instanceof OidcStateCapacityError) return loginLimited(reply);
return unavailable(reply);
}
@@ -258,20 +281,31 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
app.get("/auth/oidc/callback", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
clearOidcTransactionCookie(reply);
const callback = oidcCallbackUrl(request, loaded?.value.publicUrl);
if (!deps.sessionStore || !callback.state) return oidcCallbackFailed(reply);
let state;
if (!deps.sessionStore || !callback.state) {
clearOidcTransactionCookies(reply);
return oidcCallbackFailed(reply);
}
let state: OidcStateRecord | undefined;
try {
state = await deps.sessionStore.consumeOidcState(callback.state);
} catch {
clearOidcTransactionCookies(reply);
return oidcCallbackFailed(reply);
}
const stateTransactionProfile = oidcTransactionCookieProfileForTransport(state?.browserTransactionTransport);
clearOidcTransactionCookies(reply, stateTransactionProfile);
const configured = currentOidcConfig(loaded, deps);
if (!callback.currentUrl || !configured || !state || state.returnTo !== "/"
|| !oidcTransactionMatches(request.cookies[OIDC_TRANSACTION_COOKIE], state.browserTransactionDigest)
const configuredTransactionProfile = configured === undefined
? undefined
: oidcTransactionCookieProfile(configured.loaded);
const transaction = readOidcTransactionCookie(request, stateTransactionProfile);
const transactionMatches = oidcTransactionMatches(transaction, state?.browserTransactionDigest ?? "");
if (!callback.currentUrl || !configured || !configuredTransactionProfile || !state || !stateTransactionProfile
|| state.returnTo !== "/" || !transactionMatches
|| state.authConfigRevision !== configured.loaded.revision
|| state.issuer !== configured.config.oidc.issuer) {
|| state.issuer !== configured.config.oidc.issuer
|| stateTransactionProfile.transport !== configuredTransactionProfile.transport) {
return oidcCallbackFailed(reply);
}
try {
@@ -432,25 +466,91 @@ function oidcTransactionMatches(value: string | undefined, expectedDigest: strin
return canonical && expectedCanonical && matches;
}
function oidcTransactionCookieOptions() {
function oidcTransactionCookieProfile(loaded: LoadedAuthConfig): OidcTransactionCookieProfile | undefined {
try {
if (loaded.value.mode !== "oidc") return undefined;
const publicUrl = parseConfiguredTransportUrl(loaded.value.publicUrl, {
allowLoopbackHttp: true,
originOnly: true,
});
if (!publicUrl) return undefined;
if (publicUrl.protocol === "https:") return OIDC_TRANSACTION_COOKIE_PROFILES.https;
if (publicUrl.protocol === "http:") return OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http;
return undefined;
} catch {
return undefined;
}
}
function oidcTransactionCookieProfileForTransport(
transport: OidcTransactionTransport | undefined,
): OidcTransactionCookieProfile | undefined {
return transport === "https" || transport === "loopback_http"
? OIDC_TRANSACTION_COOKIE_PROFILES[transport]
: undefined;
}
function otherOidcTransactionCookieProfile(
profile: OidcTransactionCookieProfile,
): OidcTransactionCookieProfile {
return profile.transport === "https"
? OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http
: OIDC_TRANSACTION_COOKIE_PROFILES.https;
}
function oidcTransactionCookieOptions(profile: OidcTransactionCookieProfile) {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: true,
secure: profile.secure,
maxAge: OIDC_TRANSACTION_COOKIE_SECONDS,
};
}
function clearOidcTransactionCookie(reply: FastifyReply): void {
reply.clearCookie(OIDC_TRANSACTION_COOKIE, {
function clearOidcTransactionCookie(reply: FastifyReply, profile: OidcTransactionCookieProfile): void {
reply.clearCookie(profile.name, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
secure: profile.secure,
});
}
function clearOidcTransactionCookies(reply: FastifyReply, preferred?: OidcTransactionCookieProfile): void {
if (preferred) {
clearOidcTransactionCookie(reply, preferred);
clearOidcTransactionCookie(reply, otherOidcTransactionCookieProfile(preferred));
return;
}
clearOidcTransactionCookie(reply, OIDC_TRANSACTION_COOKIE_PROFILES.https);
clearOidcTransactionCookie(reply, OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http);
}
/**
* Browser parsers choose one duplicate cookie value differently. Parse just our two fixed names
* from the raw header and reject duplicates or a cross-transport sibling before hashing.
*/
function readOidcTransactionCookie(
request: FastifyRequest,
expected: OidcTransactionCookieProfile | undefined,
): string | undefined {
const raw = request.headers.cookie;
if (!expected || typeof raw !== "string" || raw.length > 4096 || Array.isArray(raw)) return undefined;
let transactionCookies = 0;
let expectedCookies = 0;
let expectedValue: string | undefined;
for (const part of raw.split(";")) {
const match = /^\s*(__Host-thothii_oidc_tx|thothii_oidc_tx)(?:=([^;]*))?\s*$/.exec(part);
if (!match) continue;
transactionCookies += 1;
if (match[1] !== expected.name) continue;
expectedCookies += 1;
expectedValue = match[2];
}
return transactionCookies === 1 && expectedCookies === 1 ? expectedValue : undefined;
}
function oidcCallbackUrl(
request: FastifyRequest,
publicUrl: string | undefined,
+161 -27
View File
@@ -21,7 +21,13 @@ 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";
import type {
AuthSessionRecord,
OidcStateRecord,
OidcTransactionTransport,
Permission,
Role,
} from "./types.js";
import {
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
@@ -42,6 +48,9 @@ const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_CAPACITY = 64;
const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3;
const MAX_SESSION_PRUNE_ENTRIES = 512;
// A separate scan ceiling bounds directory-walk CPU and the duplicate-detection set without
// reviving the former 512-record availability ceiling.
const MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES = 16_384;
const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
const EMPTY_HKDF_SALT = Buffer.alloc(0);
@@ -76,6 +85,7 @@ export interface OidcStateCreateInput {
authConfigRevision: string;
issuer: string;
browserTransactionDigest: string;
browserTransactionTransport: OidcTransactionTransport;
}
export interface CreatedOidcState {
@@ -149,6 +159,11 @@ interface DirectoryIdentity {
mode?: number;
}
interface DirectoryScanIdentity extends DirectoryIdentity {
mtimeMs: number;
ctimeMs: number;
}
interface TrustedFile<T> {
value: T;
identity: FileIdentity;
@@ -160,6 +175,11 @@ interface StorageDirectories {
oidc: string;
}
interface SessionDirectoryPage {
entries: string[];
more: boolean;
}
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);
@@ -207,6 +227,9 @@ const oidcStateRecordSchema = z.strictObject({
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
// Existing ten-minute records from before this field was introduced can be consumed and
// rejected by the route. New records always receive the required input field below.
browserTransactionTransport: z.enum(["https", "loopback_http"]).optional(),
capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(),
createdAt: timestamp,
expiresAt: timestamp,
@@ -254,6 +277,7 @@ const oidcStateInputSchema = z.strictObject({
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
browserTransactionTransport: z.enum(["https", "loopback_http"]),
});
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
@@ -265,6 +289,10 @@ function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode;
}
function sameDirectoryScanIdentity(left: DirectoryScanIdentity, right: DirectoryScanIdentity): boolean {
return sameDirectoryIdentity(left, right) && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
}
function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
}
@@ -340,6 +368,15 @@ function directoryIdentity(path: string): DirectoryIdentity {
};
}
function directoryScanIdentity(path: string): DirectoryScanIdentity {
const identity = directoryIdentity(path);
const info = lstatSync(path) as Stats;
if (!info.isDirectory() || info.isSymbolicLink() || info.dev !== identity.dev || info.ino !== identity.ino
|| info.uid !== identity.uid || (info.mode & 0o7777) !== identity.mode
|| !Number.isFinite(info.mtimeMs) || !Number.isFinite(info.ctimeMs)) throw invalid();
return { ...identity, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs };
}
function privateDirectory(path: string): void {
let created = false;
try {
@@ -403,6 +440,71 @@ function boundedDirectoryNames(directory: string, maximumEntries: number): strin
}
}
/**
* Return the next lexical page without retaining a complete directory listing. Every direct
* child is structurally validated during the bounded scan, so an unsafe entry cannot hide after
* a full page of ordinary sessions.
*/
function boundedSessionDirectoryPage(
directory: string,
after: string | undefined,
maximumEntries: number,
): SessionDirectoryPage {
if (!Number.isInteger(maximumEntries) || maximumEntries < 1
|| (after !== undefined && !DIGEST_FILENAME_PATTERN.test(after))) throw invalid();
let handle: ReturnType<typeof opendirSync> | undefined;
try {
const before = directoryScanIdentity(directory);
handle = opendirSync(directory);
const seen = new Set<string>();
const selected: string[] = [];
let scanned = 0;
while (true) {
const entry = handle.readSync();
if (entry === null) {
handle.closeSync();
handle = undefined;
if (!sameDirectoryScanIdentity(before, directoryScanIdentity(directory))) throw invalid();
selected.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
const more = selected.length > maximumEntries;
return { entries: more ? selected.slice(0, maximumEntries) : selected, more };
}
scanned += 1;
if (scanned > MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES) throw invalid();
const filename = entry.name;
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)) throw invalid();
seen.add(filename);
let info: Stats;
try {
info = lstatSync(filePath(directory, filename)) as Stats;
} catch (error) {
throw invalid();
}
fileIdentity(info);
if (after !== undefined && filename <= after) continue;
appendBoundedSessionFilename(selected, filename, maximumEntries + 1);
}
} catch {
throw invalid();
} finally {
if (handle !== undefined) {
try { handle.closeSync(); } catch { /* the operation is already fail-closed */ }
}
}
}
function appendBoundedSessionFilename(names: string[], filename: string, maximumEntries: number): void {
if (names.length < maximumEntries) {
names.push(filename);
return;
}
let greatest = 0;
for (let index = 1; index < names.length; index += 1) {
if (names[index] > names[greatest]) greatest = index;
}
if (filename < names[greatest]) names[greatest] = filename;
}
function openDirectory(directory: string): number | undefined {
if (process.platform === "win32") return undefined;
return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
@@ -835,12 +937,65 @@ export function createFileAuthSessionStore(
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
let sessionPruneCursor: string | undefined;
function requiredWindowsStorage(): WindowsAuthStorageBridge {
if (windowsStorage === undefined) throw invalid();
return windowsStorage;
}
async function ordinarySessionPage(after: string | undefined): Promise<SessionDirectoryPage> {
if (process.platform !== "win32") {
return boundedSessionDirectoryPage(storageDirectories(root).sessions, after, MAX_SESSION_PRUNE_ENTRIES);
}
const page = await requiredWindowsStorage().listPage(root, "sessions", after, MAX_SESSION_PRUNE_ENTRIES);
if (!page || !Array.isArray(page.entries) || typeof page.more !== "boolean") throw invalid();
return { entries: page.entries.map((entry) => entry.name), more: page.more };
}
function nextSessionPruneCursor(page: SessionDirectoryPage, after: string | undefined): string | undefined {
if (!Array.isArray(page.entries) || typeof page.more !== "boolean"
|| page.entries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
const seen = new Set<string>();
let previous = after;
for (const filename of page.entries) {
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)
|| (previous !== undefined && filename <= previous)) throw invalid();
seen.add(filename);
previous = filename;
}
if (!page.more) return undefined;
if (page.entries.length !== MAX_SESSION_PRUNE_ENTRIES || previous === undefined || previous === after) throw invalid();
return previous;
}
async function pruneOrdinarySessions(nowMs: number): Promise<number> {
const after = sessionPruneCursor;
const page = await ordinarySessionPage(after);
const next = nextSessionPruneCursor(page, after);
let removed = 0;
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
for (const filename of page.entries) {
const contents = await bridge.read(root, "sessions", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", filename)) removed += 1;
}
} else {
const directory = storageDirectories(root).sessions;
for (const filename of page.entries) {
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directory, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (trusted && sessionExpired(trusted.value, nowMs)
&& removeTrusted(directory, filename, trusted.identity)) removed += 1;
});
}
}
sessionPruneCursor = next;
return removed;
}
async function oidcStorageEntries(): Promise<string[]> {
const entries = process.platform === "win32"
? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name)
@@ -1239,6 +1394,7 @@ export function createFileAuthSessionStore(
authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer,
browserTransactionDigest: validated.browserTransactionDigest,
browserTransactionTransport: validated.browserTransactionTransport,
capacitySlot,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
@@ -1284,32 +1440,10 @@ export function createFileAuthSessionStore(
async function prune(now = new Date()): Promise<number> {
const nowMs = dateMilliseconds(now);
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
const sessionEntries = await bridge.list(root, "sessions", MAX_SESSION_PRUNE_ENTRIES);
if (sessionEntries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
let removed = 0;
for (const entry of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(entry.name)) throw invalid();
const contents = await bridge.read(root, "sessions", entry.name);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", entry.name)) removed += 1;
}
return removed + await pruneOidcStates(nowMs);
}
const directories = storageDirectories(root);
let removed = 0;
const sessionEntries = boundedDirectoryNames(directories.sessions, MAX_SESSION_PRUNE_ENTRIES);
for (const filename of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (trusted && sessionExpired(trusted.value, nowMs)
&& removeTrusted(directories.sessions, filename, trusted.identity)) removed += 1;
});
}
return removed + await pruneOidcStates(nowMs);
// Cursor advancement is process-local, so concurrent timer/manual invocations must not
// observe the same page and strand a later page forever.
return await withLock(lockKey(root, "sessions", "maintenance"), async () =>
(await pruneOrdinarySessions(nowMs)) + (await pruneOidcStates(nowMs)));
}
return {
+5
View File
@@ -55,6 +55,9 @@ export interface AuthenticationConfigProvider {
current(): LoadedAuthConfig;
}
/** The only browser transport modes accepted for an OIDC transaction cookie. */
export type OidcTransactionTransport = "https" | "loopback_http";
/** Durable, server-side representation of an opaque browser session. */
export interface AuthSessionRecord {
version: 1;
@@ -82,6 +85,8 @@ export interface OidcStateRecord {
authConfigRevision: string;
issuer: string;
browserTransactionDigest: string;
/** Optional only to safely consume and reject a short-lived pre-transport legacy state. */
browserTransactionTransport?: OidcTransactionTransport;
capacitySlot?: number;
createdAt: string;
expiresAt: string;
+63 -7
View File
@@ -24,6 +24,11 @@ export interface WindowsAuthStorageEntry {
modifiedUnixMs: number;
}
export interface WindowsAuthStoragePage {
entries: WindowsAuthStorageEntry[];
more: boolean;
}
/** Internal adapter boundary for the file-session store's native Windows path. */
export interface WindowsAuthStorageBridge {
create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>;
@@ -35,6 +40,12 @@ export interface WindowsAuthStorageBridge {
directory: WindowsAuthStorageDirectory,
maximumEntries?: number,
): Promise<WindowsAuthStorageEntry[]>;
listPage(
root: string,
directory: "sessions",
afterName: string | undefined,
maximumEntries: number,
): Promise<WindowsAuthStoragePage>;
claimConsume(root: string, filename: string): Promise<Buffer | undefined>;
readClaim(root: string, filename: string): Promise<Buffer | undefined>;
removeClaim(root: string, filename: string): Promise<boolean>;
@@ -92,6 +103,7 @@ const responseSchema = z.strictObject({
name: z.string().max(128),
modifiedUnixMs: z.number().int().safe().nonnegative(),
})).max(MAX_ENTRIES).optional(),
more: z.boolean().optional(),
});
type BridgeResponse = z.infer<typeof responseSchema>;
@@ -104,6 +116,8 @@ interface BridgeRequest {
filename?: string;
contentBase64?: string;
maximumEntries?: number;
afterName?: string;
continuation?: true;
}
function directoryMaximum(directory: WindowsAuthStorageDirectory): number {
@@ -159,8 +173,15 @@ function encodedRequest(request: BridgeRequest): Buffer {
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries)
|| request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid();
if (request.continuation === true) {
if (request.directory !== "sessions" || (request.afterName !== undefined && !DIGEST_FILENAME.test(request.afterName))) {
throw invalid();
}
} else if (request.afterName !== undefined || request.continuation !== undefined) {
throw invalid();
}
} else {
if (request.maximumEntries !== undefined) throw invalid();
if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
if (request.filename === undefined) throw invalid();
const allowClaim = request.operation === "remove" && request.directory === "oidc";
const allowOidcSlot = request.directory === "oidc"
@@ -311,6 +332,22 @@ function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefi
return canonicalBase64(response.contentBase64, maximum);
}
function listedEntries(
response: BridgeResponse,
directory: WindowsAuthStorageDirectory,
maximumEntries: number,
): WindowsAuthStorageEntry[] {
if (response.entries === undefined || response.entries.length > maximumEntries) throw invalid();
const names = new Set<string>();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc"
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid();
if (names.has(entry.name)) throw invalid();
names.add(entry.name);
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
}
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable);
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
@@ -368,13 +405,32 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge
directory,
maximumEntries,
});
if (response.entries === undefined) throw invalid();
if (response.entries.length > maximumEntries) throw invalid();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc"
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid();
if (response.more !== undefined) throw invalid();
return listedEntries(response, directory, maximumEntries);
},
async listPage(root, directory, afterName, maximumEntries) {
if (directory !== "sessions" || !Number.isInteger(maximumEntries)
|| maximumEntries < 1 || maximumEntries > MAX_ENTRIES
|| (afterName !== undefined && !DIGEST_FILENAME.test(afterName))) throw invalid();
const response = await request({
version: PROTOCOL_VERSION,
operation: "list",
root,
directory,
maximumEntries,
continuation: true,
...(afterName === undefined ? {} : { afterName }),
});
if (response.more === undefined) throw invalid();
const entries = listedEntries(response, directory, maximumEntries);
let previous = afterName;
for (const entry of entries) {
if (previous !== undefined && entry.name <= previous) throw invalid();
previous = entry.name;
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
if (response.more && entries.length !== maximumEntries) throw invalid();
if (response.more && (previous === undefined || previous === afterName)) throw invalid();
return { entries, more: response.more };
},
async claimConsume(root, filename) {
return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES);