Files
marcandClaude Opus 5 1e2375f190 Ask for the picture the way providers actually offer it
Two assumptions, both mine, and either one was enough for an account to end
up with no picture. It was looked for only in the ID token, which a provider
is free to keep lean while holding the rest at userinfo — PocketID does
exactly that — and the endpoint was never asked. Then the image was fetched
with no credentials, while avatars are commonly behind the provider's own
login.

Userinfo is now asked when something is missing, and believed only when its
sub matches the token whose signature was checked. The access token rides
along with the picture request when, and only when, the picture lives at the
provider.

The third fault was the one that cost the time: it failed silently, so there
was no way to tell which of the two it was. Every outcome is a line in the
log now. And the test provider keeps its picture out of the token and behind
its door, so the check passing is evidence for both new paths.

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

232 lines
9.9 KiB
JavaScript

/*
* 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 ACCESS_TOKEN = "test-access-token";
const ONE_PIXEL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
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",
picture: `${issuer}/picture.png`,
};
let misbehave = { wrongKey: false, wrongAudience: false };
const codes = new Map(); // code -> { challenge, nonce, identity }
const idToken = ({ nonce, sub, picture, ...rest }) => {
// `picture` is deliberately left out of the token — it is offered at
// /userinfo instead, which is what a lean provider does and what InkNote
// used never to ask for.
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`,
userinfo_endpoint: `${issuer}/userinfo`,
response_types_supported: ["code"],
id_token_signing_alg_values_supported: ["RS256"],
});
}
/* A picture to be somebody's face — one green pixel, blown up by the
browser. The point is the journey, not the portrait. */
if (url.pathname === "/picture.png") {
// Behind the provider's own door, like a real one's: without the access
// token there is nothing here.
if (req.headers.authorization !== `Bearer ${ACCESS_TOKEN}`) {
return json(res, { error: "unauthorized" }, 401);
}
res.writeHead(200, { "Content-Type": "image/png" });
return res.end(ONE_PIXEL_PNG);
}
/* The rest of what is known about somebody. A provider is free to keep the
picture here rather than in the token, and several do. */
if (url.pathname === "/userinfo") {
if (req.headers.authorization !== `Bearer ${ACCESS_TOKEN}`) {
return json(res, { error: "unauthorized" }, 401);
}
return json(res, { sub: identity.sub, ...(identity.picture ? { picture: identity.picture } : {}) });
}
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: ACCESS_TOKEN,
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));
},
};
}