185 lines
7.5 KiB
TypeScript
185 lines
7.5 KiB
TypeScript
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";
|
|
import { prepareAuthStateRoot } from "./auth-test-fixtures.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 authStateRoot = join(directory, "auth-state");
|
|
prepareAuthStateRoot(authStateRoot);
|
|
const config = loadConfig({
|
|
THT_AUTH_CONFIG_FILE: authA,
|
|
THT_AUTH_STATE_ROOT: authStateRoot,
|
|
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);
|
|
});
|