520 lines
21 KiB
TypeScript
520 lines
21 KiB
TypeScript
import { afterEach, expect, test, vi } 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 } from "../src/app.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
import { LoginFailureLimiter } from "../src/auth/routes.js";
|
|
import { createFixtureAuthStorageBridge, 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 adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
|
const publicUrl = "http://127.0.0.1:8787";
|
|
const cleanups: Array<() => Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
|
});
|
|
|
|
function localConfig(url = publicUrl) {
|
|
return {
|
|
version: 1,
|
|
mode: "local",
|
|
publicUrl: url,
|
|
local: { usersFile: "users.yaml" },
|
|
};
|
|
}
|
|
|
|
function usersYaml(options: { enabled?: boolean; username?: string } = {}): string {
|
|
const users = [
|
|
"version: 1",
|
|
"users:",
|
|
` - id: ${adminId}`,
|
|
` username: ${options.username ?? "Admin"}`,
|
|
" displayName: Local administrator",
|
|
` passwordHash: ${passwordHash}`,
|
|
" roles:",
|
|
" - admin",
|
|
` enabled: ${options.enabled ?? true}`,
|
|
" authRevision: 1",
|
|
];
|
|
if (options.enabled === false) {
|
|
users.push(
|
|
" - id: 6ba7b811-9dad-4ed1-80b4-00c04fd430c8",
|
|
" username: BackupAdmin",
|
|
` passwordHash: ${passwordHash}`,
|
|
" roles:",
|
|
" - admin",
|
|
" enabled: true",
|
|
" authRevision: 1",
|
|
);
|
|
}
|
|
return [...users, ""].join("\n");
|
|
}
|
|
|
|
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
|
const header = response.headers["set-cookie"];
|
|
if (Array.isArray(header)) return header[0] ?? "";
|
|
return header ?? "";
|
|
}
|
|
|
|
function cookiePair(setCookie: string): string {
|
|
return setCookie.split(";", 1)[0] ?? "";
|
|
}
|
|
|
|
async function createLocalApp(options: {
|
|
publicUrl?: string;
|
|
enabled?: boolean;
|
|
stateRoot?: string;
|
|
registry?: {
|
|
findByUsername(username: string): Promise<unknown>;
|
|
findBySubject(subject: string): Promise<unknown>;
|
|
verify(user: unknown, suppliedPassword: string): Promise<boolean>;
|
|
};
|
|
} = {}) {
|
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-routes-"));
|
|
chmodSync(directory, 0o700);
|
|
const authConfigFile = join(directory, "auth.yaml");
|
|
const usersFile = join(directory, "users.yaml");
|
|
const authStateRoot = options.stateRoot ?? join(directory, "auth-state");
|
|
writeFileSync(authConfigFile, stringify(localConfig(options.publicUrl)), { encoding: "utf8", mode: 0o600 });
|
|
writeFileSync(usersFile, usersYaml({ enabled: options.enabled }), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(authConfigFile, 0o600);
|
|
chmodSync(usersFile, 0o600);
|
|
prepareAuthStateRoot(authStateRoot);
|
|
const app = buildApp(loadConfig({
|
|
THT_AUTH_CONFIG_FILE: authConfigFile,
|
|
THT_AUTH_STATE_ROOT: authStateRoot,
|
|
THT_HARNESS_DIR: "/tmp/h",
|
|
}), {
|
|
...(options.registry === undefined ? {} : { localUserRegistry: options.registry }),
|
|
authStorageBridgeForTest: createFixtureAuthStorageBridge(),
|
|
} as any);
|
|
cleanups.push(async () => {
|
|
await app.close();
|
|
rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
return { app, authConfigFile, usersFile, authStateRoot, directory, publicUrl: options.publicUrl ?? publicUrl };
|
|
}
|
|
|
|
async function login(app: Awaited<ReturnType<typeof createLocalApp>>["app"], body: Record<string, unknown> = {}) {
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/auth/local/login",
|
|
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
|
|
payload: { username: "Admin", password, ...body },
|
|
});
|
|
}
|
|
|
|
test("local login sets a non-persistent opaque session cookie and exposes only a safe /me DTO", async () => {
|
|
const { app } = await createLocalApp();
|
|
|
|
const signedIn = await login(app);
|
|
expect(signedIn.statusCode).toBe(200);
|
|
const setCookie = firstSetCookie(signedIn);
|
|
expect(setCookie).toMatch(/^thothii_session=[A-Za-z0-9_-]{43}; /);
|
|
expect(setCookie).toContain("HttpOnly");
|
|
expect(setCookie).toContain("SameSite=Lax");
|
|
expect(setCookie).toContain("Path=/");
|
|
expect(setCookie).not.toMatch(/Max-Age=/i);
|
|
expect(setCookie).not.toContain("Secure");
|
|
|
|
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.json()).toEqual({
|
|
issuer: "local",
|
|
subject: adminId,
|
|
displayName: "Local administrator",
|
|
roles: ["admin"],
|
|
permissions: [
|
|
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
|
"workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage", "auth.diagnostics.read",
|
|
],
|
|
isAdmin: true,
|
|
csrfToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
|
|
session: {
|
|
method: "local",
|
|
remembered: false,
|
|
idleExpiresAt: expect.any(String),
|
|
absoluteExpiresAt: expect.any(String),
|
|
},
|
|
});
|
|
expect(JSON.stringify(me.json())).not.toContain("authConfigRevision");
|
|
expect(JSON.stringify(me.json())).not.toContain("authRevision");
|
|
expect(JSON.stringify(me.json())).not.toContain(cookiePair(setCookie).split("=", 2)[1] ?? "");
|
|
});
|
|
|
|
test("remembered login uses a persistent secure cookie under an HTTPS public URL and survives app recreation", async () => {
|
|
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-remembered-"));
|
|
chmodSync(directory, 0o700);
|
|
const authConfigFile = join(directory, "auth.yaml");
|
|
const usersFile = join(directory, "users.yaml");
|
|
const authStateRoot = join(directory, "auth-state");
|
|
writeFileSync(authConfigFile, stringify(localConfig("https://thothii.example.test")), { encoding: "utf8", mode: 0o600 });
|
|
writeFileSync(usersFile, usersYaml(), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(authConfigFile, 0o600);
|
|
chmodSync(usersFile, 0o600);
|
|
prepareAuthStateRoot(authStateRoot);
|
|
const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" });
|
|
const first = buildApp(config(), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
|
|
try {
|
|
const signedIn = await first.inject({
|
|
method: "POST",
|
|
url: "/auth/local/login",
|
|
headers: { origin: "https://thothii.example.test", "sec-fetch-site": "same-origin" },
|
|
payload: { username: "Admin", password, remember: true },
|
|
});
|
|
const setCookie = firstSetCookie(signedIn);
|
|
expect(signedIn.statusCode).toBe(200);
|
|
expect(setCookie).toContain("Max-Age=2592000");
|
|
expect(setCookie).toContain("Secure");
|
|
await first.close();
|
|
|
|
const restarted = buildApp(config(), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
|
|
cleanups.push(async () => {
|
|
await restarted.close();
|
|
rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
const me = await restarted.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.json()).toMatchObject({ subject: adminId, session: { remembered: true, method: "local" } });
|
|
} catch (error) {
|
|
await first.close();
|
|
rmSync(directory, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
test("unknown, disabled, and wrong-password logins share one generic failure contract", async () => {
|
|
const enabled = await createLocalApp();
|
|
const disabled = await createLocalApp({ enabled: false });
|
|
const attempts = await Promise.all([
|
|
login(enabled.app, { username: "Unknown" }),
|
|
login(disabled.app),
|
|
login(enabled.app, { password: `${password}!` }),
|
|
]);
|
|
|
|
for (const response of attempts) {
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" });
|
|
expect(response.headers["set-cookie"]).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test("invalid password input still reaches the local verifier with a bounded Argon2-safe surrogate", async () => {
|
|
const user = {
|
|
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
|
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
|
};
|
|
const verify = vi.fn(async () => false);
|
|
const { app } = await createLocalApp({
|
|
registry: { findByUsername: async () => user, findBySubject: async () => user, verify },
|
|
});
|
|
|
|
const response = await login(app, { password: "short" });
|
|
expect(response.statusCode).toBe(401);
|
|
const verifierPassword = verify.mock.calls[0]?.[1];
|
|
expect(verifierPassword).not.toBe("short");
|
|
expect(Buffer.byteLength(verifierPassword ?? "", "utf8")).toBeGreaterThanOrEqual(12);
|
|
});
|
|
|
|
test("local login requires the exact configured Origin and same-origin Fetch Metadata", async () => {
|
|
const { app } = await createLocalApp();
|
|
const missingOrigin = await app.inject({ method: "POST", url: "/auth/local/login", payload: { username: "Admin", password } });
|
|
const wrongOrigin = await app.inject({
|
|
method: "POST", url: "/auth/local/login", headers: { origin: "http://127.0.0.1:8788" }, payload: { username: "Admin", password },
|
|
});
|
|
const crossSite = await app.inject({
|
|
method: "POST", url: "/auth/local/login", headers: { origin: publicUrl, "sec-fetch-site": "cross-site" }, payload: { username: "Admin", password },
|
|
});
|
|
|
|
for (const response of [missingOrigin, wrongOrigin, crossSite]) {
|
|
expect(response.statusCode).toBe(403);
|
|
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
|
|
}
|
|
});
|
|
|
|
test("logout revokes the session and clears the cookie with the production attributes", async () => {
|
|
const { app } = await createLocalApp();
|
|
const signedIn = await login(app);
|
|
const setCookie = firstSetCookie(signedIn);
|
|
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
|
|
|
const loggedOut = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/logout",
|
|
headers: {
|
|
cookie: cookiePair(setCookie), origin: publicUrl, "sec-fetch-site": "same-origin",
|
|
"x-thothii-csrf": me.json().csrfToken,
|
|
},
|
|
});
|
|
expect(loggedOut.statusCode).toBe(204);
|
|
const cleared = firstSetCookie(loggedOut);
|
|
expect(cleared).toMatch(/^thothii_session=;/);
|
|
expect(cleared).toContain("Max-Age=0");
|
|
expect(cleared).toContain("HttpOnly");
|
|
expect(cleared).toContain("SameSite=Lax");
|
|
expect(cleared).toContain("Path=/");
|
|
expect(cleared).toContain("Expires=");
|
|
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } })).statusCode).toBe(401);
|
|
});
|
|
|
|
test.each([false, true])(
|
|
"an uppercase HTTPS public URL sets and clears the secure cookie for remembered=%s",
|
|
async (remember) => {
|
|
const configuredPublicUrl = "HTTPS://thothii.example.test";
|
|
const origin = new URL(configuredPublicUrl).origin;
|
|
const { app } = await createLocalApp({ publicUrl: configuredPublicUrl });
|
|
|
|
const signedIn = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/local/login",
|
|
headers: { origin, "sec-fetch-site": "same-origin" },
|
|
payload: { username: "Admin", password, remember },
|
|
});
|
|
const setCookie = firstSetCookie(signedIn);
|
|
expect(signedIn.statusCode).toBe(200);
|
|
expect(setCookie).toContain("Secure");
|
|
if (remember) expect(setCookie).toContain("Max-Age=2592000");
|
|
else expect(setCookie).not.toMatch(/Max-Age=/i);
|
|
|
|
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
|
const loggedOut = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/logout",
|
|
headers: {
|
|
cookie: cookiePair(setCookie),
|
|
origin,
|
|
"sec-fetch-site": "same-origin",
|
|
"x-thothii-csrf": me.json().csrfToken,
|
|
},
|
|
});
|
|
|
|
expect(loggedOut.statusCode).toBe(204);
|
|
expect(firstSetCookie(loggedOut)).toContain("Secure");
|
|
},
|
|
);
|
|
|
|
test("failed logins are limited by normalized username and source address", async () => {
|
|
const user = {
|
|
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
|
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
|
};
|
|
const registry = {
|
|
findByUsername: async () => user,
|
|
findBySubject: async () => user,
|
|
verify: async () => false,
|
|
};
|
|
const { app } = await createLocalApp({ registry });
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const response = await login(app, { username: "aDmIn" });
|
|
expect(response.statusCode).toBe(401);
|
|
}
|
|
const limited = await login(app, { username: "ADMIN" });
|
|
expect(limited.statusCode).toBe(429);
|
|
expect(limited.json()).toEqual({ code: "login_rate_limited", error: "Too many login attempts" });
|
|
|
|
const addressLimited = await createLocalApp({ registry });
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const response = await login(addressLimited.app, { username: `User${attempt}` });
|
|
expect(response.statusCode).toBe(401);
|
|
}
|
|
expect((await login(addressLimited.app, { username: "A-new-username" })).statusCode).toBe(429);
|
|
});
|
|
|
|
test("failed-attempt limiter keeps exact bounded windows without check-time mutation or active-key eviction", () => {
|
|
const now = 1_000_000;
|
|
const limiter = new LoginFailureLimiter({ maximumEntries: 2 });
|
|
|
|
expect(limiter.isLimited("checked-only", "127.0.0.1", now)).toBe(false);
|
|
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true);
|
|
expect(limiter.recordFailure("attacker", "127.0.0.1", now)).toBe(true);
|
|
expect(limiter.recordFailure("flood", "127.0.0.1", now)).toBe(false);
|
|
|
|
for (let attempt = 1; attempt < 10; attempt += 1) {
|
|
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true);
|
|
}
|
|
expect(limiter.isLimited("victim", "127.0.0.1", now)).toBe(true);
|
|
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(false);
|
|
expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000 - 1)).toBe(true);
|
|
expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(false);
|
|
expect(limiter.recordFailure("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(true);
|
|
});
|
|
|
|
test("failed-attempt limiter allows twenty source-address failures, then rejects the twenty-first", () => {
|
|
const limiter = new LoginFailureLimiter();
|
|
const now = 1_000_000;
|
|
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
expect(limiter.recordFailure(`user-${attempt}`, "127.0.0.1", now)).toBe(true);
|
|
}
|
|
expect(limiter.isLimited("new-user", "127.0.0.1", now)).toBe(true);
|
|
expect(limiter.recordFailure("new-user", "127.0.0.1", now)).toBe(false);
|
|
});
|
|
|
|
test("successful and pre-authentication failures never reset or consume failed-login counters", async () => {
|
|
let calls = 0;
|
|
const user = {
|
|
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
|
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
|
};
|
|
const { app } = await createLocalApp({
|
|
registry: {
|
|
findByUsername: async () => user,
|
|
findBySubject: async () => user,
|
|
verify: async () => {
|
|
calls += 1;
|
|
return calls === 10;
|
|
},
|
|
},
|
|
});
|
|
|
|
const originRejected = await app.inject({
|
|
method: "POST", url: "/auth/local/login", headers: { origin: "http://wrong.example.test" },
|
|
payload: { username: "Admin", password },
|
|
});
|
|
expect(originRejected.statusCode).toBe(403);
|
|
expect(calls).toBe(0);
|
|
for (let attempt = 0; attempt < 9; attempt += 1) expect((await login(app)).statusCode).toBe(401);
|
|
expect((await login(app)).statusCode).toBe(200);
|
|
expect((await login(app)).statusCode).toBe(401);
|
|
expect((await login(app)).statusCode).toBe(429);
|
|
expect((await login(app)).statusCode).toBe(429);
|
|
});
|
|
|
|
test("only two Argon2 verifications run concurrently and excess login attempts fail immediately", async () => {
|
|
let calls = 0;
|
|
let release!: () => void;
|
|
const blocked = new Promise<void>((resolve) => { release = resolve; });
|
|
let entered!: () => void;
|
|
const twoEntered = new Promise<void>((resolve) => { entered = resolve; });
|
|
const user = {
|
|
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
|
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
|
};
|
|
const registry = {
|
|
findByUsername: async () => user,
|
|
findBySubject: async () => user,
|
|
verify: async () => {
|
|
calls += 1;
|
|
if (calls === 2) entered();
|
|
await blocked;
|
|
return false;
|
|
},
|
|
};
|
|
const { app } = await createLocalApp({ registry });
|
|
const first = login(app);
|
|
const second = login(app);
|
|
await twoEntered;
|
|
const excess = await login(app);
|
|
expect(excess.statusCode).toBe(429);
|
|
expect(calls).toBe(2);
|
|
release();
|
|
expect((await first).statusCode).toBe(401);
|
|
expect((await second).statusCode).toBe(401);
|
|
});
|
|
|
|
test("the real native asynchronous Argon2 verifier holds two permits and releases them after completion", async () => {
|
|
const { app } = await createLocalApp();
|
|
const first = login(app, { password: `${password}!` });
|
|
const second = login(app, { password: `${password}!` });
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
const excess = await login(app, { password: `${password}!` });
|
|
expect(excess.statusCode).toBe(429);
|
|
await expect(first).resolves.toMatchObject({ statusCode: 401 });
|
|
await expect(second).resolves.toMatchObject({ statusCode: 401 });
|
|
await expect(login(app, { password: `${password}!` })).resolves.toMatchObject({ statusCode: 401 });
|
|
});
|
|
|
|
test("a verifier failure is sanitized and releases its concurrency permit", async () => {
|
|
let attempts = 0;
|
|
const user = {
|
|
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
|
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
|
};
|
|
const { app } = await createLocalApp({
|
|
registry: {
|
|
findByUsername: async () => user,
|
|
findBySubject: async () => user,
|
|
verify: async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error("fixture verifier failure");
|
|
return false;
|
|
},
|
|
},
|
|
});
|
|
|
|
const failed = await login(app);
|
|
expect(failed.statusCode).toBe(503);
|
|
expect(failed.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
|
expect((await login(app)).statusCode).toBe(401);
|
|
expect(attempts).toBe(2);
|
|
});
|
|
|
|
test("operational registry failures do dummy work, return 503, and never consume login-failure capacity", async () => {
|
|
let available = false;
|
|
let verificationCalls = 0;
|
|
const user = {
|
|
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
|
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
|
};
|
|
const { app } = await createLocalApp({
|
|
registry: {
|
|
findByUsername: async () => {
|
|
if (!available) throw new Error("registry file is unavailable");
|
|
return user;
|
|
},
|
|
findBySubject: async () => user,
|
|
verify: async () => {
|
|
verificationCalls += 1;
|
|
return false;
|
|
},
|
|
},
|
|
});
|
|
|
|
for (let attempt = 0; attempt < 11; attempt += 1) {
|
|
const response = await login(app);
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
|
}
|
|
expect(verificationCalls).toBe(0);
|
|
|
|
available = true;
|
|
for (let attempt = 0; attempt < 10; attempt += 1) expect((await login(app)).statusCode).toBe(401);
|
|
expect((await login(app)).statusCode).toBe(429);
|
|
});
|
|
|
|
test("operational config failures return 503 and never consume login-failure capacity", async () => {
|
|
const { app, authConfigFile } = await createLocalApp();
|
|
writeFileSync(authConfigFile, "version: 1\nmode: unsupported\n", { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(authConfigFile, 0o600);
|
|
|
|
for (let attempt = 0; attempt < 11; attempt += 1) {
|
|
const response = await login(app);
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
|
}
|
|
|
|
writeFileSync(authConfigFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(authConfigFile, 0o600);
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
expect((await login(app, { password: `${password}!` })).statusCode).toBe(401);
|
|
}
|
|
expect((await login(app, { password: `${password}!` })).statusCode).toBe(429);
|
|
});
|
|
|
|
test("public auth configuration is safe and unavailable OIDC login fails closed", async () => {
|
|
const { app } = await createLocalApp();
|
|
const configuration = await app.inject({ method: "GET", url: "/auth/config" });
|
|
expect(configuration.statusCode).toBe(200);
|
|
expect(configuration.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false });
|
|
expect(JSON.stringify(configuration.json())).not.toContain("users.yaml");
|
|
|
|
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
|
expect(placeholder.statusCode).toBe(503);
|
|
expect(placeholder.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
|
});
|