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:
marcandClaude Opus 5 committed 2026-08-21 18:55:19 +02:00
1 parent a2b3f91e00
commit 1e2375f190
6 files changed
+152 -19

No files matched your search

+79 -11
View File
@@ -290,16 +290,50 @@ async function completeLogin({ code, state }) {
}
if (!tokens.id_token) throw fail("token_exchange_failed", "no id_token in the response");
const claims = await verifyIdToken(tokens.id_token, attempt.nonce);
const verified = await verifyIdToken(tokens.id_token, attempt.nonce);
const claims = await withUserInfo(verified, tokens.access_token, doc);
return {
sub: String(claims.sub),
sub: String(verified.sub),
username: claims.preferred_username || null,
email: claims.email || null,
displayName: claims.name || claims.given_name || null,
picture: typeof claims.picture === "string" ? claims.picture : null,
/* Only for fetching a picture the provider keeps behind its own door, and
* only ever sent back to the provider itself. */
accessToken: tokens.access_token || null,
};
}
/**
* What the ID token did not say.
*
* A provider is free to put the bare minimum in the token and keep the rest at
* its userinfo endpoint, and several do — PocketID among them for the picture.
* Asked only when something is actually missing, so a provider that says
* everything up front is not made to answer a second request per sign-in.
*
* The reply is trusted only after its `sub` matches the token's: userinfo is an
* ordinary API answer, and the token is the thing whose signature was checked.
*/
async function withUserInfo(claims, accessToken, doc) {
const complete = claims.preferred_username && claims.name && claims.picture;
if (complete || !accessToken || !doc.userinfo_endpoint) return claims;
try {
const info = await getJson(doc.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
if (String(info.sub) !== String(claims.sub)) {
console.warn("[oidc] userinfo is about somebody else — ignored");
return claims;
}
// The signed token wins wherever both speak.
return { ...info, ...claims };
} catch (err) {
console.warn("[oidc] userinfo could not be read:", err.message);
return claims;
}
}
/**
* The profile picture, fetched by the server rather than by the browser.
*
@@ -313,21 +347,55 @@ async function completeLogin({ code, state }) {
* answer gives null and the sign-in carries on. A picture is never a reason to
* fail to let somebody in.
*/
async function fetchPicture(url, { maxBytes = 512 * 1024 } = {}) {
if (!url || !/^https?:\/\//i.test(url)) return null;
async function fetchPicture(url, { accessToken = null, maxBytes = 512 * 1024 } = {}) {
const give_up = (why) => {
console.warn(`[oidc] no profile picture: ${why}`);
return null;
};
if (!url) return give_up("the provider sent no picture for this account");
/* Some providers hand over the image itself rather than an address for it. */
const inline = /^data:(image\/[a-z+]+);base64,([\s\S]+)$/i.exec(url.trim());
if (inline) {
const bytes = Buffer.from(inline[2], "base64");
if (!bytes.length || bytes.length > maxBytes) return give_up("the inline picture is empty or too large");
return { bytes, type: inline[1].toLowerCase() };
}
if (!/^https?:\/\//i.test(url)) return give_up(`the picture address is not one we can fetch (${url.slice(0, 40)})`);
/* The token goes with the request only when the picture lives at the provider
* — which is where a picture behind a login lives. Never anywhere else: an
* access token sent to a third party is a credential given away. */
const sameHouse = (() => {
try {
return new URL(url).origin === new URL(ISSUER).origin;
} catch {
return false;
}
})();
const headers = accessToken && sameHouse ? { Authorization: `Bearer ${accessToken}` } : {};
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000), redirect: "follow" });
if (!res.ok) return null;
const res = await fetch(url, { headers, signal: AbortSignal.timeout(4000), redirect: "follow" });
if (!res.ok) {
return give_up(
res.status === 401 || res.status === 403
? `the provider would not hand it over (${res.status})${sameHouse ? "" : " — and it is hosted elsewhere, so no token was sent"}`
: `the provider answered ${res.status}`
);
}
const type = String(res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
if (!/^image\/(png|jpeg|webp|gif)$/.test(type)) return null;
if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(type)) return give_up(`it is not an image we keep (${type || "no content-type"})`);
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) return null;
if (Number.isFinite(declared) && declared > maxBytes) return give_up(`it is larger than ${maxBytes} bytes`);
const bytes = Buffer.from(await res.arrayBuffer());
// The header may have lied or been absent; the bytes are the truth.
if (!bytes.length || bytes.length > maxBytes) return null;
if (!bytes.length) return give_up("the answer was empty");
if (bytes.length > maxBytes) return give_up(`it is larger than ${maxBytes} bytes`);
return { bytes, type };
} catch {
return null;
} catch (err) {
return give_up(`it could not be fetched (${err.message})`);
}
}
+9 -3
View File
@@ -895,7 +895,7 @@ app.get("/api/auth/oidc/callback", async (req, res) => {
try {
const identity = await oidc.completeLogin({ code: req.query.code, state: req.query.state });
const user = accountForIdentity(identity);
await rememberPicture(user.id, identity.picture);
await rememberPicture(user.id, identity.picture, identity.accessToken);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
// The token itself never rides in the URL: a redirect lands in the history,
// in the proxy's log, and in the next request's referrer.
@@ -915,13 +915,14 @@ app.get("/api/auth/oidc/callback", async (req, res) => {
* to the provider anyway. Failure is silence — the account is already signed in
* by the time this runs, and a portrait is not worth a failed sign-in.
*/
async function rememberPicture(userId, url) {
async function rememberPicture(userId, url, accessToken) {
try {
if (!url) {
db.prepare("DELETE FROM user_avatars WHERE user_id = ?").run(userId);
console.log(" ℹ️ the provider sent no picture for this account");
return;
}
const picture = await oidc.fetchPicture(url);
const picture = await oidc.fetchPicture(url, { accessToken });
if (!picture) return; // unreachable or not an image: keep whatever is stored
db.prepare(
`INSERT INTO user_avatars (user_id, bytes, content_type, updated_at)
@@ -930,6 +931,7 @@ async function rememberPicture(userId, url) {
content_type = excluded.content_type,
updated_at = excluded.updated_at`
).run(userId, encryptBuffer(picture.bytes), picture.type);
console.log(` 🖼️ stored a profile picture from the provider (${picture.type}, ${picture.bytes.length} bytes)`);
} catch (err) {
console.warn("[oidc] could not store the profile picture:", err.message);
}
@@ -953,6 +955,10 @@ app.get("/api/users/:id/avatar", authMiddleware, (req, res) => {
res.set("Content-Type", row.content_type);
// Private: it is fetched with a bearer token and belongs to one person.
res.set("Cache-Control", "private, max-age=300");
// It came from somewhere else, and one of the types kept here (SVG) is a
// document when a browser is allowed to think so. It never is.
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.send(decryptBuffer(row.bytes));
} catch (err) {
console.error("[avatar] could not be read:", err.message);