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>
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import {
|
|
app,
|
|
BASE_URL,
|
|
createUser,
|
|
db,
|
|
del,
|
|
type FakeInstance,
|
|
fakeInstance,
|
|
formWithFile,
|
|
get,
|
|
patch,
|
|
PNG,
|
|
post,
|
|
put,
|
|
stubNetwork,
|
|
test,
|
|
} from "../helpers.ts";
|
|
import { assert, assertEquals } from "@std/assert";
|
|
import { createHash } from "node:crypto";
|
|
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
|
|
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,
|
|
InstanceMetadata,
|
|
OAuthClient,
|
|
} from "../../../shared/federation.ts";
|
|
|
|
const CLIENT = "https://client.test";
|
|
const CALLBACK = `${CLIENT}/federation/callback`;
|
|
|
|
function network(fake: FakeInstance, events?: () => Response) {
|
|
return stubNetwork((url, init) => {
|
|
if (events && url.pathname === "/api/federation/events") return events();
|
|
return fake.handle(url, init) ?? new Response("", { status: 404 });
|
|
});
|
|
}
|
|
|
|
/** A PKCE pair: the verifier, and the challenge the request carries. */
|
|
function pkce() {
|
|
const verifier = crypto.randomUUID() + crypto.randomUUID();
|
|
return {
|
|
verifier,
|
|
challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
};
|
|
}
|
|
|
|
function authorize(
|
|
auth: { Authorization: string },
|
|
challenge: string,
|
|
overrides: Record<string, string> = {},
|
|
) {
|
|
return post<AuthorizeResponse>("/api/oauth/codes", {
|
|
auth,
|
|
json: {
|
|
clientId: CLIENT,
|
|
redirectUri: CALLBACK,
|
|
state: "the-state",
|
|
codeChallenge: challenge,
|
|
codeChallengeMethod: "S256",
|
|
nonce: "the-nonce",
|
|
...overrides,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Trades `code` at the token endpoint, as the client's server would. */
|
|
async function trade(code: string, verifier: string, client = CLIENT) {
|
|
const res = await app.handle(
|
|
new Request(`${BASE_URL}/api/oauth/tokens`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
code,
|
|
client_id: client,
|
|
redirect_uri: CALLBACK,
|
|
code_verifier: verifier,
|
|
}),
|
|
}),
|
|
);
|
|
return { status: res!.status, body: await res!.json() };
|
|
}
|
|
|
|
async function until(check: () => boolean) {
|
|
for (let i = 0; i < 50 && !check(); i++) {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
}
|
|
|
|
test("identity provider: metadata and keys are served at the well-known paths", async () => {
|
|
const meta = await get("/.well-known/openid-configuration");
|
|
const body = JSON.parse(
|
|
new TextDecoder().decode(meta.bytes),
|
|
) as InstanceMetadata;
|
|
assertEquals(body.issuer, BASE_URL);
|
|
assertEquals(body.authorization_endpoint, `${BASE_URL}/authorize`);
|
|
assertEquals(body.gerbeur_redirect_uri, `${BASE_URL}/federation/callback`);
|
|
assertEquals(body.gerbeur_federation, 1);
|
|
const jwks = await get("/.well-known/jwks.json");
|
|
const keys = JSON.parse(new TextDecoder().decode(jwks.bytes)).keys;
|
|
assertEquals(keys[0].crv, "Ed25519");
|
|
assertEquals(keys[0].d, undefined);
|
|
});
|
|
|
|
test("identity provider: a consenting user's code trades once for their identity", async () => {
|
|
const fake = await fakeInstance(CLIENT);
|
|
using _net = network(fake);
|
|
const user = await createUser({ username: "carol" });
|
|
|
|
const client = await get<OAuthClient>(
|
|
`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`,
|
|
{ auth: user.auth },
|
|
);
|
|
assertEquals(client.data, {
|
|
clientId: CLIENT,
|
|
host: "client.test",
|
|
name: "Fake client.test",
|
|
granted: false,
|
|
});
|
|
|
|
const { verifier, challenge } = pkce();
|
|
const redirect = new URL(
|
|
(await authorize(user.auth, challenge)).data
|
|
.redirectTo,
|
|
);
|
|
assertEquals(redirect.origin + redirect.pathname, CALLBACK);
|
|
assertEquals(redirect.searchParams.get("state"), "the-state");
|
|
assertEquals(redirect.searchParams.get("iss"), BASE_URL);
|
|
const code = redirect.searchParams.get("code")!;
|
|
|
|
assertEquals((await trade(code, pkce().verifier)).status, 400);
|
|
// A failed trade used the code up too.
|
|
assertEquals((await trade(code, verifier)).status, 400);
|
|
|
|
const { challenge: c2, verifier: v2 } = pkce();
|
|
const code2 = new URL((await authorize(user.auth, c2)).data.redirectTo)
|
|
.searchParams.get("code")!;
|
|
assertEquals((await trade(code2, v2, "https://elsewhere.test")).status, 400);
|
|
const { challenge: c3, verifier: v3 } = pkce();
|
|
const code3 = new URL((await authorize(user.auth, c3)).data.redirectTo)
|
|
.searchParams.get("code")!;
|
|
const traded = await trade(code3, v3);
|
|
assertEquals(traded.status, 200);
|
|
|
|
const keys = JSON.parse(
|
|
new TextDecoder().decode((await get("/.well-known/jwks.json")).bytes),
|
|
) as JSONWebKeySet;
|
|
const claims = await verifyInstanceToken(traded.body.id_token, keys, {
|
|
issuer: BASE_URL,
|
|
audience: CLIENT,
|
|
});
|
|
assertEquals(claims.sub, user.id);
|
|
assertEquals(claims.preferred_username, "carol");
|
|
assertEquals(claims.email, user.email);
|
|
assertEquals(claims.nonce, "the-nonce");
|
|
|
|
const again = await get<OAuthClient>(
|
|
`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`,
|
|
{ auth: user.auth },
|
|
);
|
|
assertEquals(again.data.granted, true);
|
|
const grants = await get<FederationGrant[]>("/api/me/grants", {
|
|
auth: user.auth,
|
|
});
|
|
assertEquals(grants.data.map((g) => g.host), ["client.test"]);
|
|
});
|
|
|
|
test("identity provider: codes are only for a known client's own callback", async () => {
|
|
const fake = await fakeInstance(CLIENT);
|
|
using _net = network(fake);
|
|
const user = await createUser();
|
|
const admin = await createUser({ role: "admin" });
|
|
const { challenge } = pkce();
|
|
assertEquals(
|
|
(await authorize(user.auth, challenge, {
|
|
redirectUri: "https://evil.test/cb",
|
|
})).status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await authorize(user.auth, challenge, { clientId: BASE_URL })).status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await authorize(user.auth, challenge, { codeChallengeMethod: "plain" }))
|
|
.status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await authorize(user.auth, challenge, {
|
|
clientId: "https://nowhere.test",
|
|
})).status,
|
|
502,
|
|
);
|
|
await put("/api/federation/peers", {
|
|
auth: admin.auth,
|
|
json: { instance: CLIENT, status: "blocked" },
|
|
});
|
|
assertEquals((await authorize(user.auth, challenge)).status, 403);
|
|
});
|
|
|
|
test("identity provider: an account from another instance doesn't sign in elsewhere from here", async () => {
|
|
const fake = await fakeInstance(CLIENT);
|
|
using _net = network(fake);
|
|
const user = await createUser();
|
|
db.prepare(`UPDATE users SET password_hash = '' WHERE id = ?;`).run(user.id);
|
|
assertEquals((await authorize(user.auth, pkce().challenge)).status, 400);
|
|
});
|
|
|
|
test("identity provider: the instances a user signed in to hear of their changes", async () => {
|
|
const fake = await fakeInstance(CLIENT);
|
|
using _net = network(fake);
|
|
const user = await createUser({ username: "dave" });
|
|
const admin = await createUser({ role: "admin" });
|
|
await authorize(user.auth, pkce().challenge);
|
|
|
|
await patch(`/api/users/${user.id}/username`, {
|
|
auth: admin.auth,
|
|
json: { username: "david" },
|
|
});
|
|
await until(() => fake.received.length === 1);
|
|
const update = decodeJwt(fake.received[0]);
|
|
assertEquals(update.type, "update");
|
|
assertEquals(update.sub, user.id);
|
|
assertEquals(update.aud, CLIENT);
|
|
assertEquals(
|
|
(update.claims as { preferred_username: string }).preferred_username,
|
|
"david",
|
|
);
|
|
|
|
await put("/api/me/password", {
|
|
auth: user.auth,
|
|
json: { currentPassword: "correct-horse", newPassword: "new-password" },
|
|
});
|
|
await until(() => fake.received.length === 2);
|
|
assertEquals(decodeJwt(fake.received[1]).type, "revoke");
|
|
|
|
const token = (await post<{ token: string }>("/api/sessions", {
|
|
json: { username: "david", password: "new-password" },
|
|
})).data.token;
|
|
const auth = { Authorization: `Bearer ${token}` };
|
|
assertEquals(
|
|
(await del("/api/me/grants/client.test", { auth })).status,
|
|
200,
|
|
);
|
|
await until(() => fake.received.length === 3);
|
|
assertEquals(decodeJwt(fake.received[2]).type, "revoke");
|
|
assertEquals(
|
|
(await get<FederationGrant[]>("/api/me/grants", { auth })).data,
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("identity provider: an event an instance fails to take waits in the outbox", async () => {
|
|
const fake = await fakeInstance(CLIENT);
|
|
using _net = network(fake, () => new Response("", { status: 503 }));
|
|
const user = await createUser();
|
|
await authorize(user.auth, pkce().challenge);
|
|
await patch("/api/me", { auth: user.auth, json: { email: "e@new.test" } });
|
|
let row: { attempts: number } | undefined;
|
|
await until(() => {
|
|
row = db.prepare(`SELECT attempts FROM federation_outbox;`).get() as
|
|
| { attempts: number }
|
|
| undefined;
|
|
return (row?.attempts ?? 0) > 0;
|
|
});
|
|
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);
|
|
});
|