fix(auth): bind request auth snapshots
This commit is contained in:
+54
-28
@@ -1,4 +1,4 @@
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
@@ -8,11 +8,11 @@ import type { AppConfig } from "./config.js";
|
|||||||
import { ThtRunner } from "./tht/tht-runner.js";
|
import { ThtRunner } from "./tht/tht-runner.js";
|
||||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||||
import { SseHub } from "./sse/sse-hub.js";
|
import { SseHub } from "./sse/sse-hub.js";
|
||||||
import { authenticateSession } from "./auth/auth.js";
|
import { authenticateSession, captureAuthConfigSnapshot, configuredOrigin } from "./auth/auth.js";
|
||||||
import type { PrincipalContext } from "./auth/principal.js";
|
import type { PrincipalContext } from "./auth/principal.js";
|
||||||
import type { LoadedAuthConfig } from "./auth/types.js";
|
import type { LoadedAuthConfig } from "./auth/types.js";
|
||||||
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
|
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
|
||||||
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.js";
|
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore, type AuthSessionValidity } from "./auth/session-store.js";
|
||||||
import { registerAuthRoutes } from "./auth/routes.js";
|
import { registerAuthRoutes } from "./auth/routes.js";
|
||||||
import { sessionRoutes } from "./routes/sessions.js";
|
import { sessionRoutes } from "./routes/sessions.js";
|
||||||
import { sqlRoutes } from "./routes/sql.js";
|
import { sqlRoutes } from "./routes/sql.js";
|
||||||
@@ -56,6 +56,9 @@ export interface AppWithAuthSessionStore extends FastifyInstance {
|
|||||||
|
|
||||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||||
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
|
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
|
||||||
|
app.decorateRequest("authConfigSnapshot", undefined);
|
||||||
|
app.decorateRequest("authConfigSnapshotCaptured", false);
|
||||||
|
app.decorateRequest("authConfigSnapshotUnavailable", false);
|
||||||
const isolatedTestRoot = process.env.VITEST === "true"
|
const isolatedTestRoot = process.env.VITEST === "true"
|
||||||
? join(tmpdir(), `thothii-workspace-secrets-vitest-${process.pid}`)
|
? join(tmpdir(), `thothii-workspace-secrets-vitest-${process.pid}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -69,19 +72,18 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
|
|
||||||
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
|
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: cookieAuth
|
// The delegator runs at CORS's onRequest hook. It owns the one request-scoped config load
|
||||||
? (origin, callback) => {
|
// which subsequent auth hooks and routes consume, including preflights that end here.
|
||||||
try {
|
delegator: (request, callback) => {
|
||||||
const allowed = new URL(config.authentication?.current().value.publicUrl ?? "").origin;
|
const snapshot = captureAuthConfigSnapshot(request, config.authentication);
|
||||||
const requested = origin === undefined ? undefined : new URL(origin).origin;
|
const origin = configuredOrigin(snapshot);
|
||||||
callback(null, requested === allowed ? allowed : false);
|
const snapshotUsesCookies = snapshot?.value.mode === "local" || snapshot?.value.mode === "oidc";
|
||||||
} catch {
|
callback(null, {
|
||||||
callback(null, false);
|
origin: snapshotUsesCookies && origin ? corsOrigin(request, origin) : cookieAuth ? false : true,
|
||||||
}
|
credentials: snapshotUsesCookies,
|
||||||
}
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
: true,
|
});
|
||||||
credentials: cookieAuth,
|
},
|
||||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
||||||
});
|
});
|
||||||
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
|
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
|
||||||
app.register(cookie);
|
app.register(cookie);
|
||||||
@@ -167,6 +169,29 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
|
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
|
||||||
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
|
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
|
||||||
};
|
};
|
||||||
|
const localUserForSnapshot = async (loaded: LoadedAuthConfig, subject: string) => {
|
||||||
|
try {
|
||||||
|
if (loaded.value.mode !== "local") return { revision: loaded.revision, user: undefined };
|
||||||
|
const registry = resolveLocalUserRegistry(loaded);
|
||||||
|
if (!registry) throw new AuthSessionOperationalError();
|
||||||
|
const user = await registry.findBySubject(subject);
|
||||||
|
return {
|
||||||
|
revision: loaded.revision,
|
||||||
|
user: user === undefined ? undefined : {
|
||||||
|
enabled: user.enabled,
|
||||||
|
authRevision: user.authRevision,
|
||||||
|
roles: user.roles,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthSessionOperationalError) throw error;
|
||||||
|
throw new AuthSessionOperationalError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sessionValidityForSnapshot = (loaded: LoadedAuthConfig): AuthSessionValidity => ({
|
||||||
|
currentAuthConfigRevision: () => loaded.revision,
|
||||||
|
currentLocalUser: (subject) => localUserForSnapshot(loaded, subject),
|
||||||
|
});
|
||||||
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
|
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
|
||||||
? createFileAuthSessionStore(config.authStateRoot, {
|
? createFileAuthSessionStore(config.authStateRoot, {
|
||||||
currentAuthConfigRevision: () => {
|
currentAuthConfigRevision: () => {
|
||||||
@@ -179,18 +204,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
currentLocalUser: async (subject) => {
|
currentLocalUser: async (subject) => {
|
||||||
try {
|
try {
|
||||||
const loaded = config.authentication?.current();
|
const loaded = config.authentication?.current();
|
||||||
if (!loaded || loaded.value.mode !== "local") return { revision: "", user: undefined };
|
if (!loaded) return { revision: "", user: undefined };
|
||||||
const registry = resolveLocalUserRegistry(loaded);
|
return await localUserForSnapshot(loaded, subject);
|
||||||
if (!registry) throw new AuthSessionOperationalError();
|
|
||||||
const user = await registry.findBySubject(subject);
|
|
||||||
return {
|
|
||||||
revision: loaded.revision,
|
|
||||||
user: user === undefined ? undefined : {
|
|
||||||
enabled: user.enabled,
|
|
||||||
authRevision: user.authRevision,
|
|
||||||
roles: user.roles,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthSessionOperationalError) throw error;
|
if (error instanceof AuthSessionOperationalError) throw error;
|
||||||
throw new AuthSessionOperationalError();
|
throw new AuthSessionOperationalError();
|
||||||
@@ -204,6 +219,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
publicExposure: config.publicExposure,
|
publicExposure: config.publicExposure,
|
||||||
authentication: config.authentication,
|
authentication: config.authentication,
|
||||||
sessionStore: authSessionStore,
|
sessionStore: authSessionStore,
|
||||||
|
sessionValidityForSnapshot,
|
||||||
});
|
});
|
||||||
app.addHook("preHandler", (req, reply, done) => {
|
app.addHook("preHandler", (req, reply, done) => {
|
||||||
if (isMaintenanceControl(req.url)) {
|
if (isMaintenanceControl(req.url)) {
|
||||||
@@ -284,6 +300,16 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function corsOrigin(request: FastifyRequest, expectedOrigin: string): string | false {
|
||||||
|
const supplied = request.headers.origin;
|
||||||
|
if (typeof supplied !== "string") return false;
|
||||||
|
try {
|
||||||
|
return new URL(supplied).origin === expectedOrigin ? expectedOrigin : false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; }
|
function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; }
|
||||||
function isMaintenanceControl(url: string): boolean {
|
function isMaintenanceControl(url: string): boolean {
|
||||||
return /^\/internal\/maintenance\/(?:activate|deactivate|status)(?:\?|$)/.test(url);
|
return /^\/internal\/maintenance\/(?:activate|deactivate|status)(?:\?|$)/.test(url);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify";
|
import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify";
|
||||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||||
import { rolesToPermissions } from "./config.js";
|
import { rolesToPermissions } from "./config.js";
|
||||||
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js";
|
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord, LoadedAuthConfig } from "./types.js";
|
||||||
import { AuthSessionOperationalError, type AuthSessionStore } from "./session-store.js";
|
import { AuthSessionOperationalError, type AuthSessionStore, type AuthSessionValidity } from "./session-store.js";
|
||||||
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
|
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
|
||||||
import { requireSameOriginOrNonBrowser } from "./authorization.js";
|
import { requireSameOriginOrNonBrowser } from "./authorization.js";
|
||||||
|
|
||||||
@@ -13,6 +13,10 @@ declare module "fastify" {
|
|||||||
/** Internal only: never serialize or write this opaque cookie token to logs. */
|
/** Internal only: never serialize or write this opaque cookie token to logs. */
|
||||||
authSessionToken?: string;
|
authSessionToken?: string;
|
||||||
authPublicOrigin?: string;
|
authPublicOrigin?: string;
|
||||||
|
/** One immutable configuration load for the whole request, including CORS. */
|
||||||
|
authConfigSnapshot?: LoadedAuthConfig;
|
||||||
|
authConfigSnapshotCaptured?: boolean;
|
||||||
|
authConfigSnapshotUnavailable?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +29,22 @@ export interface AuthDependencies {
|
|||||||
publicExposure?: boolean;
|
publicExposure?: boolean;
|
||||||
authentication?: AuthenticationConfigProvider;
|
authentication?: AuthenticationConfigProvider;
|
||||||
sessionStore?: AuthSessionStore;
|
sessionStore?: AuthSessionStore;
|
||||||
|
sessionValidityForSnapshot?: (snapshot: LoadedAuthConfig) => AuthSessionValidity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture the authentication configuration once; CORS calls this before every other hook. */
|
||||||
|
export function captureAuthConfigSnapshot(
|
||||||
|
request: FastifyRequest,
|
||||||
|
authentication: AuthenticationConfigProvider | undefined,
|
||||||
|
): LoadedAuthConfig | undefined {
|
||||||
|
if (request.authConfigSnapshotCaptured) return request.authConfigSnapshot;
|
||||||
|
request.authConfigSnapshotCaptured = true;
|
||||||
|
try {
|
||||||
|
request.authConfigSnapshot = authentication?.current();
|
||||||
|
} catch {
|
||||||
|
request.authConfigSnapshotUnavailable = true;
|
||||||
|
}
|
||||||
|
return request.authConfigSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
|
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
|
||||||
@@ -59,6 +79,7 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
|
||||||
if (isPublicRoute(request)) return;
|
if (isPublicRoute(request)) return;
|
||||||
|
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
@@ -67,8 +88,8 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
|||||||
return requireSameOriginOrNonBrowser(request, reply);
|
return requireSameOriginOrNonBrowser(request, reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin = configuredOrigin(deps.authentication);
|
const origin = configuredOrigin(snapshot);
|
||||||
if (!origin || !deps.sessionStore) {
|
if (!snapshot || !origin || !deps.sessionStore) {
|
||||||
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||||
}
|
}
|
||||||
const token = readSessionCookie(request);
|
const token = readSessionCookie(request);
|
||||||
@@ -76,7 +97,11 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
|||||||
|
|
||||||
let session: AuthSessionRecord | undefined;
|
let session: AuthSessionRecord | undefined;
|
||||||
try {
|
try {
|
||||||
session = await deps.sessionStore.resolve(token);
|
session = await deps.sessionStore.resolve(token, undefined, deps.sessionValidityForSnapshot?.(snapshot));
|
||||||
|
if (session && session.authConfigRevision !== snapshot.revision) {
|
||||||
|
try { await deps.sessionStore.revoke(token); } catch { /* the mismatch remains denied */ }
|
||||||
|
return authenticationRequired(reply);
|
||||||
|
}
|
||||||
if (session) await deps.sessionStore.touch(token);
|
if (session) await deps.sessionStore.touch(token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthSessionOperationalError) {
|
if (error instanceof AuthSessionOperationalError) {
|
||||||
@@ -150,9 +175,9 @@ function csrfFailed(reply: FastifyReply): FastifyReply {
|
|||||||
return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" });
|
return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function configuredOrigin(authentication: AuthenticationConfigProvider | undefined): string | undefined {
|
export function configuredOrigin(snapshot: LoadedAuthConfig | undefined): string | undefined {
|
||||||
try {
|
try {
|
||||||
const publicUrl = authentication?.current().value.publicUrl;
|
const publicUrl = snapshot?.value.publicUrl;
|
||||||
return publicUrl ? new URL(publicUrl).origin : undefined;
|
return publicUrl ? new URL(publicUrl).origin : undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
+13
-15
@@ -4,7 +4,7 @@ import type { AuthenticationConfigProvider, LoadedAuthConfig } from "./types.js"
|
|||||||
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
||||||
import type { AuthSessionStore } from "./session-store.js";
|
import type { AuthSessionStore } from "./session-store.js";
|
||||||
import { rolesToPermissions } from "./config.js";
|
import { rolesToPermissions } from "./config.js";
|
||||||
import { getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
||||||
import { requirePermission, isPrincipalContext } from "./authorization.js";
|
import { requirePermission, isPrincipalContext } from "./authorization.js";
|
||||||
import { deriveCsrfToken } from "./csrf.js";
|
import { deriveCsrfToken } from "./csrf.js";
|
||||||
import { verifyWithDummy } from "./password.js";
|
import { verifyWithDummy } from "./password.js";
|
||||||
@@ -114,17 +114,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
|||||||
const limiter = new LoginFailureLimiter();
|
const limiter = new LoginFailureLimiter();
|
||||||
const verificationGate = new VerificationGate();
|
const verificationGate = new VerificationGate();
|
||||||
|
|
||||||
app.get("/auth/config", async (_request, reply) => {
|
app.get("/auth/config", async (request, reply) => {
|
||||||
try {
|
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
|
||||||
const mode = deps.authentication?.current().value.mode ?? deps.authMode;
|
if (!snapshot) return unavailable(reply);
|
||||||
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
|
const mode = snapshot.value.mode;
|
||||||
} catch {
|
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
|
||||||
return unavailable(reply);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/local/login", async (request, reply) => {
|
app.post("/auth/local/login", async (request, reply) => {
|
||||||
const configured = currentLocalConfig(deps);
|
const configured = currentLocalConfig(captureAuthConfigSnapshot(request, deps.authentication), deps);
|
||||||
if (configured.kind === "unavailable") {
|
if (configured.kind === "unavailable") {
|
||||||
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
|
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
|
||||||
}
|
}
|
||||||
@@ -189,7 +187,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
|||||||
if (!token || !deps.sessionStore) return unavailable(reply);
|
if (!token || !deps.sessionStore) return unavailable(reply);
|
||||||
try {
|
try {
|
||||||
await deps.sessionStore.revoke(token);
|
await deps.sessionStore.revoke(token);
|
||||||
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false));
|
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(request), false));
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch {
|
} catch {
|
||||||
return unavailable(reply);
|
return unavailable(reply);
|
||||||
@@ -235,7 +233,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentLocalConfig(deps: AuthRouteDependencies):
|
function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
|
||||||
| {
|
| {
|
||||||
revision: string;
|
revision: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
@@ -247,8 +245,8 @@ function currentLocalConfig(deps: AuthRouteDependencies):
|
|||||||
| { kind: "not_local" }
|
| { kind: "not_local" }
|
||||||
| { kind: "unavailable" } {
|
| { kind: "unavailable" } {
|
||||||
try {
|
try {
|
||||||
const loaded = deps.authentication?.current();
|
if (!loaded) return { kind: "unavailable" };
|
||||||
if (!loaded || loaded.value.mode !== "local") return { kind: "not_local" };
|
if (loaded.value.mode !== "local") return { kind: "not_local" };
|
||||||
const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry;
|
const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry;
|
||||||
if (!registry) return { kind: "unavailable" };
|
if (!registry) return { kind: "unavailable" };
|
||||||
const url = new URL(loaded.value.publicUrl);
|
const url = new URL(loaded.value.publicUrl);
|
||||||
@@ -265,8 +263,8 @@ function currentLocalConfig(deps: AuthRouteDependencies):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentSecure(deps: AuthRouteDependencies): boolean {
|
function currentSecure(request: FastifyRequest): boolean {
|
||||||
try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; }
|
return request.authConfigSnapshot?.value.publicUrl.startsWith("https:") ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cookieOptions(secure: boolean, remembered: boolean) {
|
function cookieOptions(secure: boolean, remembered: boolean) {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export interface AuthSessionValidity {
|
|||||||
|
|
||||||
export interface AuthSessionStore {
|
export interface AuthSessionStore {
|
||||||
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
|
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
|
||||||
resolve(token: string, now?: Date): Promise<AuthSessionRecord | undefined>;
|
resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise<AuthSessionRecord | undefined>;
|
||||||
touch(token: string, now?: Date): Promise<void>;
|
touch(token: string, now?: Date): Promise<void>;
|
||||||
revoke(token: string): Promise<void>;
|
revoke(token: string): Promise<void>;
|
||||||
prune(now?: Date): Promise<number>;
|
prune(now?: Date): Promise<number>;
|
||||||
@@ -801,7 +801,11 @@ export function createFileAuthSessionStore(
|
|||||||
throw invalid();
|
throw invalid();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSession(token: string, now = new Date()): Promise<AuthSessionRecord | undefined> {
|
async function resolveSession(
|
||||||
|
token: string,
|
||||||
|
now = new Date(),
|
||||||
|
requestValidity = validity,
|
||||||
|
): Promise<AuthSessionRecord | undefined> {
|
||||||
if (!canonicalRawValue(token)) return undefined;
|
if (!canonicalRawValue(token)) return undefined;
|
||||||
const nowMs = dateMilliseconds(now);
|
const nowMs = dateMilliseconds(now);
|
||||||
const filename = digestFilename(token);
|
const filename = digestFilename(token);
|
||||||
@@ -816,7 +820,7 @@ export function createFileAuthSessionStore(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (await recordIsCurrent(record, validity)) return record;
|
if (await recordIsCurrent(record, requestValidity)) return record;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthSessionOperationalError) throw error;
|
if (error instanceof AuthSessionOperationalError) throw error;
|
||||||
await bridge.remove(root, "sessions", filename);
|
await bridge.remove(root, "sessions", filename);
|
||||||
@@ -833,7 +837,7 @@ export function createFileAuthSessionStore(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (await recordIsCurrent(trusted.value, validity)) return trusted.value;
|
if (await recordIsCurrent(trusted.value, requestValidity)) return trusted.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthSessionOperationalError) throw error;
|
if (error instanceof AuthSessionOperationalError) throw error;
|
||||||
removeTrusted(directories.sessions, filename, trusted.identity);
|
removeTrusted(directories.sessions, filename, trusted.identity);
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ describe("local Argon2id password verification", () => {
|
|||||||
expect(isValidPasswordHash(`$${algorithm}$${version}$${parameters}$${salt}$${digest}`)).toBe(true);
|
expect(isValidPasswordHash(`$${algorithm}$${version}$${parameters}$${salt}$${digest}`)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects raw-base64 PHCs with non-zero trailing bits", async () => {
|
||||||
|
const nonCanonicalSalt = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODx$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||||
|
const nonCanonicalDigest = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC5";
|
||||||
|
|
||||||
|
for (const phc of [nonCanonicalSalt, nonCanonicalDigest]) {
|
||||||
|
expect(isValidPasswordHash(phc)).toBe(false);
|
||||||
|
await expect(verifyPassword(vectors[0].password, phc)).resolves.toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("surfaces a sanitized operational error when native Argon2 fails", async () => {
|
test("surfaces a sanitized operational error when native Argon2 fails", async () => {
|
||||||
argon2Spy.mockImplementationOnce((_algorithm, _parameters, callback) => {
|
argon2Spy.mockImplementationOnce((_algorithm, _parameters, callback) => {
|
||||||
callback(new Error("native details must not leave the verifier"));
|
callback(new Error("native details must not leave the verifier"));
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import { buildApp, type AppWithAuthSessionStore } from "../src/app.js";
|
||||||
|
import { loadAuthenticationConfig } from "../src/auth/config.js";
|
||||||
|
import type { AuthenticationConfigProvider, LoadedAuthConfig } from "../src/auth/types.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
const password = "correct horse battery staple";
|
||||||
|
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||||
|
const originA = "http://127.0.0.1:8787";
|
||||||
|
const originB = "http://127.0.0.1:8788";
|
||||||
|
const userA = { id: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", username: "AdminA" };
|
||||||
|
const userB = { id: "6ba7b811-9dad-4ed1-80b4-00c04fd430c8", username: "AdminB" };
|
||||||
|
const cleanups: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function localYaml(publicUrl: string, usersFile: string): string {
|
||||||
|
return stringify({ version: 1, mode: "local", publicUrl, local: { usersFile } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function oidcYaml(publicUrl: string): string {
|
||||||
|
return stringify({
|
||||||
|
version: 1,
|
||||||
|
mode: "oidc",
|
||||||
|
publicUrl,
|
||||||
|
oidc: {
|
||||||
|
issuer: "https://issuer.example.test/application/o/thothii/",
|
||||||
|
clientId: "thothii",
|
||||||
|
clientSecretRef: "THT_OIDC_CLIENT_SECRET",
|
||||||
|
scopes: ["openid"],
|
||||||
|
groupsClaim: "groups",
|
||||||
|
},
|
||||||
|
groupCatalog: {
|
||||||
|
driver: "authentik",
|
||||||
|
baseUrl: "https://issuer.example.test",
|
||||||
|
apiTokenRef: "THT_AUTHENTIK_API_TOKEN",
|
||||||
|
},
|
||||||
|
authorization: { groupRoles: { Users: ["user"], Admins: ["admin"] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function usersYaml(user: typeof userA): string {
|
||||||
|
return [
|
||||||
|
"version: 1", "users:", ` - id: ${user.id}`, ` username: ${user.username}`,
|
||||||
|
` passwordHash: ${passwordHash}`, " roles:", " - admin", " enabled: true", " authRevision: 1", "",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||||
|
const header = response.headers["set-cookie"];
|
||||||
|
return (Array.isArray(header) ? header[0] : header)?.split(";", 1)[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixture(first: "A" | "B", later: "A" | "B" | "oidc") {
|
||||||
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-request-snapshot-"));
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
|
const authA = join(directory, "auth-a.yaml");
|
||||||
|
const authB = join(directory, "auth-b.yaml");
|
||||||
|
const usersA = join(directory, "users-a.yaml");
|
||||||
|
const usersB = join(directory, "users-b.yaml");
|
||||||
|
writeFileSync(usersA, usersYaml(userA), { encoding: "utf8", mode: 0o600 });
|
||||||
|
writeFileSync(usersB, usersYaml(userB), { encoding: "utf8", mode: 0o600 });
|
||||||
|
writeFileSync(authA, localYaml(originA, "users-a.yaml"), { encoding: "utf8", mode: 0o600 });
|
||||||
|
writeFileSync(authB, later === "oidc" ? oidcYaml(originB) : localYaml(originB, "users-b.yaml"), { encoding: "utf8", mode: 0o600 });
|
||||||
|
for (const path of [authA, authB, usersA, usersB]) chmodSync(path, 0o600);
|
||||||
|
|
||||||
|
const snapshots = { A: loadAuthenticationConfig(authA), B: loadAuthenticationConfig(authB) };
|
||||||
|
let calls = 0;
|
||||||
|
const provider: AuthenticationConfigProvider = {
|
||||||
|
current: () => {
|
||||||
|
calls += 1;
|
||||||
|
return calls === 1 ? snapshots[first] : snapshots[later === "oidc" ? "B" : later];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = loadConfig({
|
||||||
|
THT_AUTH_CONFIG_FILE: authA,
|
||||||
|
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
|
||||||
|
THT_HARNESS_DIR: "/tmp/h",
|
||||||
|
});
|
||||||
|
config.authentication = provider;
|
||||||
|
const app = buildApp(config) as AppWithAuthSessionStore;
|
||||||
|
cleanups.push(async () => {
|
||||||
|
await app.close();
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
snapshots,
|
||||||
|
calls: () => calls,
|
||||||
|
resetCalls: () => { calls = 0; },
|
||||||
|
userFor(snapshot: LoadedAuthConfig) {
|
||||||
|
return snapshot.sourcePath === authA ? userA : userB;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ first: "A" as const, later: "B" as const },
|
||||||
|
{ first: "B" as const, later: "A" as const },
|
||||||
|
])("a $later session cannot yield data under the replacement $first CORS snapshot", async ({ first, later }) => {
|
||||||
|
const fixture = await createFixture(first, later);
|
||||||
|
const requestSnapshot = fixture.snapshots[first];
|
||||||
|
const replacementSnapshot = fixture.snapshots[later];
|
||||||
|
const user = fixture.userFor(replacementSnapshot);
|
||||||
|
const created = await fixture.app.thothiiAuthSessionStore?.create({
|
||||||
|
principal: {
|
||||||
|
issuer: "local", subject: user.id, displayName: user.username, roles: ["admin"],
|
||||||
|
permissions: ["session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read"],
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
method: "local",
|
||||||
|
remembered: false,
|
||||||
|
userAuthRevision: 1,
|
||||||
|
authConfigRevision: replacementSnapshot.revision,
|
||||||
|
idleTtlMs: 60_000,
|
||||||
|
absoluteTtlMs: 60_000,
|
||||||
|
});
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
|
||||||
|
fixture.resetCalls();
|
||||||
|
const response = await fixture.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/me",
|
||||||
|
headers: { cookie: `thothii_session=${created?.token}`, origin: requestSnapshot.value.publicUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fixture.calls()).toBe(1);
|
||||||
|
expect(response.headers["access-control-allow-origin"]).toBe(new URL(requestSnapshot.value.publicUrl).origin);
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" });
|
||||||
|
expect(response.body).not.toContain(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ first: "A" as const, later: "B" as const },
|
||||||
|
{ first: "B" as const, later: "A" as const },
|
||||||
|
])("local login uses and stamps its one $first request snapshot despite $later replacement", async ({ first, later }) => {
|
||||||
|
const fixture = await createFixture(first, later);
|
||||||
|
const snapshot = fixture.snapshots[first];
|
||||||
|
const user = fixture.userFor(snapshot);
|
||||||
|
fixture.resetCalls();
|
||||||
|
const response = await fixture.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/local/login",
|
||||||
|
headers: { origin: snapshot.value.publicUrl, "sec-fetch-site": "same-origin" },
|
||||||
|
payload: { username: user.username, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fixture.calls()).toBe(1);
|
||||||
|
const token = firstCookie(response).split("=", 2)[1] ?? "";
|
||||||
|
fixture.resetCalls();
|
||||||
|
const record = await fixture.app.thothiiAuthSessionStore?.resolve(token);
|
||||||
|
expect(record).toMatchObject({ subject: user.id, authConfigRevision: snapshot.revision });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auth config and valid preflight use the same first snapshot when the provider is replaced", async () => {
|
||||||
|
const fixture = await createFixture("A", "oidc");
|
||||||
|
fixture.resetCalls();
|
||||||
|
const preflight = await fixture.app.inject({
|
||||||
|
method: "OPTIONS",
|
||||||
|
url: "/me",
|
||||||
|
headers: { origin: originA, "access-control-request-method": "GET" },
|
||||||
|
});
|
||||||
|
expect(preflight.statusCode).toBe(204);
|
||||||
|
expect(preflight.headers["access-control-allow-origin"]).toBe(originA);
|
||||||
|
expect(fixture.calls()).toBe(1);
|
||||||
|
|
||||||
|
fixture.resetCalls();
|
||||||
|
const response = await fixture.app.inject({ method: "GET", url: "/auth/config", headers: { origin: originA } });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["access-control-allow-origin"]).toBe(originA);
|
||||||
|
expect(response.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false });
|
||||||
|
expect(fixture.calls()).toBe(1);
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
argon2SaltBytes = 16
|
argon2SaltBytes = 16
|
||||||
argon2KeyBytes uint32 = 32
|
argon2KeyBytes uint32 = 32
|
||||||
|
|
||||||
maximumPHCBytes = 256
|
maximumPHCBytes = 256
|
||||||
)
|
)
|
||||||
|
|
||||||
type argon2Parameters struct {
|
type argon2Parameters struct {
|
||||||
@@ -130,8 +130,9 @@ func decodePHCBase64(value string, minimum, maximum int) ([]byte, bool) {
|
|||||||
if value == "" || strings.ContainsRune(value, '=') || len(value) > base64.RawStdEncoding.EncodedLen(maximum) || len(value) < base64.RawStdEncoding.EncodedLen(minimum)-1 {
|
if value == "" || strings.ContainsRune(value, '=') || len(value) > base64.RawStdEncoding.EncodedLen(maximum) || len(value) < base64.RawStdEncoding.EncodedLen(minimum)-1 {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
decoded, err := base64.RawStdEncoding.DecodeString(value)
|
encoding := base64.RawStdEncoding.Strict()
|
||||||
if err != nil || len(decoded) < minimum || len(decoded) > maximum {
|
decoded, err := encoding.DecodeString(value)
|
||||||
|
if err != nil || len(decoded) < minimum || len(decoded) > maximum || encoding.EncodeToString(decoded) != value {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return decoded, true
|
return decoded, true
|
||||||
|
|||||||
@@ -57,14 +57,19 @@ func TestVerifyPasswordRejectsMalformedAndOversizedPHCBeforeHashing(t *testing.T
|
|||||||
"memory too large": "$argon2id$v=19$m=262145,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
"memory too large": "$argon2id$v=19$m=262145,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
||||||
"passes too large": "$argon2id$v=19$m=65536,t=11,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
"passes too large": "$argon2id$v=19$m=65536,t=11,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
||||||
"parallelism too large": "$argon2id$v=19$m=65536,t=3,p=5$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
"parallelism too large": "$argon2id$v=19$m=65536,t=3,p=5$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
||||||
"weak bounded policy": "$argon2id$v=19$m=8,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
"weak bounded policy": "$argon2id$v=19$m=8,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
||||||
"short salt": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0O$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
"short salt": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0O$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
||||||
"long digest": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
"long digest": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"noncanonical salt": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODx$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
|
||||||
|
"noncanonical digest": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC5",
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
if VerifyPassword(password, encoded) {
|
if VerifyPassword(password, encoded) {
|
||||||
t.Fatal("VerifyPassword() accepted an invalid PHC string")
|
t.Fatal("VerifyPassword() accepted an invalid PHC string")
|
||||||
}
|
}
|
||||||
|
if err := validatePasswordHash(encoded); err == nil {
|
||||||
|
t.Fatal("validatePasswordHash() accepted an invalid PHC string")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user