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

3
API.md
View File

@@ -221,7 +221,8 @@ key.
an invite.
- `POST /api/federation/sessions` — anyone. Finishes it, as a sign-in.
- `POST /api/federation/events` — another instance. Its signed news of one of
its users here: a new name, email or avatar, or the end of their sessions.
its users here: a new name, email or avatar, or the end of their sessions. A
4xx tells it to give up on the event, a 429 or a 5xx to try again.
- `GET /api/federation/peers` — anyone the trusted instances,
`federation:manage` every listed one.
- `PUT /api/federation/peers`, `DELETE /api/federation/peers/:host` —

View File

@@ -38,10 +38,12 @@ const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`;
/**
* Keeps each instance's metadata and keys for 10 minutes, so that a sign-in
* or an event doesn't fetch them each time. A key set is refetched sooner
* when a token names a key it lacks ({@link fetchKeys} with `fresh`).
* when a token names a key it lacks ({@link fetchKeys} with `fresh`), at most
* once a minute: anyone can send a token naming a key that doesn't exist.
*/
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
const keysRefetched = new TtlCache<true>(60_000, 500);
/**
* Checks that `url` may be reached: https on a public address, or anything
@@ -119,7 +121,7 @@ export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
/**
* Returns the public keys of the instance at `origin`, cached unless `fresh`
* asks to refetch them.
* asks to refetch them and they weren't in the last minute.
*
* @throws {Error} When they can't be fetched.
*/
@@ -127,8 +129,10 @@ export async function fetchKeys(
origin: string,
fresh = false,
): Promise<JSONWebKeySet> {
const hit = fresh ? undefined : keysCache.get(origin);
const refetch = fresh && !keysRefetched.get(origin);
const hit = refetch ? undefined : keysCache.get(origin);
if (hit) return hit;
if (refetch) keysRefetched.set(origin, true);
const { jwks_uri } = await fetchMetadata(origin);
const body = await readJson(await request(jwks_uri), `${origin}'s keys`);
if (
@@ -221,4 +225,5 @@ export async function fetchAvatar(url: string): Promise<Uint8Array> {
export function clearInstanceCaches(): void {
metadataCache.clear();
keysCache.clear();
keysRefetched.clear();
}

View File

@@ -11,14 +11,17 @@
* instance.
* - `POST /api/federation/sessions` — public. Finishes it
* (`FederationSessionRequest`) and answers the user and their tokens, as a
* sign-in does. Rate-limited per instance.
* sign-in does. Each sign-in started finishes once, so the limit on
* `/logins` bounds it.
* - `POST /api/federation/events` — public, server to server. Applies a
* home instance's signed event about one of its users (`{ event }`).
* Rate-limited per instance; a 4xx tells the sender to give up on the
* event, a 429 or a 5xx to try again.
* - `GET /api/federation/peers` — optional sign-in. Answers the trusted
* instances, or every listed one to `federation:manage`.
* - `PUT /api/federation/peers` — permission `federation:manage`. Trusts or
* blocks an instance (`SetPeerRequest`) and answers it; 502 when it
* doesn't answer as a gerbeur.
* blocks an instance (`SetPeerRequest`) and answers it; 502 when one to
* trust doesn't answer as a gerbeur.
* - `DELETE /api/federation/peers/:host` — permission `federation:manage`.
* Takes an instance off the lists.
*

View File

@@ -6,7 +6,8 @@
* - `POST /api/password-resets` — public. Emails a reset link to the account
* with that `email`, if any; always answers 200.
* - `PUT /api/password-resets/:token` — public, with a reset token. Sets the
* `newPassword`.
* `newPassword`, which ends the user's sessions here and on the instances
* they signed in to.
*
* @module
*/
@@ -20,6 +21,7 @@ import {
requestPasswordReset,
resetPassword,
} from "../services/password-reset-service.ts";
import { notifyClients } from "../services/identity-provider-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -48,7 +50,8 @@ router.put("/:token", async (ctx) => {
if (typeof newPassword !== "string") {
throw new APIException(400, "Invalid request");
}
await resetPassword(ctx.params.token, newPassword);
const userId = await resetPassword(ctx.params.token, newPassword);
await notifyClients(userId, { type: "revoke" });
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});

View File

@@ -65,12 +65,14 @@ export function listPeers(trustedOnly: boolean): FederationPeer[] {
/**
* Puts the instance `instance` names on the list of `status`, for `/manage`,
* and returns it. It must answer as a gerbeur instance, whose name is kept.
* Blocking one ends the sessions of its users here at once: their tokens stop
* counting by `getSessionState`, and this closes their sockets.
* and returns it. A trusted one must answer as a gerbeur instance, whose name
* is kept; a blocked one is blocked whether it answers or not, keeping the
* name it had. Blocking one ends the sessions of its users here at once:
* their tokens stop counting by `getSessionState`, and this closes their
* sockets.
*
* @throws {APIException} 400 when `instance` names no instance, or this one,
* 502 when it doesn't answer as a gerbeur instance.
* 502 when an instance to trust doesn't answer as a gerbeur instance.
*/
export async function setPeer(
instance: string,
@@ -81,17 +83,20 @@ export async function setPeer(
if (issuer === PUBLIC_URL) {
throw new APIException(400, "That is this instance");
}
let name: string;
let name: string | null = null;
try {
name = (await fetchMetadata(issuer)).gerbeur_site_name;
} catch (err) {
console.error(`[federation] ${issuer} can't be added:`, err);
throw new APIException(502, "That address doesn't answer as a gerbeur");
console.error(`[federation] ${issuer} doesn't answer:`, err);
if (status === "trusted") {
throw new APIException(502, "That address doesn't answer as a gerbeur");
}
}
db.prepare(
`INSERT INTO federation_peers (issuer, status, name, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status, name = excluded.name;`,
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status,
name = coalesce(excluded.name, name);`,
).run(issuer, status, name, new Date().toISOString());
if (status === "blocked") {

View File

@@ -22,11 +22,12 @@
* @module
*/
import { decodeJwt } from "@panva/jose";
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
import { createHash, randomBytes } from "node:crypto";
import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import { verifyInstanceToken } from "../lib/jwt.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import {
type FederationLoginRequest,
type FederationSessionRequest,
@@ -65,6 +66,13 @@ import { broadcastUserUpdated, updateClientUsername } from "./ws-service.ts";
/** How long a sign-in may take, from the login page to the callback. */
const LOGIN_TTL_MS = 10 * 60_000;
/**
* Allows an instance a burst of 120 events, as when many of its users change
* at once, then one a second. Each may fetch its keys, so a stranger posting
* events in an instance's name can't make this one hammer it.
*/
const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 };
/**
* Returns the origin of the instance `instance` names, checked as a home: not
* this instance, nor a blocked one.
@@ -157,50 +165,73 @@ export async function startLogin(
return url.href;
}
/**
* Returns the keys of the instance at `issuer`, refetched when `fresh` asks.
*
* @throws {APIException} 502 when they can't be fetched: the sender may try
* again, which it wouldn't after a 4xx.
*/
async function keysOf(issuer: string, fresh: boolean): Promise<JSONWebKeySet> {
try {
return await fetchKeys(issuer, fresh);
} catch (err) {
console.error(`[federation] ${issuer}'s keys failed:`, err);
throw new APIException(502, "That instance's keys can't be fetched");
}
}
/**
* Returns the payload of `token`, an identity token or an event from the
* instance at `issuer`, once its signature checks against that instance's
* keys and it is addressed to this one. The keys are refetched once when the
* token names one they lack.
*
* @throws {APIException} 401 when it doesn't verify.
* @throws {APIException} 401 when it doesn't verify, 502 when the keys can't
* be fetched.
*/
async function verifyFrom(
token: string,
issuer: string,
): Promise<Record<string, unknown>> {
const claims = { issuer, audience: ISSUER };
try {
try {
return await verifyInstanceToken(token, await fetchKeys(issuer), claims);
} catch (err) {
if ((err as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY") {
throw err;
}
return await verifyInstanceToken(
token,
await fetchKeys(issuer, true),
claims,
);
let failure: unknown;
for (const fresh of [false, true]) {
if (
fresh &&
(failure as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY"
) {
break;
}
const keys = await keysOf(issuer, fresh);
try {
return await verifyInstanceToken(token, keys, claims);
} catch (err) {
failure = err;
}
} catch (err) {
console.error(`[federation] a token from ${issuer} failed:`, err);
throw new APIException(401, "Invalid token");
}
console.error(`[federation] a token from ${issuer} failed:`, failure);
throw new APIException(401, "Invalid token");
}
/**
* Returns `claims` once checked: a name a local user could have, and an email.
*
* @throws {APIException} 502 when the home sent something else.
* Returns `claims` from the instance at `issuer` once checked: a name a local
* user could have, an email, and an avatar on that instance, so that it can't
* make this server fetch from elsewhere. Returns null when they aren't.
*/
function checkClaims(claims: unknown): IdentityClaims {
function checkClaims(claims: unknown, issuer: string): IdentityClaims | null {
if (
!isIdentityClaims(claims) ||
!USERNAME_RE.test(claims.preferred_username) ||
!EMAIL_RE.test(claims.email)
) {
throw new APIException(502, "The instance sent an invalid identity");
return null;
}
if (claims.picture !== undefined) {
try {
if (new URL(claims.picture).origin !== issuer) return null;
} catch {
return null;
}
}
return claims;
}
@@ -255,7 +286,10 @@ export async function completeLogin(
if (payload.nonce !== login.nonce || typeof payload.sub !== "string") {
throw new APIException(401, "Invalid token");
}
const claims = checkClaims(payload);
const claims = checkClaims(payload, issuer);
if (!claims) {
throw new APIException(502, "The instance sent an invalid identity");
}
const subject = payload.sub;
const identity = db.prepare(
@@ -342,10 +376,13 @@ async function applyClaims(
* Applies the signed event `token` from a user's home, for
* `POST /api/federation/events`: `update` brings their account in step,
* `revoke` ends their sessions here. Only an instance some account here comes
* from may send one, about one of those accounts.
* from, and not blocked, may send one, about one of those accounts, and at
* most {@link EVENT_LIMIT}'s rate. The sender gives up on an event at a 4xx
* other than 429, and tries it again later otherwise.
*
* @throws {APIException} 400 when it isn't an event of a known instance about
* one of its users; 401 when it doesn't verify.
* one of its users; 401 when it doesn't verify; 403 when the instance is
* blocked; 429 when it sends too many; 502 when its keys can't be fetched.
*/
export async function receiveEvent(token: string): Promise<void> {
let issuer: unknown;
@@ -361,6 +398,12 @@ export async function receiveEvent(token: string): Promise<void> {
) {
throw new APIException(400, "Unknown instance");
}
if (getPeerStatus(issuer) === "blocked") {
throw new APIException(403, "This instance is blocked here");
}
if (!takeToken(`federation-events:${issuer}`, EVENT_LIMIT)) {
throw new APIException(429, "Too many events");
}
const payload = await verifyFrom(token, issuer);
const identity = db.prepare(
`SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`,
@@ -370,7 +413,9 @@ export async function receiveEvent(token: string): Promise<void> {
}
if (payload.type === "revoke") {
revokeSessions(identity.user_id, new Date(), "Signed out by home");
} else {
await applyClaims(identity.user_id, issuer, checkClaims(payload.claims));
return;
}
const claims = checkClaims(payload.claims, issuer);
if (!claims) throw new APIException(400, "Invalid event");
await applyClaims(identity.user_id, issuer, claims);
}

View File

@@ -13,7 +13,7 @@
* the instance key.
* 3. While the user's grant lasts, the client hears of their changes: a new
* name, email or avatar, and the end of their sessions after a password
* change. {@link notifyClients} queues the signed events in
* change or reset. {@link notifyClients} queues the signed events in
* `federation_outbox`, and {@link outboxJob} delivers them.
*
* Only a local account signs in elsewhere: a user who came from another
@@ -26,7 +26,12 @@
import { createHash, randomBytes } from "node:crypto";
import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts";
import {
AVATARS_DIR,
FRONTEND_URL,
OG_SITE_NAME,
PUBLIC_URL,
} from "../config.ts";
import { signInstanceToken } from "../lib/jwt.ts";
import type { Job } from "../lib/scheduler.ts";
import {
@@ -105,9 +110,23 @@ function sha256(value: string): string {
return createHash("sha256").update(value).digest("base64url");
}
/**
* Returns the version of `user`'s avatar: when its file was last written,
* which only a new avatar does, unlike `updatedAt`, which every profile edit
* bumps.
*/
function avatarVersion(user: User): number {
try {
return Deno.statSync(`${AVATARS_DIR}/${user.id}`).mtime?.getTime() ?? 0;
} catch {
return user.updatedAt?.getTime() ?? 0;
}
}
/**
* Returns what this instance tells another of `user`: their name, email and
* avatar, whose URL changes with each new one.
* avatar, whose URL changes with each new one, so that a client fetches it
* again only then.
*/
export function identityClaims(user: User): IdentityClaims {
return {
@@ -115,7 +134,7 @@ export function identityClaims(user: User): IdentityClaims {
email: user.email,
...(user.avatarMime && {
picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${
user.updatedAt?.getTime() ?? 0
avatarVersion(user)
}`,
}),
};
@@ -348,7 +367,8 @@ export async function revokeGrant(userId: string, host: string): Promise<void> {
* `clientIds`: `update` when their name, email or avatar changed, `revoke`
* when their sessions there must end. The routes that change a user call it,
* and for an `update`, `event` may be just the type: the claims are read now.
* The events are queued in the outbox and their delivery starts at once.
* The events are queued in the outbox and their delivery starts at once,
* after those queued before for the same instance ({@link deliverTo}).
*/
export async function notifyClients(
userId: string,
@@ -381,52 +401,80 @@ export async function notifyClients(
now.toISOString(),
);
}
deliverOutbox().catch((err) =>
console.error("[federation] delivery failed:", err)
);
for (const clientId of clients) deliverTo(clientId);
}
/** Set while {@link deliverOutbox} runs, so that two runs don't overlap. */
let delivering = false;
/**
* The instances whose events {@link deliverTo} is sending, so that two runs
* don't send the same events, and those whose events came meanwhile, which
* the running one sends too once done.
*/
const delivering = new Set<string>();
const queuedMeanwhile = new Set<string>();
/**
* Sends each due event of the outbox to its instance. One it took, or refused
* for good, leaves the outbox; one that failed waits twice as long as the time
* before, up to 6 hours, until it expires after 3 days.
* Sends the outbox's events to each instance that has some, every instance at
* once so that one that doesn't answer delays none of the others.
*/
export async function deliverOutbox(): Promise<void> {
if (delivering) return;
delivering = true;
db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run(
new Date(Date.now() - EVENT_TTL_MS).toISOString(),
);
const clients = db.prepare(
`SELECT DISTINCT client_id FROM federation_outbox;`,
).all() as { client_id: string }[];
await Promise.all(clients.map((c) => deliverTo(c.client_id)));
}
/**
* Sends the events for the instance `clientId` in the order they were made,
* as long as it takes them, since each `update` carries all the claims and a
* late older one would undo a newer one. One it took, or refused for good,
* leaves the outbox. The first that fails stops the run, and waits twice as
* long as the time before, up to 6 hours, with the events behind it, until
* it expires after 3 days.
*/
async function deliverTo(clientId: string): Promise<void> {
if (delivering.has(clientId)) {
queuedMeanwhile.add(clientId);
return;
}
delivering.add(clientId);
try {
const now = new Date();
db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run(
new Date(now.getTime() - EVENT_TTL_MS).toISOString(),
);
const due = db.prepare(
`SELECT id, client_id, event, attempts FROM federation_outbox
WHERE next_attempt_at <= ? ORDER BY created_at LIMIT 100;`,
).all(now.toISOString()) as {
id: string;
client_id: string;
event: string;
attempts: number;
}[];
for (const row of due) {
try {
if (!await postEvent(row.client_id, row.event)) {
console.error(`[federation] ${row.client_id} refused an event`);
}
db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id);
} catch (err) {
const wait = Math.min(60_000 * 2 ** row.attempts, MAX_RETRY_MS);
db.prepare(
`UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`,
).run(new Date(Date.now() + wait).toISOString(), row.id);
console.error(`[federation] ${row.client_id} unreachable:`, err);
}
}
do {
queuedMeanwhile.delete(clientId);
await sendDue(clientId);
} while (queuedMeanwhile.has(clientId));
} catch (err) {
console.error(`[federation] delivery to ${clientId} failed:`, err);
} finally {
delivering = false;
delivering.delete(clientId);
}
}
/** Runs one pass of {@link deliverTo}. */
async function sendDue(clientId: string): Promise<void> {
for (;;) {
const row = db.prepare(
`SELECT id, event, attempts, next_attempt_at FROM federation_outbox
WHERE client_id = ? ORDER BY created_at, rowid LIMIT 1;`,
).get(clientId) as
| { id: string; event: string; attempts: number; next_attempt_at: string }
| undefined;
if (!row || row.next_attempt_at > new Date().toISOString()) return;
try {
if (!await postEvent(clientId, row.event)) {
console.error(`[federation] ${clientId} refused an event`);
}
db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id);
} catch (err) {
const wait = Math.min(60_000 * 2 ** row.attempts, MAX_RETRY_MS);
db.prepare(
`UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`,
).run(new Date(Date.now() + wait).toISOString(), row.id);
console.error(`[federation] ${clientId} unreachable:`, err);
return;
}
}
}

View File

@@ -65,8 +65,9 @@ export async function requestPasswordReset(email: string): Promise<void> {
}
/**
* Sets `newPassword` for the user of a reset token and marks the token used.
* Through `updateUser`, this ends the user's other sessions.
* Sets `newPassword` for the user of a reset token, marks the token used, and
* returns the user's id. Through `updateUser`, this ends the user's sessions
* here; the route tells the instances they signed in to.
*
* @throws {APIException} 400 when the password's length is out of bounds or
* the token is invalid or expired, 404 when the token isn't on record or its
@@ -75,7 +76,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
export async function resetPassword(
token: string,
newPassword: string,
): Promise<void> {
): Promise<string> {
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
@@ -118,4 +119,5 @@ export async function resetPassword(
.run(token);
throw err;
}
return payload.userId;
}

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);
});

File diff suppressed because one or more lines are too long

View File

@@ -391,7 +391,7 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#: src/pages/FederationCallback.tsx:66
#: src/pages/FederationCallback.tsx:71
msgid "Back to the login page"
msgstr "Back to the login page"
@@ -545,7 +545,7 @@ msgstr "Community"
msgid "Confirm new password"
msgstr "Confirm new password"
#: src/components/federation/FederatedSignIn.tsx:111
#: src/components/federation/FederatedSignIn.tsx:113
msgid "Connecting…"
msgstr "Connecting…"
@@ -553,7 +553,7 @@ msgstr "Connecting…"
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
#: src/components/federation/FederatedSignIn.tsx:113
#: src/components/federation/FederatedSignIn.tsx:115
msgid "Continue"
msgstr "Continue"
@@ -602,7 +602,7 @@ msgstr "Couldn't load a preview for this link — check it, or post it as-is and
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
#: src/components/federation/FederatedSignIn.tsx:102
#: src/components/federation/FederatedSignIn.tsx:104
msgid "Couldn't reach that instance"
msgstr "Couldn't reach that instance"
@@ -1066,7 +1066,7 @@ msgstr "invited by you"
msgid "Invitees"
msgstr "Invitees"
#: src/components/federation/FederatedSignIn.tsx:105
#: src/components/federation/FederatedSignIn.tsx:107
msgid "Its address, like music.example"
msgstr "Its address, like music.example"
@@ -1155,7 +1155,7 @@ msgid "Loading…"
msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:31
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:44
#: src/pages/UserLogin.tsx:71
#: src/pages/UserLogin.tsx:90
@@ -1171,7 +1171,7 @@ msgid "Log in to vote"
msgstr "Log in to vote"
#. placeholder {0}: peer.name ?? peer.host
#: src/components/federation/FederatedSignIn.tsx:97
#: src/components/federation/FederatedSignIn.tsx:99
msgid "Log in with {0}"
msgstr "Log in with {0}"
@@ -1184,7 +1184,7 @@ msgstr "Log out"
msgid "Logging in…"
msgstr "Logging in…"
#: src/pages/FederationCallback.tsx:63
#: src/pages/FederationCallback.tsx:68
#: src/pages/UserLogin.tsx:76
msgid "Login failed"
msgstr "Login failed"
@@ -1390,11 +1390,11 @@ msgstr "Open the chat"
msgid "or <0>browse files</0>"
msgstr "or <0>browse files</0>"
#: src/components/federation/FederatedSignIn.tsx:82
#: src/components/federation/FederatedSignIn.tsx:84
msgid "Or join with your account on another gerbeur"
msgstr "Or join with your account on another gerbeur"
#: src/components/federation/FederatedSignIn.tsx:83
#: src/components/federation/FederatedSignIn.tsx:85
msgid "Or log in with your account on another gerbeur"
msgstr "Or log in with your account on another gerbeur"
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
msgstr "Sign in to {name}?"
#: src/pages/Authorize.tsx:154
#: src/pages/FederationCallback.tsx:73
#: src/pages/FederationCallback.tsx:78
msgid "Signing you in…"
msgstr "Signing you in…"
@@ -1838,10 +1838,14 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed."
#: src/pages/FederationCallback.tsx:38
#: src/pages/FederationCallback.tsx:39
msgid "This sign-in link is incomplete."
msgstr "This sign-in link is incomplete."
#: src/pages/FederationCallback.tsx:47
msgid "This sign-in was started elsewhere. Start again from the login page."
msgstr "This sign-in was started elsewhere. Start again from the login page."
#: src/pages/Settings.tsx:201
msgid "This site doesn't send email, so no digest goes out."
msgstr "This site doesn't send email, so no digest goes out."

File diff suppressed because one or more lines are too long

View File

@@ -391,7 +391,7 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#: src/pages/FederationCallback.tsx:66
#: src/pages/FederationCallback.tsx:71
msgid "Back to the login page"
msgstr "Retour à la page de connexion"
@@ -545,7 +545,7 @@ msgstr "Communauté"
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
#: src/components/federation/FederatedSignIn.tsx:111
#: src/components/federation/FederatedSignIn.tsx:113
msgid "Connecting…"
msgstr "Connexion…"
@@ -553,7 +553,7 @@ msgstr "Connexion…"
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
#: src/components/federation/FederatedSignIn.tsx:113
#: src/components/federation/FederatedSignIn.tsx:115
msgid "Continue"
msgstr "Continuer"
@@ -602,7 +602,7 @@ msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publi
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
#: src/components/federation/FederatedSignIn.tsx:102
#: src/components/federation/FederatedSignIn.tsx:104
msgid "Couldn't reach that instance"
msgstr "Impossible de joindre cette instance"
@@ -1066,7 +1066,7 @@ msgstr "sur votre invitation"
msgid "Invitees"
msgstr "Invités"
#: src/components/federation/FederatedSignIn.tsx:105
#: src/components/federation/FederatedSignIn.tsx:107
msgid "Its address, like music.example"
msgstr "Son adresse, comme music.example"
@@ -1155,7 +1155,7 @@ msgid "Loading…"
msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:31
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:44
#: src/pages/UserLogin.tsx:71
#: src/pages/UserLogin.tsx:90
@@ -1171,7 +1171,7 @@ msgid "Log in to vote"
msgstr "Se connecter pour voter"
#. placeholder {0}: peer.name ?? peer.host
#: src/components/federation/FederatedSignIn.tsx:97
#: src/components/federation/FederatedSignIn.tsx:99
msgid "Log in with {0}"
msgstr "Se connecter avec {0}"
@@ -1184,7 +1184,7 @@ msgstr "Se déconnecter"
msgid "Logging in…"
msgstr "Connexion…"
#: src/pages/FederationCallback.tsx:63
#: src/pages/FederationCallback.tsx:68
#: src/pages/UserLogin.tsx:76
msgid "Login failed"
msgstr "Connexion échouée"
@@ -1390,11 +1390,11 @@ msgstr "Ouvrir la tribune"
msgid "or <0>browse files</0>"
msgstr "ou <0>parcourir les fichiers</0>"
#: src/components/federation/FederatedSignIn.tsx:82
#: src/components/federation/FederatedSignIn.tsx:84
msgid "Or join with your account on another gerbeur"
msgstr "Ou rejoignez-nous avec votre compte d'un autre gerbeur"
#: src/components/federation/FederatedSignIn.tsx:83
#: src/components/federation/FederatedSignIn.tsx:85
msgid "Or log in with your account on another gerbeur"
msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
msgstr "Se connecter à {name} ?"
#: src/pages/Authorize.tsx:154
#: src/pages/FederationCallback.tsx:73
#: src/pages/FederationCallback.tsx:78
msgid "Signing you in…"
msgstr "Connexion…"
@@ -1838,10 +1838,14 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/FederationCallback.tsx:38
#: src/pages/FederationCallback.tsx:39
msgid "This sign-in link is incomplete."
msgstr "Ce lien de connexion est incomplet."
#: src/pages/FederationCallback.tsx:47
msgid "This sign-in was started elsewhere. Start again from the login page."
msgstr "Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion."
#: src/pages/Settings.tsx:201
msgid "This site doesn't send email, so no digest goes out."
msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc."

View File

@@ -17,6 +17,7 @@ import type {
FederationLoginResponse,
FederationPeer,
} from "../../../shared/federation.ts";
import { rememberSignIn } from "../../utils/auth/federation.ts";
import { dataOr, expectOk } from "../../utils/shared/api.ts";
import { deserializeFederationPeer } from "../../utils/shared/deserialize.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
@@ -72,6 +73,7 @@ export function FederatedSignIn(
),
}),
);
rememberSignIn(authorizeUrl);
globalThis.location.assign(authorizeUrl);
});

View File

@@ -15,6 +15,7 @@ import type { Wire } from "../../shared/api.ts";
import type { FederationSessionRequest } from "../../shared/federation.ts";
import type { AuthResponse } from "../../shared/user.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { forgetSignIn, startedHere } from "../utils/auth/federation.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
@@ -23,9 +24,9 @@ import { deserializeAuthResponse } from "../utils/shared/deserialize.ts";
/**
* Renders `/federation/callback?code=&state=`. It finishes the sign-in
* (`POST /api/federation/sessions`), once: a code works only once, and a
* second request would fail the sign-in. On success the user lands on `/`,
* signed in.
* (`POST /api/federation/sessions`) if this tab started it (`startedHere`),
* and once: a code works only once, and a second request would fail the
* sign-in. On success the user lands on `/`, signed in.
*/
export function FederationCallback(): React.JSX.Element {
useDocumentTitle(t`Log in`);
@@ -34,14 +35,19 @@ export function FederationCallback(): React.JSX.Element {
const { login } = useAuth();
const code = params.get("code");
const state = params.get("state");
const [error, setError] = useState<string | null>(
code && state ? null : t`This sign-in link is incomplete.`,
const [error, setError] = useState<string | null>(() =>
!code || !state
? t`This sign-in link is incomplete.`
: !startedHere(state)
? t`This sign-in was started elsewhere. Start again from the login page.`
: null
);
const sent = useRef(false);
useEffect(() => {
if (sent.current || !code || !state) return;
if (sent.current || error || !code || !state) return;
sent.current = true;
forgetSignIn();
fetch(`${API_URL}/api/federation/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -53,7 +59,7 @@ export function FederationCallback(): React.JSX.Element {
navigate("/", { replace: true });
})
.catch((err) => setError(friendlyFetchError(err)));
}, [code, state, login, navigate]);
}, [code, state, error, login, navigate]);
return (
<PageShell centered>

View File

@@ -7,7 +7,9 @@ import { calls, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
function open(query: string) {
/** Opens the callback, in a tab that started the sign-in of state `s`. */
function open(query: string, started = "s") {
sessionStorage.setItem("federationState", started);
renderApp(<FederationCallback />, {
route: `/federation/callback${query}`,
path: "/federation/callback",
@@ -42,6 +44,16 @@ describe("FederationCallback", () => {
.toHaveAttribute("href", "/login");
});
test("a sign-in this tab didn't start is refused", async () => {
open("?code=c&state=theirs");
expect(
await screen.findByText(
"This sign-in was started elsewhere. Start again from the login page.",
),
).toBeInTheDocument();
expect(calls("POST /api/federation/sessions")).toHaveLength(0);
});
test("a link without its code says so", async () => {
open("?state=s");
expect(await screen.findByText("This sign-in link is incomplete."))

View File

@@ -167,7 +167,7 @@ describe("UserLogin", () => {
route("POST /api/federation/logins", (req) => ({
authorizeUrl: `https://${
(req.body as { instance: string }).instance.replace(/^https:\/\//, "")
}/authorize?x`,
}/authorize?state=s1`,
}));
const user = setup();
await user.click(
@@ -175,9 +175,11 @@ describe("UserLogin", () => {
);
await waitFor(() =>
expect(assign).toHaveBeenCalledWith(
"https://music.example/authorize?x",
"https://music.example/authorize?state=s1",
)
);
// The callback finishes only the sign-in this tab started.
expect(sessionStorage.getItem("federationState")).toBe("s1");
expect(calls("POST /api/federation/logins")[0].body).toEqual({
instance: "https://music.example",
});
@@ -191,7 +193,7 @@ describe("UserLogin", () => {
);
await waitFor(() =>
expect(assign).toHaveBeenLastCalledWith(
"https://films.example/authorize?x",
"https://films.example/authorize?state=s1",
)
);
});

View File

@@ -0,0 +1,42 @@
/**
* Ties a federated sign-in to the tab that started it, so that a link to the
* callback page made from someone else's sign-in can't sign this browser in
* to their account (login CSRF). `FederatedSignIn` keeps the sign-in's state
* before leaving for the other instance, in sessionStorage, which outlives
* the trip there and back in one tab, and `FederationCallback` finishes only
* a sign-in whose state it finds.
*
* @module
*/
const STATE_KEY = "federationState";
/** Keeps the state of the sign-in that `authorizeUrl` starts. */
export function rememberSignIn(authorizeUrl: string): void {
try {
sessionStorage.setItem(
STATE_KEY,
new URL(authorizeUrl).searchParams.get("state") ?? "",
);
} catch {
// Without sessionStorage, the callback refuses the sign-in.
}
}
/** Returns whether `state` is that of the sign-in this tab started. */
export function startedHere(state: string): boolean {
try {
return sessionStorage.getItem(STATE_KEY) === state;
} catch {
return false;
}
}
/** Forgets the sign-in this tab started, once it is being finished. */
export function forgetSignIn(): void {
try {
sessionStorage.removeItem(STATE_KEY);
} catch {
// Nothing was kept.
}
}