v4: federated sign-in closes the holes a review found — a callback link made from someone else's sign-in signed the visitor in to that account, a password reset left the user's sessions on other instances open, and events could be lost, reordered or delayed. FederatedSignIn keeps the sign-in's state in sessionStorage (utils/auth/federation.ts) and FederationCallback finishes only a sign-in this tab started. A password reset tells the instances the user signed in to, as a change does. The outbox delivers each instance's events in the order they were made, the first that fails holding back the rest, so a late older update no longer undoes a newer one, and delivers to every instance at once, so a dead one delays none of the others; an event queued during a run is sent by it.

Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted.

303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 16:05:11 +00:00
parent c77e173767
commit 66e402bd91
19 changed files with 447 additions and 128 deletions

View File

@@ -14,6 +14,7 @@ import {
test,
} from "../helpers.ts";
import { assert, assertEquals, assertMatch } from "@std/assert";
import { clearInstanceCaches } from "../../remote/gerbeur-instance.ts";
import type {
FederationLoginResponse,
FederationPeer,
@@ -265,6 +266,64 @@ test("federation: the home's events rename, update and sign out its users", asyn
assertEquals((await get("/api/me", { auth })).status, 401);
});
test("federation: a home's events are refused from a blocked home, with an avatar elsewhere, and put off when its keys fail", async () => {
const home = await fakeInstance(HOME);
let keysDown = false;
using net = stubNetwork((url, init) =>
keysDown && url.pathname === "/.well-known/jwks.json"
? new Response("", { status: 503 })
: home.handle(url, init) ?? new Response("", { status: 404 })
);
await trust(HOME);
await signIn(home, ALICE);
const send = async (payload: Record<string, unknown>) =>
(await post("/api/federation/events", {
json: { event: await home.event(ALICE.sub, payload) },
})).status;
// Answered 5xx, so that the home tries again rather than give up.
keysDown = true;
clearInstanceCaches();
assertEquals(await send({ type: "revoke" }), 502);
keysDown = false;
const avatars = net.calls.filter((u) => u.pathname.endsWith("/avatar"))
.length;
assertEquals(
await send({
type: "update",
claims: { ...ALICE, picture: "https://elsewhere.test/a.png" },
}),
400,
);
assertEquals(
net.calls.filter((u) => u.pathname.endsWith("/avatar")).length,
avatars,
);
await trust(HOME, "blocked");
assertEquals(
await send({
type: "update",
claims: { ...ALICE, preferred_username: "mallory" },
}),
403,
);
assertEquals(
(await get<SelfUser>("/api/users/@alice@home.test")).data.username,
"alice@home.test",
);
});
test("federation: an instance that doesn't answer can be blocked, not trusted", async () => {
using _net = network();
assertEquals((await trust("https://down.test")).status, 502);
const blocked = await trust("https://down.test", "blocked");
assertEquals(blocked.status, 200);
assertEquals(blocked.data.status, "blocked");
assertEquals(blocked.data.name, null);
});
test("federation: an account from another instance changes its password, email, avatar and name there", async () => {
const home = await fakeInstance(HOME);
using _net = network(home);

View File

@@ -6,8 +6,10 @@ import {
del,
type FakeInstance,
fakeInstance,
formWithFile,
get,
patch,
PNG,
post,
put,
stubNetwork,
@@ -16,7 +18,15 @@ import {
import { assert, assertEquals } from "@std/assert";
import { createHash } from "node:crypto";
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
import { verifyInstanceToken } from "../../lib/jwt.ts";
import {
createPasswordResetToken,
verifyInstanceToken,
} from "../../lib/jwt.ts";
import {
deliverOutbox,
identityClaims,
} from "../../services/identity-provider-service.ts";
import { getUserById } from "../../services/user-service.ts";
import type {
AuthorizeResponse,
FederationGrant,
@@ -266,3 +276,65 @@ test("identity provider: an event an instance fails to take waits in the outbox"
assert(row);
assertEquals(row.attempts, 1);
});
test("identity provider: a password reset ends the user's sessions where they signed in", async () => {
const fake = await fakeInstance(CLIENT);
using _net = network(fake);
const user = await createUser();
await authorize(user.auth, pkce().challenge);
const token = await createPasswordResetToken(user.id);
db.prepare(
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
assertEquals(
(await put(`/api/password-resets/${token}`, {
json: { newPassword: "reset-password-1" },
})).status,
200,
);
await until(() => fake.received.length === 1);
assertEquals(decodeJwt(fake.received[0]).type, "revoke");
});
test("identity provider: an instance gets its events in order, a failed one holding back the next", async () => {
const fake = await fakeInstance(CLIENT);
let down = true;
using _net = stubNetwork((url, init) =>
down && url.pathname === "/api/federation/events"
? new Response("", { status: 503 })
: fake.handle(url, init) ?? new Response("", { status: 404 })
);
const user = await createUser();
await authorize(user.auth, pkce().challenge);
await patch("/api/me", { auth: user.auth, json: { email: "a@new.test" } });
await until(() =>
!!db.prepare(`SELECT 1 FROM federation_outbox WHERE attempts > 0;`).get()
);
// Up again, but the first event still waits: the second must wait behind.
down = false;
await patch("/api/me", { auth: user.auth, json: { email: "b@new.test" } });
await new Promise((r) => setTimeout(r, 50));
assertEquals(fake.received.length, 0);
db.prepare(`UPDATE federation_outbox SET next_attempt_at = ?;`).run(
new Date().toISOString(),
);
await deliverOutbox();
assertEquals(
fake.received.map((e) => (decodeJwt(e).claims as { email: string }).email),
["a@new.test", "b@new.test"],
);
});
test("identity provider: the avatar's URL changes with the avatar only", async () => {
const user = await createUser();
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG) });
const before = identityClaims(getUserById(user.id)).picture;
assert(before);
await new Promise((r) => setTimeout(r, 5));
await patch("/api/me", { auth: user.auth, json: { description: "hi" } });
assertEquals(identityClaims(getUserById(user.id)).picture, before);
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG) });
assert(identityClaims(getUserById(user.id)).picture !== before);
});