fix(auth): paginate session maintenance safely
This commit is contained in:
+118
-18
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,7 @@ const state = "s".repeat(43);
|
||||
const nonce = "n".repeat(43);
|
||||
const verifier = "v".repeat(43);
|
||||
const transactionCookieName = "__Host-thothii_oidc_tx";
|
||||
const loopbackTransactionCookieName = "thothii_oidc_tx";
|
||||
const createdApps: Array<ReturnType<typeof Fastify>> = [];
|
||||
|
||||
function setCookieHeaders(response: { headers: Record<string, string | string[] | undefined> }): string[] {
|
||||
@@ -19,15 +20,42 @@ function setCookieHeaders(response: { headers: Record<string, string | string[]
|
||||
return header === undefined ? [] : Array.isArray(header) ? header : [header];
|
||||
}
|
||||
|
||||
function transactionCookie(response: { headers: Record<string, string | string[] | undefined> }): string | undefined {
|
||||
function transactionCookie(
|
||||
response: { headers: Record<string, string | string[] | undefined> },
|
||||
name = transactionCookieName,
|
||||
): string | undefined {
|
||||
return setCookieHeaders(response)
|
||||
.find((header) => header.startsWith(`${transactionCookieName}=`))
|
||||
.find((header) => header.startsWith(`${name}=`) && !header.includes("Max-Age=0"))
|
||||
?.split(";", 1)[0];
|
||||
}
|
||||
|
||||
function expectTransactionCleared(response: { headers: Record<string, string | string[] | undefined> }): void {
|
||||
expect(setCookieHeaders(response).some((header) =>
|
||||
header.startsWith(`${transactionCookieName}=`) && header.includes("Max-Age=0"))).toBe(true);
|
||||
function expectTransactionCleared(
|
||||
response: { headers: Record<string, string | string[] | undefined> },
|
||||
name = transactionCookieName,
|
||||
secure = true,
|
||||
): void {
|
||||
const header = setCookieHeaders(response).find((candidate) =>
|
||||
candidate.startsWith(`${name}=`) && candidate.includes("Max-Age=0"));
|
||||
expect(header).toBeDefined();
|
||||
expect(header).toContain("HttpOnly");
|
||||
expect(header).toContain("SameSite=Lax");
|
||||
expect(header).toContain("Path=/");
|
||||
expect(header).not.toContain("Domain=");
|
||||
if (secure) expect(header).toContain("Secure");
|
||||
else expect(header).not.toContain("Secure");
|
||||
}
|
||||
|
||||
function expectBothTransactionVariantsCleared(
|
||||
response: { headers: Record<string, string | string[] | undefined> },
|
||||
activeName: string,
|
||||
activeSecure: boolean,
|
||||
): void {
|
||||
expectTransactionCleared(response, activeName, activeSecure);
|
||||
expectTransactionCleared(
|
||||
response,
|
||||
activeName === transactionCookieName ? loopbackTransactionCookieName : transactionCookieName,
|
||||
activeName !== transactionCookieName,
|
||||
);
|
||||
}
|
||||
|
||||
function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig {
|
||||
@@ -58,6 +86,7 @@ function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
|
||||
version: 1, nonce, codeVerifier: verifier, returnTo: "/",
|
||||
authConfigRevision: revision, issuer,
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
browserTransactionTransport: "https",
|
||||
createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z",
|
||||
...extra,
|
||||
} as OidcStateRecord;
|
||||
@@ -106,6 +135,8 @@ function fixture(options: {
|
||||
});
|
||||
(record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest =
|
||||
input.browserTransactionDigest as string;
|
||||
(record as OidcStateRecord & { browserTransactionTransport?: string }).browserTransactionTransport =
|
||||
input.browserTransactionTransport as string | undefined ?? "https";
|
||||
if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo;
|
||||
storedState = record;
|
||||
return { state, record: storedState };
|
||||
@@ -149,7 +180,10 @@ afterEach(async () => {
|
||||
|
||||
async function beginOidcLogin(subject: ReturnType<typeof fixture>) {
|
||||
const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
return { response, cookie: transactionCookie(response) };
|
||||
return {
|
||||
response,
|
||||
cookie: transactionCookie(response) ?? transactionCookie(response, loopbackTransactionCookieName),
|
||||
};
|
||||
}
|
||||
|
||||
async function finishOidcLogin(
|
||||
@@ -208,12 +242,16 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
|
||||
expect(subject.stateInputs[0]).toMatchObject({
|
||||
returnTo: "/", authConfigRevision: revision, issuer,
|
||||
browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
browserTransactionTransport: "https",
|
||||
});
|
||||
expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`));
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("HttpOnly");
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("SameSite=Lax");
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("Secure");
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("Path=/");
|
||||
const issued = setCookieHeaders(start)
|
||||
.find((header) => header.startsWith(`${transactionCookieName}=`) && !header.includes("Max-Age=0"));
|
||||
expect(issued).toContain("HttpOnly");
|
||||
expect(issued).toContain("SameSite=Lax");
|
||||
expect(issued).toContain("Secure");
|
||||
expect(issued).toContain("Path=/");
|
||||
expect(issued).not.toContain("Domain=");
|
||||
expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie");
|
||||
|
||||
const callback = await subject.app.inject({
|
||||
@@ -226,7 +264,7 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
|
||||
expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly");
|
||||
expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax");
|
||||
expect(setCookieHeaders(callback).join("\n")).toContain("Secure");
|
||||
expectTransactionCleared(callback);
|
||||
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
||||
expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`);
|
||||
expect(subject.creates).toHaveLength(1);
|
||||
expect(subject.creates[0]).toMatchObject({
|
||||
@@ -242,12 +280,103 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
|
||||
expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://127.42.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
])("uses the non-prefixed, non-Secure transaction cookie for literal loopback OIDC %s", async (publicUrl) => {
|
||||
const subject = fixture({ loaded: config({ publicUrl }) });
|
||||
const { response: start, cookie } = await beginOidcLogin(subject);
|
||||
|
||||
expect(start.statusCode).toBe(302);
|
||||
expect(subject.stateInputs[0]).toMatchObject({ browserTransactionTransport: "loopback_http" });
|
||||
expect(cookie).toMatch(new RegExp(`^${loopbackTransactionCookieName}=[A-Za-z0-9_-]{43}$`));
|
||||
const issued = setCookieHeaders(start).find((header) => header.startsWith(`${loopbackTransactionCookieName}=`));
|
||||
expect(issued).toContain("HttpOnly");
|
||||
expect(issued).toContain("SameSite=Lax");
|
||||
expect(issued).toContain("Path=/");
|
||||
expect(issued).not.toContain("Secure");
|
||||
expect(issued).not.toContain("Domain=");
|
||||
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(subject.callbacks[0]?.href).toBe(`${publicUrl}/api/auth/oidc/callback?code=good&state=${state}`);
|
||||
expectBothTransactionVariantsCleared(callback, loopbackTransactionCookieName, false);
|
||||
});
|
||||
|
||||
test("uses the consumed state transport to reject cross-mode and shadowed transaction cookies", async () => {
|
||||
const subject = fixture({ loaded: config({ publicUrl: "http://127.0.0.1:8787" }) });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
expect(cookie).toBeDefined();
|
||||
|
||||
const failed = await finishOidcLogin(subject, [
|
||||
cookie,
|
||||
`${transactionCookieName}=${"x".repeat(43)}`,
|
||||
].join("; "));
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
|
||||
});
|
||||
|
||||
test("rejects a transaction presented only under the wrong transport cookie name", async () => {
|
||||
const subject = fixture();
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const wrongTransportCookie = cookie?.replace(transactionCookieName, loopbackTransactionCookieName);
|
||||
|
||||
const failed = await finishOidcLogin(subject, wrongTransportCookie);
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
});
|
||||
|
||||
test("rejects duplicate same-mode transaction cookies instead of trusting a parser-selected value", async () => {
|
||||
const subject = fixture();
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
expect(cookie).toBeDefined();
|
||||
|
||||
const failed = await finishOidcLogin(subject, [
|
||||
cookie,
|
||||
`${transactionCookieName}=${"x".repeat(43)}`,
|
||||
].join("; "));
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
});
|
||||
|
||||
test("pins the callback transaction transport to its captured configuration snapshot", async () => {
|
||||
const subject = fixture({ loaded: config({ publicUrl: "http://127.42.0.1:8787" }) });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
subject.setConfig(config({ publicUrl: "https://thothii.example.test" }));
|
||||
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
|
||||
});
|
||||
|
||||
test("clears the state-pinned loopback transaction variant after a terminal callback failure", async () => {
|
||||
const subject = fixture({
|
||||
loaded: config({ publicUrl: "http://127.0.0.1:8787" }),
|
||||
callbackFailure: true,
|
||||
});
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
|
||||
});
|
||||
|
||||
test("consumes state on callback failure and refuses replay", async () => {
|
||||
const subject = fixture({ callbackFailure: true });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
const replay = await finishOidcLogin(subject, cookie);
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toHaveLength(1);
|
||||
@@ -260,6 +389,7 @@ test("consumes state when the protocol becomes unavailable before callback", asy
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
});
|
||||
|
||||
test("rejects a consumed state with a non-root return target", async () => {
|
||||
@@ -269,6 +399,7 @@ test("rejects a consumed state with a non-root return target", async () => {
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect(subject.creates).toEqual([]);
|
||||
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
||||
});
|
||||
|
||||
test("rejects an OIDC state when its configuration revision changes before callback", async () => {
|
||||
@@ -278,6 +409,7 @@ test("rejects an OIDC state when its configuration revision changes before callb
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
||||
});
|
||||
|
||||
test("rejects an OIDC state when its issuer changes before callback", async () => {
|
||||
@@ -310,7 +442,7 @@ test("rejects a callback from a different browser and clears the transaction coo
|
||||
|
||||
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expect(subject.creates).toEqual([]);
|
||||
|
||||
@@ -328,7 +460,7 @@ test.each([
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const failed = await finishOidcLogin(subject, cookie, query);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
|
||||
@@ -345,7 +477,7 @@ test("boundedly burns a canonical state from an oversized callback URL", async (
|
||||
const failed = await finishOidcLogin(subject, cookie, oversized);
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401);
|
||||
@@ -358,11 +490,19 @@ test("rejects an empty direct groups claim without creating a session cookie", a
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expectTransactionCleared(callback);
|
||||
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
|
||||
expect(subject.creates).toEqual([]);
|
||||
expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token");
|
||||
});
|
||||
|
||||
test("clears both transaction variants when the callback state cannot be consumed", async () => {
|
||||
const subject = fixture();
|
||||
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||
});
|
||||
|
||||
test("uses one captured instant for token TTL derivation and session creation", async () => {
|
||||
const tokenExpiresAt = new Date(Date.now() + 120_000);
|
||||
const subject = fixture({ identity: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -84,9 +85,14 @@ function oidcInput(nonce: string, codeVerifier: string) {
|
||||
authConfigRevision: revision,
|
||||
issuer: "https://issuer.example.test",
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
browserTransactionTransport: "https" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionFilename(index: number): string {
|
||||
return `${index.toString(16).padStart(64, "0")}.json`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fsHooks.afterRead = undefined;
|
||||
fsHooks.afterWrite = undefined;
|
||||
@@ -334,6 +340,104 @@ describe("file-backed auth session store", () => {
|
||||
expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("allows startup maintenance past 512 live records and keeps OIDC available", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const liveSeed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
|
||||
const liveContents = readFileSync(digestPath(storageRoot, "sessions", liveSeed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", liveSeed.token));
|
||||
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
|
||||
const expiredContents = readFileSync(digestPath(storageRoot, "sessions", expiredSeed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", expiredSeed.token));
|
||||
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), liveContents, { mode: 0o600 });
|
||||
}
|
||||
const expiredName = sessionFilename(512);
|
||||
writeFileSync(join(storageRoot, "sessions", expiredName), expiredContents, { mode: 0o600 });
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(0);
|
||||
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(true);
|
||||
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
|
||||
await expect(store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base))
|
||||
.resolves.toMatchObject({ record: { nonce: "n".repeat(43) } });
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(1);
|
||||
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(false);
|
||||
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("serializes concurrent continuation passes without stranding a later page", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const liveSeed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
|
||||
const liveContents = readFileSync(digestPath(storageRoot, "sessions", liveSeed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", liveSeed.token));
|
||||
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
|
||||
const expiredContents = readFileSync(digestPath(storageRoot, "sessions", expiredSeed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", expiredSeed.token));
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), liveContents, { mode: 0o600 });
|
||||
}
|
||||
const expiredName = sessionFilename(512);
|
||||
writeFileSync(join(storageRoot, "sessions", expiredName), expiredContents, { mode: 0o600 });
|
||||
|
||||
await expect(Promise.all([
|
||||
store.prune(new Date(base.getTime() + 2 * 60_000)),
|
||||
store.prune(new Date(base.getTime() + 2 * 60_000)),
|
||||
])).resolves.toEqual([0, 1]);
|
||||
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(false);
|
||||
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("fails closed on an unsafe ordinary record beyond the first continuation page", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
|
||||
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), contents, { mode: 0o600 });
|
||||
}
|
||||
symlinkSync(join(storageRoot, "missing-target"), join(storageRoot, "sessions", sessionFilename(512)));
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
|
||||
.rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("fails closed if the ordinary-session directory changes during a page scan", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
|
||||
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
|
||||
const filename = sessionFilename(0);
|
||||
const sessionsDirectory = join(storageRoot, "sessions");
|
||||
writeFileSync(join(sessionsDirectory, filename), contents, { mode: 0o600 });
|
||||
fsHooks.beforeLstat = (path) => {
|
||||
if (path !== join(sessionsDirectory, filename)) return false;
|
||||
const changed = new Date(base.getTime() + 60 * 60_000);
|
||||
utimesSync(sessionsDirectory, changed, changed);
|
||||
return true;
|
||||
};
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
|
||||
.rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test("persists the OIDC transaction transport as consumed state metadata", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const created = await store.createOidcState({
|
||||
...oidcInput("n".repeat(43), "v".repeat(43)),
|
||||
browserTransactionTransport: "loopback_http",
|
||||
});
|
||||
|
||||
await expect(store.consumeOidcState(created.state)).resolves.toMatchObject({
|
||||
browserTransactionTransport: "loopback_http",
|
||||
});
|
||||
});
|
||||
|
||||
test("creates bounded OIDC state records that expire and are single-use", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
@@ -347,6 +451,7 @@ describe("file-backed auth session store", () => {
|
||||
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
|
||||
authConfigRevision: revision, issuer: "https://issuer.example.test",
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
browserTransactionTransport: "https",
|
||||
});
|
||||
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
||||
|
||||
@@ -740,6 +845,19 @@ describe("file-backed auth session store", () => {
|
||||
.filter((entry) => entry.startsWith(`${directory}/`))
|
||||
.map((entry) => ({ name: entry.slice(directory.length + 1), modifiedUnixMs: base.getTime() }));
|
||||
},
|
||||
listPage: async (_root: string, directory: string, after: string | undefined, maximumEntries: number) => {
|
||||
calls.push("list");
|
||||
if (directory !== "sessions") throw new Error("only ordinary sessions use continuation pages");
|
||||
const names = [...records.keys()]
|
||||
.filter((entry) => entry.startsWith("sessions/"))
|
||||
.map((entry) => entry.slice("sessions/".length))
|
||||
.filter((name) => after === undefined || name > after)
|
||||
.sort();
|
||||
return {
|
||||
entries: names.slice(0, maximumEntries).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
|
||||
more: names.length > maximumEntries,
|
||||
};
|
||||
},
|
||||
claimConsume: async (_root: string, filename: string) => {
|
||||
calls.push("claim-consume");
|
||||
const entry = key("oidc", filename);
|
||||
@@ -778,6 +896,75 @@ describe("file-backed auth session store", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("continues Windows ordinary-session maintenance with a strict 512-record page cursor", async () => {
|
||||
const records = new Map<string, Buffer>();
|
||||
const pages: Array<{ after: string | undefined; maximumEntries: number }> = [];
|
||||
const key = (directory: string, filename: string) => `${directory}/${filename}`;
|
||||
const bridge = {
|
||||
create: async (_root: string, directory: string, filename: string, contents: Buffer) => {
|
||||
const entry = key(directory, filename);
|
||||
if (records.has(entry)) return false;
|
||||
records.set(entry, Buffer.from(contents));
|
||||
return true;
|
||||
},
|
||||
read: async (_root: string, directory: string, filename: string) => {
|
||||
const value = records.get(key(directory, filename));
|
||||
return value === undefined ? undefined : Buffer.from(value);
|
||||
},
|
||||
remove: async (_root: string, directory: string, filename: string) => records.delete(key(directory, filename)),
|
||||
list: async (_root: string, directory: string) => {
|
||||
if (directory === "sessions") throw new Error("ordinary maintenance must use a continuation page");
|
||||
return [];
|
||||
},
|
||||
listPage: async (_root: string, directory: string, after: string | undefined, maximumEntries: number) => {
|
||||
if (directory !== "sessions") throw new Error("only ordinary sessions use continuation pages");
|
||||
pages.push({ after, maximumEntries });
|
||||
const names = [...records.keys()]
|
||||
.filter((entry) => entry.startsWith("sessions/"))
|
||||
.map((entry) => entry.slice("sessions/".length))
|
||||
.filter((name) => after === undefined || name > after)
|
||||
.sort();
|
||||
return {
|
||||
entries: names.slice(0, maximumEntries).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
|
||||
more: names.length > maximumEntries,
|
||||
};
|
||||
},
|
||||
};
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (!originalPlatform) throw new Error("platform descriptor unavailable");
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
try {
|
||||
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", {
|
||||
currentAuthConfigRevision: () => revision,
|
||||
findLocalUser: async () => validLocalUser,
|
||||
}, { windowsStorageBridge: bridge as never });
|
||||
await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
|
||||
const liveContents = [...records.values()][0];
|
||||
records.clear();
|
||||
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
|
||||
const expiredContents = [...records.values()][0];
|
||||
records.clear();
|
||||
if (!liveContents || !expiredContents) throw new Error("seed records missing");
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
records.set(key("sessions", sessionFilename(index)), Buffer.from(liveContents));
|
||||
}
|
||||
const expiredName = sessionFilename(512);
|
||||
records.set(key("sessions", expiredName), Buffer.from(expiredContents));
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(0);
|
||||
expect(records.has(key("sessions", expiredName))).toBe(true);
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(1);
|
||||
expect(records.has(key("sessions", expiredName))).toBe(false);
|
||||
expect(records.has(key("sessions", sessionFilename(0)))).toBe(true);
|
||||
expect(pages).toEqual([
|
||||
{ after: undefined, maximumEntries: 512 },
|
||||
{ after: sessionFilename(511), maximumEntries: 512 },
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
test("starts OIDC on Windows without listing more than 256 ordinary session records", async () => {
|
||||
const records = new Map<string, Buffer>();
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
@@ -863,6 +1050,8 @@ describe("file-backed auth session store", () => {
|
||||
directory: "sessions" | "oidc";
|
||||
filename?: string;
|
||||
contentBase64?: string;
|
||||
continuation?: boolean;
|
||||
afterName?: string;
|
||||
};
|
||||
const entry = request.filename === undefined ? undefined : key(request.directory, request.filename);
|
||||
switch (request.operation) {
|
||||
@@ -877,6 +1066,17 @@ describe("file-backed auth session store", () => {
|
||||
case "remove":
|
||||
return response({ removed: entry !== undefined && records.delete(entry) });
|
||||
case "list":
|
||||
if (request.continuation === true) {
|
||||
const names = [...records.keys()]
|
||||
.filter((value) => value.startsWith(`${request.directory}/`))
|
||||
.map((value) => value.slice(request.directory.length + 1))
|
||||
.filter((name) => request.afterName === undefined || name > request.afterName)
|
||||
.sort();
|
||||
return response({
|
||||
entries: names.slice(0, 512).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
|
||||
more: names.length > 512,
|
||||
});
|
||||
}
|
||||
return response({
|
||||
// Raw lower-camel entry objects, exactly as authstorage's Go response emits them.
|
||||
entries: [...records.keys()]
|
||||
@@ -909,11 +1109,19 @@ describe("file-backed auth session store", () => {
|
||||
test("prunes empty Windows directories through the required Go entries array", async () => {
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
|
||||
stderr: Buffer.alloc(0),
|
||||
}),
|
||||
invoke: async ({ input }) => {
|
||||
const request = JSON.parse(input.toString("utf8")) as { continuation?: boolean };
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({
|
||||
version: 1,
|
||||
ok: true,
|
||||
entries: [],
|
||||
...(request.continuation === true ? { more: false } : {}),
|
||||
})}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
},
|
||||
});
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (!originalPlatform) throw new Error("platform descriptor unavailable");
|
||||
@@ -936,7 +1144,12 @@ describe("file-backed auth session store", () => {
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async ({ input }) => {
|
||||
const request = JSON.parse(input.toString("utf8")) as { operation: string; directory: string; filename?: string };
|
||||
const request = JSON.parse(input.toString("utf8")) as {
|
||||
operation: string;
|
||||
directory: string;
|
||||
filename?: string;
|
||||
continuation?: boolean;
|
||||
};
|
||||
const response = (value: Record<string, unknown>) => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`),
|
||||
@@ -947,6 +1160,7 @@ describe("file-backed auth session store", () => {
|
||||
entries: request.directory === "oidc"
|
||||
? [{ name: claim, modifiedUnixMs: base.getTime() }]
|
||||
: [],
|
||||
...(request.continuation === true ? { more: false } : {}),
|
||||
});
|
||||
}
|
||||
if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) {
|
||||
|
||||
@@ -22,6 +22,7 @@ process.once("message", async (message) => {
|
||||
authConfigRevision: "a".repeat(64),
|
||||
issuer: "https://issuer.example.test",
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
browserTransactionTransport: "https",
|
||||
}, now);
|
||||
created += 1;
|
||||
} catch (error) {
|
||||
|
||||
@@ -215,6 +215,58 @@ describe("Windows auth-storage bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("uses a strict, bounded continuation page for ordinary Windows session maintenance", async () => {
|
||||
const invoke = vi.fn(async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${filename}","modifiedUnixMs":1}],"more":false}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
}));
|
||||
const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke }) as unknown as {
|
||||
listPage(root: string, directory: "sessions", after: string | undefined, maximumEntries: number): Promise<{
|
||||
entries: Array<{ name: string; modifiedUnixMs: number }>;
|
||||
more: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
await expect(bridge.listPage(root, "sessions", "0".repeat(64) + ".json", 512)).resolves.toEqual({
|
||||
entries: [{ name: filename, modifiedUnixMs: 1 }],
|
||||
more: false,
|
||||
});
|
||||
expect(JSON.parse(invoke.mock.calls[0][0].input.toString("utf8"))).toMatchObject({
|
||||
operation: "list",
|
||||
directory: "sessions",
|
||||
maximumEntries: 512,
|
||||
continuation: true,
|
||||
afterName: "0".repeat(64) + ".json",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects ambiguous ordinary-session continuation responses", async () => {
|
||||
const after = "f".repeat(64) + ".json";
|
||||
const low = "a".repeat(64) + ".json";
|
||||
const high = "b".repeat(64) + ".json";
|
||||
const cases = [
|
||||
{ label: "missing more marker", body: { entries: [{ name: filename, modifiedUnixMs: 1 }] } },
|
||||
{ label: "more without a full page", body: { entries: [{ name: filename, modifiedUnixMs: 1 }], more: true } },
|
||||
{ label: "non-progressing name", body: { entries: [{ name: low, modifiedUnixMs: 1 }], more: false } },
|
||||
{ label: "duplicate names", body: { entries: [{ name: high, modifiedUnixMs: 1 }, { name: high, modifiedUnixMs: 2 }], more: false } },
|
||||
];
|
||||
|
||||
for (const { body } of cases) {
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...body })}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
}),
|
||||
}) as unknown as {
|
||||
listPage(root: string, directory: "sessions", afterName: string | undefined, maximumEntries: number): Promise<unknown>;
|
||||
};
|
||||
await expect(bridge.listPage(root, "sessions", after, 512)).rejects.toThrow("auth_session_store_invalid");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a bounded ordinary-session page larger than the legacy 256-entry limit", async () => {
|
||||
const entries = Array.from({ length: 300 }, (_unused, index) => ({
|
||||
name: `${index.toString(16).padStart(64, "0")}.json`,
|
||||
|
||||
@@ -41,6 +41,8 @@ type request struct {
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
MaximumEntries int `json:"maximumEntries,omitempty"`
|
||||
AfterName string `json:"afterName,omitempty"`
|
||||
Continuation bool `json:"continuation,omitempty"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
@@ -53,6 +55,7 @@ type response struct {
|
||||
Claimed bool `json:"claimed,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
|
||||
More *bool `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on
|
||||
@@ -143,6 +146,19 @@ func execute(input request) (response, error) {
|
||||
if limit == 0 {
|
||||
limit = defaultMaximumEntries
|
||||
}
|
||||
if input.Continuation {
|
||||
page, err := safeio.ListCanonicalPrivateDirectoryPage(
|
||||
directory,
|
||||
limit,
|
||||
input.AfterName,
|
||||
digestFilename.MatchString,
|
||||
)
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
more := page.More
|
||||
return response{Version: protocolVersion, OK: true, Entries: &page.Entries, More: &more}, nil
|
||||
}
|
||||
entries, err := safeio.ListCanonicalPrivateDirectory(directory, limit)
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
@@ -182,17 +198,20 @@ func execute(input request) (response, error) {
|
||||
func validOperationShape(input request) bool {
|
||||
noContents := input.ContentBase64 == ""
|
||||
noMaximumEntries := input.MaximumEntries == 0
|
||||
noAfterName := input.AfterName == ""
|
||||
noContinuation := !input.Continuation
|
||||
switch input.Operation {
|
||||
case "create", "replace":
|
||||
return noMaximumEntries && (digestFilename.MatchString(input.Filename) || (input.Operation == "create" && input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
|
||||
return noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Operation == "create" && input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
|
||||
case "read":
|
||||
return noContents && noMaximumEntries && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
|
||||
return noContents && noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
|
||||
case "remove":
|
||||
return noContents && noMaximumEntries && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && (claimFilename.MatchString(input.Filename) || oidcSlotFilename.MatchString(input.Filename))))
|
||||
return noContents && noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && (claimFilename.MatchString(input.Filename) || oidcSlotFilename.MatchString(input.Filename))))
|
||||
case "list":
|
||||
return input.Filename == "" && noContents && input.MaximumEntries >= 0 && input.MaximumEntries <= maximumEntries
|
||||
return input.Filename == "" && noContents && input.MaximumEntries >= 0 && input.MaximumEntries <= maximumEntries &&
|
||||
((noContinuation && noAfterName) || (input.Continuation && input.Directory == "sessions" && input.MaximumEntries >= 1 && (noAfterName || digestFilename.MatchString(input.AfterName))))
|
||||
case "claim-consume", "read-claim", "remove-claim":
|
||||
return input.Directory == "oidc" && noContents && noMaximumEntries && digestFilename.MatchString(input.Filename)
|
||||
return input.Directory == "oidc" && noContents && noMaximumEntries && noAfterName && noContinuation && digestFilename.MatchString(input.Filename)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -34,6 +34,34 @@ func TestProtocolListUsesACallerSuppliedBound(t *testing.T) {
|
||||
runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename, MaximumEntries: 1})
|
||||
}
|
||||
|
||||
func TestProtocolListPaginatesOrdinarySessionRecords(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
for index := 0; index < 513; index++ {
|
||||
filename := fmt.Sprintf("%064x.json", index)
|
||||
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
||||
}
|
||||
|
||||
first := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 512, Continuation: true})
|
||||
if first.Entries == nil || len(*first.Entries) != 512 || first.More == nil || !*first.More {
|
||||
t.Fatalf("first continuation page = %#v", first)
|
||||
}
|
||||
second := runRequest(t, request{
|
||||
Version: 1,
|
||||
Operation: "list",
|
||||
Root: root,
|
||||
Directory: "sessions",
|
||||
MaximumEntries: 512,
|
||||
Continuation: true,
|
||||
AfterName: (*first.Entries)[511].Name,
|
||||
})
|
||||
if second.Entries == nil || len(*second.Entries) != 1 || second.More == nil || *second.More || (*second.Entries)[0].Name != fmt.Sprintf("%064x.json", 512) {
|
||||
t.Fatalf("second continuation page = %#v", second)
|
||||
}
|
||||
|
||||
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "oidc", MaximumEntries: 1, Continuation: true, AfterName: fmt.Sprintf("%064x.json", 0)})
|
||||
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true, AfterName: "../unsafe.json"})
|
||||
}
|
||||
|
||||
func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
filename := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json"
|
||||
@@ -215,6 +243,7 @@ func TestProtocolRejectsBoundsReparseAndUnexpectedStorageNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true})
|
||||
|
||||
outer := privateTestRoot(t)
|
||||
linkedRoot := filepath.Join(outer, "linked-auth")
|
||||
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
|
||||
var ErrUnsafeFile = errors.New("unsafe file")
|
||||
|
||||
// Pagination must never allocate or iterate indefinitely over a hostile private directory. This
|
||||
// is deliberately independent from an auth record page: it permits many bounded pages while
|
||||
// putting a fixed upper bound on one sorted keyset scan.
|
||||
const maximumPrivateDirectoryPageScanEntries = 16384
|
||||
|
||||
// EnsurePrivateDirectory creates only the final canonical directory with the platform's
|
||||
// owner-only protection, or validates an existing directory has that protection.
|
||||
func EnsurePrivateDirectory(path string) error {
|
||||
@@ -86,6 +91,28 @@ type PrivateDirectoryEntry struct {
|
||||
ModifiedUnixMs int64 `json:"modifiedUnixMs"`
|
||||
}
|
||||
|
||||
// PrivateDirectoryPage is one lexically ordered, keyset-paginated directory page. More is true
|
||||
// only when the page is full and another validated entry follows AfterName.
|
||||
type PrivateDirectoryPage struct {
|
||||
Entries []PrivateDirectoryEntry
|
||||
More bool
|
||||
}
|
||||
|
||||
func privateDirectoryScanSnapshot(path string) (os.FileInfo, error) {
|
||||
if err := ValidatePrivateDirectory(path); err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func samePrivateDirectoryScanSnapshot(left, right os.FileInfo) bool {
|
||||
return left != nil && right != nil && os.SameFile(left, right) && left.Mode() == right.Mode() && left.ModTime().Equal(right.ModTime())
|
||||
}
|
||||
|
||||
// ListCanonicalPrivateDirectory lists regular, non-symlinked direct children from an owner-only
|
||||
// directory. It returns no content and bounds the number of entries before allocating output.
|
||||
func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDirectoryEntry, error) {
|
||||
@@ -125,6 +152,103 @@ func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDi
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListCanonicalPrivateDirectoryPage scans a private directory without materializing a complete
|
||||
// output listing, validates every encountered child, and keeps only the next bounded lexical page.
|
||||
// The caller supplies the filename grammar because safeio is intentionally storage-format agnostic.
|
||||
func ListCanonicalPrivateDirectoryPage(
|
||||
path string,
|
||||
maximumEntries int,
|
||||
afterName string,
|
||||
validName func(string) bool,
|
||||
) (PrivateDirectoryPage, error) {
|
||||
if maximumEntries < 1 || maximumEntries > 4096 || validName == nil || (afterName != "" && !validName(afterName)) {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
before, err := privateDirectoryScanSnapshot(path)
|
||||
if err != nil {
|
||||
return PrivateDirectoryPage{}, err
|
||||
}
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
defer directory.Close()
|
||||
|
||||
seen := make(map[string]struct{}, maximumEntries+1)
|
||||
selected := make([]PrivateDirectoryEntry, 0, maximumEntries+1)
|
||||
scanned := 0
|
||||
for {
|
||||
entries, readErr := directory.ReadDir(1)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
break
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
scanned++
|
||||
if scanned > maximumPrivateDirectoryPageScanEntries {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
name := entries[0].Name()
|
||||
if name == "" || strings.Contains(name, string(filepath.Separator)) || !validName(name) {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
entryPath := filepath.Join(path, name)
|
||||
if err := ValidatePrivateRegular(entryPath); err != nil {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
info, err := os.Lstat(entryPath)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || !hasSingleLink(info) {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
if name > afterName {
|
||||
selected = appendBoundedPrivateDirectoryEntry(selected, PrivateDirectoryEntry{
|
||||
Name: name, ModifiedUnixMs: info.ModTime().UnixMilli(),
|
||||
}, maximumEntries+1)
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
}
|
||||
after, err := privateDirectoryScanSnapshot(path)
|
||||
if err != nil || !samePrivateDirectoryScanSnapshot(before, after) {
|
||||
return PrivateDirectoryPage{}, ErrUnsafeFile
|
||||
}
|
||||
sort.Slice(selected, func(left, right int) bool { return selected[left].Name < selected[right].Name })
|
||||
more := len(selected) > maximumEntries
|
||||
if more {
|
||||
selected = selected[:maximumEntries]
|
||||
}
|
||||
return PrivateDirectoryPage{Entries: selected, More: more}, nil
|
||||
}
|
||||
|
||||
func appendBoundedPrivateDirectoryEntry(
|
||||
entries []PrivateDirectoryEntry,
|
||||
entry PrivateDirectoryEntry,
|
||||
maximumEntries int,
|
||||
) []PrivateDirectoryEntry {
|
||||
if len(entries) < maximumEntries {
|
||||
return append(entries, entry)
|
||||
}
|
||||
maximum := 0
|
||||
for index := 1; index < len(entries); index++ {
|
||||
if entries[index].Name > entries[maximum].Name {
|
||||
maximum = index
|
||||
}
|
||||
}
|
||||
if entry.Name < entries[maximum].Name {
|
||||
entries[maximum] = entry
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error {
|
||||
return writeCanonicalNewFile(path, contents, mode, false)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
||||
)
|
||||
@@ -125,3 +127,69 @@ func TestListCanonicalPrivateDirectoryBoundsAndSortsValidatedEntries(t *testing.
|
||||
t.Fatalf("257-entry listing error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCanonicalPrivateDirectoryPageContinuesPastOneBoundedPage(t *testing.T) {
|
||||
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-page-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
if err := ProtectPrivateDirectory(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validName := func(name string) bool {
|
||||
return len(name) == len(fmt.Sprintf("%064x.json", 0)) && strings.HasSuffix(name, ".json")
|
||||
}
|
||||
for index := 0; index < 513; index++ {
|
||||
path := filepath.Join(root, fmt.Sprintf("%064x.json", index))
|
||||
if err := WriteCanonicalNewFile(path, []byte("record"), 0o600); err != nil {
|
||||
t.Fatalf("WriteCanonicalNewFile(%d) error = %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
first, err := ListCanonicalPrivateDirectoryPage(root, 512, "", validName)
|
||||
if err != nil || len(first.Entries) != 512 || !first.More || first.Entries[0].Name != fmt.Sprintf("%064x.json", 0) || first.Entries[511].Name != fmt.Sprintf("%064x.json", 511) {
|
||||
t.Fatalf("first page = %#v error = %v", first, err)
|
||||
}
|
||||
second, err := ListCanonicalPrivateDirectoryPage(root, 512, first.Entries[511].Name, validName)
|
||||
if err != nil || len(second.Entries) != 1 || second.More || second.Entries[0].Name != fmt.Sprintf("%064x.json", 512) {
|
||||
t.Fatalf("second page = %#v error = %v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCanonicalPrivateDirectoryPageRejectsInPlaceDirectoryMutation(t *testing.T) {
|
||||
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-page-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
if err := ProtectPrivateDirectory(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filename := fmt.Sprintf("%064x.json", 0)
|
||||
if err := WriteCanonicalNewFile(filepath.Join(root, filename), []byte("record"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := false
|
||||
_, err = ListCanonicalPrivateDirectoryPage(root, 1, "", func(name string) bool {
|
||||
if !changed {
|
||||
changed = true
|
||||
at := time.Unix(1_893_456_245, 0)
|
||||
if changeErr := os.Chtimes(root, at, at); changeErr != nil {
|
||||
t.Fatalf("Chtimes() error = %v", changeErr)
|
||||
}
|
||||
}
|
||||
return name == filename
|
||||
})
|
||||
if !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("unstable page error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user