~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
197 lines · 11.5 KB Raw History
// node --test rust/host/oauth.test.mjs   (needs `cargo wasm`, `cargo host`)
// The Rust host's server OAuth sessions against the same fake authorization server + PDS as the Node one
// (javascript/src/oauth-server/fake.mjs, no live accounts) and the same cases as
// javascript/src/oauth-server/oauth-server.test.mjs: `atlibs serve` behind a reverse-proxied FQDN (--origin;
// the proxy forwards the public Host) signs users in with its confidential client, runs 8 statusphere runs on an
// expiring token with one refresh, 3 users x 3 runs with no cross-use, refuses unknown sessions, revokes on
// sign-out; `atlibs serve` with no --origin uses the loopback client; `atlibs login` (the loopback redirect, as
// knotless) saves a session that `atlibs run --as` uses, and `atlibs logout` revokes it.
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtemp } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";

import { browser, fakeAtproto } from "../../javascript/src/oauth-server/fake.mjs";
import { atlibs, hostBin, scopeFixture } from "./conformance.mjs";

const ROOT = new URL("../../", import.meta.url).pathname;
const ORIGIN = "https://site.example";
let fake, served;
const proxied = new Map(); // public host -> local port: the reverse proxy

/** fetch-style, to a local port with the public Host header (what a reverse proxy forwards). */
const via = (port, host) => (req) =>
	req.arrayBuffer().then(
		(body) =>
			new Promise((resolve, reject) => {
				const url = new URL(req.url);
				const headers = Object.fromEntries(req.headers);
				const out = httpRequest({ host: "127.0.0.1", port, path: url.pathname + url.search, method: req.method, headers: { ...headers, host } }, (res) => {
					const chunks = [];
					res.on("data", (c) => chunks.push(c));
					res.on("end", () => {
						const h = new Headers();
						for (const [k, v] of Object.entries(res.headers)) for (const one of [v].flat()) h.append(k, one);
						resolve(new Response([204, 301, 302, 303, 304].includes(res.statusCode) ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h }));
					});
				});
				out.on("error", reject);
				out.end(Buffer.from(body));
			}),
	);
const route = (url) => {
	const { host } = new URL(url);
	return proxied.has(host) ? via(proxied.get(host), host) : globalThis.fetch;
};

const env = (state) => ({ PATH: process.env.PATH, HOME: process.env.HOME, ATLIBS_SERVER_STATE: state, ATLIBS_ALLOW_PRIVATE: "1", ATLIBS_PLC_URL: fake.base, ATLIBS_HANDLE_RESOLVER: fake.base, ATLIBS_BROWSER: "true" });

function serve(state, ...args) {
	const ref = args[0] && !args[0].startsWith("--") ? args.shift() : "examples/rust/statusphere";
	const child = spawn(hostBin(), ["serve", ref, "--port", "0", ...args], { cwd: ROOT, env: env(state) });
	served.push(child);
	return new Promise((resolve, reject) => {
		let err = "";
		child.stderr.on("data", (chunk) => {
			err += chunk;
			const found = err.match(/atlibs serve: http:\/\/127\.0\.0\.1:(\d+)\//);
			if (found) resolve(Number(found[1]));
		});
		child.on("exit", (code) => reject(new Error(`serve exited ${code}: ${err}`)));
	});
}

before(async () => {
	served = [];
	fake = await fakeAtproto({ fetchClient: (url) => route(url)(new Request(url)) });
});
after(async () => {
	for (const child of served) child.kill();
	await fake?.close();
});

async function signIn(base, user) {
	const b = browser(route);
	const back = await b.go(`${base}/oauth/login?handle=${user}.test`);
	assert.equal(back.finalUrl, `${base}/`, `${user} came back signed in (${back.status} ${back.status >= 400 ? await back.text() : ""})`);
	const handle = b.cookie(base, "atlibs_session");
	assert.ok(handle, `${user}'s session cookie`);
	return handle;
}

const run = (base, handle, status = "🙂") =>
	route(base)(new Request(`${base}/run/set_status`, { method: "POST", headers: { "content-type": "application/json", ...(handle && { authorization: `Bearer ${handle}` }) }, body: JSON.stringify({ status, now: new Date().toISOString() }) }));

test("atlibs serve behind a reverse-proxied FQDN acts for each signed-in user: one refresh for N runs, no cross-use", async () => {
	const state = join(await mkdtemp(join(tmpdir(), "atlibs-oauth-serve-")), "state");
	proxied.set("site.example", await serve(state, "--origin", ORIGIN));
	const meta = await (await route(ORIGIN)(new Request(`${ORIGIN}/oauth/client-metadata.json`))).json();
	assert.equal(meta.client_id, `${ORIGIN}/oauth/client-metadata.json`, "client_id on --origin");
	assert.deepEqual(meta.redirect_uris, [`${ORIGIN}/oauth/callback`]);
	assert.equal(meta.token_endpoint_auth_method, "private_key_jwt");
	assert.equal((await route(ORIGIN)(new Request(`${ORIGIN}/oauth/jwks.json`)).then((r) => r.json())).keys.length, 1);
	assert.equal((await via(proxied.get("site.example"), "evil.example")(new Request("http://evil.example/oauth/login?handle=alice.test"))).status, 403, "Host is not trusted");

	// N=8 concurrent runs for alice on an expiring token: one refresh, all succeed, all as alice
	fake.accessTtl = 1;
	const alice = await signIn(ORIGIN, "alice");
	fake.accessTtl = 3600;
	fake.refreshes = 0;
	const ran = await Promise.all(Array.from({ length: 8 }, () => run(ORIGIN, alice).then((r) => r.json())));
	assert.deepEqual(ran.map((r) => r.error), Array(8).fill(undefined), JSON.stringify(ran.find((r) => r.error)));
	assert.ok(ran.every((r) => r.result.uri.startsWith(`at://${fake.did("alice")}/`)));
	assert.equal(fake.refreshes, 1, "one refresh for 8 runs");

	// tokens refused server-side (revoked / expired early): runs renew once and retry
	fake.expireAll();
	fake.refreshes = 0;
	const again = await Promise.all(Array.from({ length: 4 }, () => run(ORIGIN, alice).then((r) => r.json())));
	assert.deepEqual(again.map((r) => r.error), Array(4).fill(undefined));
	assert.equal(fake.refreshes, 1);

	// 3 users x 3 concurrent runs: every write is the writer's own repo with their own token
	const users = { alice, bob: await signIn(ORIGIN, "bob"), carol: await signIn(ORIGIN, "carol") };
	fake.writes.length = 0;
	const all = await Promise.all(Object.entries(users).flatMap(([user, handle]) => [1, 2, 3].map(async () => [user, await run(ORIGIN, handle).then((r) => r.json())])));
	for (const [user, ranAs] of all) {
		assert.equal(ranAs.error, undefined, `${user}: ${ranAs.error}`);
		assert.ok(ranAs.result.uri.startsWith(`at://${fake.did(user)}/`), `${user}'s run wrote ${ranAs.result.uri}`);
	}
	assert.equal(fake.writes.length, 9);
	assert.ok(fake.writes.every((w) => w.sub === w.repo), "no token wrote another user's repo");

	// unknown session: 401; no session and no --as / --app-password: signed out (the write needs a session)
	assert.equal((await run(ORIGIN, "forged")).status, 401);
	assert.match((await run(ORIGIN).then((r) => r.json())).error, /missing field `did`|needs a session/, "signed out");

	// sign-out revokes bob's tokens; his handle stops working; alice is untouched
	const revocations = fake.revocations;
	const out = await route(ORIGIN)(new Request(`${ORIGIN}/oauth/logout`, { method: "POST", headers: { authorization: `Bearer ${users.bob}` } })).then((r) => r.json());
	assert.equal(out.signedOut, fake.did("bob"));
	assert.equal(fake.revocations, revocations + 1);
	assert.equal((await run(ORIGIN, users.bob)).status, 401);
	assert.equal((await run(ORIGIN, alice).then((r) => r.json())).error, undefined);
});

test("atlibs serve with no --origin signs in with the loopback client", async () => {
	const state = join(await mkdtemp(join(tmpdir(), "atlibs-oauth-loopback-")), "state");
	const port = await serve(state);
	const base = `http://127.0.0.1:${port}`;
	const handle = await signIn(base, "carol");
	const ran = await run(base, handle).then((r) => r.json());
	assert.equal(ran.error, undefined, ran.error);
	assert.ok(ran.result.uri.startsWith(`at://${fake.did("carol")}/`));
	assert.equal((await fetch(`${base}/oauth/client-metadata.json`)).status, 404, "a loopback client has no document of its own");
});

test("atlibs login (loopback redirect) saves a session per DID; run --as uses it; logout revokes it", async () => {
	const state = join(await mkdtemp(join(tmpdir(), "atlibs-oauth-login-")), "state");
	const login = spawn(hostBin(), ["login", "alice.test", "--no-open"], { cwd: ROOT, env: env(state) });
	let out = "", err = "";
	login.stdout.on("data", (c) => (out += c));
	const url = await new Promise((resolve, reject) => {
		login.stderr.on("data", (chunk) => {
			err += chunk;
			const found = err.match(/\n {2}(http\S+)/);
			if (found) resolve(found[1]);
		});
		login.on("exit", (code) => reject(new Error(`login exited ${code}: ${err}`)));
	});
	const page = await browser().go(url); // the fake approves; the browser lands on the CLI's listener
	assert.match(await page.text(), /Signed in/);
	assert.equal(await new Promise((resolve) => login.on("exit", resolve)), 0, err);
	assert.match(out, new RegExp(`signed in as alice.test \\(${fake.did("alice")}\\)`));

	const input = JSON.stringify({ status: "🙂", now: new Date().toISOString() });
	const { stdout } = await atlibs(["run", "examples/rust/statusphere", "set_status", input, "--as", "alice.test"], { env: env(state) });
	assert.ok(JSON.parse(stdout).result.uri.startsWith(`at://${fake.did("alice")}/`));
	// no flag: signed out, whatever the environment holds
	await assert.rejects(atlibs(["run", "examples/rust/statusphere", "set_status", input], { env: { ...env(state), ATPROTO_HANDLE: "alice.test", ATPROTO_PASSWORD: "pw" } }), /missing field `did`|needs a session/);
	const revocations = fake.revocations;
	assert.match((await atlibs(["logout", "alice.test"], { env: env(state) })).stdout, /signed out alice.test/);
	assert.equal(fake.revocations, revocations + 1);
	await assert.rejects(atlibs(["run", "examples/rust/statusphere", "set_status", input, "--as", "alice.test"], { env: env(state) }), /no OAuth session here/);
});

// Server sessions: one sign-in with the union of the served modules' scopes; each run is held to
// its own module's session record, so a may not write b's collection (nor b a's) with the user's token.
test("atlibs serve: two modules under one sign-in, each run limited to its own module's scope", async () => {
	const dir = await scopeFixture();
	const state = join(dir, "state");
	proxied.set("scopes.example", await serve(state, join(dir, "comp.json"), "--origin", "https://scopes.example"));
	const base = "https://scopes.example";
	const meta = await (await route(base)(new Request(`${base}/oauth/client-metadata.json`))).json();
	assert.ok(["repo:com.example.a?action=create", "repo:com.example.b?action=create", "blob:*/*"].every((s) => meta.scope.split(" ").includes(s)), meta.scope);
	const alice = await signIn(base, "alice");
	const create = (instance, collection) =>
		route(base)(new Request(`${base}/run/${instance}/caller`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${alice}` }, body: JSON.stringify({ nsid: "com.atproto.repo.createRecord", input: { repo: fake.did("alice"), collection, record: {} } }) })).then((r) => r.json());
	fake.writes.length = 0;
	assert.equal((await create("a", "com.example.a")).result.q.ok, true);
	assert.equal((await create("a", "com.example.b")).result.q.error, "a may not call com.atproto.repo.createRecord with sessions sa");
	assert.equal((await create("b", "com.example.b")).result.q.ok, true);
	assert.equal((await create("b", "com.example.a")).result.q.error, "b may not call com.atproto.repo.createRecord with sessions sb");
	assert.deepEqual(fake.writes.map((w) => w.collection), ["com.example.a", "com.example.b"], "refused writes never reached the PDS");
});