378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import {
|
|
app,
|
|
BASE_URL,
|
|
createUser,
|
|
db,
|
|
del,
|
|
type FakeInstance,
|
|
fakeInstance,
|
|
federationOff,
|
|
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,
|
|
withdrawAllGrants,
|
|
} 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);
|
|
assertEquals(body.gerbeur_outgoing, true);
|
|
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: with sign-ins elsewhere off, no instance signs the users in, and their grants are withdrawn", async () => {
|
|
const fake = await fakeInstance(CLIENT);
|
|
using _net = network(fake);
|
|
const user = await createUser();
|
|
const { verifier, challenge } = pkce();
|
|
const pending = new URL(
|
|
(await authorize(user.auth, challenge)).data.redirectTo,
|
|
).searchParams.get("code")!;
|
|
|
|
using _off = federationOff("outgoing");
|
|
const meta = await get("/.well-known/openid-configuration");
|
|
assertEquals(
|
|
(JSON.parse(new TextDecoder().decode(meta.bytes)) as InstanceMetadata)
|
|
.gerbeur_outgoing,
|
|
false,
|
|
);
|
|
assertEquals(
|
|
(await get(`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`, {
|
|
auth: user.auth,
|
|
})).status,
|
|
403,
|
|
);
|
|
assertEquals((await authorize(user.auth, challenge)).status, 403);
|
|
|
|
assertEquals(await withdrawAllGrants(), 1);
|
|
await until(() => fake.received.length === 1);
|
|
assertEquals(decodeJwt(fake.received[0]).type, "revoke");
|
|
assertEquals((await trade(pending, verifier)).status, 400);
|
|
assertEquals(
|
|
(await get<FederationGrant[]>("/api/me/grants", { auth: user.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);
|
|
});
|