1542 lines
61 KiB
TypeScript
1542 lines
61 KiB
TypeScript
import { createHash, hkdfSync, randomBytes } from "node:crypto";
|
|
import {
|
|
accessSync,
|
|
closeSync,
|
|
constants,
|
|
fchmodSync,
|
|
fstatSync,
|
|
fsyncSync,
|
|
lstatSync,
|
|
linkSync,
|
|
mkdirSync,
|
|
openSync,
|
|
opendirSync,
|
|
readSync,
|
|
realpathSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeSync,
|
|
} from "node:fs";
|
|
import type { Stats } from "node:fs";
|
|
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
import { z } from "zod";
|
|
import type { PrincipalContext } from "./principal.js";
|
|
import type {
|
|
AuthSessionRecord,
|
|
OidcStateRecord,
|
|
OidcTransactionTransport,
|
|
Permission,
|
|
Role,
|
|
} from "./types.js";
|
|
import {
|
|
createWindowsAuthStorageBridge,
|
|
type WindowsAuthStorageBridge,
|
|
} from "./windows-auth-storage.js";
|
|
|
|
const TOKEN_BYTES = 32;
|
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.json$/;
|
|
const CLAIM_FILENAME_PATTERN = /^[a-f0-9]{64}\.claim$/;
|
|
const OIDC_SLOT_FILENAME_PATTERN = /^slot-(\d{2})\.json$/;
|
|
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
const PRIVATE_FILE_MODE = 0o600;
|
|
const MAX_SESSION_RECORD_BYTES = 16 * 1024;
|
|
const MAX_OIDC_STATE_RECORD_BYTES = 8 * 1024;
|
|
const MAX_OIDC_SLOT_RECORD_BYTES = 512;
|
|
const MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
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);
|
|
const ROLES = ["user", "admin"] as const;
|
|
const PERMISSIONS = [
|
|
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
|
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
|
] as const satisfies readonly Permission[];
|
|
|
|
const invalid = (): Error => new Error("auth_session_store_invalid");
|
|
|
|
export interface SessionCreateInput {
|
|
principal: PrincipalContext;
|
|
method: "local" | "oidc" | "upstream";
|
|
remembered: boolean;
|
|
userAuthRevision?: number;
|
|
authConfigRevision: string;
|
|
idleTtlMs: number;
|
|
absoluteTtlMs: number;
|
|
}
|
|
|
|
export interface CreatedAuthSession {
|
|
token: string;
|
|
csrfToken: string;
|
|
record: AuthSessionRecord;
|
|
}
|
|
|
|
export interface OidcStateCreateInput {
|
|
nonce: string;
|
|
codeVerifier: string;
|
|
returnTo: "/";
|
|
authConfigRevision: string;
|
|
issuer: string;
|
|
browserTransactionDigest: string;
|
|
browserTransactionTransport: OidcTransactionTransport;
|
|
}
|
|
|
|
export interface CreatedOidcState {
|
|
state: string;
|
|
record: OidcStateRecord;
|
|
}
|
|
|
|
export interface LocalSessionUser {
|
|
enabled: boolean;
|
|
authRevision: number;
|
|
roles: readonly Role[];
|
|
}
|
|
|
|
export interface CurrentLocalSessionUser {
|
|
revision: string;
|
|
user: LocalSessionUser | undefined;
|
|
}
|
|
|
|
/** Operational validity-source failures must not masquerade as revoked credentials. */
|
|
export class AuthSessionOperationalError extends Error {
|
|
constructor() {
|
|
super("auth_session_operational_error");
|
|
}
|
|
}
|
|
|
|
export class OidcStateCapacityError extends Error {
|
|
constructor() {
|
|
super("auth_oidc_state_capacity");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The route layer supplies the current installation revision and local-registry lookup.
|
|
* Supplying this hook makes every resolve an authorization-generation check.
|
|
*/
|
|
export interface AuthSessionValidity {
|
|
currentAuthConfigRevision(): string | Promise<string>;
|
|
findLocalUser?(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
|
|
currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise<CurrentLocalSessionUser>;
|
|
}
|
|
|
|
export interface AuthSessionStore {
|
|
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
|
|
resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise<AuthSessionRecord | undefined>;
|
|
touch(token: string, now?: Date): Promise<void>;
|
|
revoke(token: string): Promise<void>;
|
|
prune(now?: Date): Promise<number>;
|
|
createOidcState(input: OidcStateCreateInput, now?: Date): Promise<CreatedOidcState>;
|
|
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
|
|
}
|
|
|
|
/** Narrow test seam for the native Windows tht-backed storage adaptor. */
|
|
export interface FileAuthSessionStoreOptions {
|
|
windowsStorageBridge?: WindowsAuthStorageBridge;
|
|
/** Test-only capacity seam; production always uses the fixed 64-state bound. */
|
|
oidcStateCapacity?: number;
|
|
}
|
|
|
|
interface FileIdentity {
|
|
dev: number;
|
|
ino: number;
|
|
uid: number;
|
|
size: number;
|
|
mtimeMs: number;
|
|
}
|
|
|
|
interface DirectoryIdentity {
|
|
dev: number;
|
|
ino: number;
|
|
uid: number;
|
|
mode?: number;
|
|
}
|
|
|
|
interface DirectoryScanIdentity extends DirectoryIdentity {
|
|
mtimeMs: number;
|
|
ctimeMs: number;
|
|
}
|
|
|
|
interface TrustedFile<T> {
|
|
value: T;
|
|
identity: FileIdentity;
|
|
}
|
|
|
|
interface StorageDirectories {
|
|
root: string;
|
|
sessions: string;
|
|
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);
|
|
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
});
|
|
const role = z.enum(ROLES);
|
|
const permission = z.enum(PERMISSIONS);
|
|
const distinct = <T>(items: readonly T[]): boolean => new Set(items).size === items.length;
|
|
const sessionRecordSchema = z.strictObject({
|
|
version: z.literal(1),
|
|
issuer: text,
|
|
subject: text,
|
|
displayName: text.optional(),
|
|
method: z.enum(["local", "oidc", "upstream"]),
|
|
roles: z.array(role).max(ROLES.length).refine(distinct),
|
|
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
|
|
userAuthRevision: z.number().int().positive().safe().optional(),
|
|
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
remembered: z.boolean(),
|
|
createdAt: timestamp,
|
|
lastSeenAt: timestamp,
|
|
idleExpiresAt: timestamp,
|
|
absoluteExpiresAt: timestamp,
|
|
}).superRefine((record, context) => {
|
|
const createdAt = Date.parse(record.createdAt);
|
|
const lastSeenAt = Date.parse(record.lastSeenAt);
|
|
const idleExpiresAt = Date.parse(record.idleExpiresAt);
|
|
const absoluteExpiresAt = Date.parse(record.absoluteExpiresAt);
|
|
if (lastSeenAt < createdAt || idleExpiresAt < lastSeenAt || idleExpiresAt > absoluteExpiresAt
|
|
|| absoluteExpiresAt < createdAt || absoluteExpiresAt - createdAt > MAX_TTL_MS) {
|
|
context.addIssue({ code: "custom", message: "invalid session lifetime" });
|
|
}
|
|
if (record.method === "local" && record.userAuthRevision === undefined) {
|
|
context.addIssue({ code: "custom", message: "local revision is required" });
|
|
}
|
|
if (record.method !== "local" && record.userAuthRevision !== undefined) {
|
|
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
|
|
}
|
|
});
|
|
const oidcStateRecordSchema = z.strictObject({
|
|
version: z.literal(1),
|
|
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
|
|
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
|
|
returnTo: z.literal("/"),
|
|
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,
|
|
}).superRefine((record, context) => {
|
|
const lifetime = Date.parse(record.expiresAt) - Date.parse(record.createdAt);
|
|
if (lifetime <= 0 || lifetime > OIDC_STATE_TTL_MS) {
|
|
context.addIssue({ code: "custom", message: "invalid OIDC state lifetime" });
|
|
}
|
|
});
|
|
const oidcSlotRecordSchema = z.strictObject({
|
|
version: z.literal(1),
|
|
stateFilename: z.string().regex(DIGEST_FILENAME_PATTERN),
|
|
expiresAt: timestamp,
|
|
});
|
|
const sessionInputSchema = z.strictObject({
|
|
principal: z.strictObject({
|
|
issuer: text,
|
|
subject: text,
|
|
displayName: text.optional(),
|
|
roles: z.array(role).max(ROLES.length).refine(distinct),
|
|
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
|
|
isAdmin: z.boolean(),
|
|
}),
|
|
method: z.enum(["local", "oidc", "upstream"]),
|
|
remembered: z.boolean(),
|
|
userAuthRevision: z.number().int().positive().safe().optional(),
|
|
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
idleTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
|
|
absoluteTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
|
|
}).superRefine((input, context) => {
|
|
if (input.principal.isAdmin !== input.principal.roles.includes("admin")) {
|
|
context.addIssue({ code: "custom", message: "principal roles disagree" });
|
|
}
|
|
if (input.method === "local" && input.userAuthRevision === undefined) {
|
|
context.addIssue({ code: "custom", message: "local revision is required" });
|
|
}
|
|
if (input.method !== "local" && input.userAuthRevision !== undefined) {
|
|
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
|
|
}
|
|
});
|
|
const oidcStateInputSchema = z.strictObject({
|
|
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
|
|
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
|
|
returnTo: z.literal("/"),
|
|
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 {
|
|
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.size === right.size
|
|
&& left.mtimeMs === right.mtimeMs;
|
|
}
|
|
|
|
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
|
return left.dev === right.dev && left.ino === right.ino && left.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";
|
|
}
|
|
|
|
function isAlreadyExists(error: unknown): boolean {
|
|
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
|
|
}
|
|
|
|
function canonicalRawValue(value: string): boolean {
|
|
if (typeof value !== "string" || !TOKEN_PATTERN.test(value)) return false;
|
|
try {
|
|
const bytes = Buffer.from(value, "base64url");
|
|
return bytes.length === TOKEN_BYTES && bytes.toString("base64url") === value;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function digestFilename(rawValue: string): string {
|
|
return `${createHash("sha256").update(rawValue).digest("hex")}.json`;
|
|
}
|
|
|
|
function claimFilename(filename: string): string {
|
|
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
|
|
return filename.slice(0, -".json".length) + ".claim";
|
|
}
|
|
|
|
function oidcSlotFilename(index: number): string {
|
|
if (!Number.isInteger(index) || index < 0 || index >= OIDC_STATE_CAPACITY) throw invalid();
|
|
return `slot-${String(index).padStart(2, "0")}.json`;
|
|
}
|
|
|
|
function oidcSlotIndex(filename: string): number | undefined {
|
|
const match = OIDC_SLOT_FILENAME_PATTERN.exec(filename);
|
|
if (!match) return undefined;
|
|
const index = Number(match[1]);
|
|
return Number.isInteger(index) && index >= 0 && index < OIDC_STATE_CAPACITY ? index : undefined;
|
|
}
|
|
|
|
function assertFilename(filename: string): void {
|
|
if (!DIGEST_FILENAME_PATTERN.test(filename) && !CLAIM_FILENAME_PATTERN.test(filename)
|
|
&& oidcSlotIndex(filename) === undefined) throw invalid();
|
|
}
|
|
|
|
function filePath(directory: string, filename: string): string {
|
|
assertFilename(filename);
|
|
const path = join(directory, filename);
|
|
if (dirname(path) !== directory) throw invalid();
|
|
return path;
|
|
}
|
|
|
|
function ownerId(): number {
|
|
const getEffectiveUserId = process.geteuid;
|
|
if (typeof getEffectiveUserId !== "function") throw invalid();
|
|
const euid = getEffectiveUserId();
|
|
if (!Number.isSafeInteger(euid) || euid < 0) throw invalid();
|
|
return euid;
|
|
}
|
|
|
|
function fileIdentity(info: Stats, expectedLinks = 1): FileIdentity {
|
|
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== expectedLinks
|
|
|| info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_FILE_MODE || info.size < 0) {
|
|
throw invalid();
|
|
}
|
|
return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs };
|
|
}
|
|
|
|
function directoryIdentity(path: string): DirectoryIdentity {
|
|
const info = lstatSync(path) as Stats;
|
|
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path) throw invalid();
|
|
if (info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
|
|
return {
|
|
dev: info.dev,
|
|
ino: info.ino,
|
|
uid: info.uid,
|
|
mode: info.mode & 0o7777,
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
interface SessionRootAncestor {
|
|
path: string;
|
|
descriptor: number;
|
|
dev: number;
|
|
ino: number;
|
|
uid: number;
|
|
}
|
|
|
|
function validateSessionRootSyntax(root: string): void {
|
|
if (process.platform === "win32" || typeof root !== "string" || root.length === 0
|
|
|| root.includes("\0") || /\p{Cc}/u.test(root) || !isAbsolute(root) || normalize(root) !== root) throw invalid();
|
|
}
|
|
|
|
function sameAncestor(ancestor: SessionRootAncestor, info: Stats): boolean {
|
|
return info.isDirectory() && !info.isSymbolicLink() && ancestor.dev === info.dev
|
|
&& ancestor.ino === info.ino && ancestor.uid === info.uid;
|
|
}
|
|
|
|
function withSessionRootPreflight<T>(root: string, use: (exists: boolean) => T): T {
|
|
validateSessionRootSyntax(root);
|
|
const ancestors: SessionRootAncestor[] = [];
|
|
try {
|
|
const components = root.split("/").filter((component) => component.length > 0);
|
|
let current = "/";
|
|
for (let index = -1; index < components.length; index += 1) {
|
|
if (index >= 0) current = join(current, components[index]!);
|
|
let info: Stats;
|
|
try {
|
|
info = lstatSync(current) as Stats;
|
|
} catch (error) {
|
|
if (!isNotFound(error) || index !== components.length - 1) throw invalid();
|
|
accessSync(dirname(current), constants.W_OK | constants.X_OK);
|
|
for (const ancestor of ancestors) {
|
|
const observed = lstatSync(ancestor.path) as Stats;
|
|
if (!sameAncestor(ancestor, observed) || realpathSync(ancestor.path) !== ancestor.path) throw invalid();
|
|
}
|
|
const result = use(false);
|
|
for (const ancestor of ancestors) {
|
|
const observed = lstatSync(ancestor.path) as Stats;
|
|
if (!sameAncestor(ancestor, observed) || realpathSync(ancestor.path) !== ancestor.path) throw invalid();
|
|
}
|
|
return result;
|
|
}
|
|
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(current) !== current) throw invalid();
|
|
const descriptor = openSync(current, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
|
|
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
const opened = fstatSync(descriptor) as Stats;
|
|
if (!opened.isDirectory() || opened.dev !== info.dev || opened.ino !== info.ino || opened.uid !== info.uid) {
|
|
closeSync(descriptor);
|
|
throw invalid();
|
|
}
|
|
ancestors.push({ path: current, descriptor, dev: info.dev, ino: info.ino, uid: info.uid });
|
|
}
|
|
directoryIdentity(root);
|
|
const result = use(true);
|
|
for (const ancestor of ancestors) {
|
|
const observed = lstatSync(ancestor.path) as Stats;
|
|
if (!sameAncestor(ancestor, observed) || realpathSync(ancestor.path) !== ancestor.path) throw invalid();
|
|
}
|
|
return result;
|
|
} catch {
|
|
throw invalid();
|
|
} finally {
|
|
for (const ancestor of ancestors.reverse()) {
|
|
try { closeSync(ancestor.descriptor); } catch { /* preflight has already failed closed */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function privateDirectory(path: string): void {
|
|
withSessionRootPreflight(path, (exists) => {
|
|
if (exists) return;
|
|
try {
|
|
mkdirSync(path, { recursive: false, mode: PRIVATE_DIRECTORY_MODE });
|
|
} catch (error) {
|
|
if (!isAlreadyExists(error)) throw invalid();
|
|
directoryIdentity(path);
|
|
return;
|
|
}
|
|
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
|
|
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
try {
|
|
fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE);
|
|
const opened = fstatSync(descriptor) as Stats;
|
|
const current = directoryIdentity(path);
|
|
if (opened.dev !== current.dev || opened.ino !== current.ino || opened.uid !== current.uid
|
|
|| (opened.mode & 0o7777) !== current.mode) throw invalid();
|
|
} finally {
|
|
try { closeSync(descriptor); } catch { /* creation already fails closed */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Side-effect-free POSIX validator shared by runtime storage and static diagnostics. */
|
|
export function validateAuthSessionRoot(root: string): void {
|
|
try {
|
|
withSessionRootPreflight(root, () => undefined);
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function storageDirectories(root: string): StorageDirectories {
|
|
// Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this
|
|
// POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod.
|
|
validateSessionRootSyntax(root);
|
|
privateDirectory(root);
|
|
validateAuthSessionRoot(root);
|
|
const sessions = join(root, "sessions");
|
|
const oidc = join(root, "oidc");
|
|
privateDirectory(sessions);
|
|
privateDirectory(oidc);
|
|
return { root, sessions, oidc };
|
|
}
|
|
|
|
function boundedDirectoryNames(directory: string, maximumEntries: number): string[] {
|
|
if (!Number.isInteger(maximumEntries) || maximumEntries < 1) throw invalid();
|
|
let handle: ReturnType<typeof opendirSync> | undefined;
|
|
try {
|
|
const before = directoryIdentity(directory);
|
|
handle = opendirSync(directory);
|
|
const names: string[] = [];
|
|
while (names.length <= maximumEntries) {
|
|
const entry = handle.readSync();
|
|
if (entry === null) {
|
|
handle.closeSync();
|
|
handle = undefined;
|
|
if (!sameDirectoryIdentity(before, directoryIdentity(directory))) throw invalid();
|
|
return names;
|
|
}
|
|
names.push(entry.name);
|
|
}
|
|
throw invalid();
|
|
} catch {
|
|
throw invalid();
|
|
} finally {
|
|
if (handle !== undefined) {
|
|
try { handle.closeSync(); } catch { /* the operation is already fail-closed */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
}
|
|
|
|
function syncDirectory(directory: string): void {
|
|
if (process.platform === "win32") return;
|
|
let descriptor: number | undefined;
|
|
try {
|
|
descriptor = openDirectory(directory);
|
|
if (descriptor === undefined) throw invalid();
|
|
fsyncSync(descriptor);
|
|
} catch {
|
|
throw invalid();
|
|
} finally {
|
|
if (descriptor !== undefined) {
|
|
try { closeSync(descriptor); } catch { /* converted to a sanitized failure above */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeFully(descriptor: number, contents: Buffer): void {
|
|
let offset = 0;
|
|
while (offset < contents.length) {
|
|
const written = writeSync(descriptor, contents, offset, contents.length - offset);
|
|
if (written <= 0) throw invalid();
|
|
offset += written;
|
|
}
|
|
}
|
|
|
|
function readTrusted<T>(
|
|
directory: string,
|
|
filename: string,
|
|
maximumBytes: number,
|
|
parse: (source: string) => T,
|
|
expectedLinks = 1,
|
|
): TrustedFile<T> | undefined {
|
|
const path = filePath(directory, filename);
|
|
let directoryDescriptor: number | undefined;
|
|
let descriptor: number | undefined;
|
|
try {
|
|
const beforeDirectory = directoryIdentity(directory);
|
|
const beforePath = lstatSync(path) as Stats;
|
|
const before = fileIdentity(beforePath, expectedLinks);
|
|
if (before.size > maximumBytes) throw invalid();
|
|
|
|
directoryDescriptor = openDirectory(directory);
|
|
const openedDirectory = directoryDescriptor === undefined
|
|
? beforeDirectory
|
|
: directoryIdentityFromDescriptor(directoryDescriptor);
|
|
if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid();
|
|
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
const opened = fileIdentity(fstatSync(descriptor) as Stats, expectedLinks);
|
|
if (!sameFileIdentity(before, opened) || opened.size > maximumBytes) throw invalid();
|
|
|
|
const contents = Buffer.allocUnsafe(maximumBytes + 1);
|
|
let offset = 0;
|
|
while (offset < contents.length) {
|
|
const read = readSync(descriptor, contents, offset, contents.length - offset, null);
|
|
if (read === 0) break;
|
|
offset += read;
|
|
}
|
|
if (offset > maximumBytes) throw invalid();
|
|
|
|
const after = fileIdentity(fstatSync(descriptor) as Stats, expectedLinks);
|
|
const afterPath = fileIdentity(lstatSync(path) as Stats, expectedLinks);
|
|
const afterDirectory = directoryIdentity(directory);
|
|
const afterOpenedDirectory = directoryDescriptor === undefined
|
|
? afterDirectory
|
|
: directoryIdentityFromDescriptor(directoryDescriptor);
|
|
if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath)
|
|
|| !sameDirectoryIdentity(beforeDirectory, afterDirectory)
|
|
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
|
|
|
|
const source = new TextDecoder("utf-8", { fatal: true }).decode(contents.subarray(0, offset));
|
|
return { value: parse(source), identity: after };
|
|
} catch (error) {
|
|
if (isNotFound(error)) return undefined;
|
|
throw invalid();
|
|
} finally {
|
|
if (descriptor !== undefined) {
|
|
try { closeSync(descriptor); } catch { /* descriptor is no longer trusted */ }
|
|
}
|
|
if (directoryDescriptor !== undefined) {
|
|
try { closeSync(directoryDescriptor); } catch { /* descriptor is no longer trusted */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function directoryIdentityFromDescriptor(descriptor: number): DirectoryIdentity {
|
|
const info = fstatSync(descriptor) as Stats;
|
|
if (!info.isDirectory() || info.isSymbolicLink()) throw invalid();
|
|
if (info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
|
|
return {
|
|
dev: info.dev,
|
|
ino: info.ino,
|
|
uid: info.uid,
|
|
mode: info.mode & 0o7777,
|
|
};
|
|
}
|
|
|
|
function writeExclusive(directory: string, filename: string, contents: Buffer): boolean {
|
|
const path = filePath(directory, filename);
|
|
let descriptor: number | undefined;
|
|
try {
|
|
descriptor = openSync(
|
|
path,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
|
|
PRIVATE_FILE_MODE,
|
|
);
|
|
fchmodSync(descriptor, PRIVATE_FILE_MODE);
|
|
writeFully(descriptor, contents);
|
|
fsyncSync(descriptor);
|
|
const written = fileIdentity(fstatSync(descriptor) as Stats);
|
|
if (written.size !== contents.length) throw invalid();
|
|
closeSync(descriptor);
|
|
descriptor = undefined;
|
|
syncDirectory(directory);
|
|
return true;
|
|
} catch (error) {
|
|
if (isNotFound(error)) throw invalid();
|
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") return false;
|
|
throw invalid();
|
|
} finally {
|
|
if (descriptor !== undefined) {
|
|
try { closeSync(descriptor); } catch { /* best effort only */ }
|
|
try { unlinkSync(path); } catch { /* only our exclusive temporary record can remain */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function replaceTrusted(
|
|
directory: string,
|
|
filename: string,
|
|
expected: FileIdentity,
|
|
contents: Buffer,
|
|
): void {
|
|
const path = filePath(directory, filename);
|
|
const temporary = join(directory, `.${filename}.${randomBytes(12).toString("hex")}.tmp`);
|
|
let descriptor: number | undefined;
|
|
try {
|
|
descriptor = openSync(
|
|
temporary,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
|
|
PRIVATE_FILE_MODE,
|
|
);
|
|
fchmodSync(descriptor, PRIVATE_FILE_MODE);
|
|
writeFully(descriptor, contents);
|
|
fsyncSync(descriptor);
|
|
const temporaryInfo = fileIdentity(fstatSync(descriptor) as Stats);
|
|
if (temporaryInfo.size !== contents.length) throw invalid();
|
|
closeSync(descriptor);
|
|
descriptor = undefined;
|
|
|
|
const current = fileIdentity(lstatSync(path) as Stats);
|
|
if (!sameFileIdentity(expected, current)) throw invalid();
|
|
renameSync(temporary, path);
|
|
syncDirectory(directory);
|
|
} catch {
|
|
throw invalid();
|
|
} finally {
|
|
if (descriptor !== undefined) {
|
|
try { closeSync(descriptor); } catch { /* the failing write remains untrusted */ }
|
|
}
|
|
try { unlinkSync(temporary); } catch { /* rename or no creation: nothing to remove */ }
|
|
}
|
|
}
|
|
|
|
function removeTrusted(
|
|
directory: string,
|
|
filename: string,
|
|
expected?: FileIdentity,
|
|
expectedLinks = 1,
|
|
): boolean {
|
|
const path = filePath(directory, filename);
|
|
try {
|
|
const beforeDirectory = directoryIdentity(directory);
|
|
const current = fileIdentity(lstatSync(path) as Stats, expectedLinks);
|
|
if (expected && !sameFileIdentity(expected, current)) throw invalid();
|
|
// Revalidate both names immediately before unlink. On POSIX unlink never follows a final
|
|
// symlink, and this closes the observable replacement window before that operation.
|
|
const finalDirectory = directoryIdentity(directory);
|
|
const final = fileIdentity(lstatSync(path) as Stats, expectedLinks);
|
|
if (!sameDirectoryIdentity(beforeDirectory, finalDirectory) || !sameFileIdentity(current, final)
|
|
|| (expected !== undefined && !sameFileIdentity(expected, final))) throw invalid();
|
|
unlinkSync(path);
|
|
syncDirectory(directory);
|
|
return true;
|
|
} catch (error) {
|
|
if (isNotFound(error)) return false;
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function parseSessionRecord(source: string): AuthSessionRecord {
|
|
try {
|
|
return sessionRecordSchema.parse(JSON.parse(source)) as AuthSessionRecord;
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function parseOidcStateRecord(source: string): OidcStateRecord {
|
|
try {
|
|
return oidcStateRecordSchema.parse(JSON.parse(source)) as OidcStateRecord;
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
type OidcSlotRecord = z.infer<typeof oidcSlotRecordSchema>;
|
|
|
|
function parseOidcSlotRecord(source: string): OidcSlotRecord {
|
|
try {
|
|
return oidcSlotRecordSchema.parse(JSON.parse(source));
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function parseWindowsRecord<T>(contents: Buffer, maximumBytes: number, parse: (source: string) => T): T {
|
|
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > maximumBytes) throw invalid();
|
|
try {
|
|
return parse(new TextDecoder("utf-8", { fatal: true }).decode(contents));
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
interface OidcStateClaim {
|
|
state: TrustedFile<OidcStateRecord>;
|
|
claimIdentity: FileIdentity;
|
|
}
|
|
|
|
interface StoredOidcSlot {
|
|
filename: string;
|
|
index: number;
|
|
record: OidcSlotRecord;
|
|
identity?: FileIdentity;
|
|
}
|
|
|
|
function inspectOidcStateClaim(
|
|
directory: string,
|
|
filename: string,
|
|
): OidcStateClaim | "orphan" | undefined {
|
|
const claimedFilename = claimFilename(filename);
|
|
let claimInfo: Stats;
|
|
try {
|
|
claimInfo = lstatSync(filePath(directory, claimedFilename)) as Stats;
|
|
} catch (error) {
|
|
if (isNotFound(error)) return undefined;
|
|
throw invalid();
|
|
}
|
|
let sourceInfo: Stats;
|
|
try {
|
|
sourceInfo = lstatSync(filePath(directory, filename)) as Stats;
|
|
} catch (error) {
|
|
if (!isNotFound(error)) throw invalid();
|
|
// A consumer may have just unlinked the source and not yet removed its claim. Do not
|
|
// remove that orphan here: doing so could make the winning consumer fail closed after it
|
|
// has read the state. prune() removes abandoned orphan claims after the state lifetime.
|
|
fileIdentity(claimInfo, 1);
|
|
return "orphan";
|
|
}
|
|
const sourceIdentity = fileIdentity(sourceInfo, 2);
|
|
const claimIdentity = fileIdentity(claimInfo, 2);
|
|
if (!sameFileIdentity(sourceIdentity, claimIdentity)) throw invalid();
|
|
const state = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, 2);
|
|
if (!state || !sameFileIdentity(sourceIdentity, state.identity)) throw invalid();
|
|
return { state, claimIdentity };
|
|
}
|
|
|
|
function oidcClaimExists(directory: string, filename: string): boolean {
|
|
const claimedFilename = claimFilename(filename);
|
|
try {
|
|
const info = lstatSync(filePath(directory, claimedFilename)) as Stats;
|
|
if (info.nlink !== 1 && info.nlink !== 2) throw invalid();
|
|
fileIdentity(info, info.nlink);
|
|
return true;
|
|
} catch (error) {
|
|
if (isNotFound(error)) return false;
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Claim a state by creating a hard link with a deterministic digest-only name. link(2) / NTFS
|
|
* CreateHardLink fails if another process has already installed that name, unlike rename which
|
|
* can overwrite the previous claimant. A crash leaves the pair unavailable until expiry/prune.
|
|
*/
|
|
function claimOidcState(directory: string, filename: string): OidcStateClaim | undefined {
|
|
// A competing process may be partway through consumption. It has already won and must be
|
|
// the only process allowed to deserialize the record; this process simply treats it as used.
|
|
if (oidcClaimExists(directory, filename)) return undefined;
|
|
|
|
const statePath = filePath(directory, filename);
|
|
const claimedFilename = claimFilename(filename);
|
|
const claimedPath = filePath(directory, claimedFilename);
|
|
let before: FileIdentity;
|
|
try {
|
|
before = fileIdentity(lstatSync(statePath) as Stats);
|
|
} catch (error) {
|
|
if (isNotFound(error)) return undefined;
|
|
// A winner can install its hard-link after the first claim check and before this state
|
|
// identity check. That process owns the state; this contender must fail closed as used.
|
|
if (oidcClaimExists(directory, filename)) return undefined;
|
|
throw invalid();
|
|
}
|
|
try {
|
|
linkSync(statePath, claimedPath);
|
|
} catch (error) {
|
|
if (isNotFound(error)) return undefined;
|
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") return undefined;
|
|
throw invalid();
|
|
}
|
|
try {
|
|
const sourceIdentity = fileIdentity(lstatSync(statePath) as Stats, 2);
|
|
const claimIdentity = fileIdentity(lstatSync(claimedPath) as Stats, 2);
|
|
if (!sameFileIdentity(before, sourceIdentity) || !sameFileIdentity(sourceIdentity, claimIdentity)) throw invalid();
|
|
const state = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, 2);
|
|
if (!state || !sameFileIdentity(state.identity, sourceIdentity)) throw invalid();
|
|
return { state, claimIdentity };
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function removeClaimedOidcState(directory: string, filename: string, claim: OidcStateClaim): void {
|
|
if (!removeTrusted(directory, filename, claim.state.identity, 2)) throw invalid();
|
|
if (!removeTrusted(directory, claimFilename(filename), claim.claimIdentity)) throw invalid();
|
|
}
|
|
|
|
function serialize(record: AuthSessionRecord | OidcStateRecord | OidcSlotRecord, maximumBytes: number): Buffer {
|
|
const contents = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
|
if (contents.length > maximumBytes) throw invalid();
|
|
return contents;
|
|
}
|
|
|
|
function dateMilliseconds(now: Date): number {
|
|
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw invalid();
|
|
return now.getTime();
|
|
}
|
|
|
|
function isoAt(milliseconds: number): string {
|
|
if (!Number.isSafeInteger(milliseconds) || !Number.isFinite(milliseconds)) throw invalid();
|
|
try {
|
|
return new Date(milliseconds).toISOString();
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
function sessionExpired(record: AuthSessionRecord, nowMs: number): boolean {
|
|
return nowMs >= Date.parse(record.idleExpiresAt) || nowMs >= Date.parse(record.absoluteExpiresAt);
|
|
}
|
|
|
|
function oidcStateExpired(record: OidcStateRecord, nowMs: number): boolean {
|
|
return nowMs >= Date.parse(record.expiresAt);
|
|
}
|
|
|
|
function equalRoleSets(left: readonly Role[], right: readonly Role[]): boolean {
|
|
if (!distinct(left) || !distinct(right) || left.length !== right.length) return false;
|
|
if (!left.every((value) => ROLES.includes(value)) || !right.every((value) => ROLES.includes(value))) return false;
|
|
return [...left].sort().every((value, index) => value === [...right].sort()[index]);
|
|
}
|
|
|
|
function validLocalUser(user: LocalSessionUser | undefined, record: AuthSessionRecord): boolean {
|
|
return user !== undefined && user.enabled === true && Number.isSafeInteger(user.authRevision)
|
|
&& user.authRevision > 0 && user.authRevision === record.userAuthRevision
|
|
&& equalRoleSets(user.roles, record.roles);
|
|
}
|
|
|
|
async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionValidity | undefined): Promise<boolean> {
|
|
// A root-only store remains useful for creation/diagnostics, but is intentionally incapable
|
|
// of authenticating a principal. Task 8 must supply config and local-registry dependencies.
|
|
if (!validity) return false;
|
|
if (record.method === "local" && validity.currentLocalUser) {
|
|
const current = await validity.currentLocalUser(record.subject);
|
|
return typeof current.revision === "string" && current.revision === record.authConfigRevision
|
|
&& validLocalUser(current.user, record);
|
|
}
|
|
const revision = await validity.currentAuthConfigRevision();
|
|
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
|
|
if (record.method !== "local") return true;
|
|
return validity.findLocalUser === undefined ? false : validLocalUser(await validity.findLocalUser(record.subject), record);
|
|
}
|
|
|
|
const locks = new Map<string, Promise<void>>();
|
|
|
|
async function withLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
|
const previous = locks.get(key) ?? Promise.resolve();
|
|
let release: (() => void) | undefined;
|
|
const current = new Promise<void>((resolve) => { release = resolve; });
|
|
locks.set(key, current);
|
|
await previous;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
release?.();
|
|
if (locks.get(key) === current) locks.delete(key);
|
|
}
|
|
}
|
|
|
|
function lockKey(root: string, directory: "sessions" | "oidc", filename: string): string {
|
|
return `${root}\0${directory}\0${filename}`;
|
|
}
|
|
|
|
/** Derive a one-way, domain-separated 256-bit CSRF value without persisting it. */
|
|
export function deriveCsrfToken(sessionToken: string): string {
|
|
if (!canonicalRawValue(sessionToken)) throw invalid();
|
|
try {
|
|
const sessionBytes = Buffer.from(sessionToken, "base64url");
|
|
return Buffer.from(hkdfSync("sha256", sessionBytes, EMPTY_HKDF_SALT, CSRF_CONTEXT, TOKEN_BYTES))
|
|
.toString("base64url");
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
export function createFileAuthSessionStore(
|
|
root: string,
|
|
validity?: AuthSessionValidity,
|
|
options: FileAuthSessionStoreOptions = {},
|
|
): AuthSessionStore {
|
|
const oidcStateCapacity = options.oidcStateCapacity ?? OIDC_STATE_CAPACITY;
|
|
if (!Number.isInteger(oidcStateCapacity) || oidcStateCapacity < 1 || oidcStateCapacity > OIDC_STATE_CAPACITY) {
|
|
throw invalid();
|
|
}
|
|
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)
|
|
: boundedDirectoryNames(storageDirectories(root).oidc, MAX_OIDC_STORAGE_ENTRIES);
|
|
if (entries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
|
|
if (entries.some((entry) => !DIGEST_FILENAME_PATTERN.test(entry)
|
|
&& !CLAIM_FILENAME_PATTERN.test(entry) && oidcSlotIndex(entry) === undefined)) throw invalid();
|
|
return entries;
|
|
}
|
|
|
|
async function storedOidcSlots(suppliedEntries?: string[]): Promise<StoredOidcSlot[]> {
|
|
const entries = suppliedEntries ?? await oidcStorageEntries();
|
|
const slots: StoredOidcSlot[] = [];
|
|
const stateFilenames = new Set<string>();
|
|
for (const filename of entries) {
|
|
const index = oidcSlotIndex(filename);
|
|
if (index === undefined) continue;
|
|
if (process.platform === "win32") {
|
|
const contents = await requiredWindowsStorage().read(root, "oidc", filename);
|
|
if (!contents) continue;
|
|
const record = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
|
|
if (stateFilenames.has(record.stateFilename)) throw invalid();
|
|
stateFilenames.add(record.stateFilename);
|
|
slots.push({ filename, index, record });
|
|
continue;
|
|
}
|
|
let trusted: TrustedFile<OidcSlotRecord> | undefined;
|
|
let lastError: unknown;
|
|
for (const retryDelayMs of [0, 1, 2, 4, 8, 16, 32]) {
|
|
if (retryDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
try {
|
|
trusted = readTrusted(
|
|
storageDirectories(root).oidc,
|
|
filename,
|
|
MAX_OIDC_SLOT_RECORD_BYTES,
|
|
parseOidcSlotRecord,
|
|
);
|
|
lastError = undefined;
|
|
break;
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
if (lastError !== undefined) throw invalid();
|
|
if (!trusted) continue;
|
|
if (stateFilenames.has(trusted.value.stateFilename)) throw invalid();
|
|
stateFilenames.add(trusted.value.stateFilename);
|
|
slots.push({ filename, index, record: trusted.value, identity: trusted.identity });
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
async function removeOidcSlot(slot: StoredOidcSlot): Promise<void> {
|
|
if (process.platform === "win32") {
|
|
const current = await requiredWindowsStorage().read(root, "oidc", slot.filename);
|
|
if (!current) return;
|
|
const record = parseWindowsRecord(current, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
|
|
if (record.stateFilename !== slot.record.stateFilename || record.expiresAt !== slot.record.expiresAt
|
|
|| !await requiredWindowsStorage().remove(root, "oidc", slot.filename)) throw invalid();
|
|
return;
|
|
}
|
|
if (!slot.identity || !removeTrusted(storageDirectories(root).oidc, slot.filename, slot.identity)) throw invalid();
|
|
}
|
|
|
|
async function releaseOidcSlot(index: number | undefined, stateFilename: string): Promise<void> {
|
|
if (index === undefined) return;
|
|
const filename = oidcSlotFilename(index);
|
|
const slot = (await storedOidcSlots([filename]))[0];
|
|
if (!slot || slot.record.stateFilename !== stateFilename) throw invalid();
|
|
await removeOidcSlot(slot);
|
|
}
|
|
|
|
async function reserveOidcSlot(stateFilename: string, expiresAt: string): Promise<number> {
|
|
const entries = await oidcStorageEntries();
|
|
const slots = await storedOidcSlots(entries);
|
|
const representedStates = new Set(slots.map((slot) => slot.record.stateFilename));
|
|
const legacyStates = new Set<string>();
|
|
for (const entry of entries) {
|
|
const filename = CLAIM_FILENAME_PATTERN.test(entry)
|
|
? `${entry.slice(0, -".claim".length)}.json`
|
|
: entry;
|
|
if (DIGEST_FILENAME_PATTERN.test(filename) && !representedStates.has(filename)) legacyStates.add(filename);
|
|
}
|
|
const availableSlotCount = oidcStateCapacity - legacyStates.size;
|
|
if (availableSlotCount <= 0) throw new OidcStateCapacityError();
|
|
const occupied = new Set(slots.map((slot) => slot.index));
|
|
const record: OidcSlotRecord = { version: 1, stateFilename, expiresAt };
|
|
const contents = serialize(record, MAX_OIDC_SLOT_RECORD_BYTES);
|
|
for (let index = 0; index < availableSlotCount; index += 1) {
|
|
if (occupied.has(index)) continue;
|
|
const filename = oidcSlotFilename(index);
|
|
const created = process.platform === "win32"
|
|
? await requiredWindowsStorage().create(root, "oidc", filename, contents)
|
|
: writeExclusive(storageDirectories(root).oidc, filename, contents);
|
|
if (created) return index;
|
|
}
|
|
throw new OidcStateCapacityError();
|
|
}
|
|
|
|
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
|
|
const nowMs = dateMilliseconds(now);
|
|
let validated: z.infer<typeof sessionInputSchema>;
|
|
try {
|
|
validated = sessionInputSchema.parse(input);
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
const absoluteExpiresMs = nowMs + validated.absoluteTtlMs;
|
|
const idleExpiresMs = Math.min(nowMs + validated.idleTtlMs, absoluteExpiresMs);
|
|
if (!Number.isSafeInteger(absoluteExpiresMs) || !Number.isSafeInteger(idleExpiresMs)) throw invalid();
|
|
const record: AuthSessionRecord = {
|
|
version: 1,
|
|
issuer: validated.principal.issuer,
|
|
subject: validated.principal.subject,
|
|
...(validated.principal.displayName === undefined ? {} : { displayName: validated.principal.displayName }),
|
|
method: validated.method,
|
|
roles: [...validated.principal.roles],
|
|
permissions: [...validated.principal.permissions],
|
|
...(validated.userAuthRevision === undefined ? {} : { userAuthRevision: validated.userAuthRevision }),
|
|
authConfigRevision: validated.authConfigRevision,
|
|
remembered: validated.remembered,
|
|
createdAt: isoAt(nowMs),
|
|
lastSeenAt: isoAt(nowMs),
|
|
idleExpiresAt: isoAt(idleExpiresMs),
|
|
absoluteExpiresAt: isoAt(absoluteExpiresMs),
|
|
};
|
|
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
|
|
if (process.platform === "win32") {
|
|
const bridge = requiredWindowsStorage();
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const token = randomBytes(TOKEN_BYTES).toString("base64url");
|
|
if (await bridge.create(root, "sessions", digestFilename(token), contents)) {
|
|
return { token, csrfToken: deriveCsrfToken(token), record };
|
|
}
|
|
}
|
|
throw invalid();
|
|
}
|
|
const directories = storageDirectories(root);
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const token = randomBytes(TOKEN_BYTES).toString("base64url");
|
|
const filename = digestFilename(token);
|
|
if (writeExclusive(directories.sessions, filename, contents)) {
|
|
return { token, csrfToken: deriveCsrfToken(token), record };
|
|
}
|
|
}
|
|
throw invalid();
|
|
}
|
|
|
|
async function resolveSession(
|
|
token: string,
|
|
now = new Date(),
|
|
requestValidity = validity,
|
|
): Promise<AuthSessionRecord | undefined> {
|
|
if (!canonicalRawValue(token)) return undefined;
|
|
const nowMs = dateMilliseconds(now);
|
|
const filename = digestFilename(token);
|
|
return withLock(lockKey(root, "sessions", filename), async () => {
|
|
if (process.platform === "win32") {
|
|
const bridge = requiredWindowsStorage();
|
|
const contents = await bridge.read(root, "sessions", filename);
|
|
if (!contents) return undefined;
|
|
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
|
if (sessionExpired(record, nowMs)) {
|
|
await bridge.remove(root, "sessions", filename);
|
|
return undefined;
|
|
}
|
|
try {
|
|
if (await recordIsCurrent(record, requestValidity)) return record;
|
|
} catch (error) {
|
|
if (error instanceof AuthSessionOperationalError) throw error;
|
|
await bridge.remove(root, "sessions", filename);
|
|
throw invalid();
|
|
}
|
|
await bridge.remove(root, "sessions", filename);
|
|
return undefined;
|
|
}
|
|
const directories = storageDirectories(root);
|
|
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
|
if (!trusted) return undefined;
|
|
if (sessionExpired(trusted.value, nowMs)) {
|
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
|
return undefined;
|
|
}
|
|
try {
|
|
if (await recordIsCurrent(trusted.value, requestValidity)) return trusted.value;
|
|
} catch (error) {
|
|
if (error instanceof AuthSessionOperationalError) throw error;
|
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
|
throw invalid();
|
|
}
|
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
async function touchSession(token: string, now = new Date()): Promise<void> {
|
|
if (!canonicalRawValue(token)) return;
|
|
const nowMs = dateMilliseconds(now);
|
|
const filename = digestFilename(token);
|
|
await withLock(lockKey(root, "sessions", filename), async () => {
|
|
if (process.platform === "win32") {
|
|
const bridge = requiredWindowsStorage();
|
|
const contents = await bridge.read(root, "sessions", filename);
|
|
if (!contents) return;
|
|
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
|
if (sessionExpired(record, nowMs)) {
|
|
await bridge.remove(root, "sessions", filename);
|
|
return;
|
|
}
|
|
const lastSeenMs = Date.parse(record.lastSeenAt);
|
|
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
|
|
const idleWindowMs = Date.parse(record.idleExpiresAt) - lastSeenMs;
|
|
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
|
|
const touched: AuthSessionRecord = {
|
|
...record,
|
|
lastSeenAt: isoAt(nowMs),
|
|
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(record.absoluteExpiresAt))),
|
|
};
|
|
await bridge.replace(root, "sessions", filename, serialize(touched, MAX_SESSION_RECORD_BYTES));
|
|
return;
|
|
}
|
|
const directories = storageDirectories(root);
|
|
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
|
if (!trusted) return;
|
|
if (sessionExpired(trusted.value, nowMs)) {
|
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
|
return;
|
|
}
|
|
const lastSeenMs = Date.parse(trusted.value.lastSeenAt);
|
|
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
|
|
const idleWindowMs = Date.parse(trusted.value.idleExpiresAt) - lastSeenMs;
|
|
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
|
|
const touched: AuthSessionRecord = {
|
|
...trusted.value,
|
|
lastSeenAt: isoAt(nowMs),
|
|
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(trusted.value.absoluteExpiresAt))),
|
|
};
|
|
replaceTrusted(
|
|
directories.sessions,
|
|
filename,
|
|
trusted.identity,
|
|
serialize(touched, MAX_SESSION_RECORD_BYTES),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function revokeSession(token: string): Promise<void> {
|
|
if (!canonicalRawValue(token)) return;
|
|
const filename = digestFilename(token);
|
|
await withLock(lockKey(root, "sessions", filename), async () => {
|
|
if (process.platform === "win32") {
|
|
await requiredWindowsStorage().remove(root, "sessions", filename);
|
|
return;
|
|
}
|
|
const directories = storageDirectories(root);
|
|
removeTrusted(directories.sessions, filename);
|
|
});
|
|
}
|
|
|
|
async function pruneOidcStates(nowMs: number): Promise<number> {
|
|
if (process.platform === "win32") {
|
|
const bridge = requiredWindowsStorage();
|
|
const oidcEntries = await bridge.list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES);
|
|
if (oidcEntries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
|
|
const stateNames = new Set(oidcEntries
|
|
.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry.name))
|
|
.map((entry) => entry.name));
|
|
const claimEntries = new Map(oidcEntries
|
|
.filter((entry) => CLAIM_FILENAME_PATTERN.test(entry.name))
|
|
.map((entry) => [entry.name, entry]));
|
|
const slotEntries = oidcEntries.filter((entry) => oidcSlotIndex(entry.name) !== undefined);
|
|
if (stateNames.size + claimEntries.size + slotEntries.length !== oidcEntries.length) throw invalid();
|
|
let removed = 0;
|
|
for (const filename of stateNames) {
|
|
const claim = claimFilename(filename);
|
|
const contents = claimEntries.has(claim)
|
|
? await bridge.readClaim(root, filename)
|
|
: await bridge.read(root, "oidc", filename);
|
|
if (!contents) continue;
|
|
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
|
if (oidcStateExpired(record, nowMs)) {
|
|
const didRemove = claimEntries.has(claim)
|
|
? await bridge.removeClaim(root, filename)
|
|
: await bridge.remove(root, "oidc", filename);
|
|
if (didRemove) removed += 1;
|
|
}
|
|
}
|
|
for (const [claim, entry] of claimEntries) {
|
|
const filename = `${claim.slice(0, -".claim".length)}.json`;
|
|
if (stateNames.has(filename)) continue;
|
|
if (nowMs >= entry.modifiedUnixMs + OIDC_STATE_TTL_MS
|
|
&& await bridge.remove(root, "oidc", claim)) removed += 1;
|
|
}
|
|
for (const entry of slotEntries) {
|
|
const contents = await bridge.read(root, "oidc", entry.name);
|
|
if (!contents) continue;
|
|
const slot = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
|
|
if (nowMs < Date.parse(slot.expiresAt)) continue;
|
|
const claim = claimFilename(slot.stateFilename);
|
|
const stateContents = claimEntries.has(claim)
|
|
? await bridge.readClaim(root, slot.stateFilename)
|
|
: await bridge.read(root, "oidc", slot.stateFilename);
|
|
if (stateContents) {
|
|
const state = parseWindowsRecord(stateContents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
|
if (!oidcStateExpired(state, nowMs)) throw invalid();
|
|
const didRemove = claimEntries.has(claim)
|
|
? await bridge.removeClaim(root, slot.stateFilename)
|
|
: await bridge.remove(root, "oidc", slot.stateFilename);
|
|
if (didRemove) removed += 1;
|
|
}
|
|
if (!await bridge.remove(root, "oidc", entry.name)) throw invalid();
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
const directory = storageDirectories(root).oidc;
|
|
const oidcEntries = boundedDirectoryNames(directory, MAX_OIDC_STORAGE_ENTRIES);
|
|
const stateFilenames = new Set(oidcEntries.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry)));
|
|
let removed = 0;
|
|
for (const filename of stateFilenames) {
|
|
await withLock(lockKey(root, "oidc", filename), async () => {
|
|
const claim = inspectOidcStateClaim(directory, filename);
|
|
if (claim === "orphan") return;
|
|
if (claim) {
|
|
if (oidcStateExpired(claim.state.value, nowMs)) {
|
|
removeClaimedOidcState(directory, filename, claim);
|
|
removed += 1;
|
|
}
|
|
return;
|
|
}
|
|
const trusted = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
|
if (trusted && oidcStateExpired(trusted.value, nowMs)
|
|
&& removeTrusted(directory, filename, trusted.identity)) removed += 1;
|
|
});
|
|
}
|
|
for (const claimedFilename of oidcEntries) {
|
|
if (!CLAIM_FILENAME_PATTERN.test(claimedFilename)) continue;
|
|
const filename = `${claimedFilename.slice(0, -".claim".length)}.json`;
|
|
if (stateFilenames.has(filename)) continue;
|
|
await withLock(lockKey(root, "oidc", filename), async () => {
|
|
const claim = inspectOidcStateClaim(directory, filename);
|
|
if (claim !== "orphan") return;
|
|
const claimIdentity = fileIdentity(lstatSync(filePath(directory, claimedFilename)) as Stats);
|
|
if (nowMs >= claimIdentity.mtimeMs + OIDC_STATE_TTL_MS
|
|
&& removeTrusted(directory, claimedFilename, claimIdentity)) removed += 1;
|
|
});
|
|
}
|
|
const slots = await storedOidcSlots(oidcEntries.filter((entry) => oidcSlotIndex(entry) !== undefined));
|
|
for (const slot of slots) {
|
|
if (nowMs < Date.parse(slot.record.expiresAt)) continue;
|
|
await withLock(lockKey(root, "oidc", slot.record.stateFilename), async () => {
|
|
const claim = inspectOidcStateClaim(directory, slot.record.stateFilename);
|
|
if (claim && claim !== "orphan") {
|
|
if (!oidcStateExpired(claim.state.value, nowMs)) throw invalid();
|
|
removeClaimedOidcState(directory, slot.record.stateFilename, claim);
|
|
removed += 1;
|
|
} else if (!claim) {
|
|
const trusted = readTrusted(
|
|
directory,
|
|
slot.record.stateFilename,
|
|
MAX_OIDC_STATE_RECORD_BYTES,
|
|
parseOidcStateRecord,
|
|
);
|
|
if (trusted) {
|
|
if (!oidcStateExpired(trusted.value, nowMs)) throw invalid();
|
|
if (removeTrusted(directory, slot.record.stateFilename, trusted.identity)) removed += 1;
|
|
}
|
|
}
|
|
await removeOidcSlot(slot);
|
|
});
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
async function createOidcState(input: OidcStateCreateInput, now = new Date()): Promise<CreatedOidcState> {
|
|
const nowMs = dateMilliseconds(now);
|
|
let validated: z.infer<typeof oidcStateInputSchema>;
|
|
try {
|
|
validated = oidcStateInputSchema.parse(input);
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
const expiresMs = nowMs + OIDC_STATE_TTL_MS;
|
|
if (!Number.isSafeInteger(expiresMs)) throw invalid();
|
|
return withLock(lockKey(root, "oidc", "capacity"), async () => {
|
|
await pruneOidcStates(nowMs);
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const state = randomBytes(TOKEN_BYTES).toString("base64url");
|
|
const filename = digestFilename(state);
|
|
const capacitySlot = await reserveOidcSlot(filename, isoAt(expiresMs));
|
|
const record: OidcStateRecord = {
|
|
version: 1,
|
|
nonce: validated.nonce,
|
|
codeVerifier: validated.codeVerifier,
|
|
returnTo: validated.returnTo,
|
|
authConfigRevision: validated.authConfigRevision,
|
|
issuer: validated.issuer,
|
|
browserTransactionDigest: validated.browserTransactionDigest,
|
|
browserTransactionTransport: validated.browserTransactionTransport,
|
|
capacitySlot,
|
|
createdAt: isoAt(nowMs),
|
|
expiresAt: isoAt(expiresMs),
|
|
};
|
|
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
|
|
const created = process.platform === "win32"
|
|
? await requiredWindowsStorage().create(root, "oidc", filename, contents)
|
|
: writeExclusive(storageDirectories(root).oidc, filename, contents);
|
|
if (created) return { state, record };
|
|
await releaseOidcSlot(capacitySlot, filename);
|
|
}
|
|
throw invalid();
|
|
});
|
|
}
|
|
|
|
async function consumeOidcState(state: string, now = new Date()): Promise<OidcStateRecord | undefined> {
|
|
if (!canonicalRawValue(state)) return undefined;
|
|
const nowMs = dateMilliseconds(now);
|
|
const filename = digestFilename(state);
|
|
return withLock(lockKey(root, "oidc", filename), async () => {
|
|
if (process.platform === "win32") {
|
|
const contents = await requiredWindowsStorage().claimConsume(root, filename);
|
|
if (!contents) return undefined;
|
|
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
|
await releaseOidcSlot(record.capacitySlot, filename);
|
|
return oidcStateExpired(record, nowMs) ? undefined : record;
|
|
}
|
|
const directories = storageDirectories(root);
|
|
const claim = claimOidcState(directories.oidc, filename);
|
|
// An installed claim belongs to another process/store instance. Only the process which
|
|
// created the hard link is allowed to receive the record.
|
|
if (!claim) return undefined;
|
|
if (oidcStateExpired(claim.state.value, nowMs)) {
|
|
removeClaimedOidcState(directories.oidc, filename, claim);
|
|
await releaseOidcSlot(claim.state.value.capacitySlot, filename);
|
|
return undefined;
|
|
}
|
|
removeClaimedOidcState(directories.oidc, filename, claim);
|
|
await releaseOidcSlot(claim.state.value.capacitySlot, filename);
|
|
return claim.state.value;
|
|
});
|
|
}
|
|
|
|
async function prune(now = new Date()): Promise<number> {
|
|
const nowMs = dateMilliseconds(now);
|
|
// 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 {
|
|
create: createSession,
|
|
resolve: resolveSession,
|
|
touch: touchSession,
|
|
revoke: revokeSession,
|
|
prune,
|
|
createOidcState,
|
|
consumeOidcState,
|
|
};
|
|
}
|