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>
213 lines
9.0 KiB
JavaScript
213 lines
9.0 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 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, ...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"],
|
|
});
|
|
}
|
|
|
|
/* 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") {
|
|
res.writeHead(200, { "Content-Type": "image/png" });
|
|
return res.end(ONE_PIXEL_PNG);
|
|
}
|
|
|
|
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));
|
|
},
|
|
};
|
|
}
|