/* * 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(`
Signing in as ${identity.preferred_username}
Approve Deny `); } 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)); }, }; }