/* 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 `` 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()); }