fix(auth): add Windows session storage bridge
This commit is contained in:
@@ -22,6 +22,10 @@ import { dirname, isAbsolute, join, normalize } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { PrincipalContext } from "./principal.js";
|
||||
import type { AuthSessionRecord, OidcStateRecord, Permission, Role } from "./types.js";
|
||||
import {
|
||||
createWindowsAuthStorageBridge,
|
||||
type WindowsAuthStorageBridge,
|
||||
} from "./windows-auth-storage.js";
|
||||
|
||||
const TOKEN_BYTES = 32;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
@@ -96,6 +100,11 @@ export interface AuthSessionStore {
|
||||
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
|
||||
}
|
||||
|
||||
/** Narrow test seam for the native Windows tht-backed storage adaptor. */
|
||||
export interface FileAuthSessionStoreOptions {
|
||||
windowsStorageBridge?: WindowsAuthStorageBridge;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
@@ -300,10 +309,8 @@ function privateDirectory(path: string): void {
|
||||
}
|
||||
|
||||
function storageDirectories(root: string): StorageDirectories {
|
||||
// Node's chmod is not a Windows DACL boundary. The existing Go operator store has a
|
||||
// CreateFile security-descriptor path, but no equivalent safe Node primitive is available.
|
||||
// Refuse before probing or creating the configured root rather than publishing browser state
|
||||
// with inherited ACLs.
|
||||
// Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this
|
||||
// POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod.
|
||||
if (process.platform === "win32") throw invalid();
|
||||
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|
||||
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
|
||||
@@ -526,6 +533,15 @@ function parseOidcStateRecord(source: string): OidcStateRecord {
|
||||
}
|
||||
}
|
||||
|
||||
function parseWindowsRecord<T>(contents: Buffer, maximumBytes: number, parse: (source: string) => T): T {
|
||||
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > maximumBytes) throw invalid();
|
||||
try {
|
||||
return parse(new TextDecoder("utf-8", { fatal: true }).decode(contents));
|
||||
} catch {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
interface OidcStateClaim {
|
||||
state: TrustedFile<OidcStateRecord>;
|
||||
claimIdentity: FileIdentity;
|
||||
@@ -701,7 +717,20 @@ export function deriveCsrfToken(sessionToken: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFileAuthSessionStore(root: string, validity?: AuthSessionValidity): AuthSessionStore {
|
||||
export function createFileAuthSessionStore(
|
||||
root: string,
|
||||
validity?: AuthSessionValidity,
|
||||
options: FileAuthSessionStoreOptions = {},
|
||||
): AuthSessionStore {
|
||||
const windowsStorage = process.platform === "win32"
|
||||
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
|
||||
: undefined;
|
||||
|
||||
function requiredWindowsStorage(): WindowsAuthStorageBridge {
|
||||
if (windowsStorage === undefined) throw invalid();
|
||||
return windowsStorage;
|
||||
}
|
||||
|
||||
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
|
||||
const nowMs = dateMilliseconds(now);
|
||||
let validated: z.infer<typeof sessionInputSchema>;
|
||||
@@ -730,6 +759,16 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
absoluteExpiresAt: isoAt(absoluteExpiresMs),
|
||||
};
|
||||
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
|
||||
if (process.platform === "win32") {
|
||||
const bridge = requiredWindowsStorage();
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const token = randomBytes(TOKEN_BYTES).toString("base64url");
|
||||
if (await bridge.create(root, "sessions", digestFilename(token), contents)) {
|
||||
return { token, csrfToken: deriveCsrfToken(token), record };
|
||||
}
|
||||
}
|
||||
throw invalid();
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const token = randomBytes(TOKEN_BYTES).toString("base64url");
|
||||
@@ -746,6 +785,24 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
const nowMs = dateMilliseconds(now);
|
||||
const filename = digestFilename(token);
|
||||
return withLock(lockKey(root, "sessions", filename), async () => {
|
||||
if (process.platform === "win32") {
|
||||
const bridge = requiredWindowsStorage();
|
||||
const contents = await bridge.read(root, "sessions", filename);
|
||||
if (!contents) return undefined;
|
||||
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||
if (sessionExpired(record, nowMs)) {
|
||||
await bridge.remove(root, "sessions", filename);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
if (await recordIsCurrent(record, validity)) return record;
|
||||
} catch {
|
||||
await bridge.remove(root, "sessions", filename);
|
||||
throw invalid();
|
||||
}
|
||||
await bridge.remove(root, "sessions", filename);
|
||||
return undefined;
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||
if (!trusted) return undefined;
|
||||
@@ -769,6 +826,27 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
const nowMs = dateMilliseconds(now);
|
||||
const filename = digestFilename(token);
|
||||
await withLock(lockKey(root, "sessions", filename), async () => {
|
||||
if (process.platform === "win32") {
|
||||
const bridge = requiredWindowsStorage();
|
||||
const contents = await bridge.read(root, "sessions", filename);
|
||||
if (!contents) return;
|
||||
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||
if (sessionExpired(record, nowMs)) {
|
||||
await bridge.remove(root, "sessions", filename);
|
||||
return;
|
||||
}
|
||||
const lastSeenMs = Date.parse(record.lastSeenAt);
|
||||
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
|
||||
const idleWindowMs = Date.parse(record.idleExpiresAt) - lastSeenMs;
|
||||
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
|
||||
const touched: AuthSessionRecord = {
|
||||
...record,
|
||||
lastSeenAt: isoAt(nowMs),
|
||||
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(record.absoluteExpiresAt))),
|
||||
};
|
||||
await bridge.replace(root, "sessions", filename, serialize(touched, MAX_SESSION_RECORD_BYTES));
|
||||
return;
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||
if (!trusted) return;
|
||||
@@ -798,6 +876,10 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
if (!canonicalRawValue(token)) return;
|
||||
const filename = digestFilename(token);
|
||||
await withLock(lockKey(root, "sessions", filename), async () => {
|
||||
if (process.platform === "win32") {
|
||||
await requiredWindowsStorage().remove(root, "sessions", filename);
|
||||
return;
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
removeTrusted(directories.sessions, filename);
|
||||
});
|
||||
@@ -822,6 +904,14 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
expiresAt: isoAt(expiresMs),
|
||||
};
|
||||
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
|
||||
if (process.platform === "win32") {
|
||||
const bridge = requiredWindowsStorage();
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const state = randomBytes(TOKEN_BYTES).toString("base64url");
|
||||
if (await bridge.create(root, "oidc", digestFilename(state), contents)) return { state, record };
|
||||
}
|
||||
throw invalid();
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const state = randomBytes(TOKEN_BYTES).toString("base64url");
|
||||
@@ -835,6 +925,12 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
const nowMs = dateMilliseconds(now);
|
||||
const filename = digestFilename(state);
|
||||
return withLock(lockKey(root, "oidc", filename), async () => {
|
||||
if (process.platform === "win32") {
|
||||
const contents = await requiredWindowsStorage().claimConsume(root, filename);
|
||||
if (!contents) return undefined;
|
||||
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
||||
return oidcStateExpired(record, nowMs) ? undefined : record;
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
const claim = claimOidcState(directories.oidc, filename);
|
||||
// An installed claim belongs to another process/store instance. Only the process which
|
||||
@@ -851,6 +947,45 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
|
||||
|
||||
async function prune(now = new Date()): Promise<number> {
|
||||
const nowMs = dateMilliseconds(now);
|
||||
if (process.platform === "win32") {
|
||||
const bridge = requiredWindowsStorage();
|
||||
const sessionEntries = await bridge.list(root, "sessions");
|
||||
const oidcEntries = await bridge.list(root, "oidc");
|
||||
let removed = 0;
|
||||
for (const entry of sessionEntries) {
|
||||
if (!DIGEST_FILENAME_PATTERN.test(entry.name)) throw invalid();
|
||||
const contents = await bridge.read(root, "sessions", entry.name);
|
||||
if (!contents) continue;
|
||||
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
|
||||
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", entry.name)) removed += 1;
|
||||
}
|
||||
const stateNames = new Set(oidcEntries.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry.name)).map((entry) => entry.name));
|
||||
const claimEntries = new Map(oidcEntries
|
||||
.filter((entry) => CLAIM_FILENAME_PATTERN.test(entry.name))
|
||||
.map((entry) => [entry.name, entry]));
|
||||
if (stateNames.size + claimEntries.size !== oidcEntries.length) throw invalid();
|
||||
for (const filename of stateNames) {
|
||||
const claim = claimFilename(filename);
|
||||
const contents = claimEntries.has(claim)
|
||||
? await bridge.readClaim(root, filename)
|
||||
: await bridge.read(root, "oidc", filename);
|
||||
if (!contents) continue;
|
||||
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
|
||||
if (oidcStateExpired(record, nowMs)) {
|
||||
const didRemove = claimEntries.has(claim)
|
||||
? await bridge.removeClaim(root, filename)
|
||||
: await bridge.remove(root, "oidc", filename);
|
||||
if (didRemove) removed += 1;
|
||||
}
|
||||
}
|
||||
for (const [claim, entry] of claimEntries) {
|
||||
const filename = `${claim.slice(0, -".claim".length)}.json`;
|
||||
if (stateNames.has(filename)) continue;
|
||||
if (nowMs >= entry.modifiedUnixMs + OIDC_STATE_TTL_MS
|
||||
&& await bridge.remove(root, "oidc", claim)) removed += 1;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
const directories = storageDirectories(root);
|
||||
let removed = 0;
|
||||
const pruneDirectory = async (
|
||||
|
||||
Reference in New Issue
Block a user