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);
|
||||
|
||||
Reference in New Issue
Block a user