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> {
|
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();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) return new Uint8Array();
|
if (!reader) return new Uint8Array();
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
@@ -144,12 +147,15 @@ function exactResult(name: string, parsed: unknown): GroupResult {
|
|||||||
|| Array.isArray(record.pagination)) return "unreachable";
|
|| Array.isArray(record.pagination)) return "unreachable";
|
||||||
const next = (record.pagination as { next?: unknown }).next;
|
const next = (record.pagination as { next?: unknown }).next;
|
||||||
if (next !== null && next !== undefined) return "ambiguous";
|
if (next !== null && next !== undefined) return "ambiguous";
|
||||||
if (record.results.length === 0) return "missing";
|
const resultNames: string[] = [];
|
||||||
if (record.results.length !== 1) return "ambiguous";
|
for (const result of record.results) {
|
||||||
const result = record.results[0];
|
if (!result || typeof result !== "object" || Array.isArray(result)
|
||||||
if (!result || typeof result !== "object" || Array.isArray(result)
|
|| typeof (result as { name?: unknown }).name !== "string") return "unreachable";
|
||||||
|| (result as { name?: unknown }).name !== name) return "missing";
|
resultNames.push((result as { name: string }).name);
|
||||||
return "present";
|
}
|
||||||
|
const exactMatches = resultNames.filter((candidate) => candidate === name).length;
|
||||||
|
if (exactMatches === 0) return "missing";
|
||||||
|
return exactMatches === 1 ? "present" : "ambiguous";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog {
|
export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog {
|
||||||
|
|||||||
+131
-31
@@ -1,5 +1,15 @@
|
|||||||
import { createHash } from "node:crypto";
|
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 { parseDocument } from "yaml";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import type {
|
||||||
@@ -60,25 +70,126 @@ const oidcSchema = z.strictObject({
|
|||||||
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
|
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;
|
let fd: number | undefined;
|
||||||
try {
|
try {
|
||||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
const before = storageIdentity(path);
|
||||||
const info = fstatSync(fd);
|
directoryDescriptor = openDirectoryDescriptor(dirname(path));
|
||||||
if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
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 buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
|
||||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
let offset = 0;
|
||||||
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
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 {
|
return {
|
||||||
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)),
|
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)),
|
||||||
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs },
|
identity: afterPath,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
} finally {
|
} finally {
|
||||||
if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ }
|
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(); }
|
} catch { throw invalid(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: FileIdentity } {
|
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
|
||||||
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
|
|
||||||
const read = readBoundedConfig(path);
|
const read = readBoundedConfig(path);
|
||||||
const value = parseAuthenticationConfig(read.source);
|
const value = parseAuthenticationConfig(read.source);
|
||||||
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
|
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;
|
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 {
|
export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider {
|
||||||
let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined;
|
let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined;
|
||||||
return { current(): LoadedAuthConfig {
|
return { current(): LoadedAuthConfig {
|
||||||
const before = fileIdentity(path);
|
const before = storageIdentity(path);
|
||||||
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
|
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
|
try {
|
||||||
if (sameIdentity(identity, fileIdentity(path))) {
|
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
|
||||||
cached = { identity, loaded };
|
if (sameIdentity(identity, storageIdentity(path))) {
|
||||||
return loaded;
|
cached = { identity, loaded };
|
||||||
}
|
return loaded;
|
||||||
|
}
|
||||||
|
} catch { /* retry one concurrent atomic replacement, then fail closed */ }
|
||||||
}
|
}
|
||||||
throw invalid();
|
throw invalid();
|
||||||
} };
|
} };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AuthenticationConfigProvider, AuthMode } from "./types.js";
|
import type { AuthenticationConfigProvider, AuthMode } from "./types.js";
|
||||||
import type { LocalUserRegistry } from "./local-registry.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";
|
import type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics, GroupCatalog } from "./group-catalog.js";
|
||||||
|
|
||||||
export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js";
|
export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js";
|
||||||
@@ -12,11 +13,11 @@ export interface AuthDiagnoser {
|
|||||||
export interface AuthDiagnoserDependencies {
|
export interface AuthDiagnoserDependencies {
|
||||||
authMode: AuthMode;
|
authMode: AuthMode;
|
||||||
authStateRoot: string;
|
authStateRoot: string;
|
||||||
|
/** Platform integrations may inject an equivalent side-effect-free owner/ACL validator. */
|
||||||
|
sessionRootValidator?: (root: string) => void | Promise<void>;
|
||||||
authentication?: AuthenticationConfigProvider;
|
authentication?: AuthenticationConfigProvider;
|
||||||
secrets?: ReadonlyMap<string, string>;
|
secrets?: ReadonlyMap<string, string>;
|
||||||
localUserRegistry?: LocalUserRegistry;
|
localUserRegistry?: LocalUserRegistry;
|
||||||
/** Enables a host integration to inspect a local registry without exposing user records. */
|
|
||||||
hasEnabledLocalAdmin?: () => Promise<boolean>;
|
|
||||||
oidcProtocol?: OidcProtocol;
|
oidcProtocol?: OidcProtocol;
|
||||||
groupCatalog?: GroupCatalog;
|
groupCatalog?: GroupCatalog;
|
||||||
}
|
}
|
||||||
@@ -25,11 +26,6 @@ function check(code: AuthDiagnosticCode, message: string, field?: string): AuthD
|
|||||||
return { level: "error", code, message, ...(field === undefined ? {} : { field }) };
|
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 {
|
function secretPresent(secrets: ReadonlyMap<string, string> | undefined, name: string): boolean {
|
||||||
const value = secrets?.get(name);
|
const value = secrets?.get(name);
|
||||||
return typeof value === "string" && value.length > 0 && value.length <= 16 * 1024 && !/\p{Cc}/u.test(value);
|
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> {
|
async function localRegistryIsUsable(deps: AuthDiagnoserDependencies): Promise<AuthDiagnostic | undefined> {
|
||||||
try {
|
try {
|
||||||
if (deps.hasEnabledLocalAdmin) {
|
if (!deps.localUserRegistry) {
|
||||||
if (!await deps.hasEnabledLocalAdmin()) return check("local_admin_missing", "No enabled local administrator is configured.");
|
return check("local_user_registry_invalid", "The local user registry is unavailable.");
|
||||||
return undefined;
|
}
|
||||||
|
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;
|
return undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return check("local_user_registry_invalid", "The local user registry is invalid.");
|
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> {
|
async inspect(options): Promise<AuthDiagnostics> {
|
||||||
const checks: AuthDiagnostic[] = [];
|
const checks: AuthDiagnostic[] = [];
|
||||||
const signal = options.signal ?? new AbortController().signal;
|
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") {
|
if (deps.authMode === "none" || deps.authMode === "mock") {
|
||||||
const result = ordered(checks);
|
const result = ordered(checks);
|
||||||
@@ -122,7 +121,11 @@ export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagno
|
|||||||
await deps.oidcProtocol.diagnose(signal);
|
await deps.oidcProtocol.diagnose(signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
checks.push(check(
|
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.",
|
"The OIDC provider could not be validated.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface LocalUserRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalUserRegistry {
|
export interface LocalUserRegistry {
|
||||||
|
/** Safe production diagnostic probe; never returns user records or hashes. */
|
||||||
|
hasEnabledAdmin(): Promise<boolean>;
|
||||||
findByUsername(username: string): Promise<LocalUserRecord | undefined>;
|
findByUsername(username: string): Promise<LocalUserRecord | undefined>;
|
||||||
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
|
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
|
||||||
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
|
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
|
||||||
@@ -191,13 +193,11 @@ function parseRegistry(source: string): LocalUserRecord[] {
|
|||||||
const parsed = registrySchema.parse(document.toJSON());
|
const parsed = registrySchema.parse(document.toJSON());
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
const usernames = new Set<string>();
|
const usernames = new Set<string>();
|
||||||
let enabledAdmin = false;
|
|
||||||
const records = parsed.users.map((user) => {
|
const records = parsed.users.map((user) => {
|
||||||
const normalizedUsername = normalizeUsername(user.username);
|
const normalizedUsername = normalizeUsername(user.username);
|
||||||
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
|
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
|
||||||
ids.add(user.id);
|
ids.add(user.id);
|
||||||
usernames.add(normalizedUsername);
|
usernames.add(normalizedUsername);
|
||||||
if (user.enabled && user.roles.includes("admin")) enabledAdmin = true;
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -209,7 +209,6 @@ function parseRegistry(source: string): LocalUserRecord[] {
|
|||||||
authRevision: user.authRevision,
|
authRevision: user.authRevision,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (!enabledAdmin) throw invalid();
|
|
||||||
return records;
|
return records;
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
@@ -241,13 +240,22 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
|||||||
throw invalid();
|
throw invalid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function operationalRecords(): LocalUserRecord[] {
|
||||||
|
const records = current();
|
||||||
|
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
async hasEnabledAdmin(): Promise<boolean> {
|
||||||
|
return current().some((user) => user.enabled && user.roles.includes("admin"));
|
||||||
|
},
|
||||||
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
|
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
|
||||||
const normalized = normalizeUsername(username);
|
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> {
|
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> {
|
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
|
||||||
if (!user || !user.enabled) {
|
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 {
|
export interface OidcProtocolOptions {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -373,7 +381,14 @@ function availabilityFailure(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function protocolFailure(error: unknown): OidcProtocolError {
|
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();
|
return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,22 +478,40 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|||||||
const configuration = async (): Promise<Configuration> => {
|
const configuration = async (): Promise<Configuration> => {
|
||||||
if (!discovered) {
|
if (!discovered) {
|
||||||
discovered = (async () => {
|
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 {
|
try {
|
||||||
const config = await discovery(
|
const config = await discovery(
|
||||||
issuerUrl,
|
issuerUrl,
|
||||||
options.clientId,
|
options.clientId,
|
||||||
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
||||||
undefined,
|
undefined,
|
||||||
{ [customFetch]: transport.customFetch, timeout: httpTimeoutMs / 1000 },
|
{ [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 },
|
||||||
);
|
);
|
||||||
const metadata = config.serverMetadata();
|
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.authorization_endpoint);
|
||||||
httpsEndpoint(metadata.token_endpoint);
|
httpsEndpoint(metadata.token_endpoint);
|
||||||
httpsEndpoint(metadata.jwks_uri);
|
httpsEndpoint(metadata.jwks_uri);
|
||||||
return config;
|
return config;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw protocolFailure(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 {
|
function storageDirectories(root: string): StorageDirectories {
|
||||||
// Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this
|
// 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.
|
// POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod.
|
||||||
if (process.platform === "win32") throw invalid();
|
validateSessionRootSyntax(root);
|
||||||
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|
|
||||||
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
|
|
||||||
privateDirectory(root);
|
privateDirectory(root);
|
||||||
|
validateAuthSessionRoot(root);
|
||||||
const sessions = join(root, "sessions");
|
const sessions = join(root, "sessions");
|
||||||
const oidc = join(root, "oidc");
|
const oidc = join(root, "oidc");
|
||||||
privateDirectory(sessions);
|
privateDirectory(sessions);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
@@ -7,7 +7,8 @@ import { buildApp } from "../src/app.js";
|
|||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
test("configured OIDC advertises login but fails closed without its runtime client secret", async () => {
|
test("configured OIDC advertises login but fails closed without its runtime client secret", async () => {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-"));
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-oidc-mode-"));
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
const file = join(directory, "auth.yaml");
|
const file = join(directory, "auth.yaml");
|
||||||
writeFileSync(file, stringify({
|
writeFileSync(file, stringify({
|
||||||
version: 1, mode: "oidc", publicUrl: "https://thothii.example.org",
|
version: 1, mode: "oidc", publicUrl: "https://thothii.example.org",
|
||||||
@@ -17,7 +18,8 @@ test("configured OIDC advertises login but fails closed without its runtime clie
|
|||||||
},
|
},
|
||||||
groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
||||||
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
||||||
}), "utf8");
|
}), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(file, 0o600);
|
||||||
try {
|
try {
|
||||||
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
|
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
chmodSync,
|
||||||
|
linkSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
realpathSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
import {
|
import {
|
||||||
createAuthenticationConfigProvider,
|
createAuthenticationConfigProvider,
|
||||||
@@ -33,10 +43,12 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function writeFixture(value: unknown): string {
|
function writeFixture(value: unknown): string {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-config-"));
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-"));
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
directories.push(directory);
|
directories.push(directory);
|
||||||
const file = join(directory, "auth.yaml");
|
const file = join(directory, "auth.yaml");
|
||||||
writeFileSync(file, stringify(value), "utf8");
|
writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(file, 0o600);
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +226,8 @@ test("provider reloads after an atomic configuration replacement", () => {
|
|||||||
const provider = createAuthenticationConfigProvider(file);
|
const provider = createAuthenticationConfigProvider(file);
|
||||||
const original = provider.current();
|
const original = provider.current();
|
||||||
const replacement = `${file}.replacement`;
|
const replacement = `${file}.replacement`;
|
||||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
|
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(replacement, 0o600);
|
||||||
renameSync(replacement, file);
|
renameSync(replacement, file);
|
||||||
|
|
||||||
const reloaded = provider.current();
|
const reloaded = provider.current();
|
||||||
@@ -225,13 +238,91 @@ test("provider reloads after an atomic configuration replacement", () => {
|
|||||||
test("provider retries when replacement occurs between its read and cache identity check", () => {
|
test("provider retries when replacement occurs between its read and cache identity check", () => {
|
||||||
const file = writeFixture(localConfig());
|
const file = writeFixture(localConfig());
|
||||||
const replacement = `${file}.replacement`;
|
const replacement = `${file}.replacement`;
|
||||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
|
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(replacement, 0o600);
|
||||||
const provider = createAuthenticationConfigProvider(file);
|
const provider = createAuthenticationConfigProvider(file);
|
||||||
readHook.callback = () => renameSync(replacement, file);
|
readHook.callback = () => renameSync(replacement, file);
|
||||||
|
|
||||||
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999");
|
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(["symlink", "hard link", "mode wider than 0600", "non-private parent"])(
|
||||||
|
"rejects auth.yaml with unsafe %s storage",
|
||||||
|
(kind) => {
|
||||||
|
const file = writeFixture(localConfig());
|
||||||
|
if (kind === "symlink") {
|
||||||
|
const target = `${file}.target`;
|
||||||
|
renameSync(file, target);
|
||||||
|
symlinkSync(target, file);
|
||||||
|
} else if (kind === "hard link") linkSync(file, `${file}.link`);
|
||||||
|
else if (kind === "mode wider than 0600") chmodSync(file, 0o640);
|
||||||
|
else chmodSync(dirname(file), 0o750);
|
||||||
|
|
||||||
|
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("rejects auth.yaml beneath a symlinked parent without exposing its path", () => {
|
||||||
|
const outer = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-parent-"));
|
||||||
|
chmodSync(outer, 0o700);
|
||||||
|
directories.push(outer);
|
||||||
|
const realDirectory = join(outer, "real-auth");
|
||||||
|
const linkedDirectory = join(outer, "linked-auth");
|
||||||
|
mkdirSync(realDirectory, { mode: 0o700 });
|
||||||
|
chmodSync(realDirectory, 0o700);
|
||||||
|
const realFile = join(realDirectory, "auth.yaml");
|
||||||
|
writeFileSync(realFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(realFile, 0o600);
|
||||||
|
symlinkSync(realDirectory, linkedDirectory);
|
||||||
|
const unsafePath = join(linkedDirectory, "auth.yaml");
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadAuthenticationConfig(unsafePath);
|
||||||
|
throw new Error("unsafe auth configuration unexpectedly loaded");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as Error).message).toBe("authentication configuration is invalid");
|
||||||
|
expect(String(error)).not.toContain(unsafePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects auth.yaml when its owner is not the runtime owner", () => {
|
||||||
|
const geteuid = process.geteuid;
|
||||||
|
if (!geteuid) return;
|
||||||
|
const owner = geteuid();
|
||||||
|
const file = writeFixture(localConfig());
|
||||||
|
const spy = vi.spyOn(process, "geteuid").mockReturnValue(owner + 1);
|
||||||
|
try {
|
||||||
|
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provider redacts an absent canonical path", () => {
|
||||||
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-absent-"));
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
|
directories.push(directory);
|
||||||
|
const missing = join(directory, "private-path-UNIQUE-4K6.yaml");
|
||||||
|
|
||||||
|
try {
|
||||||
|
createAuthenticationConfigProvider(missing).current();
|
||||||
|
throw new Error("missing authentication configuration unexpectedly loaded");
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as Error).message).toBe("authentication configuration is invalid");
|
||||||
|
expect(String(error)).not.toContain(missing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a path replacement during the bounded auth.yaml read", () => {
|
||||||
|
const file = writeFixture(localConfig());
|
||||||
|
const replacement = `${file}.replacement`;
|
||||||
|
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(replacement, 0o600);
|
||||||
|
readHook.callback = () => renameSync(replacement, file);
|
||||||
|
|
||||||
|
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects input larger than one MiB", () => {
|
test("rejects input larger than one MiB", () => {
|
||||||
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
||||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
import { expect, test, vi } from "vitest";
|
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { createAuthDiagnoser } from "../src/auth/diagnostics.js";
|
import { createAuthDiagnoser } from "../src/auth/diagnostics.js";
|
||||||
import { OidcJwksUnavailableError } from "../src/auth/oidc-client.js";
|
import { createAuthenticationConfigProvider } from "../src/auth/config.js";
|
||||||
|
import { createAuthentikGroupCatalog } from "../src/auth/authentik-group-catalog.js";
|
||||||
|
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
|
||||||
|
import { createOidcProtocol, OidcJwksUnavailableError } from "../src/auth/oidc-client.js";
|
||||||
import type { LoadedAuthConfig } from "../src/auth/types.js";
|
import type { LoadedAuthConfig } from "../src/auth/types.js";
|
||||||
|
|
||||||
const sentinels = [
|
const sentinels = [
|
||||||
"oidc-client-secret-UNIQUE-7P3", "authentik-api-token-UNIQUE-9Q7",
|
"oidc-client-secret-UNIQUE-7P3", "authentik-api-token-UNIQUE-9Q7",
|
||||||
"cookie-UNIQUE-5M1", "$argon2id$v=19$password-hash-UNIQUE-2T8", "/private/path-UNIQUE-4K6",
|
"cookie-UNIQUE-5M1", "$argon2id$v=19$password-hash-UNIQUE-2T8", "/private/path-UNIQUE-4K6",
|
||||||
];
|
];
|
||||||
|
const roots: string[] = [];
|
||||||
|
const acceptSessionRoot = () => undefined;
|
||||||
|
const validPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function privateRoot(): string {
|
||||||
|
const root = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-diagnostics-"));
|
||||||
|
chmodSync(root, 0o700);
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
function oidcConfig(): LoadedAuthConfig {
|
function oidcConfig(): LoadedAuthConfig {
|
||||||
return {
|
return {
|
||||||
@@ -21,40 +41,64 @@ function oidcConfig(): LoadedAuthConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localConfig(sourcePath: string): LoadedAuthConfig {
|
||||||
|
return {
|
||||||
|
revision: "b".repeat(64), sourcePath,
|
||||||
|
value: {
|
||||||
|
version: 1, mode: "local", publicUrl: "http://127.0.0.1:8080",
|
||||||
|
session: { regularTtlSeconds: 1, regularIdleSeconds: 1, rememberTtlSeconds: 1, rememberIdleSeconds: 1, oidcTtlSeconds: 1 },
|
||||||
|
local: { usersFile: "users.yaml" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function registryYaml(role: "user" | "admin", passwordHash = validPasswordHash): string {
|
||||||
|
return [
|
||||||
|
"version: 1", "users:", " - id: 6ba7b810-9dad-4ed1-80b4-00c04fd430c8",
|
||||||
|
" username: Admin", " displayName: Admin", ` passwordHash: ${passwordHash}`,
|
||||||
|
" roles:", ` - ${role}`, " enabled: true", " authRevision: 1", "",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
test("reports deterministic live OIDC checks and silently ignores unrelated groups", async () => {
|
test("reports deterministic live OIDC checks and silently ignores unrelated groups", async () => {
|
||||||
const oidcDiagnose = vi.fn(async () => undefined);
|
const oidcDiagnose = vi.fn(async () => undefined);
|
||||||
const groupCatalog = { verifyConfiguredGroups: vi.fn(async (names: readonly string[]) => {
|
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
|
||||||
expect(names).toEqual(["TOT Admin", "TOT Users"]);
|
const requested = new URL(String(input)).searchParams.get("name");
|
||||||
return [];
|
return Response.json({
|
||||||
}) };
|
pagination: { next: null },
|
||||||
|
results: [{ name: requested }, { name: "Unmapped Corporate Group" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const groupCatalog = createAuthentikGroupCatalog({
|
||||||
|
baseUrl: "https://authentik.example.test", apiToken: sentinels[1]!, fetch,
|
||||||
|
});
|
||||||
const report = await createAuthDiagnoser({
|
const report = await createAuthDiagnoser({
|
||||||
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
|
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
|
||||||
|
sessionRootValidator: acceptSessionRoot,
|
||||||
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
||||||
oidcProtocol: { diagnose: oidcDiagnose }, groupCatalog,
|
oidcProtocol: { diagnose: oidcDiagnose }, groupCatalog,
|
||||||
}).inspect({ live: true });
|
}).inspect({ live: true });
|
||||||
|
|
||||||
expect(report).toEqual({ ready: true, mode: "oidc", checks: [expect.objectContaining({ level: "info", code: "auth_ready" })] });
|
expect(report).toEqual({ ready: true, mode: "oidc", checks: [expect.objectContaining({ level: "info", code: "auth_ready" })] });
|
||||||
expect(oidcDiagnose).toHaveBeenCalledOnce();
|
expect(oidcDiagnose).toHaveBeenCalledOnce();
|
||||||
expect(groupCatalog.verifyConfiguredGroups).toHaveBeenCalledOnce();
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetch.mock.calls.map(([input]) => new URL(String(input)).searchParams.get("name")))
|
||||||
|
.toEqual(["TOT Admin", "TOT Users"]);
|
||||||
expect(report.checks).not.toContainEqual(expect.objectContaining({ level: "warning" }));
|
expect(report.checks).not.toContainEqual(expect.objectContaining({ level: "warning" }));
|
||||||
expect(JSON.stringify(report)).not.toContain("Unmapped Corporate Group");
|
expect(JSON.stringify(report)).not.toContain("Unmapped Corporate Group");
|
||||||
|
expect(report.checks.map((item) => item.message).join("\n")).not.toContain("Unmapped Corporate Group");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async () => {
|
test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async () => {
|
||||||
const oidc = createAuthDiagnoser({
|
const oidc = createAuthDiagnoser({
|
||||||
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", secrets: new Map(),
|
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", secrets: new Map(),
|
||||||
|
sessionRootValidator: acceptSessionRoot,
|
||||||
});
|
});
|
||||||
const local = createAuthDiagnoser({
|
const local = createAuthDiagnoser({
|
||||||
authMode: "local", authStateRoot: "/safe/auth-state", secrets: new Map(),
|
authMode: "local", authStateRoot: "/safe/auth-state", secrets: new Map(),
|
||||||
authentication: { current: () => ({
|
sessionRootValidator: acceptSessionRoot,
|
||||||
revision: "b".repeat(64), sourcePath: "/safe/auth.yaml",
|
authentication: { current: () => localConfig("/safe/auth.yaml") },
|
||||||
value: {
|
localUserRegistry: { hasEnabledAdmin: async () => false } as never,
|
||||||
version: 1, mode: "local", publicUrl: "http://127.0.0.1:8080",
|
|
||||||
session: { regularTtlSeconds: 1, regularIdleSeconds: 1, rememberTtlSeconds: 1, rememberIdleSeconds: 1, oidcTtlSeconds: 1 },
|
|
||||||
local: { usersFile: "users.yaml" },
|
|
||||||
},
|
|
||||||
}) },
|
|
||||||
hasEnabledLocalAdmin: async () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(oidc.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [
|
await expect(oidc.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [
|
||||||
@@ -65,9 +109,53 @@ test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async (
|
|||||||
] });
|
] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("distinguishes a valid registry without an enabled admin from a malformed registry", async () => {
|
||||||
|
const validRoot = privateRoot();
|
||||||
|
const validUsers = join(validRoot, "users.yaml");
|
||||||
|
writeFileSync(validUsers, registryYaml("user"), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(validUsers, 0o600);
|
||||||
|
const validReport = await createAuthDiagnoser({
|
||||||
|
authMode: "local", authStateRoot: validRoot,
|
||||||
|
authentication: { current: () => localConfig(join(validRoot, "auth.yaml")) },
|
||||||
|
localUserRegistry: createLocalUserRegistry(validUsers),
|
||||||
|
}).inspect({ live: false });
|
||||||
|
expect(validReport.checks).toEqual([expect.objectContaining({ code: "local_admin_missing" })]);
|
||||||
|
|
||||||
|
const malformedRoot = privateRoot();
|
||||||
|
const malformedUsers = join(malformedRoot, "users.yaml");
|
||||||
|
writeFileSync(malformedUsers, registryYaml("admin", sentinels[3]), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(malformedUsers, 0o600);
|
||||||
|
const malformedReport = await createAuthDiagnoser({
|
||||||
|
authMode: "local", authStateRoot: malformedRoot,
|
||||||
|
authentication: { current: () => localConfig(join(malformedRoot, "auth.yaml")) },
|
||||||
|
localUserRegistry: createLocalUserRegistry(malformedUsers),
|
||||||
|
}).inspect({ live: false });
|
||||||
|
expect(malformedReport.checks).toEqual([expect.objectContaining({ code: "local_user_registry_invalid" })]);
|
||||||
|
expect(JSON.stringify(malformedReport)).not.toContain(sentinels[3]);
|
||||||
|
expect(JSON.stringify(malformedReport)).not.toContain(malformedUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps unsafe auth.yaml storage from the real provider to a redacted config failure", async () => {
|
||||||
|
const root = privateRoot();
|
||||||
|
const unsafePath = join(root, basename(sentinels[4]!));
|
||||||
|
writeFileSync(unsafePath, [
|
||||||
|
"version: 1", "mode: local", "publicUrl: http://127.0.0.1:8080", "local:", " usersFile: users.yaml", "",
|
||||||
|
].join("\n"), { encoding: "utf8", mode: 0o640 });
|
||||||
|
chmodSync(unsafePath, 0o640);
|
||||||
|
const report = await createAuthDiagnoser({
|
||||||
|
authMode: "local", authStateRoot: root,
|
||||||
|
authentication: createAuthenticationConfigProvider(unsafePath),
|
||||||
|
}).inspect({ live: false });
|
||||||
|
|
||||||
|
expect(report.checks).toEqual([expect.objectContaining({ code: "auth_config_invalid" })]);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(unsafePath);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(sentinels[4]);
|
||||||
|
});
|
||||||
|
|
||||||
test("maps OIDC and group catalog failures to only the closed diagnostics code union", async () => {
|
test("maps OIDC and group catalog failures to only the closed diagnostics code union", async () => {
|
||||||
const report = await createAuthDiagnoser({
|
const report = await createAuthDiagnoser({
|
||||||
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
|
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
|
||||||
|
sessionRootValidator: acceptSessionRoot,
|
||||||
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
||||||
oidcProtocol: { diagnose: async () => { throw new Error("issuer unavailable"); } },
|
oidcProtocol: { diagnose: async () => { throw new Error("issuer unavailable"); } },
|
||||||
groupCatalog: { verifyConfiguredGroups: async () => [{ level: "error", code: "oidc_mapped_group_missing", message: "configured group is missing", field: "TOT Users" }] },
|
groupCatalog: { verifyConfiguredGroups: async () => [{ level: "error", code: "oidc_mapped_group_missing", message: "configured group is missing", field: "TOT Users" }] },
|
||||||
@@ -80,6 +168,7 @@ test("maps OIDC and group catalog failures to only the closed diagnostics code u
|
|||||||
test("reports a JWKS validation failure through its closed diagnostic code", async () => {
|
test("reports a JWKS validation failure through its closed diagnostic code", async () => {
|
||||||
const report = await createAuthDiagnoser({
|
const report = await createAuthDiagnoser({
|
||||||
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
|
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
|
||||||
|
sessionRootValidator: acceptSessionRoot,
|
||||||
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
||||||
oidcProtocol: { diagnose: async () => { throw new OidcJwksUnavailableError(); } },
|
oidcProtocol: { diagnose: async () => { throw new OidcJwksUnavailableError(); } },
|
||||||
groupCatalog: { verifyConfiguredGroups: async () => [] },
|
groupCatalog: { verifyConfiguredGroups: async () => [] },
|
||||||
@@ -88,6 +177,40 @@ test("reports a JWKS validation failure through its closed diagnostic code", asy
|
|||||||
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_jwks_unreachable" })]);
|
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_jwks_unreachable" })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps a concrete discovery adapter issuer mismatch to its dedicated code", async () => {
|
||||||
|
const loaded = oidcConfig();
|
||||||
|
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
if (!url.pathname.includes(".well-known")) throw new Error("JWKS must not be requested after issuer mismatch");
|
||||||
|
return Response.json({
|
||||||
|
issuer: "https://different-issuer.example.test",
|
||||||
|
authorization_endpoint: "https://issuer.example.test/authorize",
|
||||||
|
token_endpoint: "https://issuer.example.test/token",
|
||||||
|
jwks_uri: "https://issuer.example.test/jwks",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code"],
|
||||||
|
subject_types_supported: ["public"],
|
||||||
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const oidcProtocol = createOidcProtocol({
|
||||||
|
issuer: loaded.value.mode === "oidc" ? loaded.value.oidc.issuer : "",
|
||||||
|
clientId: "thothii", clientSecret: sentinels[0]!,
|
||||||
|
callbackUrl: "https://thothii.example.test/api/auth/oidc/callback",
|
||||||
|
scopes: ["openid"], groupsClaim: "groups", fetch,
|
||||||
|
});
|
||||||
|
const report = await createAuthDiagnoser({
|
||||||
|
authMode: "oidc", authentication: { current: () => loaded }, authStateRoot: "/safe/auth-state",
|
||||||
|
sessionRootValidator: acceptSessionRoot,
|
||||||
|
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
|
||||||
|
oidcProtocol, groupCatalog: { verifyConfiguredGroups: async () => [] },
|
||||||
|
}).inspect({ live: true });
|
||||||
|
|
||||||
|
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_issuer_mismatch" })]);
|
||||||
|
expect(fetch).toHaveBeenCalledOnce();
|
||||||
|
expect(JSON.stringify(report)).not.toContain("different-issuer");
|
||||||
|
});
|
||||||
|
|
||||||
test("redacts exceptional configuration, registry, protocol, and catalog errors", async () => {
|
test("redacts exceptional configuration, registry, protocol, and catalog errors", async () => {
|
||||||
const detail = sentinels.join(" ");
|
const detail = sentinels.join(" ");
|
||||||
const report = await createAuthDiagnoser({
|
const report = await createAuthDiagnoser({
|
||||||
@@ -100,3 +223,41 @@ test("redacts exceptional configuration, registry, protocol, and catalog errors"
|
|||||||
for (const sentinel of sentinels) expect(rendered).not.toContain(sentinel);
|
for (const sentinel of sentinels) expect(rendered).not.toContain(sentinel);
|
||||||
expect(report.checks.every((check) => check.level === "error" || check.level === "info")).toBe(true);
|
expect(report.checks.every((check) => check.level === "error" || check.level === "info")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.skipIf(process.platform === "win32")("uses the runtime validator for canonical, private session roots", async () => {
|
||||||
|
const valid = privateRoot();
|
||||||
|
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: valid }).inspect({ live: false }))
|
||||||
|
.resolves.toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
|
||||||
|
|
||||||
|
const realRoot = join(privateRoot(), "real-auth");
|
||||||
|
mkdirSync(realRoot, { mode: 0o700 });
|
||||||
|
chmodSync(realRoot, 0o700);
|
||||||
|
const linkedRoot = join(privateRoot(), "linked-auth");
|
||||||
|
symlinkSync(realRoot, linkedRoot);
|
||||||
|
const absent = join(privateRoot(), "absent-auth");
|
||||||
|
const blockedParent = join(privateRoot(), "not-a-directory");
|
||||||
|
writeFileSync(blockedParent, "blocked", { mode: 0o600 });
|
||||||
|
const traversal = `${valid}/../${basename(valid)}`;
|
||||||
|
|
||||||
|
for (const unsafe of [traversal, linkedRoot, absent, join(blockedParent, "auth")]) {
|
||||||
|
const report = await createAuthDiagnoser({ authMode: "none", authStateRoot: unsafe }).inspect({ live: false });
|
||||||
|
expect(report).toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
|
||||||
|
expect(JSON.stringify(report)).not.toContain(unsafe);
|
||||||
|
}
|
||||||
|
|
||||||
|
chmodSync(valid, 0o750);
|
||||||
|
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: valid }).inspect({ live: false }))
|
||||||
|
.resolves.toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a platform storage validator without exposing its root or failure", async () => {
|
||||||
|
const platformRoot = "C:\\private-path-UNIQUE-6R2\\auth";
|
||||||
|
const sessionRootValidator = vi.fn(async () => { throw new Error(`${platformRoot} denied`); });
|
||||||
|
const report = await createAuthDiagnoser({
|
||||||
|
authMode: "none", authStateRoot: platformRoot, sessionRootValidator,
|
||||||
|
}).inspect({ live: false });
|
||||||
|
|
||||||
|
expect(sessionRootValidator).toHaveBeenCalledWith(platformRoot);
|
||||||
|
expect(report.checks).toEqual([expect.objectContaining({ code: "auth_session_store_invalid" })]);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(platformRoot);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const fsHooks = vi.hoisted(() => ({
|
const fsHooks = vi.hoisted(() => ({
|
||||||
@@ -66,6 +66,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
|||||||
import {
|
import {
|
||||||
createFileAuthSessionStore,
|
createFileAuthSessionStore,
|
||||||
deriveCsrfToken,
|
deriveCsrfToken,
|
||||||
|
validateAuthSessionRoot,
|
||||||
type AuthSessionStore,
|
type AuthSessionStore,
|
||||||
type FileAuthSessionStoreOptions,
|
type FileAuthSessionStoreOptions,
|
||||||
type SessionCreateInput,
|
type SessionCreateInput,
|
||||||
@@ -241,6 +242,41 @@ async function isolatedOidcCreator(storageRoot: string, attempts: number): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("file-backed auth session store", () => {
|
describe("file-backed auth session store", () => {
|
||||||
|
test.skipIf(process.platform === "win32")("exports its side-effect-free canonical session-root validator", () => {
|
||||||
|
const valid = root();
|
||||||
|
expect(() => validateAuthSessionRoot(valid)).not.toThrow();
|
||||||
|
|
||||||
|
const outer = root();
|
||||||
|
const realRoot = join(outer, "real-auth");
|
||||||
|
mkdirSync(realRoot, { mode: 0o700 });
|
||||||
|
chmodSync(realRoot, 0o700);
|
||||||
|
const linkedRoot = join(outer, "linked-auth");
|
||||||
|
symlinkSync(realRoot, linkedRoot);
|
||||||
|
const traversal = `${valid}/../${basename(valid)}`;
|
||||||
|
const absent = join(outer, "absent-auth");
|
||||||
|
const fileParent = join(outer, "not-a-directory");
|
||||||
|
writeFileSync(fileParent, "blocked", { mode: 0o600 });
|
||||||
|
|
||||||
|
for (const unsafe of [traversal, linkedRoot, absent, join(fileParent, "auth")]) {
|
||||||
|
expect(() => validateAuthSessionRoot(unsafe)).toThrow("auth_session_store_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
chmodSync(valid, 0o750);
|
||||||
|
expect(() => validateAuthSessionRoot(valid)).toThrow("auth_session_store_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(process.platform === "win32")("rejects a session root owned by another identity", () => {
|
||||||
|
const storageRoot = root();
|
||||||
|
fsHooks.transformLstat = (observed, info) => {
|
||||||
|
if (observed !== storageRoot) return info;
|
||||||
|
const foreign = Object.create(info) as import("node:fs").Stats;
|
||||||
|
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
|
||||||
|
return foreign;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
||||||
const storageRoot = root();
|
const storageRoot = root();
|
||||||
const store = createFileAuthSessionStore(storageRoot);
|
const store = createFileAuthSessionStore(storageRoot);
|
||||||
|
|||||||
@@ -84,6 +84,47 @@ test("refuses declared and streamed group catalog bodies larger than one MiB", a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["malformed", "not-a-number"],
|
||||||
|
["oversized", String(1024 * 1024 + 1)],
|
||||||
|
])("cancels a %s declared-size body without waiting for hanging cancellation", async (_caseName, contentLength) => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
pull() { /* early declared-size rejection must not read */ },
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
return new Promise<void>(() => { /* cancellation remains advisory */ });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const completion = catalog(vi.fn<typeof globalThis.fetch>(async () => new Response(body, {
|
||||||
|
headers: { "content-length": contentLength },
|
||||||
|
}))).verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
||||||
|
|
||||||
|
await expect(Promise.race([
|
||||||
|
completion,
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve("timed-out"), 100)),
|
||||||
|
])).resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(body.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains a rejected declared-size cancellation without an unhandled rejection", async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
pull() { /* early declared-size rejection must not read */ },
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
return Promise.reject(new Error("cancellation-detail-must-stay-contained"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(catalog(vi.fn<typeof globalThis.fetch>(async () => new Response(body, {
|
||||||
|
headers: { "content-length": "invalid" },
|
||||||
|
}))).verifyConfiguredGroups(["TOT Users"], new AbortController().signal))
|
||||||
|
.resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("aborts a hanging request at five seconds", async () => {
|
test("aborts a hanging request at five seconds", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
function authFile(value: unknown): { directory: string; file: string } {
|
function authFile(value: unknown): { directory: string; file: string } {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-"));
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-auth-config-"));
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
const file = join(directory, "auth.yaml");
|
const file = join(directory, "auth.yaml");
|
||||||
writeFileSync(file, stringify(value), "utf8");
|
writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(file, 0o600);
|
||||||
return { directory, file };
|
return { directory, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("loadConfig rejects an auth config path that exists but is not a regular file", () => {
|
test("loadConfig rejects an auth config path that exists but is not a regular file", () => {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-directory-"));
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-auth-config-directory-"));
|
||||||
try {
|
try {
|
||||||
expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory }))
|
expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory }))
|
||||||
.toThrow("authentication configuration is invalid");
|
.toThrow("authentication configuration is invalid");
|
||||||
|
|||||||
@@ -125,6 +125,18 @@ describe("local user registry", () => {
|
|||||||
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports whether a structurally valid registry has an enabled administrator", async () => {
|
||||||
|
const admin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml())).path);
|
||||||
|
const usersOnly = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ role: "user" }))).path);
|
||||||
|
const disabledAdmin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ enabled: false }))).path);
|
||||||
|
|
||||||
|
await expect(admin.hasEnabledAdmin()).resolves.toBe(true);
|
||||||
|
await expect(usersOnly.hasEnabledAdmin()).resolves.toBe(false);
|
||||||
|
await expect(disabledAdmin.hasEnabledAdmin()).resolves.toBe(false);
|
||||||
|
await expectInvalid(usersOnly.findByUsername("admin"));
|
||||||
|
await expectInvalid(disabledAdmin.findBySubject(adminId));
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects a valid registry under a non-private authentication directory", async () => {
|
test("rejects a valid registry under a non-private authentication directory", async () => {
|
||||||
const fixture = writeRegistry(registryYaml(userYaml()));
|
const fixture = writeRegistry(registryYaml(userYaml()));
|
||||||
chmodSync(fixture.root, 0o750);
|
chmodSync(fixture.root, 0o750);
|
||||||
@@ -151,7 +163,6 @@ describe("local user registry", () => {
|
|||||||
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
||||||
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
||||||
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
||||||
["no enabled admin", registryYaml(userYaml({ role: "user" }))],
|
|
||||||
["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))],
|
["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))],
|
||||||
["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))],
|
["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))],
|
||||||
["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))],
|
["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))],
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createSign, generateKeyPairSync } from "node:crypto";
|
import { createSign, generateKeyPairSync } from "node:crypto";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { createOidcProtocol, OidcJwksUnavailableError, OidcProtocolError, OidcProviderUnavailableError } from "../src/auth/oidc-client.js";
|
import {
|
||||||
|
createOidcProtocol,
|
||||||
|
OidcIssuerMismatchError,
|
||||||
|
OidcJwksUnavailableError,
|
||||||
|
OidcProtocolError,
|
||||||
|
OidcProviderUnavailableError,
|
||||||
|
} from "../src/auth/oidc-client.js";
|
||||||
|
|
||||||
const issuer = "https://issuer.example.test";
|
const issuer = "https://issuer.example.test";
|
||||||
const clientId = "thothii";
|
const clientId = "thothii";
|
||||||
@@ -284,8 +290,14 @@ test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", a
|
|||||||
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
|
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
|
||||||
scopes: ["openid"], groupsClaim: "groups",
|
scopes: ["openid"], groupsClaim: "groups",
|
||||||
})).toThrow(OidcProtocolError);
|
})).toThrow(OidcProtocolError);
|
||||||
await expect(protocol({ discoveryIssuer: "https://other.example.test" }).authorizationUrl({ state, nonce, codeVerifier: verifier }))
|
try {
|
||||||
.rejects.toThrow(OidcProtocolError);
|
await protocol({ discoveryIssuer: "https://other.example.test" })
|
||||||
|
.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||||
|
throw new Error("issuer mismatch unexpectedly accepted");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(OidcIssuerMismatchError);
|
||||||
|
expect((error as Error).message).toBe("oidc_issuer_mismatch");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
|
|||||||
Reference in New Issue
Block a user