fix(auth): make diagnostics match runtime safety
This commit is contained in:
@@ -107,7 +107,10 @@ function validContentLength(response: Response): boolean {
|
||||
}
|
||||
|
||||
async function readBounded(response: Response, signal: AbortSignal): Promise<Uint8Array | undefined> {
|
||||
if (!validContentLength(response)) return undefined;
|
||||
if (!validContentLength(response)) {
|
||||
cancelResponse(response);
|
||||
return undefined;
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return new Uint8Array();
|
||||
const chunks: Uint8Array[] = [];
|
||||
@@ -144,12 +147,15 @@ function exactResult(name: string, parsed: unknown): GroupResult {
|
||||
|| Array.isArray(record.pagination)) return "unreachable";
|
||||
const next = (record.pagination as { next?: unknown }).next;
|
||||
if (next !== null && next !== undefined) return "ambiguous";
|
||||
if (record.results.length === 0) return "missing";
|
||||
if (record.results.length !== 1) return "ambiguous";
|
||||
const result = record.results[0];
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)
|
||||
|| (result as { name?: unknown }).name !== name) return "missing";
|
||||
return "present";
|
||||
const resultNames: string[] = [];
|
||||
for (const result of record.results) {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)
|
||||
|| typeof (result as { name?: unknown }).name !== "string") return "unreachable";
|
||||
resultNames.push((result as { name: string }).name);
|
||||
}
|
||||
const exactMatches = resultNames.filter((candidate) => candidate === name).length;
|
||||
if (exactMatches === 0) return "missing";
|
||||
return exactMatches === 1 ? "present" : "ambiguous";
|
||||
}
|
||||
|
||||
export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog {
|
||||
|
||||
+131
-31
@@ -1,5 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs";
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import type { Stats } from "node:fs";
|
||||
import { dirname, isAbsolute, normalize } from "node:path";
|
||||
import { parseDocument } from "yaml";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
@@ -60,25 +70,126 @@ const oidcSchema = z.strictObject({
|
||||
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
|
||||
});
|
||||
|
||||
interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number }
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
uid: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
mode: number;
|
||||
nlink: number;
|
||||
}
|
||||
|
||||
function readBoundedConfig(path: string): { source: string; identity: FileIdentity } {
|
||||
interface DirectoryIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
uid: number;
|
||||
mode: number;
|
||||
ctimeMs: number;
|
||||
}
|
||||
|
||||
interface StorageIdentity {
|
||||
file: FileIdentity;
|
||||
directory: DirectoryIdentity;
|
||||
}
|
||||
|
||||
function validateCanonicalPath(path: string): void {
|
||||
if (typeof path !== "string" || path.length === 0 || path.trim() !== path
|
||||
|| path.includes("\0") || !isAbsolute(path) || normalize(path) !== path
|
||||
|| realpathSync(path) !== path || realpathSync(dirname(path)) !== dirname(path)) throw invalid();
|
||||
}
|
||||
|
||||
function runtimeOwner(): number {
|
||||
if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid();
|
||||
const owner = process.geteuid();
|
||||
if (!Number.isSafeInteger(owner) || owner < 0) throw invalid();
|
||||
return owner;
|
||||
}
|
||||
|
||||
function fileMetadata(info: Stats): FileIdentity {
|
||||
const mode = info.mode & 0o7777;
|
||||
if (!info.isFile() || info.uid !== runtimeOwner() || info.nlink !== 1 || mode !== 0o600
|
||||
|| info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||
return {
|
||||
dev: info.dev, ino: info.ino, uid: info.uid, size: info.size,
|
||||
mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, mode, nlink: info.nlink,
|
||||
};
|
||||
}
|
||||
|
||||
function directoryMetadata(info: Stats): DirectoryIdentity {
|
||||
const mode = info.mode & 0o7777;
|
||||
if (!info.isDirectory() || info.uid !== runtimeOwner() || mode !== 0o700) throw invalid();
|
||||
return { dev: info.dev, ino: info.ino, uid: info.uid, mode, ctimeMs: info.ctimeMs };
|
||||
}
|
||||
|
||||
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 && left.ctimeMs === right.ctimeMs
|
||||
&& left.mode === right.mode && left.nlink === right.nlink;
|
||||
}
|
||||
|
||||
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
|
||||
&& left.mode === right.mode && left.ctimeMs === right.ctimeMs;
|
||||
}
|
||||
|
||||
function sameIdentity(left: StorageIdentity, right: StorageIdentity): boolean {
|
||||
return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory);
|
||||
}
|
||||
|
||||
function storageIdentity(path: string): StorageIdentity {
|
||||
try {
|
||||
validateCanonicalPath(path);
|
||||
return {
|
||||
file: fileMetadata(lstatSync(path) as Stats),
|
||||
directory: directoryMetadata(lstatSync(dirname(path)) as Stats),
|
||||
};
|
||||
} catch {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function openDirectoryDescriptor(path: string): number {
|
||||
return openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
|
||||
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
||||
}
|
||||
|
||||
function readBoundedConfig(path: string): { source: string; identity: StorageIdentity } {
|
||||
let directoryDescriptor: number | undefined;
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const info = fstatSync(fd);
|
||||
if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||
const before = storageIdentity(path);
|
||||
directoryDescriptor = openDirectoryDescriptor(dirname(path));
|
||||
const openedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats);
|
||||
if (!sameDirectoryIdentity(before.directory, openedDirectory)) throw invalid();
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const opened = fileMetadata(fstatSync(fd) as Stats);
|
||||
if (!sameFileIdentity(before.file, opened)) throw invalid();
|
||||
const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
|
||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
||||
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
if (offset > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||
const afterFile = fileMetadata(fstatSync(fd) as Stats);
|
||||
const afterPath = storageIdentity(path);
|
||||
const afterOpenedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats);
|
||||
if (!sameFileIdentity(opened, afterFile) || !sameFileIdentity(afterFile, afterPath.file)
|
||||
|| !sameDirectoryIdentity(before.directory, afterPath.directory)
|
||||
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
|
||||
validateCanonicalPath(path);
|
||||
return {
|
||||
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)),
|
||||
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs },
|
||||
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)),
|
||||
identity: afterPath,
|
||||
};
|
||||
} catch {
|
||||
throw invalid();
|
||||
} finally {
|
||||
if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ }
|
||||
if (directoryDescriptor !== undefined) try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +243,7 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig {
|
||||
} catch { throw invalid(); }
|
||||
}
|
||||
|
||||
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: FileIdentity } {
|
||||
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
|
||||
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
|
||||
const read = readBoundedConfig(path);
|
||||
const value = parseAuthenticationConfig(read.source);
|
||||
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
|
||||
@@ -143,29 +253,19 @@ export function loadAuthenticationConfig(path: string): LoadedAuthConfig {
|
||||
return loadAuthenticationConfigWithIdentity(path).loaded;
|
||||
}
|
||||
|
||||
function fileIdentity(path: string): FileIdentity {
|
||||
try {
|
||||
const info = statSync(path);
|
||||
if (!info.isFile()) throw invalid();
|
||||
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
||||
} catch { throw invalid(); }
|
||||
}
|
||||
|
||||
function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
||||
}
|
||||
|
||||
export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider {
|
||||
let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined;
|
||||
let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined;
|
||||
return { current(): LoadedAuthConfig {
|
||||
const before = fileIdentity(path);
|
||||
const before = storageIdentity(path);
|
||||
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
|
||||
if (sameIdentity(identity, fileIdentity(path))) {
|
||||
cached = { identity, loaded };
|
||||
return loaded;
|
||||
}
|
||||
try {
|
||||
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
|
||||
if (sameIdentity(identity, storageIdentity(path))) {
|
||||
cached = { identity, loaded };
|
||||
return loaded;
|
||||
}
|
||||
} catch { /* retry one concurrent atomic replacement, then fail closed */ }
|
||||
}
|
||||
throw invalid();
|
||||
} };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AuthenticationConfigProvider, AuthMode } from "./types.js";
|
||||
import type { LocalUserRegistry } from "./local-registry.js";
|
||||
import { OidcJwksUnavailableError, type OidcProtocol } from "./oidc-client.js";
|
||||
import { OidcIssuerMismatchError, OidcJwksUnavailableError, type OidcProtocol } from "./oidc-client.js";
|
||||
import { validateAuthSessionRoot } from "./session-store.js";
|
||||
import type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics, GroupCatalog } from "./group-catalog.js";
|
||||
|
||||
export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js";
|
||||
@@ -12,11 +13,11 @@ export interface AuthDiagnoser {
|
||||
export interface AuthDiagnoserDependencies {
|
||||
authMode: AuthMode;
|
||||
authStateRoot: string;
|
||||
/** Platform integrations may inject an equivalent side-effect-free owner/ACL validator. */
|
||||
sessionRootValidator?: (root: string) => void | Promise<void>;
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
secrets?: ReadonlyMap<string, string>;
|
||||
localUserRegistry?: LocalUserRegistry;
|
||||
/** Enables a host integration to inspect a local registry without exposing user records. */
|
||||
hasEnabledLocalAdmin?: () => Promise<boolean>;
|
||||
oidcProtocol?: OidcProtocol;
|
||||
groupCatalog?: GroupCatalog;
|
||||
}
|
||||
@@ -25,11 +26,6 @@ function check(code: AuthDiagnosticCode, message: string, field?: string): AuthD
|
||||
return { level: "error", code, message, ...(field === undefined ? {} : { field }) };
|
||||
}
|
||||
|
||||
function sessionRootIsSafe(value: string): boolean {
|
||||
return typeof value === "string" && value.startsWith("/") && value.trim() === value
|
||||
&& value.length > 1 && !value.includes("\0") && !/\p{Cc}/u.test(value);
|
||||
}
|
||||
|
||||
function secretPresent(secrets: ReadonlyMap<string, string> | undefined, name: string): boolean {
|
||||
const value = secrets?.get(name);
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 16 * 1024 && !/\p{Cc}/u.test(value);
|
||||
@@ -51,13 +47,12 @@ function stableCompare(left: string, right: string): number {
|
||||
|
||||
async function localRegistryIsUsable(deps: AuthDiagnoserDependencies): Promise<AuthDiagnostic | undefined> {
|
||||
try {
|
||||
if (deps.hasEnabledLocalAdmin) {
|
||||
if (!await deps.hasEnabledLocalAdmin()) return check("local_admin_missing", "No enabled local administrator is configured.");
|
||||
return undefined;
|
||||
if (!deps.localUserRegistry) {
|
||||
return check("local_user_registry_invalid", "The local user registry is unavailable.");
|
||||
}
|
||||
if (!await deps.localUserRegistry.hasEnabledAdmin()) {
|
||||
return check("local_admin_missing", "No enabled local administrator is configured.");
|
||||
}
|
||||
if (!deps.localUserRegistry) return check("local_user_registry_invalid", "The local user registry is unavailable.");
|
||||
// The registry's safe parser refuses to load without an enabled admin; this probe never exposes users.
|
||||
await deps.localUserRegistry.findByUsername("diagnostic-probe");
|
||||
return undefined;
|
||||
} catch {
|
||||
return check("local_user_registry_invalid", "The local user registry is invalid.");
|
||||
@@ -69,7 +64,11 @@ export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagno
|
||||
async inspect(options): Promise<AuthDiagnostics> {
|
||||
const checks: AuthDiagnostic[] = [];
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
if (!sessionRootIsSafe(deps.authStateRoot)) checks.push(check("auth_session_store_invalid", "The authentication session store is invalid."));
|
||||
try {
|
||||
await (deps.sessionRootValidator ?? validateAuthSessionRoot)(deps.authStateRoot);
|
||||
} catch {
|
||||
checks.push(check("auth_session_store_invalid", "The authentication session store is invalid."));
|
||||
}
|
||||
|
||||
if (deps.authMode === "none" || deps.authMode === "mock") {
|
||||
const result = ordered(checks);
|
||||
@@ -122,7 +121,11 @@ export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagno
|
||||
await deps.oidcProtocol.diagnose(signal);
|
||||
} catch (error) {
|
||||
checks.push(check(
|
||||
error instanceof OidcJwksUnavailableError ? "oidc_jwks_unreachable" : "oidc_discovery_unreachable",
|
||||
error instanceof OidcIssuerMismatchError
|
||||
? "oidc_issuer_mismatch"
|
||||
: error instanceof OidcJwksUnavailableError
|
||||
? "oidc_jwks_unreachable"
|
||||
: "oidc_discovery_unreachable",
|
||||
"The OIDC provider could not be validated.",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface LocalUserRecord {
|
||||
}
|
||||
|
||||
export interface LocalUserRegistry {
|
||||
/** Safe production diagnostic probe; never returns user records or hashes. */
|
||||
hasEnabledAdmin(): Promise<boolean>;
|
||||
findByUsername(username: string): Promise<LocalUserRecord | undefined>;
|
||||
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
|
||||
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
|
||||
@@ -191,13 +193,11 @@ function parseRegistry(source: string): LocalUserRecord[] {
|
||||
const parsed = registrySchema.parse(document.toJSON());
|
||||
const ids = new Set<string>();
|
||||
const usernames = new Set<string>();
|
||||
let enabledAdmin = false;
|
||||
const records = parsed.users.map((user) => {
|
||||
const normalizedUsername = normalizeUsername(user.username);
|
||||
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
|
||||
ids.add(user.id);
|
||||
usernames.add(normalizedUsername);
|
||||
if (user.enabled && user.roles.includes("admin")) enabledAdmin = true;
|
||||
return Object.freeze({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -209,7 +209,6 @@ function parseRegistry(source: string): LocalUserRecord[] {
|
||||
authRevision: user.authRevision,
|
||||
});
|
||||
});
|
||||
if (!enabledAdmin) throw invalid();
|
||||
return records;
|
||||
} catch {
|
||||
throw invalid();
|
||||
@@ -241,13 +240,22 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
||||
throw invalid();
|
||||
}
|
||||
|
||||
function operationalRecords(): LocalUserRecord[] {
|
||||
const records = current();
|
||||
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
|
||||
return records;
|
||||
}
|
||||
|
||||
return {
|
||||
async hasEnabledAdmin(): Promise<boolean> {
|
||||
return current().some((user) => user.enabled && user.roles.includes("admin"));
|
||||
},
|
||||
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
|
||||
const normalized = normalizeUsername(username);
|
||||
return current().find((user) => user.normalizedUsername === normalized);
|
||||
return operationalRecords().find((user) => user.normalizedUsername === normalized);
|
||||
},
|
||||
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
|
||||
return current().find((user) => user.id === id);
|
||||
return operationalRecords().find((user) => user.id === id);
|
||||
},
|
||||
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
|
||||
if (!user || !user.enabled) {
|
||||
|
||||
@@ -48,6 +48,14 @@ export class OidcJwksUnavailableError extends OidcProtocolError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Discovery completed with metadata for a different issuer than the configured trust anchor. */
|
||||
export class OidcIssuerMismatchError extends OidcProtocolError {
|
||||
constructor() {
|
||||
super("oidc_issuer_mismatch");
|
||||
this.name = "OidcIssuerMismatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OidcProtocolOptions {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
@@ -373,7 +381,14 @@ function availabilityFailure(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function protocolFailure(error: unknown): OidcProtocolError {
|
||||
if (error instanceof OidcProtocolError) return error;
|
||||
let current = error;
|
||||
const seen = new Set<object>();
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
if (current instanceof OidcProtocolError) return current;
|
||||
if (!current || typeof current !== "object" || seen.has(current)) break;
|
||||
seen.add(current);
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError();
|
||||
}
|
||||
|
||||
@@ -463,22 +478,40 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
||||
const configuration = async (): Promise<Configuration> => {
|
||||
if (!discovered) {
|
||||
discovered = (async () => {
|
||||
let inspectingDiscovery = true;
|
||||
const issuerCheckingFetch: CustomFetch = async (input, init) => {
|
||||
const response = await transport.customFetch(input, init);
|
||||
if (inspectingDiscovery) {
|
||||
try {
|
||||
const metadata = await response.clone().json() as { issuer?: unknown };
|
||||
if (typeof metadata?.issuer === "string" && metadata.issuer !== options.issuer) {
|
||||
throw new OidcIssuerMismatchError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof OidcIssuerMismatchError) throw error;
|
||||
// The OIDC library owns all other discovery-document validation.
|
||||
}
|
||||
}
|
||||
return response;
|
||||
};
|
||||
try {
|
||||
const config = await discovery(
|
||||
issuerUrl,
|
||||
options.clientId,
|
||||
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
||||
undefined,
|
||||
{ [customFetch]: transport.customFetch, timeout: httpTimeoutMs / 1000 },
|
||||
{ [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 },
|
||||
);
|
||||
const metadata = config.serverMetadata();
|
||||
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
|
||||
if (metadata.issuer !== options.issuer) throw new OidcIssuerMismatchError();
|
||||
httpsEndpoint(metadata.authorization_endpoint);
|
||||
httpsEndpoint(metadata.token_endpoint);
|
||||
httpsEndpoint(metadata.jwks_uri);
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw protocolFailure(error);
|
||||
} finally {
|
||||
inspectingDiscovery = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -399,13 +399,27 @@ function privateDirectory(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** Side-effect-free POSIX validator shared by runtime storage and static diagnostics. */
|
||||
export function validateAuthSessionRoot(root: string): void {
|
||||
try {
|
||||
validateSessionRootSyntax(root);
|
||||
directoryIdentity(root);
|
||||
} 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.
|
||||
if (process.platform === "win32") throw invalid();
|
||||
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|
||||
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
|
||||
validateSessionRootSyntax(root);
|
||||
privateDirectory(root);
|
||||
validateAuthSessionRoot(root);
|
||||
const sessions = join(root, "sessions");
|
||||
const oidc = join(root, "oidc");
|
||||
privateDirectory(sessions);
|
||||
|
||||
Reference in New Issue
Block a user