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>
197 lines
8.5 KiB
JavaScript
197 lines
8.5 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 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));
|
|
},
|
|
};
|
|
}
|