Files
gerbeur/api/test/services/identity-provider-service.test.ts

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