The suite proved a picture was drawn somewhere on the page. Where it belongs is the account button at the top of the sidebar — the person icon's place — so that is what the check now names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
14 KiB
JavaScript
327 lines
14 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");
|
|
check(created?.linkedToProvider === true, "and is marked as belonging to the provider");
|
|
|
|
/* The picture belongs where the person icon was: in the account button at the
|
|
top of the sidebar. That is the place it is looked for. */
|
|
check(
|
|
(await page.locator('[title="Account"] [data-avatar]').count()) === 1,
|
|
"the account button in the sidebar wears the picture instead of the person icon"
|
|
);
|
|
|
|
/* ── what the two people involved see ──
|
|
* The one signing in: no password tab, because there is no password behind
|
|
* it. The administrator: which accounts these are, without asking anybody. */
|
|
await page.getByTitle("Account").click();
|
|
await page.waitForTimeout(900);
|
|
const tabs = await page.locator("button").allInnerTexts();
|
|
check(
|
|
!(await page.getByRole("button", { name: "Password", exact: true }).count()),
|
|
"the account panel offers no password to change",
|
|
tabs.join(" | ").slice(0, 120)
|
|
);
|
|
check(
|
|
(await page.locator("[data-account-sso]").count()) === 1,
|
|
"and says which provider it signs in through instead"
|
|
);
|
|
check(
|
|
(await page.locator("[data-account-sso]").innerText()).includes("Test IdP"),
|
|
"by name",
|
|
await page.locator("[data-account-sso]").innerText()
|
|
);
|
|
|
|
/* The picture the provider has of them. It is fetched from *this* server with
|
|
a bearer token — an `<img src>` cannot carry one, so the bytes come through
|
|
the API and become an object URL. Which is also why "it is on the page" is
|
|
not enough: ask the browser whether it actually decoded. */
|
|
const drawn = await page.locator("[data-avatar]").first().evaluate((img) => ({
|
|
src: img.currentSrc || img.src,
|
|
width: img.naturalWidth,
|
|
}));
|
|
/* The provider keeps this one out of the token and behind its own door, which
|
|
is what a lean provider does — so this passing means both the second
|
|
question to /userinfo and the token sent with the picture request. */
|
|
check(drawn.width > 0, "the profile picture from the provider is drawn", JSON.stringify(drawn));
|
|
check(
|
|
drawn.src.startsWith("blob:"),
|
|
"from bytes this server handed over, not from the provider's address",
|
|
drawn.src.slice(0, 40)
|
|
);
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(500);
|
|
|
|
await adminPage.getByTitle("Account").click();
|
|
await adminPage.waitForTimeout(700);
|
|
await adminPage.getByRole("button", { name: "Administration" }).click();
|
|
await adminPage.waitForTimeout(1500);
|
|
const ssoRow = adminPage.locator(`text=${newcomer}`).locator("xpath=ancestor::div[1]");
|
|
check(
|
|
(await adminPage.locator("[data-user-sso]").count()) >= 1,
|
|
"the administrator sees which accounts come from the provider",
|
|
`${await adminPage.locator("[data-user-sso]").count()} marked`
|
|
);
|
|
check(
|
|
(await ssoRow.innerText()).includes("SSO"),
|
|
"on the row of the account itself",
|
|
(await ssoRow.innerText()).replace(/\n/g, " ").slice(0, 100)
|
|
);
|
|
check(
|
|
(await adminPage.locator("body").innerText()).includes("Give a password"),
|
|
"and is offered to give it a password rather than to reset one"
|
|
);
|
|
check(
|
|
(await adminPage.locator(`[data-avatar="${created.id}"]`).count()) === 1,
|
|
"and sees their picture beside the name"
|
|
);
|
|
await adminPage.keyboard.press("Escape");
|
|
await adminPage.waitForTimeout(400);
|
|
await adminPage.keyboard.press("Escape");
|
|
await adminPage.waitForTimeout(600);
|
|
|
|
/* 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);
|
|
|
|
/* An account that had a password and was then linked: it keeps the password,
|
|
and still manages it nowhere but through an administrator. */
|
|
await second.getByTitle("Account").click();
|
|
await second.waitForTimeout(900);
|
|
check(
|
|
!(await second.getByRole("button", { name: "Password", exact: true }).count()),
|
|
"an account linked to the provider is offered no password tab either"
|
|
);
|
|
check(
|
|
(await second.locator("[data-account-sso]").innerText()).includes("administrator"),
|
|
"and is told who changes the one it has",
|
|
await second.locator("[data-account-sso]").innerText()
|
|
);
|
|
await second.keyboard.press("Escape");
|
|
await second.waitForTimeout(400);
|
|
|
|
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"
|
|
);
|
|
check(
|
|
linked[0]?.linkedToProvider === true,
|
|
"while still being shown as one that can use the provider"
|
|
);
|
|
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());
|
|
}
|