430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import {
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
type FakeInstance,
|
|
fakeInstance,
|
|
federationOff,
|
|
formWithFile,
|
|
get,
|
|
patch,
|
|
PNG,
|
|
post,
|
|
put,
|
|
stubNetwork,
|
|
test,
|
|
} from "../helpers.ts";
|
|
import { assert, assertEquals, assertMatch } from "@std/assert";
|
|
import { clearInstanceCaches } from "../../remote/gerbeur-instance.ts";
|
|
import type {
|
|
FederationLoginResponse,
|
|
FederationPeer,
|
|
} from "../../../shared/federation.ts";
|
|
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
|
|
|
|
const HOME = "https://home.test";
|
|
|
|
/** Puts `fake` behind the network, every other host answering 404. */
|
|
function network(...fakes: FakeInstance[]) {
|
|
return stubNetwork((url, init) => {
|
|
for (const fake of fakes) {
|
|
const res = fake.handle(url, init);
|
|
if (res) return res;
|
|
}
|
|
return new Response("", { status: 404 });
|
|
});
|
|
}
|
|
|
|
async function trust(issuer: string, status = "trusted") {
|
|
const admin = await createUser({ role: "admin" });
|
|
return await put<FederationPeer>("/api/federation/peers", {
|
|
auth: admin.auth,
|
|
json: { instance: issuer, status },
|
|
});
|
|
}
|
|
|
|
/** Signs in through `home` as `identity`, as the pages would. */
|
|
async function signIn(
|
|
home: FakeInstance,
|
|
identity: {
|
|
sub: string;
|
|
preferred_username: string;
|
|
email: string;
|
|
picture?: string;
|
|
},
|
|
inviteToken?: string,
|
|
) {
|
|
const start = await post<FederationLoginResponse>("/api/federation/logins", {
|
|
json: { instance: new URL(home.origin).host, inviteToken },
|
|
});
|
|
if (start.status !== 200) return start;
|
|
const authorizeUrl = start.data.authorizeUrl;
|
|
const code = home.issue(authorizeUrl, identity);
|
|
return await post<AuthResponse>("/api/federation/sessions", {
|
|
json: { code, state: new URL(authorizeUrl).searchParams.get("state") },
|
|
});
|
|
}
|
|
|
|
const ALICE = {
|
|
sub: "alice-id",
|
|
preferred_username: "alice",
|
|
email: "alice@home.example",
|
|
picture: `${HOME}/api/users/alice-id/avatar?v=1`,
|
|
};
|
|
|
|
test("federation: a trusted instance's user signs in as name@host, once made", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using net = network(home);
|
|
assertEquals((await trust("home.test")).data.name, "Fake home.test");
|
|
|
|
const start = await post<FederationLoginResponse>("/api/federation/logins", {
|
|
json: { instance: "home.test" },
|
|
});
|
|
const url = new URL(start.data.authorizeUrl);
|
|
assertEquals(url.origin + url.pathname, `${HOME}/authorize`);
|
|
assertEquals(url.searchParams.get("client_id"), "http://gerbeur.test");
|
|
assertEquals(
|
|
url.searchParams.get("redirect_uri"),
|
|
"http://gerbeur.test/federation/callback",
|
|
);
|
|
assertEquals(url.searchParams.get("code_challenge_method"), "S256");
|
|
|
|
const first = await signIn(home, ALICE);
|
|
const { user, token } = first.data as AuthResponse;
|
|
assertEquals(user.username, "alice@home.test");
|
|
assertEquals(user.email, "alice@home.example");
|
|
assertEquals(user.role, "user");
|
|
assertEquals(user.avatarMime, "image/png");
|
|
assert(net.calls.some((u) => u.pathname === "/api/users/alice-id/avatar"));
|
|
|
|
const me = await get<SelfUser>("/api/me", {
|
|
auth: { Authorization: `Bearer ${token}` },
|
|
});
|
|
assertEquals(me.data.id, user.id);
|
|
|
|
// The same identity signs in to the same account.
|
|
const again = await signIn(home, ALICE);
|
|
assertEquals((again.data as AuthResponse).user.id, user.id);
|
|
|
|
// It has no password here.
|
|
const login = await post("/api/sessions", {
|
|
json: { username: "alice@home.test", password: "" },
|
|
});
|
|
assertEquals(login.status, 401);
|
|
assertEquals(
|
|
(await get<SelfUser>("/api/users/@alice@home.test")).data.id,
|
|
user.id,
|
|
);
|
|
// The client encodes the handle's `@`.
|
|
assertEquals(
|
|
(await get<SelfUser>("/api/users/@alice%40home.test")).data.id,
|
|
user.id,
|
|
);
|
|
});
|
|
|
|
test("federation: a login works once, with the code the home made for it", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using _net = network(home);
|
|
await trust(HOME);
|
|
const start = await post<FederationLoginResponse>("/api/federation/logins", {
|
|
json: { instance: HOME },
|
|
});
|
|
const state = new URL(start.data.authorizeUrl).searchParams.get("state");
|
|
const wrong = await post("/api/federation/sessions", {
|
|
json: { code: "made-up", state },
|
|
});
|
|
assertEquals(wrong.status, 502);
|
|
// The failed try used the login up.
|
|
const code = home.issue(start.data.authorizeUrl, ALICE);
|
|
const replay = await post("/api/federation/sessions", {
|
|
json: { code, state },
|
|
});
|
|
assertEquals(replay.status, 400);
|
|
});
|
|
|
|
test("federation: an instance on neither list needs an invite, claimed on joining", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using _net = network(home);
|
|
const refused = await signIn(home, ALICE);
|
|
assertEquals(refused.status, 403);
|
|
|
|
const inviter = await createUser();
|
|
const { token } = (await post<{ token: string }>("/api/invites", {
|
|
auth: inviter.auth,
|
|
})).data;
|
|
const joined = await signIn(home, ALICE, token);
|
|
const { user } = joined.data as AuthResponse;
|
|
assertEquals(user.username, "alice@home.test");
|
|
const row = db.prepare(`SELECT invited_by FROM users WHERE id = ?;`).get(
|
|
user.id,
|
|
) as { invited_by: string };
|
|
assertEquals(row.invited_by, inviter.id);
|
|
assertEquals((await get(`/api/invites/${token}`)).status, 404);
|
|
|
|
// Once here, no invite is needed; another user of that instance still
|
|
// needs one.
|
|
assertEquals((await signIn(home, ALICE)).status, 200);
|
|
const bob = { ...ALICE, sub: "bob-id", preferred_username: "bob" };
|
|
assertEquals((await signIn(home, bob)).status, 403);
|
|
});
|
|
|
|
test("federation: blocking an instance refuses its users and ends their sessions", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using _net = network(home);
|
|
await trust(HOME);
|
|
const { token } = (await signIn(home, ALICE)).data as AuthResponse;
|
|
const auth = { Authorization: `Bearer ${token}` };
|
|
assertEquals((await get("/api/me", { auth })).status, 200);
|
|
|
|
await trust(HOME, "blocked");
|
|
assertEquals((await get("/api/me", { auth })).status, 401);
|
|
assertEquals((await signIn(home, ALICE)).status, 403);
|
|
|
|
const admin = await createUser({ role: "admin" });
|
|
const peers = await get<FederationPeer[]>("/api/federation/peers", {
|
|
auth: admin.auth,
|
|
});
|
|
assertEquals(peers.data.map((p) => p.status), ["blocked"]);
|
|
assertEquals((await get<FederationPeer[]>("/api/federation/peers")).data, []);
|
|
assertEquals(
|
|
(await del("/api/federation/peers/home.test", { auth: admin.auth }))
|
|
.status,
|
|
200,
|
|
);
|
|
assertEquals((await get("/api/me", { auth })).status, 200);
|
|
});
|
|
|
|
test("federation: with sign-ins from other instances off, none starts or finishes, and those in are out until it is on", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using _net = network(home);
|
|
await trust(HOME);
|
|
const { token } = (await signIn(home, ALICE)).data as AuthResponse;
|
|
const auth = { Authorization: `Bearer ${token}` };
|
|
const started = await post<FederationLoginResponse>(
|
|
"/api/federation/logins",
|
|
{ json: { instance: "home.test" } },
|
|
);
|
|
const state = new URL(started.data.authorizeUrl).searchParams.get("state");
|
|
const code = home.issue(started.data.authorizeUrl, ALICE);
|
|
|
|
{
|
|
using _off = federationOff("incoming");
|
|
assertEquals((await get("/api/me", { auth })).status, 401);
|
|
assertEquals(
|
|
(await post("/api/federation/logins", {
|
|
json: { instance: "home.test" },
|
|
})).status,
|
|
403,
|
|
);
|
|
assertEquals(
|
|
(await post("/api/federation/sessions", { json: { code, state } }))
|
|
.status,
|
|
403,
|
|
);
|
|
}
|
|
assertEquals((await get("/api/me", { auth })).status, 200);
|
|
});
|
|
|
|
test("federation: a home whose users don't sign in elsewhere is refused before the user leaves", async () => {
|
|
const home = await fakeInstance(HOME, { gerbeur_outgoing: false });
|
|
using _net = network(home);
|
|
await trust(HOME);
|
|
const start = await post("/api/federation/logins", {
|
|
json: { instance: "home.test" },
|
|
});
|
|
assertEquals(start.status, 403);
|
|
assertEquals(count(`SELECT COUNT(*) AS n FROM federation_logins;`), 0);
|
|
});
|
|
|
|
test("federation: only admins edit the peers, which must answer as gerbeurs", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using _net = network(home);
|
|
const user = await createUser();
|
|
const admin = await createUser({ role: "admin" });
|
|
assertEquals(
|
|
(await put("/api/federation/peers", {
|
|
auth: user.auth,
|
|
json: { instance: HOME, status: "trusted" },
|
|
})).status,
|
|
403,
|
|
);
|
|
for (const instance of ["elsewhere.test", "https://home.test/path"]) {
|
|
const res = await put("/api/federation/peers", {
|
|
auth: admin.auth,
|
|
json: { instance, status: "trusted" },
|
|
});
|
|
assert(res.status >= 400, instance);
|
|
}
|
|
assertEquals(
|
|
(await put("/api/federation/peers", {
|
|
auth: admin.auth,
|
|
json: { instance: "http://gerbeur.test", status: "trusted" },
|
|
})).status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("federation: the home's events rename, update and sign out its users", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
const other = await fakeInstance("https://other.test");
|
|
using _net = network(home, other);
|
|
await trust(HOME);
|
|
const { user, token } = (await signIn(home, ALICE)).data as AuthResponse;
|
|
const auth = { Authorization: `Bearer ${token}` };
|
|
|
|
const update = await home.event(ALICE.sub, {
|
|
type: "update",
|
|
claims: { ...ALICE, preferred_username: "alicia", email: "a@new.example" },
|
|
});
|
|
assertEquals(
|
|
(await post("/api/federation/events", { json: { event: update } }))
|
|
.status,
|
|
204,
|
|
);
|
|
const renamed = await get<SelfUser>("/api/me", { auth });
|
|
assertEquals(renamed.data.username, "alicia@home.test");
|
|
assertEquals(renamed.data.email, "a@new.example");
|
|
// The old handle still leads to them.
|
|
assertEquals(
|
|
(await get<SelfUser>("/api/users/@alice@home.test")).data.id,
|
|
user.id,
|
|
);
|
|
|
|
// Another instance can't speak for this one's users.
|
|
const forged = await other.event(ALICE.sub, { type: "revoke" });
|
|
assertEquals(
|
|
(await post("/api/federation/events", { json: { event: forged } }))
|
|
.status,
|
|
400,
|
|
);
|
|
|
|
// Revoking a little later than the token was issued, as it would be.
|
|
await new Promise((r) => setTimeout(r, 1100));
|
|
const revoke = await home.event(ALICE.sub, { type: "revoke" });
|
|
assertEquals(
|
|
(await post("/api/federation/events", { json: { event: revoke } }))
|
|
.status,
|
|
204,
|
|
);
|
|
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);
|
|
await trust(HOME);
|
|
const { user, token } = (await signIn(home, ALICE)).data as AuthResponse;
|
|
const auth = { Authorization: `Bearer ${token}` };
|
|
const admin = await createUser({ role: "admin" });
|
|
|
|
assertEquals(
|
|
(await put("/api/me/password", {
|
|
auth,
|
|
json: { currentPassword: "", newPassword: "long-enough" },
|
|
})).status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await patch("/api/me", { auth, json: { email: "x@example.test" } }))
|
|
.status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await patch<SelfUser>("/api/me", { auth, json: { description: "hi" } }))
|
|
.data.description,
|
|
"hi",
|
|
);
|
|
assertEquals(
|
|
(await put("/api/me/avatar", { auth, form: formWithFile(PNG) })).status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await patch(`/api/users/${user.id}/username`, {
|
|
auth: admin.auth,
|
|
json: { username: "bob" },
|
|
})).status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("federation: an email may be both a local account's and a remote one's", async () => {
|
|
const home = await fakeInstance(HOME);
|
|
using _net = network(home);
|
|
await trust(HOME);
|
|
const local = await createUser({ email: ALICE.email });
|
|
const res = await signIn(home, ALICE);
|
|
assertMatch((res.data as AuthResponse).user.username, /@home\.test$/);
|
|
// A reset by that address reaches the local account only.
|
|
const row = db.prepare(
|
|
`SELECT COUNT(*) AS n FROM users WHERE lower(email) = lower(?);`,
|
|
).get(ALICE.email) as { n: number };
|
|
assertEquals(row.n, 2);
|
|
assertEquals(
|
|
(await post("/api/password-resets", { json: { email: ALICE.email } }))
|
|
.status,
|
|
200,
|
|
);
|
|
const tokens = db.prepare(`SELECT user_id FROM password_reset_tokens;`)
|
|
.all() as { user_id: string }[];
|
|
assert(tokens.every((t) => t.user_id === local.id));
|
|
});
|