1// Browser sign-in for hosted sites. The site origin is deliberately kept2// separate from the relay HTTP doors: this module only identifies a visitor3// and never fetches or publishes site data.4import { sha256 } from "@noble/hashes/sha2.js";5import { validate, tagValues, type Event } from "./event.ts";6import { now } from "./event.ts";7import { KIND_AUTH } from "./kinds.ts";8import { page, FONTS } from "./ui.ts";9import type { Relay } from "./relay.ts";1011export const SITE_AUTH_PATH = "/.well-known/nsite/auth";12const CHALLENGE_TTL = 5 * 60;13const COOKIE_TTL = 7 * 24 * 60 * 60;14const MAX_BODY = 32 * 1024;15const COOKIE = "__Host-nsite";1617type AuthRelay = Pick<Relay, "identity" | "settings" | "sql">;1819const b64url = (s: string) => btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");20const unb64 = (s: string) => atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - s.length % 4) % 4));21const text = (req: Request, status: number, message: string) => new Response(req.method === "HEAD" ? null : message, {22 status, headers: { "cache-control": "private, no-store", "content-type": "text/plain; charset=utf-8" },23});24const reject = (req: Request, message = "Authentication required", status = 401) => text(req, status, message);2526function exactNIP98(header: string, url: string, method: string, body: string): Event | string {27 const m = /^Nostr\s+(\S+)$/i.exec(header.trim());28 if (!m) return "auth-required: missing NIP-98 Authorization header";29 let e: Event;30 try { e = JSON.parse(unb64(m[1])); } catch { return "auth-required: malformed NIP-98 token"; }31 const bad = validate(e);32 if (bad) return "auth-required: " + bad;33 if (e.kind !== 27235) return "auth-required: token must be kind 27235";34 const t = now();35 if (Math.abs(t - e.created_at) > 60) return "auth-required: token expired";36 if (tagValues(e, "u").length !== 1 || tagValues(e, "u")[0] !== url) return "auth-required: token was signed for another URL";37 if (tagValues(e, "method").length !== 1 || (tagValues(e, "method")[0] ?? "").toUpperCase() !== method.toUpperCase()) return "auth-required: token was signed for another method";38 const payload = tagValues(e, "payload");39 if (body !== "" && (payload.length !== 1 || payload[0] !== hex(sha256(new TextEncoder().encode(body))))) return "auth-required: token payload hash does not match the body";40 if (body === "" && (payload.length > 1 || (payload.length === 1 && payload[0] !== hex(sha256(new Uint8Array()))))) return "auth-required: token payload hash does not match the body";41 return e;42}4344const hex = (bytes: Uint8Array) => [...bytes].map((x) => x.toString(16).padStart(2, "0")).join("");45function authPage(origin: string, action: string, returnUrl: string, nonce: string, expires: number): Response {46 const scriptNonce = b64url(crypto.randomUUID());47 const jsq = (v: string) => JSON.stringify(v).replace(/</g, "\\u003c");48 const js = `const challenge=${jsq(nonce)}, action=${jsq(action)}, returnUrl=${jsq(returnUrl)};49async function sign(){50 if(!window.nostr){document.querySelector('#msg').textContent='Install a NIP-07 signer first';return}51 const e=await window.nostr.signEvent({kind:${KIND_AUTH},created_at:Math.floor(Date.now()/1000),tags:[['relay',${jsq(origin)}],['challenge',challenge]],content:'bind.ws site sign-in'});52 const body=JSON.stringify({event:e}); const p=await window.nostr.signEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[['u',action],['method','POST'],['payload',await crypto.subtle.digest('SHA-256',new TextEncoder().encode(body)).then(x=>[...new Uint8Array(x)].map(x=>x.toString(16).padStart(2,'0')).join(''))]],content:''});53 const r=await fetch(action,{method:'POST',headers:{'content-type':'application/json','authorization':'Nostr '+btoa(JSON.stringify(p))},body}); if(r.ok) location.assign(returnUrl); else document.querySelector('#msg').textContent=await r.text(); }54document.querySelector('button').onclick=()=>sign().catch(e=>document.querySelector('#msg').textContent=String(e));`;55 // The shared shell supplies the layout; system fonts keep the sign-in56 // page independent of external hosts under its narrow content policy.57 const html = page("Sign in", `<main><h1>Sign in</h1><p class="note" id="msg">Your Nostr signer opens this site under the relay's read rule.</p><button class="btn pri">Sign in</button></main><script nonce="${scriptNonce}">${js}</script>`).replace(FONTS, "");58 return new Response(html, { status: 401, headers: { "cache-control": "no-store", "content-type": "text/html; charset=utf-8", "content-security-policy": `default-src 'none'; base-uri 'none'; frame-ancestors 'none'; connect-src 'self'; style-src 'unsafe-inline'; script-src 'nonce-${scriptNonce}'`, "x-content-type-options": "nosniff", "x-nsite-challenge-expires": String(expires) } });59}6061function cookieEvent(relay: AuthRelay, label: string, origin: string, pubkey: string, expires: number): Event {62 return relay.identity.sign(KIND_AUTH, [["site", label], ["origin", origin], ["pubkey", pubkey], ["expiration", String(expires)], ["purpose", "nsite-cookie"]], "bind.ws site cookie");63}6465async function challengePage(relay: AuthRelay, req: Request, label: string): Promise<Response> {66 const u = new URL(req.url);67 const origin = u.origin;68 relay.sql.exec(`CREATE TABLE IF NOT EXISTS nsite_auth_challenges (nonce TEXT PRIMARY KEY, label TEXT NOT NULL, origin TEXT NOT NULL, return_url TEXT NOT NULL, expires INTEGER NOT NULL)`);69 const t = now();70 relay.sql.exec(`DELETE FROM nsite_auth_challenges WHERE expires<=?`, t);71 const outstanding = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM nsite_auth_challenges WHERE label=? AND origin=?`, label, origin).one().n;72 const total = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM nsite_auth_challenges`).one().n;73 if (outstanding >= 32 || total >= 512) return text(req, 429, "Too many sign-in attempts");74 const nonce = b64url(crypto.randomUUID() + crypto.randomUUID());75 const expires = t + CHALLENGE_TTL;76 const returnUrl = req.method === "GET" && u.pathname === SITE_AUTH_PATH77 ? (() => { const raw = u.searchParams.get("return"); if (!raw) return origin + "/"; try { const x = new URL(raw, origin); return x.origin === origin ? x.href : origin + "/"; } catch { return origin + "/"; } })()78 : u.href;79 relay.sql.exec(`INSERT INTO nsite_auth_challenges(nonce,label,origin,return_url,expires) VALUES(?,?,?,?,?)`, nonce, label, origin, returnUrl, expires);80 const action = origin + SITE_AUTH_PATH;81 return authPage(origin, action, returnUrl, nonce, expires);82}8384function readCookie(relay: AuthRelay, value: string, label: string, origin: string): { pubkey: string; expires: number } | null {85 try {86 const e = JSON.parse(unb64(value)) as Event;87 if (validate(e) || e.kind !== KIND_AUTH || e.pubkey !== relay.identity.pubkey) return null;88 if (tagValues(e, "purpose").length !== 1 || tagValues(e, "purpose")[0] !== "nsite-cookie") return null;89 if (tagValues(e, "site")[0] !== label || tagValues(e, "origin")[0] !== origin) return null;90 const pubkey = tagValues(e, "pubkey")[0] ?? "";91 const expires = Number(tagValues(e, "expiration")[0]);92 return /^[0-9a-f]{64}$/.test(pubkey) && Number.isSafeInteger(expires) && expires > now() ? { pubkey, expires } : null;93 } catch { return null; }94}9596// siteIdentity returns the visitor's proved pubkeys, or the sign-in response.97export async function siteIdentity(relay: AuthRelay, req: Request, label: string): Promise<{ pubkeys: string[] } | Response> {98 const url = new URL(req.url);99 const origin = url.origin;100 const authPath = url.pathname === SITE_AUTH_PATH;101 const cookie = req.headers.get("cookie")?.split(";").map((x) => x.trim()).find((x) => x.startsWith(COOKIE + "="))?.slice(COOKIE.length + 1);102 if (cookie) {103 const c = readCookie(relay, cookie, label, origin);104 if (c) {105 const gate = relay.settings.mayRead([c.pubkey]);106 if (!gate && !authPath) return { pubkeys: [c.pubkey] };107 if (!authPath) return reject(req, gate, 403);108 }109 }110 if (!authPath) {111 const supplied = req.headers.get("authorization");112 if (supplied) {113 const proof = exactNIP98(supplied, req.url, req.method, "");114 if (typeof proof === "string") return reject(req, proof);115 const gate = relay.settings.mayRead([proof.pubkey]);116 return gate ? reject(req, gate, 403) : { pubkeys: [proof.pubkey] };117 }118 const gate = relay.settings.mayRead([]);119 if (!gate) return { pubkeys: [] };120 if (req.method === "GET" && /\btext\/html\b/i.test(req.headers.get("accept") ?? "")) return challengePage(relay, req, label);121 return reject(req, gate);122 }123 if (req.method === "GET") {124 const supplied = req.headers.get("authorization");125 if (supplied) {126 const proof = exactNIP98(supplied, req.url, req.method, "");127 if (typeof proof === "string") return reject(req, proof);128 const gate = relay.settings.mayRead([proof.pubkey]);129 if (gate) return reject(req, gate, 403);130 }131 if (!/\btext\/html\b/i.test(req.headers.get("accept") ?? "")) return reject(req);132 if (req.headers.get("origin") && req.headers.get("origin") !== origin) return reject(req);133 return challengePage(relay, req, label);134 }135 if (req.method !== "POST" || req.headers.get("origin") !== origin) return reject(req);136 const declared = Number(req.headers.get("content-length"));137 if (Number.isFinite(declared) && declared > MAX_BODY) return text(req, 413, "Request body too large");138 if (!req.body) return reject(req, "Malformed sign-in request");139 const reader = req.body.getReader();140 const chunks: Uint8Array[] = []; let size = 0;141 for (;;) {142 const part = await reader.read();143 if (part.done) break;144 size += part.value.byteLength;145 if (size > MAX_BODY) { await reader.cancel(); return text(req, 413, "Request body too large"); }146 chunks.push(part.value);147 }148 // Assemble after the bounded read; this avoids Request.text() accepting an149 // unbounded upload while retaining the exact bytes used by NIP-98.150 const bytes = new Uint8Array(size); let at = 0;151 for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.byteLength; }152 const rawBody = new TextDecoder().decode(bytes);153 if (rawBody.length > MAX_BODY) return text(req, 413, "Request body too large");154 let input: { event?: Event };155 try { input = JSON.parse(rawBody); } catch { return reject(req, "Malformed sign-in request"); }156 const proof = exactNIP98(req.headers.get("authorization") ?? "", req.url, req.method, rawBody);157 if (typeof proof === "string" || !input || !input.event) return reject(req, "Invalid sign-in proof");158 const e = input.event; const bad = validate(e); const t = now();159 if (bad || e.kind !== KIND_AUTH || Math.abs(t - e.created_at) > 120 || e.pubkey !== proof.pubkey || tagValues(e, "challenge").length !== 1 || tagValues(e, "relay").length !== 1) return reject(req, "Invalid sign-in proof");160 const nonce = tagValues(e, "challenge")[0] ?? "";161 relay.sql.exec(`CREATE TABLE IF NOT EXISTS nsite_auth_challenges (nonce TEXT PRIMARY KEY, label TEXT NOT NULL, origin TEXT NOT NULL, return_url TEXT NOT NULL, expires INTEGER NOT NULL)`);162 const stored = relay.sql.exec<{ label: string; origin: string; return_url: string; expires: number }>(`DELETE FROM nsite_auth_challenges WHERE nonce=? AND expires>? RETURNING label,origin,return_url,expires`, nonce, t).toArray()[0];163 if (!stored || stored.label !== label || stored.origin !== origin || tagValues(e, "relay")[0] !== origin) return reject(req, "Invalid or expired challenge");164 if (relay.settings.mayRead([e.pubkey])) return reject(req, "You are not allowed to read this site", 403);165 await relay.identity.ensure();166 const expires = t + COOKIE_TTL;167 const signed = cookieEvent(relay, label, origin, e.pubkey, expires);168 return new Response(null, { status: 204, headers: { "set-cookie": `${COOKIE}=${b64url(JSON.stringify(signed))}; Path=/; Max-Age=${COOKIE_TTL}; HttpOnly; Secure; SameSite=Lax`, "cache-control": "no-store" } });169}