mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The per-account password limit refused every attempt once ten failures had landed in fifteen minutes, counted before the password was checked. Ten guesses from two addresses therefore locked a password-only account, the app's password sign-in included, and repeating them kept it locked for as long as anyone cared to. A place the account has signed in from before is now neither counted nor held back: a remembered browser, a phone presenting the device id a login of this account was issued to, or an address a session or device login of this account came from in the last thirty days. Anywhere else gets five free failures a day and then a wait that doubles with each failure, from thirty seconds to at most fifteen minutes, instead of a wall. The per-IP limit is unchanged, and an unknown identifier is limited the same way on its hash. Under an SSO-only policy, a password sign-in to an account other than the one a pending link names is now refused before the password is checked, so that exception no longer tells right passwords from wrong ones.
201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
/**
|
|
* A single sign-on identity is linked to an existing account only after the
|
|
* account's own credential is proved in the same browser.
|
|
*
|
|
* The callback leaves a sealed pending link (unit-tested in the callback's own
|
|
* suite); this pins the other half against real Postgres: which sign-ins
|
|
* complete it, which do not, and that an SSO-only policy opens the password
|
|
* form for exactly the account the IdP vouched for and nothing else.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-oidc-pending-link-32-bytes-minimum-1234567890";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const { hashPassword } = await import("@/lib/auth/password");
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
|
|
const PASSWORD = "Correct horse battery staple!42";
|
|
const ISSUER = "https://idp.example.test";
|
|
const OIDC_ENV = {
|
|
OIDC_ISSUER_URL: ISSUER,
|
|
OIDC_CLIENT_ID: "client",
|
|
OIDC_CLIENT_SECRET: "secret",
|
|
};
|
|
|
|
function pendingLinkFor(userId: string, expiresInMs = 60_000): void {
|
|
cookieJar.set(
|
|
"hl_oidc_link",
|
|
encrypt(
|
|
JSON.stringify({
|
|
u: userId,
|
|
i: ISSUER,
|
|
s: "idp-sub-1",
|
|
e: Date.now() + expiresInMs,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function passwordLogin(identifier: string, password = PASSWORD) {
|
|
const { POST } = await import("@/app/api/auth/login/route");
|
|
return POST(
|
|
new NextRequest("http://localhost/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ email: identifier, password }),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function linkOf(id: string) {
|
|
return getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id },
|
|
select: { oidcIssuer: true, oidcSub: true },
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
process.env.APP_URL = "http://localhost:3000";
|
|
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
|
const hash = await hashPassword(PASSWORD);
|
|
await getPrismaClient().user.createMany({
|
|
data: [
|
|
{
|
|
id: "acct-a",
|
|
username: "acct-a",
|
|
email: "a@example.test",
|
|
passwordHash: hash,
|
|
onboardingCompletedAt: new Date(),
|
|
},
|
|
{
|
|
id: "acct-b",
|
|
username: "acct-b",
|
|
email: "b@example.test",
|
|
passwordHash: hash,
|
|
onboardingCompletedAt: new Date(),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const key of [...Object.keys(OIDC_ENV), "OIDC_ONLY"]) {
|
|
delete process.env[key];
|
|
}
|
|
});
|
|
|
|
describe("pending single sign-on link", () => {
|
|
it("is completed by a password sign-in to the named account", async () => {
|
|
pendingLinkFor("acct-a");
|
|
const res = await passwordLogin("acct-a");
|
|
expect(res.status).toBe(200);
|
|
expect(await linkOf("acct-a")).toEqual({
|
|
oidcIssuer: ISSUER,
|
|
oidcSub: "idp-sub-1",
|
|
});
|
|
expect(
|
|
await getPrismaClient().auditLog.count({
|
|
where: { userId: "acct-a", action: "auth.oidc.linked" },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("is not completed by a sign-in to a different account", async () => {
|
|
pendingLinkFor("acct-a");
|
|
const res = await passwordLogin("acct-b");
|
|
expect(res.status).toBe(200);
|
|
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
|
expect(await linkOf("acct-b")).toEqual({ oidcIssuer: null, oidcSub: null });
|
|
});
|
|
|
|
it("is not completed by a wrong password", async () => {
|
|
pendingLinkFor("acct-a");
|
|
const res = await passwordLogin("acct-a", "not the password");
|
|
expect(res.status).toBe(401);
|
|
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
|
});
|
|
|
|
it("expires", async () => {
|
|
pendingLinkFor("acct-a", -1);
|
|
await passwordLogin("acct-a");
|
|
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
|
});
|
|
|
|
it("refuses a forged cookie", async () => {
|
|
cookieJar.set(
|
|
"hl_oidc_link",
|
|
JSON.stringify({ u: "acct-a", i: ISSUER, s: "x", e: Date.now() + 1e6 }),
|
|
);
|
|
await passwordLogin("acct-a");
|
|
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
|
});
|
|
});
|
|
|
|
describe("under an SSO-only policy", () => {
|
|
beforeEach(() => {
|
|
Object.assign(process.env, OIDC_ENV, { OIDC_ONLY: "true" });
|
|
});
|
|
|
|
it("keeps password sign-in closed without a pending link", async () => {
|
|
const res = await passwordLogin("acct-a");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("opens it for the account the pending link names, and links it", async () => {
|
|
pendingLinkFor("acct-a");
|
|
const res = await passwordLogin("acct-a");
|
|
expect(res.status).toBe(200);
|
|
expect((await linkOf("acct-a")).oidcSub).toBe("idp-sub-1");
|
|
});
|
|
|
|
it("answers the same for another account's right and wrong password", async () => {
|
|
// Refused before the password is checked, so the exception cannot be
|
|
// used to test passwords of accounts the link does not name.
|
|
pendingLinkFor("acct-a");
|
|
const right = await passwordLogin("acct-b");
|
|
const wrong = await passwordLogin("acct-b", "not the password");
|
|
expect(right.status).toBe(403);
|
|
expect(wrong.status).toBe(403);
|
|
expect(await wrong.json()).toEqual(await right.json());
|
|
});
|
|
|
|
it("keeps it closed for any other account", async () => {
|
|
pendingLinkFor("acct-a");
|
|
const res = await passwordLogin("acct-b");
|
|
expect(res.status).toBe(403);
|
|
expect(
|
|
await getPrismaClient().session.count({ where: { userId: "acct-b" } }),
|
|
).toBe(0);
|
|
});
|
|
});
|