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>
This commit is contained in:
1 parent
a2b3f91e00
commit
1e2375f190
6 files changed
+152
-19
No files matched your search
+21
-2
@@ -20,6 +20,7 @@ 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"
|
||||
@@ -89,7 +90,10 @@ export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret
|
||||
let misbehave = { wrongKey: false, wrongAudience: false };
|
||||
const codes = new Map(); // code -> { challenge, nonce, identity }
|
||||
|
||||
const idToken = ({ nonce, sub, ...rest }) => {
|
||||
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);
|
||||
@@ -117,6 +121,7 @@ export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret
|
||||
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"],
|
||||
});
|
||||
@@ -125,10 +130,24 @@ export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret
|
||||
/* 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" }] });
|
||||
@@ -183,7 +202,7 @@ export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret
|
||||
if (computed !== entry.challenge) return json(res, { error: "invalid_grant" }, 400);
|
||||
|
||||
return json(res, {
|
||||
access_token: "not-used-by-inknote",
|
||||
access_token: ACCESS_TOKEN,
|
||||
token_type: "Bearer",
|
||||
expires_in: 300,
|
||||
id_token: idToken({ nonce: entry.nonce, ...entry.identity }),
|
||||
|
||||
Reference in new issue
Block a user