OpenID Connect, off unless four settings are present. It is a second way to the same token the password login mints — every route and the WebSocket still know only a signed JWT — which is why it fits in one file and needs no new dependency: Node imports a JWK itself, so checking an ID token is a signature and five claims. Authorization Code with PKCE, exchanged on the server so the client secret stays there. The browser comes home with a one-time ticket rather than the token, because a token in a redirect URL sits in the history, in the proxy's log and in the referrer of the next request. Somebody the app has not seen gets an account; an account that already exists under that name is linked to the identity rather than duplicated, by subject from then on. Never an administrator. An account made this way has no password here, and the interface stops offering one — but an admin can still set one, which is the way back in if the provider is ever gone. The suite signs in at a provider of its own that signs real RS256 tokens, and holds down the four ways the journey must fail as well as the way it must work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
9.7 KiB
JavaScript
230 lines
9.7 KiB
JavaScript
/* Signing in through an identity provider.
|
|
*
|
|
* The provider is real enough to be worth testing against: it signs a real
|
|
* RS256 token with a key InkNote fetches and checks (e2e/fakeIdp.mjs). So the
|
|
* claims under test are not "a button exists" but:
|
|
*
|
|
* • the whole redirect journey ends in a signed-in app, with an account that
|
|
* did not exist before — and never an administrator;
|
|
* • the same person coming back a second time is the same account, and an
|
|
* account that already existed under that name is linked rather than
|
|
* duplicated;
|
|
* • the token in the URL is a one-time ticket, not the session token, and the
|
|
* address is cleaned behind it;
|
|
* • a refusal at the provider, a token signed with the wrong key and a token
|
|
* addressed to somebody else all end at the sign-in screen, signed out.
|
|
*
|
|
* The last group is the point of the exercise. A sign-in that only works is not
|
|
* evidence of anything.
|
|
*/
|
|
import { BASE, api, check, launch, newPage, report } from "./lib.mjs";
|
|
import { startFakeIdp } from "./fakeIdp.mjs";
|
|
|
|
const ISSUER = "http://idp.test:3011";
|
|
const ADMIN = {
|
|
user: process.env.INKNOTE_ADMIN || "marc",
|
|
pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
|
|
};
|
|
|
|
const unique = Math.floor(Math.random() * 1e6);
|
|
const idp = await startFakeIdp({
|
|
port: 3011,
|
|
issuer: ISSUER,
|
|
clientId: "inknote-test",
|
|
clientSecret: "test-secret",
|
|
});
|
|
|
|
const browser = await launch();
|
|
let adminPage;
|
|
const madeUsers = [];
|
|
try {
|
|
/* An administrator, to look at the accounts this creates and to clean up. */
|
|
adminPage = await newPage(browser);
|
|
await adminPage.goto(BASE, { waitUntil: "networkidle" });
|
|
await adminPage.getByPlaceholder("Username").fill(ADMIN.user);
|
|
await adminPage.getByPlaceholder("Password").fill(ADMIN.pass);
|
|
await adminPage.getByRole("button", { name: "Sign In" }).click();
|
|
await adminPage.waitForSelector("text=Dashboard", { timeout: 20000 });
|
|
|
|
const accounts = async () => (await api(adminPage, "GET", "/admin/users")).body;
|
|
const accountNamed = async (name) => (await accounts()).find((u) => u.username === name);
|
|
|
|
/* ── the offer ── */
|
|
const page = await newPage(browser);
|
|
const seen = [];
|
|
page.on("framenavigated", (f) => f === page.mainFrame() && seen.push(f.url()));
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(800);
|
|
|
|
const methods = await page.evaluate(async () => (await fetch("/api/auth/methods")).json());
|
|
check(methods?.oidc?.enabled === true, "the server says there is a second way in", JSON.stringify(methods));
|
|
check(
|
|
!JSON.stringify(methods).includes("secret") && !JSON.stringify(methods).includes("client"),
|
|
"and says nothing else about it to a stranger",
|
|
JSON.stringify(methods)
|
|
);
|
|
check((await page.locator("[data-oidc-signin]").count()) === 1, "the sign-in screen offers it");
|
|
check(
|
|
(await page.locator("[data-oidc-signin]").innerText()).includes("Test IdP"),
|
|
"under the name it was given",
|
|
await page.locator("[data-oidc-signin]").innerText()
|
|
);
|
|
check((await page.getByPlaceholder("Password").count()) === 1, "and the password form is still there");
|
|
|
|
/* ── someone the app has never seen ── */
|
|
const newcomer = `sso-newcomer-${unique}`;
|
|
await idp.setNext({
|
|
identity: { sub: `subject-new-${unique}`, preferred_username: newcomer, name: "A Newcomer" },
|
|
});
|
|
|
|
await page.locator("[data-oidc-signin]").click();
|
|
await page.waitForLoadState("networkidle");
|
|
check(page.url().startsWith(`${ISSUER}/authorize`), "pressing it leads to the provider", page.url());
|
|
const authorizeUrl = new URL(page.url());
|
|
check(
|
|
authorizeUrl.searchParams.get("code_challenge_method") === "S256" &&
|
|
!!authorizeUrl.searchParams.get("state") &&
|
|
!!authorizeUrl.searchParams.get("nonce"),
|
|
"with PKCE, a state and a nonce",
|
|
authorizeUrl.search.slice(0, 120)
|
|
);
|
|
|
|
await page.locator("[data-idp-approve]").click();
|
|
await page.waitForSelector("text=Dashboard", { timeout: 20000 });
|
|
await page.waitForTimeout(1500);
|
|
check(true, "approving there signs in here");
|
|
|
|
const created = await accountNamed(newcomer);
|
|
madeUsers.push(created?.id);
|
|
check(!!created, "an account was made for them", newcomer);
|
|
check(created?.isAdmin === false, "and it is not an administrator");
|
|
check(created?.signsInWithProvider === true, "and has no password of its own");
|
|
|
|
/* The address is cleaned, and what was in it was a ticket, not the token. */
|
|
const returned = seen.find((u) => u.includes("oidc="));
|
|
check(!!returned, "the provider sent the browser home with something in the URL");
|
|
check(
|
|
!!returned && !returned.includes(await page.evaluate(() => localStorage.getItem("inknote_token"))),
|
|
"which was not the session token"
|
|
);
|
|
check(!page.url().includes("oidc"), "and the address was cleaned afterwards", page.url());
|
|
|
|
/* A session lives in two places — IndexedDB and localStorage — so "signed out
|
|
again" means a browser that never saw this one. Every case below gets one. */
|
|
const signedOutBrowser = () => newPage(browser);
|
|
|
|
/* ── the ticket is single use ── */
|
|
const replay = await signedOutBrowser();
|
|
await replay.goto(returned, { waitUntil: "networkidle" });
|
|
await replay.waitForTimeout(2000);
|
|
check(
|
|
(await replay.getByPlaceholder("Password").count()) === 1,
|
|
"the same ticket a second time is worth nothing",
|
|
replay.url()
|
|
);
|
|
await replay.context().close();
|
|
|
|
/* ── the same person again is the same account ── */
|
|
const again = await signedOutBrowser();
|
|
await again.goto(BASE, { waitUntil: "networkidle" });
|
|
await again.locator("[data-oidc-signin]").click();
|
|
await again.waitForLoadState("networkidle");
|
|
await again.locator("[data-idp-approve]").click();
|
|
await again.waitForSelector("text=Dashboard", { timeout: 20000 });
|
|
await again.waitForTimeout(1200);
|
|
const afterSecond = (await accounts()).filter((u) => u.username.startsWith(`sso-newcomer-${unique}`));
|
|
check(afterSecond.length === 1, "signing in again does not make a second account", `${afterSecond.length}`);
|
|
await again.context().close();
|
|
|
|
/* ── an account that already existed here ── */
|
|
const existing = `sso-existing-${unique}`;
|
|
const madeByAdmin = await api(adminPage, "POST", "/admin/users", {
|
|
username: existing,
|
|
password: "e2epass123",
|
|
displayName: "Already Here",
|
|
});
|
|
madeUsers.push(madeByAdmin.body?.id);
|
|
check(madeByAdmin.status === 201, "an ordinary account exists under that name");
|
|
|
|
await idp.setNext({
|
|
identity: { sub: `subject-existing-${unique}`, preferred_username: existing, name: "Already Here" },
|
|
});
|
|
const second = await signedOutBrowser();
|
|
await second.goto(BASE, { waitUntil: "networkidle" });
|
|
await second.locator("[data-oidc-signin]").click();
|
|
await second.waitForLoadState("networkidle");
|
|
await second.locator("[data-idp-approve]").click();
|
|
await second.waitForSelector("text=Dashboard", { timeout: 20000 });
|
|
await second.waitForTimeout(1200);
|
|
|
|
const linked = (await accounts()).filter((u) => u.username === existing);
|
|
check(linked.length === 1, "the identity is linked to it rather than duplicated", `${linked.length}`);
|
|
check(
|
|
linked[0]?.signsInWithProvider === false,
|
|
"and it keeps the password it already had — one way in does not close the other"
|
|
);
|
|
await second.context().close();
|
|
|
|
/* ── the ways it must fail ── */
|
|
const signedOut = async (page) => (await page.getByPlaceholder("Password").count()) === 1;
|
|
|
|
const denied = await signedOutBrowser();
|
|
await denied.goto(BASE, { waitUntil: "networkidle" });
|
|
await denied.locator("[data-oidc-signin]").click();
|
|
await denied.waitForLoadState("networkidle");
|
|
await denied.locator("[data-idp-deny]").click();
|
|
await denied.waitForTimeout(2000);
|
|
check(await signedOut(denied), "a refusal at the provider ends signed out");
|
|
check(
|
|
(await denied.locator("body").innerText()).includes("did not allow"),
|
|
"and says so on the sign-in screen",
|
|
(await denied.locator("body").innerText()).slice(0, 120)
|
|
);
|
|
await denied.context().close();
|
|
|
|
const refusedBy = async (label, misbehave) => {
|
|
await idp.setNext({
|
|
identity: { sub: `subject-forged-${unique}`, preferred_username: `sso-forged-${unique}`, name: "Forged" },
|
|
misbehave,
|
|
});
|
|
const forged = await signedOutBrowser();
|
|
await forged.goto(BASE, { waitUntil: "networkidle" });
|
|
await forged.locator("[data-oidc-signin]").click();
|
|
await forged.waitForLoadState("networkidle");
|
|
await forged.locator("[data-idp-approve]").click();
|
|
await forged.waitForTimeout(2000);
|
|
check(await signedOut(forged), label);
|
|
check(
|
|
!(await accounts()).some((u) => u.username === `sso-forged-${unique}`),
|
|
`${label} — and no account was made from it`
|
|
);
|
|
await forged.context().close();
|
|
};
|
|
await refusedBy("a token signed with a key the provider does not publish is refused", { wrongKey: true });
|
|
await refusedBy("a token addressed to another client is refused", { wrongAudience: true });
|
|
|
|
/* ── an account made this way has no password ── */
|
|
const tryPassword = await adminPage.evaluate(async (name) => {
|
|
const r = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: name, password: "" }),
|
|
});
|
|
return r.status;
|
|
}, newcomer);
|
|
check(tryPassword === 401, "and cannot be signed into with a password", `${tryPassword}`);
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.stack || e.message);
|
|
} finally {
|
|
/* Tidy up whatever the run made, including the accounts the provider caused. */
|
|
try {
|
|
for (const id of madeUsers.filter(Boolean)) await api(adminPage, "DELETE", `/admin/users/${id}`);
|
|
await adminPage?.context().close();
|
|
} catch {
|
|
/* the report matters more than the cleanup */
|
|
}
|
|
await browser.close();
|
|
await idp.stop();
|
|
process.exit(report());
|
|
}
|