fix(auth): bind current local registry and CORS
This commit is contained in:
+50
-20
@@ -2,7 +2,7 @@ import Fastify, { type FastifyInstance } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ThtRunner } from "./tht/tht-runner.js";
|
||||
@@ -10,8 +10,9 @@ import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||
import { SseHub } from "./sse/sse-hub.js";
|
||||
import { authenticateSession } from "./auth/auth.js";
|
||||
import type { PrincipalContext } from "./auth/principal.js";
|
||||
import { createLocalUserRegistry, type LocalUserRegistry } from "./auth/local-registry.js";
|
||||
import { createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.js";
|
||||
import type { LoadedAuthConfig } from "./auth/types.js";
|
||||
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
|
||||
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.js";
|
||||
import { registerAuthRoutes } from "./auth/routes.js";
|
||||
import { sessionRoutes } from "./routes/sessions.js";
|
||||
import { sqlRoutes } from "./routes/sql.js";
|
||||
@@ -66,10 +67,20 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
installationId: config.workspaceRegistry.installationId,
|
||||
});
|
||||
|
||||
// Allow any origin in dev/e2e; tighten in production via config if needed.
|
||||
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
origin: cookieAuth
|
||||
? (origin, callback) => {
|
||||
try {
|
||||
const allowed = new URL(config.authentication?.current().value.publicUrl ?? "").origin;
|
||||
const requested = origin === undefined ? undefined : new URL(origin).origin;
|
||||
callback(null, requested === allowed ? allowed : false);
|
||||
} catch {
|
||||
callback(null, false);
|
||||
}
|
||||
}
|
||||
: true,
|
||||
credentials: cookieAuth,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
});
|
||||
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
|
||||
@@ -150,22 +161,40 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
|
||||
|
||||
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
|
||||
const loadedAuthentication = config.authentication?.current();
|
||||
const configuredLocalRegistry = loadedAuthentication?.value.mode === "local"
|
||||
? createLocalUserRegistry(join(dirname(loadedAuthentication.sourcePath), loadedAuthentication.value.local.usersFile))
|
||||
const localRegistryResolver = deps?.localUserRegistry === undefined
|
||||
? createCurrentLocalUserRegistryResolver()
|
||||
: undefined;
|
||||
const localUserRegistry = deps?.localUserRegistry ?? configuredLocalRegistry;
|
||||
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
|
||||
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
|
||||
};
|
||||
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
|
||||
? createFileAuthSessionStore(config.authStateRoot, {
|
||||
currentAuthConfigRevision: () => config.authentication?.current().revision ?? "",
|
||||
findLocalUser: async (subject) => {
|
||||
if (config.authentication?.current().value.mode !== "local") return undefined;
|
||||
const user = await localUserRegistry?.findBySubject(subject);
|
||||
return user === undefined ? undefined : {
|
||||
enabled: user.enabled,
|
||||
authRevision: user.authRevision,
|
||||
roles: user.roles,
|
||||
};
|
||||
currentAuthConfigRevision: () => {
|
||||
try {
|
||||
return config.authentication?.current().revision ?? "";
|
||||
} catch {
|
||||
throw new AuthSessionOperationalError();
|
||||
}
|
||||
},
|
||||
currentLocalUser: async (subject) => {
|
||||
try {
|
||||
const loaded = config.authentication?.current();
|
||||
if (!loaded || loaded.value.mode !== "local") return { 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();
|
||||
}
|
||||
},
|
||||
})
|
||||
: undefined);
|
||||
@@ -204,7 +233,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
authMode: config.authMode,
|
||||
authentication: config.authentication,
|
||||
sessionStore: authSessionStore,
|
||||
localUserRegistry,
|
||||
localUserRegistry: deps?.localUserRegistry,
|
||||
resolveLocalUserRegistry,
|
||||
});
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastif
|
||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||
import { rolesToPermissions } from "./config.js";
|
||||
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js";
|
||||
import type { AuthSessionStore } from "./session-store.js";
|
||||
import { AuthSessionOperationalError, type AuthSessionStore } from "./session-store.js";
|
||||
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
|
||||
import { requireSameOriginOrNonBrowser } from "./authorization.js";
|
||||
|
||||
@@ -78,7 +78,10 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
||||
try {
|
||||
session = await deps.sessionStore.resolve(token);
|
||||
if (session) await deps.sessionStore.touch(token);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionOperationalError) {
|
||||
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
}
|
||||
return authenticationRequired(reply);
|
||||
}
|
||||
if (!session) return authenticationRequired(reply);
|
||||
@@ -174,7 +177,11 @@ function singleHeader(value: string | string[] | undefined): string | false | un
|
||||
|
||||
function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean {
|
||||
const origin = singleHeader(request.headers.origin);
|
||||
if (origin !== expectedOrigin) return false;
|
||||
try {
|
||||
if (origin === undefined || origin === false || new URL(origin).origin !== expectedOrigin) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const fetchSite = singleHeader(request.headers["sec-fetch-site"]);
|
||||
return fetchSite === undefined || fetchSite === "same-origin";
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import type { Stats } from "node:fs";
|
||||
import { dirname, isAbsolute, normalize } from "node:path";
|
||||
import { dirname, isAbsolute, join, normalize } from "node:path";
|
||||
import { parseDocument } from "yaml";
|
||||
import { z } from "zod";
|
||||
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
|
||||
import type { Role } from "./types.js";
|
||||
import type { LoadedAuthConfig, Role } from "./types.js";
|
||||
|
||||
const MAX_USERS_YAML_BYTES = 1 << 20;
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/;
|
||||
@@ -37,6 +37,11 @@ export interface LocalUserRegistry {
|
||||
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Keeps only the registry named by the current coherent authentication-config snapshot. */
|
||||
export interface CurrentLocalUserRegistryResolver {
|
||||
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
@@ -253,3 +258,17 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCurrentLocalUserRegistryResolver(): CurrentLocalUserRegistryResolver {
|
||||
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
|
||||
return {
|
||||
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
|
||||
if (loaded.value.mode !== "local") return undefined;
|
||||
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
|
||||
if (current?.usersPath === usersPath) return current.registry;
|
||||
const registry = createLocalUserRegistry(usersPath);
|
||||
current = { usersPath, registry };
|
||||
return registry;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { argon2, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const MAXIMUM_PHC_BYTES = 256;
|
||||
const MAXIMUM_MEMORY_KIB = 256 * 1024;
|
||||
const MAXIMUM_PASSES = 10;
|
||||
const MAXIMUM_PARALLELISM = 4;
|
||||
const MINIMUM_SALT_BYTES = 16;
|
||||
const MAXIMUM_SALT_BYTES = 64;
|
||||
const MINIMUM_KEY_BYTES = 16;
|
||||
const MAXIMUM_KEY_BYTES = 64;
|
||||
const ARGON2_MEMORY_KIB = 65_536;
|
||||
const ARGON2_PASSES = 3;
|
||||
const ARGON2_PARALLELISM = 1;
|
||||
const ARGON2_SALT_BYTES = 16;
|
||||
const ARGON2_KEY_BYTES = 32;
|
||||
const MINIMUM_PASSWORD_BYTES = 12;
|
||||
const MAXIMUM_PASSWORD_BYTES = 1024;
|
||||
|
||||
@@ -19,6 +17,13 @@ interface Argon2Parameters {
|
||||
digest: Buffer;
|
||||
}
|
||||
|
||||
/** Internal-only signal for unavailable native Argon2 work; never expose its cause. */
|
||||
export class LocalPasswordVerificationError extends Error {
|
||||
constructor() {
|
||||
super("local_password_verification_failed");
|
||||
}
|
||||
}
|
||||
|
||||
function parseDecimal(value: string, maximum: number): number | undefined {
|
||||
if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined;
|
||||
const parsed = Number(value);
|
||||
@@ -46,13 +51,13 @@ function parsePHC(encoded: string): Argon2Parameters | undefined {
|
||||
|
||||
const parameterParts = parts[3].split(",");
|
||||
if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined;
|
||||
const memory = parseDecimal(parameterParts[0].slice(2), MAXIMUM_MEMORY_KIB);
|
||||
const passes = parseDecimal(parameterParts[1].slice(2), MAXIMUM_PASSES);
|
||||
const parallelism = parseDecimal(parameterParts[2].slice(2), MAXIMUM_PARALLELISM);
|
||||
if (memory === undefined || memory < 8 || passes === undefined || passes === 0 || parallelism === undefined || parallelism === 0 || memory < 8 * parallelism) return undefined;
|
||||
const memory = parseDecimal(parameterParts[0].slice(2), ARGON2_MEMORY_KIB);
|
||||
const passes = parseDecimal(parameterParts[1].slice(2), ARGON2_PASSES);
|
||||
const parallelism = parseDecimal(parameterParts[2].slice(2), ARGON2_PARALLELISM);
|
||||
if (memory !== ARGON2_MEMORY_KIB || passes !== ARGON2_PASSES || parallelism !== ARGON2_PARALLELISM) return undefined;
|
||||
|
||||
const salt = decodeRawBase64(parts[4], MINIMUM_SALT_BYTES, MAXIMUM_SALT_BYTES);
|
||||
const digest = decodeRawBase64(parts[5], MINIMUM_KEY_BYTES, MAXIMUM_KEY_BYTES);
|
||||
const salt = decodeRawBase64(parts[4], ARGON2_SALT_BYTES, ARGON2_SALT_BYTES);
|
||||
const digest = decodeRawBase64(parts[5], ARGON2_KEY_BYTES, ARGON2_KEY_BYTES);
|
||||
if (!salt || !digest) {
|
||||
salt?.fill(0);
|
||||
digest?.fill(0);
|
||||
@@ -92,6 +97,7 @@ function deriveArgon2(message: Buffer, parameters: Argon2Parameters): Promise<Bu
|
||||
}
|
||||
settled = true;
|
||||
if (error || !derived) {
|
||||
derived?.fill(0);
|
||||
reject(error ?? new Error("argon2_failed"));
|
||||
return;
|
||||
}
|
||||
@@ -135,7 +141,7 @@ export async function verifyPassword(password: string, encoded: string): Promise
|
||||
derived = await deriveArgon2(message, parameters);
|
||||
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
|
||||
} catch {
|
||||
return false;
|
||||
throw new LocalPasswordVerificationError();
|
||||
} finally {
|
||||
message.fill(0);
|
||||
derived?.fill(0);
|
||||
|
||||
+54
-10
@@ -1,12 +1,13 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { AuthenticationConfigProvider } from "./types.js";
|
||||
import type { AuthenticationConfigProvider, LoadedAuthConfig } from "./types.js";
|
||||
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
||||
import type { AuthSessionStore } from "./session-store.js";
|
||||
import { rolesToPermissions } from "./config.js";
|
||||
import { getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
||||
import { requirePermission, isPrincipalContext } from "./authorization.js";
|
||||
import { deriveCsrfToken } from "./csrf.js";
|
||||
import { verifyWithDummy } from "./password.js";
|
||||
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
const REMEMBER_COOKIE_SECONDS = 2_592_000;
|
||||
@@ -18,7 +19,9 @@ export interface AuthRouteDependencies {
|
||||
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
sessionStore?: AuthSessionStore;
|
||||
/** Test-only compatibility seam; production resolves from each loaded config snapshot. */
|
||||
localUserRegistry?: LocalUserRegistry;
|
||||
resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
@@ -90,6 +93,23 @@ class VerificationGate {
|
||||
}
|
||||
}
|
||||
|
||||
async function unavailableAfterDummy(
|
||||
gate: VerificationGate,
|
||||
password: string,
|
||||
reply: FastifyReply,
|
||||
): Promise<FastifyReply> {
|
||||
try {
|
||||
const completed = await gate.run(async () => {
|
||||
await verifyWithDummy(password);
|
||||
return true;
|
||||
});
|
||||
if (completed === undefined) return loginLimited(reply);
|
||||
} catch {
|
||||
// Preserve the sanitized operational outcome below.
|
||||
}
|
||||
return unavailable(reply);
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
|
||||
const limiter = new LoginFailureLimiter();
|
||||
const verificationGate = new VerificationGate();
|
||||
@@ -105,25 +125,29 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
|
||||
app.post("/auth/local/login", async (request, reply) => {
|
||||
const configured = currentLocalConfig(deps);
|
||||
if (!configured || !deps.localUserRegistry || !deps.sessionStore) return unavailable(reply);
|
||||
if (configured.kind === "unavailable") {
|
||||
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
|
||||
}
|
||||
if (configured.kind !== "local" || !deps.sessionStore) return unavailable(reply);
|
||||
const originCheck = requireExactOrigin(request, reply, configured.origin);
|
||||
if (originCheck !== true) return originCheck;
|
||||
|
||||
const payload = loginPayload(request);
|
||||
const safePassword = argon2SafePassword(payload.password);
|
||||
const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
||||
const sourceAddress = boundedAddress(request.ip);
|
||||
if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply);
|
||||
|
||||
let user: LocalUserRecord | undefined;
|
||||
try {
|
||||
if (payload.username.length > 0) user = await deps.localUserRegistry.findByUsername(payload.username);
|
||||
if (payload.username.length > 0) user = await configured.registry.findByUsername(payload.username);
|
||||
} catch {
|
||||
user = undefined;
|
||||
return unavailableAfterDummy(verificationGate, safePassword, reply);
|
||||
}
|
||||
let verified: boolean | undefined;
|
||||
try {
|
||||
verified = await verificationGate.run(async () =>
|
||||
deps.localUserRegistry!.verify(user, argon2SafePassword(payload.password)));
|
||||
configured.registry.verify(user, safePassword));
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
@@ -177,7 +201,18 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
if (!isPrincipalContext(principal)) return principal;
|
||||
const session = request.authSession;
|
||||
const token = request.authSessionToken;
|
||||
if (!session || !token) return unavailable(reply);
|
||||
if (!session || !token) {
|
||||
return {
|
||||
issuer: principal.issuer,
|
||||
subject: principal.subject,
|
||||
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
|
||||
roles: principal.roles,
|
||||
permissions: principal.permissions,
|
||||
isAdmin: principal.isAdmin,
|
||||
csrfToken: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
issuer: principal.issuer,
|
||||
@@ -200,24 +235,33 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
|
||||
});
|
||||
}
|
||||
|
||||
function currentLocalConfig(deps: AuthRouteDependencies): {
|
||||
function currentLocalConfig(deps: AuthRouteDependencies):
|
||||
| {
|
||||
revision: string;
|
||||
origin: string;
|
||||
secure: boolean;
|
||||
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
|
||||
} | undefined {
|
||||
registry: LocalUserRegistry;
|
||||
kind: "local";
|
||||
}
|
||||
| { kind: "not_local" }
|
||||
| { kind: "unavailable" } {
|
||||
try {
|
||||
const loaded = deps.authentication?.current();
|
||||
if (!loaded || loaded.value.mode !== "local") return undefined;
|
||||
if (!loaded || loaded.value.mode !== "local") return { kind: "not_local" };
|
||||
const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry;
|
||||
if (!registry) return { kind: "unavailable" };
|
||||
const url = new URL(loaded.value.publicUrl);
|
||||
return {
|
||||
kind: "local",
|
||||
revision: loaded.revision,
|
||||
origin: url.origin,
|
||||
secure: url.protocol === "https:",
|
||||
session: loaded.value.session,
|
||||
registry,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,13 +81,26 @@ export interface LocalSessionUser {
|
||||
roles: readonly Role[];
|
||||
}
|
||||
|
||||
export interface CurrentLocalSessionUser {
|
||||
revision: string;
|
||||
user: LocalSessionUser | undefined;
|
||||
}
|
||||
|
||||
/** Operational validity-source failures must not masquerade as revoked credentials. */
|
||||
export class AuthSessionOperationalError extends Error {
|
||||
constructor() {
|
||||
super("auth_session_operational_error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The route layer supplies the current installation revision and local-registry lookup.
|
||||
* Supplying this hook makes every resolve an authorization-generation check.
|
||||
*/
|
||||
export interface AuthSessionValidity {
|
||||
currentAuthConfigRevision(): string | Promise<string>;
|
||||
findLocalUser(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
|
||||
findLocalUser?(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
|
||||
currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise<CurrentLocalSessionUser>;
|
||||
}
|
||||
|
||||
export interface AuthSessionStore {
|
||||
@@ -682,10 +695,15 @@ async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionV
|
||||
// A root-only store remains useful for creation/diagnostics, but is intentionally incapable
|
||||
// of authenticating a principal. Task 8 must supply config and local-registry dependencies.
|
||||
if (!validity) return false;
|
||||
if (record.method === "local" && validity.currentLocalUser) {
|
||||
const current = await validity.currentLocalUser(record.subject);
|
||||
return typeof current.revision === "string" && current.revision === record.authConfigRevision
|
||||
&& validLocalUser(current.user, record);
|
||||
}
|
||||
const revision = await validity.currentAuthConfigRevision();
|
||||
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
|
||||
if (record.method !== "local") return true;
|
||||
return validLocalUser(await validity.findLocalUser(record.subject), record);
|
||||
return validity.findLocalUser === undefined ? false : validLocalUser(await validity.findLocalUser(record.subject), record);
|
||||
}
|
||||
|
||||
const locks = new Map<string, Promise<void>>();
|
||||
@@ -799,7 +817,8 @@ export function createFileAuthSessionStore(
|
||||
}
|
||||
try {
|
||||
if (await recordIsCurrent(record, validity)) return record;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionOperationalError) throw error;
|
||||
await bridge.remove(root, "sessions", filename);
|
||||
throw invalid();
|
||||
}
|
||||
@@ -815,7 +834,8 @@ export function createFileAuthSessionStore(
|
||||
}
|
||||
try {
|
||||
if (await recordIsCurrent(trusted.value, validity)) return trusted.value;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionOperationalError) throw error;
|
||||
removeTrusted(directories.sessions, filename, trusted.identity);
|
||||
throw invalid();
|
||||
}
|
||||
|
||||
@@ -698,16 +698,29 @@ export function sessionRoutes(
|
||||
if (!await authorize(principal, id)) return reply.code(404).send({ error: "session not found" });
|
||||
} catch (error) { return lifecycleFailure(reply, error); }
|
||||
const rt = d.mgr.get(id);
|
||||
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
|
||||
// (where @fastify/cors injects headers), so we must set them explicitly here.
|
||||
const origin = (req.headers.origin as string | undefined) ?? "*";
|
||||
// reply.raw.writeHead bypasses Fastify's CORS hook. Only a cookie-authenticated request
|
||||
// from the exact configured public origin receives credentialed SSE CORS headers.
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
||||
let isConfiguredOrigin = false;
|
||||
try {
|
||||
isConfiguredOrigin = req.authPublicOrigin !== undefined
|
||||
&& origin !== undefined
|
||||
&& new URL(origin).origin === req.authPublicOrigin;
|
||||
} catch {
|
||||
isConfiguredOrigin = false;
|
||||
}
|
||||
const corsHeaders = isConfiguredOrigin
|
||||
? {
|
||||
"Access-Control-Allow-Origin": req.authPublicOrigin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
}
|
||||
: {};
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
...corsHeaders,
|
||||
});
|
||||
// Send the handshake immediately. Without this, Node waits for the first event body and
|
||||
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
|
||||
|
||||
Reference in New Issue
Block a user