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 = {}, ) { return post("/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( `/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( `/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`, { auth: user.auth }, ); assertEquals(again.data.granted, true); const grants = await get("/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("/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("/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); });