Files
InkNote/e2e/oidc.mjs
T
marcandClaude Opus 5 a2b3f91e00 The face from the provider, and whose business a password is
The password tab was hidden for accounts with no password, which is not the
same as accounts that sign in through the provider — an account linked to one
kept its password and therefore kept the tab. That is precisely the account
the person setting this up is using. It goes by "signs in over there" now; the
password it kept is the emergency door, set by an administrator.

And the picture. The provider sends one, the server fetches it at each
sign-in and stores it encrypted in a table beside the users rather than in
them, and serves it back through the API — so the page never talks to the
provider, no third-party cookie is needed for a face to appear, and it
survives the provider being down like everything else here. Fetched once per
account into an object URL, since an img tag cannot carry a bearer token.

The suite's provider now serves a picture too, and the check asks the browser
whether it actually decoded rather than whether the element exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:30:58 +02:00

317 lines
13 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");
/* ── 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,
}));
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());
}