A second way in: signing in through an identity provider

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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-18 16:38:24 +02:00
1 parent c86c63922a
commit 43a98b0972
17 files changed
+1271 -44

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
/*
* A provider to sign in at, for the OIDC suite.
*
* Small on purpose: discovery, an authorize page with two buttons, the token
* exchange, and a key set. It is a real OpenID Connect provider in the only
* sense that matters here — it signs a real RS256 ID token with a real key that
* InkNote fetches and checks, so the suite exercises the verification rather
* than stepping around it.
*
* It also does the things a provider should refuse, on request: sign with the
* wrong key, or return a token addressed to somebody else. A check that the
* good case works proves very little on its own.
*
* Bound to every interface, because two different machines talk to it: the
* browser (in this container) and the InkNote server (in another one, reaching
* it through the Docker host). Both must know it under the *same* name, or the
* issuer in the token will not match — see run.sh and docker-compose.dev.yml.
*/
import crypto from "node:crypto";
import http from "node:http";
const b64url = (buf) => Buffer.from(buf).toString("base64url");
const json = (res, body, status = 200) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
/*
* The key this provider signs with, written out rather than generated.
*
* InkNote caches a provider's keys for an hour and only goes back for them when
* it meets a `kid` it does not know — which is right for a real provider, whose
* key does not change between two test runs, and fatal for one that made a new
* key every time: the second run of the day would be refused with a perfectly
* correct "signature does not match".
*
* It is a test key for a test provider on a test port. It signs nothing that
* exists outside this suite.
*/
const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC7T8xvhzB1EVAU
3+A1xOuwCz3OS2ALv9pgpMf8l+Hu6v09Ey3EhxcfGRzh+5c+wLXVD/f7q57VORo3
49fdLUWw9Nb7mKKugEsxOiD3O49jW4C71QILslYxuCkF71JLPFNzVVLiABs+C4zR
PVFmNJIAv27VDiK2w/eEKyNTY0p2776FliQ5U+7VXdM04fqQP0UXjrfcSS2mDBCb
Wa49reQJf7vZy0plyD2dTO/rq2O8UsQoOB+lHYAt4vgjWXdTrf1pcGWJlxO/n/0H
x4XmG0ItUKaD8HTbzO74I8mi/7/9rG2jWP1rK3U1Zozm0X0nhEtlxF7J7rtsFywY
bJ7Zk2hZAgMBAAECggEARdlpHAHm0REGsvIVUxe+6CQZBS/YfikqDzYDma8twoGA
s+DOBWZ2+dLMkXzXlsAYTn6o9AG4tjyYCVV+ruzt+yIidSojKD2RTKcnmeps+xkp
NWImZCQ5vkq1iZAIOSQi3nRn9v0hK471ESXzj9B0zbCVbP/bdBo51+CaSMNTBmyV
0W6kfn+F/ZPh2dJwAnyWyhmAtYItsZUfbh2jrReCY2ttQBnUzfhRXWWsjMpWIRi3
Uxe3Hru0qjHBXDUklchDmy01V/7OCNKvpB7PRgT0OdwYr4ClPDQCx8u7MunGwqL+
UM8NOb+JtC2IUoMtazJUa6EaR+UnkJ6feZzI6LUT/QKBgQD7rRczPaKLn8d4xktl
bKA+yVMvU92Lt7cipB2NNHei2wtNxTlzwmoyE7GdkYvqL0e4xUK0JhI4ho+/sZJh
6FGaRh3um54UXlqBMcidAFqz27VdF34WPFYNLkGv+C758Vi9a2UUTkcxKFkpGasn
Gcg0FwmOJVM5SEzcuSazsKXq8wKBgQC+h5+igKcV3vMNlF5lZ7PrY1DXc65V6n1E
1TtrPn8ikvcGqj84/ZZ4gIjwQjsDu8DISQr/DcITdRpt64YmpCMlv9+0b99P+p5m
0zG1rRxzeSAoQNiZNiVCr1gyNstEZN1c0XYAAMIUukahfRALwrcJHUAkfENwkKUn
baRl7tKagwKBgGRJhfwqLFJc+fx7b+HblkNOsDIkxhx9uaE5KnlxFdc0V93GoG0r
ZGvOAzHZpJpN+tN672if3xTdMfzeAom3LIVAOX8yTZyqOIBQ2TMOyBXxj+SnjtvH
cTbRXuWDDR0V+hmI6P9dber9vAGvs87h7DAtmeYlo3MpB0/RC8YAtyzXAoGADnUi
IuWaSCmTzer9Qrqcx3bQ0ph/mRtjCA8aJCgo44m9Xu8aPwvXGCvAuGJ+MelUVp5c
TEBSAsH3EHI3ysTXueyWYA3BJ0OAM5NBCWUqbIS7cPKCvRWDvEyr55uKzxTMS1fF
mXh1Qbdk1qozxoCu/O0QSfR39Zm0PoquV+HIb1sCgYBOB+7vCS89FeAtsZ0tvqhq
c9mYiRxBBAd1li4tmp3KJW6qY4kceeXSmcTn5WHKeB7qft3fceOGF2UyJAjvllkB
wH6nPXH9qF4cf5PWLg7Mzwpa8t+sVdmVdwn5Sqx/5ms5hXIeCMEfko944KTmJjCN
wBfCzAoruyg8JjnOuMJ1dw==
-----END PRIVATE KEY-----`;
export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret }) {
const real = {
privateKey: crypto.createPrivateKey(TEST_PRIVATE_KEY),
publicKey: crypto.createPublicKey(TEST_PRIVATE_KEY),
};
const other = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); // never published
const kid = "test-key-1";
/* Who the next sign-in is, and what the provider should do wrong this time.
Set by the suite through POST /who before each attempt. */
let identity = { sub: "subject-1", preferred_username: "someone", name: "Some One" };
let misbehave = { wrongKey: false, wrongAudience: false };
const codes = new Map(); // code -> { challenge, nonce, identity }
const idToken = ({ nonce, sub, ...rest }) => {
const key = misbehave.wrongKey ? other.privateKey : real.privateKey;
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(
JSON.stringify({
iss: issuer,
sub,
aud: misbehave.wrongAudience ? "somebody-else" : clientId,
exp: now + 300,
iat: now,
nonce,
...rest,
})
);
const signature = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), key);
return `${header}.${payload}.${b64url(signature)}`;
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, issuer);
if (url.pathname === "/.well-known/openid-configuration") {
return json(res, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ["code"],
id_token_signing_alg_values_supported: ["RS256"],
});
}
if (url.pathname === "/jwks") {
const jwk = real.publicKey.export({ format: "jwk" });
return json(res, { keys: [{ ...jwk, kid, use: "sig", alg: "RS256" }] });
}
/* The suite says who is about to sign in, and how the provider should behave. */
if (url.pathname === "/who" && req.method === "POST") {
const body = await new Promise((resolve) => {
let d = "";
req.on("data", (c) => (d += c));
req.on("end", () => resolve(JSON.parse(d || "{}")));
});
identity = { ...identity, ...(body.identity || {}) };
misbehave = { wrongKey: false, wrongAudience: false, ...(body.misbehave || {}) };
return json(res, { ok: true, identity, misbehave });
}
if (url.pathname === "/authorize") {
const back = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state");
const code = crypto.randomBytes(16).toString("hex");
codes.set(code, {
challenge: url.searchParams.get("code_challenge"),
nonce: url.searchParams.get("nonce"),
identity,
});
// A page with a button rather than an immediate redirect: this is where a
// person types a password or taps a passkey, and the suite should have to
// go through it like one.
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(`<!doctype html><meta charset="utf-8"><title>Test IdP</title>
<body style="font-family:sans-serif;padding:40px">
<h1>Test identity provider</h1>
<p>Signing in as <b>${identity.preferred_username}</b></p>
<a data-idp-approve href="${back}?code=${code}&state=${encodeURIComponent(state)}">Approve</a>
<a data-idp-deny href="${back}?error=access_denied&state=${encodeURIComponent(state)}">Deny</a>
</body>`);
}
if (url.pathname === "/token" && req.method === "POST") {
const body = await new Promise((resolve) => {
let d = "";
req.on("data", (c) => (d += c));
req.on("end", () => resolve(new URLSearchParams(d)));
});
const entry = codes.get(body.get("code"));
codes.delete(body.get("code")); // one exchange per code, like the real thing
if (!entry) return json(res, { error: "invalid_grant" }, 400);
if (body.get("client_secret") !== clientSecret) return json(res, { error: "invalid_client" }, 401);
const verifier = body.get("code_verifier") || "";
const computed = b64url(crypto.createHash("sha256").update(verifier).digest());
if (computed !== entry.challenge) return json(res, { error: "invalid_grant" }, 400);
return json(res, {
access_token: "not-used-by-inknote",
token_type: "Bearer",
expires_in: 300,
id_token: idToken({ nonce: entry.nonce, ...entry.identity }),
});
}
json(res, { error: "not_found" }, 404);
});
await new Promise((resolve) => server.listen(port, "0.0.0.0", resolve));
return {
/** Sets who signs in next, and whether the provider misbehaves. */
async setNext(body) {
const r = await fetch(`${issuer}/who`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.json();
},
async stop() {
await new Promise((resolve) => server.close(resolve));
},
};
}
+229
View File
@@ -0,0 +1,229 @@
/* 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());
}
+2 -1
View File
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin oidc editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
mkdir -p shots
if [ ! -d node_modules ]; then
@@ -32,6 +32,7 @@ for s in "${SUITES[@]}"; do
echo
echo "═══ $s ═══"
docker run --rm --network host \
--add-host idp.test:127.0.0.1 \
-v "$PWD:/w" -v "$PWD/shots:/out" -w /w \
-e INKNOTE_URL="$URL" \
-e INKNOTE_ADMIN="${INKNOTE_ADMIN:-marc}" \