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