fix(auth): harden async login boundaries
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { stringify } from "yaml";
|
||||
import { buildApp, type BuildAppDeps } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
export const localPassword = "correct horse battery staple";
|
||||
export const localPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
export const localAdminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
export const localPublicUrl = "http://127.0.0.1:8787";
|
||||
|
||||
export interface LocalAuthFixture {
|
||||
app: FastifyInstance;
|
||||
cookie: string;
|
||||
csrfToken: string;
|
||||
downstreamHits(): number;
|
||||
resetDownstreamHits(): void;
|
||||
sessionHeaders(overrides?: Record<string, string | undefined>): Record<string, string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||
const header = response.headers["set-cookie"];
|
||||
return Array.isArray(header) ? header[0] ?? "" : header ?? "";
|
||||
}
|
||||
|
||||
function cookiePair(setCookie: string): string {
|
||||
return setCookie.split(";", 1)[0] ?? "";
|
||||
}
|
||||
|
||||
/** Creates a production-local app and authenticates through the real login/session boundary. */
|
||||
export async function createLocalAuthFixture(deps?: BuildAppDeps): Promise<LocalAuthFixture> {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-fixture-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const authConfigFile = join(directory, "auth.yaml");
|
||||
const usersFile = join(directory, "users.yaml");
|
||||
writeFileSync(authConfigFile, stringify({
|
||||
version: 1,
|
||||
mode: "local",
|
||||
publicUrl: localPublicUrl,
|
||||
local: { usersFile: "users.yaml" },
|
||||
}), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(usersFile, [
|
||||
"version: 1",
|
||||
"users:",
|
||||
` - id: ${localAdminId}`,
|
||||
" username: Admin",
|
||||
" displayName: Local administrator",
|
||||
` passwordHash: ${localPasswordHash}`,
|
||||
" roles:",
|
||||
" - admin",
|
||||
" enabled: true",
|
||||
" authRevision: 1",
|
||||
"",
|
||||
].join("\n"), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authConfigFile, 0o600);
|
||||
chmodSync(usersFile, 0o600);
|
||||
|
||||
const app = buildApp(loadConfig({
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}), deps);
|
||||
let downstream = 0;
|
||||
app.addHook("preHandler", async () => { downstream += 1; });
|
||||
|
||||
try {
|
||||
const signedIn = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/local/login",
|
||||
headers: { origin: localPublicUrl, "sec-fetch-site": "same-origin" },
|
||||
payload: { username: "Admin", password: localPassword },
|
||||
});
|
||||
if (signedIn.statusCode !== 200) throw new Error("local_auth_fixture_login_failed");
|
||||
const cookie = cookiePair(firstSetCookie(signedIn));
|
||||
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie } });
|
||||
const csrfToken = (me.json() as { csrfToken?: unknown }).csrfToken;
|
||||
if (me.statusCode !== 200 || typeof csrfToken !== "string") throw new Error("local_auth_fixture_session_failed");
|
||||
downstream = 0;
|
||||
|
||||
return {
|
||||
app,
|
||||
cookie,
|
||||
csrfToken,
|
||||
downstreamHits: () => downstream,
|
||||
resetDownstreamHits: () => { downstream = 0; },
|
||||
sessionHeaders(overrides = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
cookie,
|
||||
origin: localPublicUrl,
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-thothii-csrf": csrfToken,
|
||||
};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === undefined) delete headers[key];
|
||||
else headers[key] = value;
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
async close() {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user