clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / test / object / site-auth.test.ts
site-auth.test.ts 9.0K
1import { SELF, runInDurableObject, env } from "cloudflare:test";2import { describe, expect, it } from "vitest";3import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";4import { sha256 } from "@noble/hashes/sha2.js";5import { ev, rpc, nip98, pk, now } from "../helpers/relay.ts";6import { upload } from "../helpers/media.ts";7import { WS } from "../helpers/ws.ts";8import { siteLabel } from "../../src/sites.ts";9import { siteIdentity, SITE_AUTH_PATH } from "../../src/site-auth.ts";10import type { Relay } from "../../src/relay.ts";1112const hex = (b: Uint8Array) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");13const token = (sk: Uint8Array, url: string, body: string) => {14 const e = finalizeEvent({ kind: 27235, content: "", tags: [["u", url], ["method", "POST"], ["payload", hex(sha256(new TextEncoder().encode(body)))]], created_at: Math.floor(Date.now() / 1000) }, sk);15 return "Nostr " + btoa(JSON.stringify(e));16};1718describe("hosted site sign-in", () => {19 it("issues a relay-signed cookie, consumes challenges once, and checks the cookie", async () => {20 const stub = env.RELAY.getByName("site-auth-direct");21 const user = generateSecretKey();22 const url = "https://site-auth.bind.ws/private/index.html";23 const response = await runInDurableObject(stub, async (relay: Relay) => {24 relay.settings.update({ reads: "auth" });25 const page = await siteIdentity(relay, new Request(url, { headers: { accept: "text/html" } }), "site");26 expect(page).toBeInstanceOf(Response);27 const html = await (page as Response).text();28 const nonce = /const challenge="([^"]+)"/.exec(html)?.[1];29 expect(nonce).toBeTruthy();30 const challenge = finalizeEvent({ kind: 22242, content: "bind.ws site sign-in", tags: [["relay", "https://site-auth.bind.ws"], ["challenge", nonce!]], created_at: Math.floor(Date.now() / 1000) }, user);31 const body = JSON.stringify({ event: challenge });32 return siteIdentity(relay, new Request("https://site-auth.bind.ws/.well-known/nsite/auth", { method: "POST", headers: { origin: "https://site-auth.bind.ws", authorization: token(user, "https://site-auth.bind.ws/.well-known/nsite/auth", body), "content-type": "application/json" }, body }), "site");33 });34 expect(response).toBeInstanceOf(Response);35 expect((response as Response).status).toBe(204);36 const setCookie = (response as Response).headers.get("set-cookie")!;37 expect(setCookie).toMatch(/^__Host-nsite=/);38 expect(setCookie).toMatch(/HttpOnly/);39 expect(setCookie).toMatch(/Secure/);40 expect(setCookie).not.toMatch(/Domain=/i);41 await runInDurableObject(stub, async (relay: Relay) => {42 const value = setCookie.split(";", 1)[0];43 const who = await siteIdentity(relay, new Request(url, { headers: { cookie: value } }), "site");44 expect(who).toEqual({ pubkeys: [getPublicKey(user)] });45 });46 });47});484950async function protectedSite(name: string) {51 const host = name + ".bind.ws", owner = generateSecretKey(), member = generateSecretKey();52 await rpc(host, owner, "claim");53 await rpc(host, owner, "allowpubkey", pk(member));54 const file = await upload(host, owner, "private site bytes");55 const e = ev(owner, 15128, "", [["path", "/index.html", file.sha]]);56 const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true); c.ws.close();57 await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());58 await rpc(host, owner, "setpolicy", { reads: "members" });59 return { name, host, owner, member, origin: "https://" + siteLabel(e) + ".bind.ws", label: siteLabel(e) };60}6162async function signInRequest(origin: string, member: Uint8Array, path = "/") {63 const page = await SELF.fetch(origin + path, { headers: { accept: "text/html" } });64 expect(page.status).toBe(401);65 expect(page.headers.get("content-security-policy")).toContain("connect-src 'self'");66 const html = await page.text();67 expect(html).not.toContain("private site bytes");68 const nonce = /const challenge="([^"]+)"/.exec(html)?.[1];69 expect(nonce).toBeTruthy();70 const challenge = ev(member, 22242, "bind.ws site sign-in", [["relay", origin], ["challenge", nonce!]]);71 const body = JSON.stringify({ event: challenge });72 return { nonce, html, body, request: new Request(origin + SITE_AUTH_PATH, { method: "POST", headers: { origin, authorization: token(member, origin + SITE_AUTH_PATH, body), "content-type": "application/json" }, body }) };73}7475describe("site sign-in at the public door", () => {76 it("opens a member's site with a host-only cookie and consumes a challenge only once", async () => {77 const f = await protectedSite("auth-public");78 const proof = await signInRequest(f.origin, f.member);79 const replies = await Promise.all([SELF.fetch(proof.request.clone()), SELF.fetch(proof.request.clone())]);80 expect(replies.map((r) => r.status).sort()).toEqual([204, 401]);81 const cookie = replies.find((r) => r.status === 204)!.headers.get("set-cookie")!;82 expect(cookie).toContain("Secure"); expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("SameSite=Lax"); expect(cookie).not.toContain("Domain=");83 const value = cookie.split(";")[0];84 const page = await SELF.fetch(f.origin + "/", { headers: { cookie: value } });85 expect(page.status).toBe(200); expect(await page.text()).toBe("private site bytes");86 expect(page.headers.get("cache-control")).toContain("no-store");87 const discoveryURL = f.origin + "/.well-known/nostr.json?path=%2F";88 const discovery = await SELF.fetch(discoveryURL, { headers: { cookie: value } });89 expect(discovery.status).toBe(200);90 expect(await discovery.json()).toEqual({ "/": { filter: { kinds: [15128], authors: [pk(f.owner)], limit: 1 }, relays: [`wss://${f.host}`] } });91 const altered = value.slice(0, -5) + "zzzzz";92 expect((await SELF.fetch(f.origin + "/", { headers: { cookie: altered } })).status).toBe(401);93 // A cookie for this site does not authenticate at the relay door.94 expect((await SELF.fetch("https://" + f.host + "/query", { method: "POST", headers: { cookie: value }, body: "[]" })).status).toBe(401);95 await rpc(f.host, f.owner, "removemember", pk(f.member));96 expect((await SELF.fetch(f.origin + "/", { headers: { cookie: value } })).status).toBe(403);97 expect((await SELF.fetch(discoveryURL, { headers: { cookie: value } })).status).toBe(403);98 });99100 it("rejects cross-origin proofs, expired challenges and malformed exchanges", async () => {101 const f = await protectedSite("auth-reject");102 const proof = await signInRequest(f.origin, f.member, "/index.html?x=1");103 expect(proof.html).toContain('returnUrl="' + f.origin + '/index.html?x=1"');104 const wrongHeaders = new Headers(proof.request.headers);105 wrongHeaders.set("origin", "https://other.test");106 const wrong = new Request(proof.request.url, { method: "POST", headers: wrongHeaders, body: proof.body });107 expect((await SELF.fetch(wrong)).status).toBe(401);108 await runInDurableObject(env.RELAY.getByName(f.name), (r) => { r.sql.exec(`UPDATE nsite_auth_challenges SET expires=? WHERE nonce=?`, now() - 1, proof.nonce); });109 expect((await SELF.fetch(proof.request)).status).toBe(401);110 const nullBody = "null";111 expect((await SELF.fetch(f.origin + SITE_AUTH_PATH, { method: "POST", headers: { origin: f.origin, authorization: token(f.member, f.origin + SITE_AUTH_PATH, nullBody) }, body: nullBody })).status).toBe(401);112 const head = await SELF.fetch(f.origin + "/", { method: "HEAD", headers: { accept: "text/html" } });113 expect(head.status).toBe(401); expect(await head.text()).toBe("");114 const huge = await SELF.fetch(f.origin + SITE_AUTH_PATH, { method: "POST", headers: { origin: f.origin }, body: "x".repeat(33 * 1024) });115 expect(huge.status).toBe(413);116 const outsider = await signInRequest(f.origin, generateSecretKey());117 expect((await SELF.fetch(outsider.request)).status).toBe(403);118 });119120 it("requires exact NIP-98 URLs and binds signed cookies to origin, site and lifetime", async () => {121 const f = await protectedSite("auth-binding");122 const url = f.origin + "/";123 const good = await nip98(f.member, url);124 expect((await SELF.fetch(url, { headers: { authorization: good } })).status).toBe(200);125 expect((await SELF.fetch(url + "?different=1", { headers: { authorization: good } })).status).toBe(401);126 expect((await SELF.fetch(url.replace("https:", "http:"), { headers: { authorization: good } })).status).toBe(401);127 const cookies = await runInDurableObject(env.RELAY.getByName(f.name), (r) => {128 const make = (label: string, origin: string, expires: number) => {129 const signed = r.identity.sign(22242, [["site", label], ["origin", origin], ["pubkey", pk(f.member)], ["expiration", String(expires)], ["purpose", "nsite-cookie"]]);130 return "__Host-nsite=" + btoa(JSON.stringify(signed)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");131 };132 return [make("wrong-site", f.origin, now() + 60), make(f.label, "https://elsewhere.test", now() + 60), make(f.label, f.origin, now() - 1)];133 });134 for (const cookie of cookies) expect((await SELF.fetch(url, { headers: { cookie } })).status).toBe(401);135 });136});