clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Sign in with passkeys to see private pages

cat committed a6d10bc
@simplewebauthn/server 14 checks passkeys for the private server
(lib/login.ts), and @simplewebauthn/browser, shipped as a hashed asset,
drives them on /login/ (site/assets/login.ts). The relying party is the
origin of FORGE_BASE_URL; every response must come from it. Sign-in is
usernameless and needs user verification, and a passkey is only added
through the link ssh passkey prints: the code rides in the URL fragment
and works once, within ten minutes, for the ssh user. ML-DSA-44 comes
first among the algorithms on runtimes that have it.

A session is a __Host- cookie (Secure, HttpOnly, SameSite=Lax) whose
hash is in identity.db; it lasts 30 days from its last use, renewed at
most hourly. Sign-out is a POST from the forge's own page with a CSRF
token derived from the session. Signed in, /login/ and /private/ (the
private repos you can read) carry the user's accepted credential ids, so
the page can tell the password manager with the Signal API which
passkeys still work after passkeys rm; an unknown passkey is signalled
as such. The private server serves the forge's assets itself, so these
pages work before a build ships them. /login/ and /private/ are not
user names any more.

test/authenticator.ts is a software authenticator (ES256, attestation
"none") that runs registration and sign-in over HTTP, so the scenarios
cover the link, the cookie, sliding expiry, sign-out, origin, RP ID,
user verification, counters and replayed challenges.
commit
a6d10bc30524ec373b36ea37189f345149e46e33
parent
dcbb5255e4f0f8bc79fcbf7975dcb7184e22106b
author
cat <admin@dev.site>
kind
feat risk score 2.7 of 3, Jev's estimate
16 files changed, +1296 -26
Mbin/forge2+
Mlib/assets.ts10+-
Mlib/forge.ts2+-
Mlib/identity.ts16+
Alib/login.ts177++++++++++++++++
Mlib/private-server.ts189+++++++++++++++--
Mpackage-lock.json336++++++++++++++++++++++++++++++
Mpackage.json2+
Asite/_includes/login.njk48++++
Asite/_includes/private.njk35+++
Asite/assets/login.ts111++++++++++
Atest/authenticator.ts129++++++++++++
Atest/scenarios/identity-enroll.sh48++++
Atest/scenarios/identity-passkey.sh58+++++
Atest/scenarios/identity-sessions.sh92++++++++
Atest/scenarios/private-web-session.sh67++++++
M bin/forge +2 -0
M lib/assets.ts +7 -3
@@ -1,7 +1,8 @@
11 // Assets under content-hashed names, /assets/style.<hash>.css, so browsers22 // can cache them forever: the stylesheet, the logo, the vendored scripts, our3-// TypeScript with its types stripped, mermaid, and sqlite-wasm (its script4-// and its .wasm, for code search). 11ty's bundle plugin3+// TypeScript with its types stripped, mermaid, sqlite-wasm (its script and4+// its .wasm, for code search), and @simplewebauthn/browser (passkeys, on5+// the sign-in page). 11ty's bundle plugin56 // writes the templates' own CSS next to them, also named by a hash.67 //78 // Pages from older builds keep linking older names: forge build swaps one
@@ -18,12 +19,15 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1819 const src = path.join(root, 'site', 'assets');1920 const MERMAID = path.join(root, 'node_modules', 'mermaid', 'dist', 'mermaid.min.js');2021 const SQLITE = path.join(root, 'node_modules', '@sqlite.org', 'sqlite-wasm', 'dist');22+const WEBAUTHN = path.join(root, 'node_modules', '@simplewebauthn', 'browser', 'dist', 'bundle', 'index.umd.min.js');2123 // Files from node_modules: short name, the name they ship as, the source.2224 const VENDOR: [string, string, string][] = [2325 ['mermaid', 'mermaid.js', MERMAID],2426 // The browser build, an ES module that codesearch.ts imports in its worker.2527 ['sqlite3', 'sqlite3.js', path.join(SQLITE, 'index.mjs')],2628 ['sqlite3wasm', 'sqlite3.wasm', path.join(SQLITE, 'sqlite3.wasm')],29+ // A classic script that defines SimpleWebAuthnBrowser, for login.ts.30+ ['webauthn', 'webauthn.js', WEBAUTHN],2731 ];2832 const VERSIONS = 'versions.json';2933 // Code versions whose files stay: enough for a rebuild of every repo to finish
@@ -39,7 +43,7 @@ export interface Asset {
3943 }4044 4145 // By short name: style, logo, fixi, ssexi, diffs, keys, live, search,42-// codesearch, ui, mermaid, sqlite3, sqlite3wasm.46+// codesearch, login, ui, mermaid, sqlite3, sqlite3wasm, webauthn.4347 export type Assets = Record<string, Asset>;4448 4549 let memo: { key: string; assets: Assets } | null = null;
M lib/forge.ts +1 -1
M lib/identity.ts +16 -0
A lib/login.ts +177 -0
@@ -0,0 +1,177 @@
1+// Passkey sign-in, with @simplewebauthn/server, for lib/private-server.ts.2+//3+// The relying party is the forge's own origin, FORGE_BASE_URL: its host is4+// the RP ID, and every response must come from that origin. Sign-in is5+// usernameless: the browser offers the passkeys it has for the forge (they6+// are discoverable), and the credential says whose it is. User verification7+// is required, since a passkey is the only factor. A passkey is added only8+// through a link `ssh <host> passkey` prints, which works once, for ten9+// minutes, and binds the passkey to the ssh user; the code travels in the10+// URL's fragment, so it never reaches a server log or a Referer. Supported11+// algorithms are the library's: ML-DSA-44 first where the runtime has it12+// (Node 24.7 and later), then EdDSA, ES256 and RS256.13+//14+// Challenges live in memory for five minutes and are used once. A signed-in15+// browser gets the user's accepted credential ids back, so the page can tell16+// the password manager which passkeys still work (the WebAuthn Signal API,17+// signalAllAcceptedCredentials), and a passkey the forge doesn't know gets18+// signalUnknownCredential.19+import type http from 'node:http';20+import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';21+import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server';22+import type { Identity, Passkey } from './identity.ts';23+24+const CHALLENGE_TTL = 5 * 60 * 1000;25+26+export interface RelyingParty {27+ id: string;28+ origin: string;29+ name: string;30+}31+32+export function relyingParty(baseUrl: string, name: string): RelyingParty | null {33+ if (!baseUrl) return null;34+ try {35+ const u = new URL(baseUrl);36+ return { id: u.hostname, origin: u.origin, name };37+ } catch {38+ return null;39+ }40+}41+42+// "Chrome on macOS": what a session or passkey is listed as.43+export function agentName(ua: string | undefined): string {44+ const s = ua ?? '';45+ const browser = /Edg\//.test(s) ? 'Edge' : /Firefox\//.test(s) ? 'Firefox' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /^curl\//.test(s) ? 'curl' : '';46+ const os = /iPhone|iPad/.test(s) ? 'iOS' : /Android/.test(s) ? 'Android' : /Macintosh|Mac OS X/.test(s) ? 'macOS' : /Windows/.test(s) ? 'Windows' : /Linux/.test(s) ? 'Linux' : '';47+ return [browser, os].filter(Boolean).join(' on ') || 'a browser';48+}49+50+export class LoginError extends Error {51+ data: Record<string, unknown>;52+ constructor(message: string, data: Record<string, unknown> = {}) {53+ super(message);54+ this.data = data;55+ }56+}57+58+// What the page needs to tell the password manager which passkeys work.59+export interface Signal {60+ rpID: string;61+ userID: string;62+ allAcceptedCredentialIDs: string[];63+}64+65+export class Login {66+ rp: RelyingParty;67+ private id: () => Identity;68+ private isUser: (name: string) => boolean;69+ private challenges = new Map<string, { kind: 'login' | 'enroll'; code?: string; expires: number }>();70+71+ constructor(rp: RelyingParty, id: () => Identity, isUser: (name: string) => boolean) {72+ this.rp = rp;73+ this.id = id;74+ this.isUser = isUser;75+ }76+77+ private remember(challenge: string, kind: 'login' | 'enroll', code?: string): void {78+ const t = Date.now();79+ for (const [c, v] of this.challenges) if (v.expires < t || this.challenges.size > 1000) this.challenges.delete(c);80+ this.challenges.set(challenge, { kind, code, expires: t + CHALLENGE_TTL });81+ }82+83+ // A challenge this server gave out for this, not used or expired: used up.84+ private take(challenge: string, kind: 'login' | 'enroll', code?: string): boolean {85+ const v = this.challenges.get(challenge);86+ if (!v) return false;87+ this.challenges.delete(challenge);88+ return v.kind === kind && v.code === code && v.expires >= Date.now();89+ }90+91+ signal(user: string): Signal {92+ return { rpID: this.rp.id, userID: this.id().handle(user), allAcceptedCredentialIDs: this.id().passkeys(user).map((p) => p.credential) };93+ }94+95+ async loginOptions(): Promise<unknown> {96+ const opts = await generateAuthenticationOptions({ rpID: this.rp.id, userVerification: 'required', allowCredentials: [] });97+ this.remember(opts.challenge, 'login');98+ return opts;99+ }100+101+ // The passkey's user, after checking its signature over our challenge.102+ async loginVerify(body: unknown): Promise<{ user: string; passkey: Passkey }> {103+ const res = body as AuthenticationResponseJSON;104+ if (!res || typeof res.id !== 'string' || typeof res.response !== 'object') throw new LoginError('not a passkey response');105+ const key = this.id().passkey(res.id);106+ if (!key || !this.isUser(key.user)) {107+ throw new LoginError("The forge doesn't know this passkey (it may have been removed). Use another, or run ssh passkey again for a new one.", {108+ unknown: { rpID: this.rp.id, credentialID: res.id },109+ });110+ }111+ const handle = res.response.userHandle;112+ if (handle && this.id().userOfHandle(handle) !== key.user) throw new LoginError("This passkey doesn't belong to the account it says");113+ let v;114+ try {115+ v = await verifyAuthenticationResponse({116+ response: res, expectedChallenge: (c) => this.take(c, 'login'), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,117+ credential: { id: key.credential, publicKey: new Uint8Array(key.public_key), counter: key.counter, transports: key.transports ? key.transports.split(',') : undefined },118+ });119+ } catch (e) {120+ throw new LoginError(`Sign-in failed: ${(e as Error).message}`);121+ }122+ if (!v.verified) throw new LoginError('Sign-in failed');123+ this.id().usedPasskey(key.credential, v.authenticationInfo.newCounter);124+ return { user: key.user, passkey: key };125+ }126+127+ async enrollOptions(code: unknown): Promise<unknown> {128+ const e = typeof code === 'string' ? this.id().enrollment(code) : null;129+ if (!e || !this.isUser(e.user)) throw new LoginError('This link has expired or has been used. Run ssh passkey again for a new one.', { expired: true });130+ const opts = await generateRegistrationOptions({131+ rpName: this.rp.name, rpID: this.rp.id, userName: e.user, userDisplayName: e.user,132+ userID: new Uint8Array(Buffer.from(this.id().handle(e.user), 'base64url')),133+ attestationType: 'none',134+ excludeCredentials: this.id().passkeys(e.user).map((p) => ({ id: p.credential, transports: p.transports ? p.transports.split(',') : undefined })),135+ authenticatorSelection: { residentKey: 'required', userVerification: 'required' },136+ });137+ this.remember(opts.challenge, 'enroll', code as string);138+ return opts;139+ }140+141+ // Check the new passkey, then use the link up and keep the passkey.142+ async enrollVerify(code: unknown, body: unknown, name: string): Promise<{ user: string; passkey: Passkey }> {143+ const res = body as RegistrationResponseJSON;144+ if (typeof code !== 'string' || !res || typeof res.id !== 'string' || typeof res.response !== 'object') throw new LoginError('not a passkey response');145+ let v;146+ try {147+ v = await verifyRegistrationResponse({148+ response: res, expectedChallenge: (c) => this.take(c, 'enroll', code), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,149+ });150+ } catch (e) {151+ throw new LoginError(`The passkey wasn't added: ${(e as Error).message}`);152+ }153+ if (!v.verified) throw new LoginError("The passkey wasn't added");154+ const c = v.registrationInfo.credential;155+ if (this.id().passkey(c.id)) throw new LoginError('This passkey is added already');156+ const key = this.id().enrolled(code, { credential: c.id, public_key: c.publicKey, counter: c.counter, transports: (res.response.transports ?? []).join(','), name });157+ if (!key) throw new LoginError('This link has expired or has been used. Run ssh passkey again for a new one.', { expired: true });158+ return { user: key.user, passkey: key };159+ }160+}161+162+// A request body, as JSON or a form, of at most 64K.163+export function readBody(req: http.IncomingMessage): Promise<string> {164+ return new Promise((ok, no) => {165+ const parts: Buffer[] = [];166+ let n = 0;167+ req.on('data', (d: Buffer) => {168+ n += d.length;169+ if (n > 65536) {170+ no(new LoginError('request too large'));171+ req.destroy();172+ } else parts.push(d);173+ });174+ req.on('end', () => ok(Buffer.concat(parts).toString('utf8')));175+ req.on('error', no);176+ });177+}
M lib/private-server.ts +167 -22
@@ -7,40 +7,56 @@
77 // is "private, no-cache": the same revalidation, kept out of shared caches.88 //99 // A reader is the repo's owner, a collaborator or a name in its readers10-// (lib/access.ts canRead, the rule bin/forge applies), who is still a user11-// of the forge and shows a token: Authorization: Bearer <token>, or basic12-// auth with the token as the password (or the user name), for curl and git.13-// Everyone else, a page that isn't there, and a repo that isn't, get the14-// same 404, byte for byte; so does any missing page of www, since Caddy15-// sends those here too.10+// (lib/access.ts canRead, the rule bin/forge applies) who is still a user of11+// the forge and is signed in with a passkey (the session cookie) or shows a12+// token: Authorization: Bearer <token>, or basic auth with the token as the13+// password (or the user name), for curl and git. Everyone else, a page that14+// isn't there, and a repo that isn't, get the same 404, byte for byte; so15+// does any missing page of www, since Caddy sends those here too.1616 //17-// node lib/private-server.ts (FORGE_ROOT, FORGE_PRIVATE_PORT, default 8093)17+// /login/ sign in, add a passkey from an ssh link, sign out (lib/login.ts)18+// /private/ the private repos you can read19+// /assets/<file> the forge's assets, for pages here before a build has shipped them20+// /<owner>/<repo>/ a private repo's pages21+//22+// node lib/private-server.ts (FORGE_ROOT, FORGE_BASE_URL, FORGE_PRIVATE_PORT: 8093)1823 import http from 'node:http';1924 import fs from 'node:fs';2025 import path from 'node:path';2126 import { fileURLToPath } from 'node:url';22-import { Identity, TOKEN_PREFIX, type Token } from './identity.ts';23-import { canRead, isPublic, repoDir, users } from './access.ts';27+import { Identity, SESSION_TTL, TOKEN_PREFIX, cookieValue, csrfToken, sameSecret, sessionCookie, type Session, type Token } from './identity.ts';28+import { canRead, isPublic, readable, repoDir, users } from './access.ts';2429 import { resolve, sendFile } from './static.ts';30+import { Login, LoginError, agentName, readBody, relyingParty } from './login.ts';31+import { pageRenderer, type Render } from './render-page.ts';32+import { assets } from './assets.ts';33+import { summarize } from './repo.ts';34+import { config as forgeConfig } from './forge.ts';2535 2636 export interface PrivateConfig {2737 root: string;2838 state: string;29- // The private root, and www (for nothing but the missing pages it sends).39+ // The private root (bin/forge build_private).3040 priv: string;41+ // The forge's origin, which passkeys belong to; without it, no sign-in.3142 baseUrl: string;43+ name?: string;44+ ssh?: string;3245 }3346 3447 export function privateConfig(env: NodeJS.ProcessEnv = process.env): PrivateConfig {3548 const root = path.resolve(env.FORGE_ROOT || 'demo/root');3649 const state = path.join(root, '.forge');37- return { root, state, priv: env.FORGE_PRIVATE_WWW || path.join(state, 'private', 'www'), baseUrl: (env.FORGE_BASE_URL || '').replace(/\/$/, '') };50+ return {51+ root, state, priv: env.FORGE_PRIVATE_WWW || path.join(state, 'private', 'www'), baseUrl: (env.FORGE_BASE_URL || '').replace(/\/$/, ''),52+ name: env.FORGE_NAME || 'forge', ssh: env.FORGE_SSH || 'git@localhost',53+ };3854 }3955 4056 // What every missing or unreadable page gets.4157 export const NOT_FOUND = Buffer.from(4258 '<!DOCTYPE html>\n<html lang="en">\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head>\n' +43- '<body><h1>Not found</h1><p>There is no page here. Private repos show their pages only to their readers, signed in.</p><p><a href="/">All repositories</a></p></body>\n</html>\n',59+ '<body><h1>Not found</h1><p>There is no page here. Private repos show their pages only to their readers, <a href="/login/">signed in</a>.</p><p><a href="/">All repositories</a></p></body>\n</html>\n',4460 );4561 4662 export function notFound(res: http.ServerResponse, head = false): void {
@@ -48,10 +64,15 @@ export function notFound(res: http.ServerResponse, head = false): void {
4864 res.end(head ? undefined : NOT_FOUND);4965 }5066 51-// Who is asking.67+// Who is asking: a token's holder, or a signed-in session's user.5268 export interface Who {5369 user: string;5470 token: Token | null;71+ session: Session | null;72+ // The session cookie's value, and whether to send the cookie again (its73+ // lifetime slid forward).74+ secret: string | null;75+ refresh: boolean;5576 }5677 5778 // A token from Authorization: Bearer, or Basic with the token as the
@@ -89,7 +110,18 @@ export class Gate {
89110 const t = this.id.token(value);90111 if (!t || !this.isUser(t.user)) return null;91112 this.id.usedToken(t);92- return { user: t.user, token: t };113+ return { user: t.user, token: t, session: null, secret: null, refresh: false };114+ }115+116+ bySession(secret: string | null): Who | null {117+ if (!secret) return null;118+ const s = this.id.session(secret);119+ if (!s || !this.isUser(s.user)) return null;120+ return { user: s.user, token: null, session: s, secret, refresh: this.id.touchSession(s) };121+ }122+123+ who(req: http.IncomingMessage): Who | null {124+ return this.byToken(presented(req)) ?? this.bySession(cookieValue(req.headers.cookie));93125 }94126 95127 // May who read the private repo slug (at dir)? A token for one repo reads that repo only.
@@ -99,14 +131,36 @@ export class Gate {
99131 }100132 }101133 134+// The sign-in and list pages: no framing, scripts and styles of our own only.135+const PAGE_HEADERS = {136+ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'same-origin',137+ 'Content-Security-Policy': "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",138+};139+140+// Where to go after signing in: a path on this site.141+const safeNext = (s: string | null): string => (s && /^\/(?![/\\])/.test(s) && !/[\u0000-\u001f]/.test(s) ? s : '');142+102143 // The server's requests, for servePrivate and demo/serve.ts.103144 export function privateHandler(cfg: PrivateConfig): http.RequestListener {104145 const gate = new Gate(cfg);146+ const rp = relyingParty(cfg.baseUrl, cfg.name ?? 'forge');147+ const login = rp ? new Login(rp, () => gate.id, (u) => gate.isUser(u)) : null;148+ let renderer: Render | null = null;149+ const render = (template: string, data: Record<string, unknown>): string => {150+ renderer ??= pageRenderer();151+ const forge = { config: { ...forgeConfig({ ...process.env, FORGE_ROOT: cfg.root }), name: cfg.name ?? 'forge', baseUrl: cfg.baseUrl, ssh: cfg.ssh ?? 'git@localhost' } };152+ return renderer(template, { forge, ...data });153+ };154+ const cookie = (who: Who | null): Record<string, string> => (who?.refresh && who.secret ? { 'Set-Cookie': sessionCookie(who.secret, SESSION_TTL) } : {});155+ const signalJson = (user: string): string => (login ? JSON.stringify(login.signal(user)) : '');105156 106157 function page(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {107158 const head = req.method === 'HEAD';108159 const m = /^\/([^/]+)\/([^/]+)(?:\/|$)/.exec(url.pathname);109160 if (!m || (req.method !== 'GET' && !head)) return notFound(res, head);161+ // Who is asking is worked out before anything about the repo, so the162+ // work (and time) of a 404 doesn't depend on whether the repo is there.163+ const who = gate.who(req);110164 let slug: string;111165 try {112166 slug = `${decodeURIComponent(m[1])}/${decodeURIComponent(m[2])}`;
@@ -114,26 +168,117 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
114168 return notFound(res, head);115169 }116170 const dir = repoDir(cfg.root, slug);117- if (!dir || isPublic(dir)) return notFound(res, head);118- const who = gate.byToken(presented(req));119- if (!gate.reads(who, slug, dir)) return notFound(res, head);171+ if (!dir || isPublic(dir) || !gate.reads(who, slug, dir)) return notFound(res, head);120172 const file = resolve(cfg.priv, url.pathname);121173 if (!file) return notFound(res, head);122174 if (file === 'redirect') {123- res.writeHead(308, { Location: url.pathname + '/' + url.search }).end();175+ res.writeHead(308, { Location: url.pathname + '/' + url.search, ...cookie(who) }).end();124176 return;125177 }126- sendFile(req, res, file, { 'Cache-Control': 'private, no-cache' });178+ sendFile(req, res, file, { 'Cache-Control': 'private, no-cache', ...cookie(who) });127179 }128180 129- return (req, res) => {181+ // The forge's assets by hashed name, and the CSS bundles of the pages182+ // rendered here, in case a build hasn't put them in www yet.183+ function asset(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {184+ const name = url.pathname.slice('/assets/'.length);185+ const a = Object.values(assets()).find((x) => x.file === name);186+ const css = renderer?.bundles.get(url.pathname);187+ const data = a?.data ?? (css != null ? Buffer.from(css) : null);188+ if (!data) return notFound(res, req.method === 'HEAD');189+ const type = /\.css$/.test(name) ? 'text/css; charset=utf-8' : /\.js$/.test(name) ? 'text/javascript; charset=utf-8' : /\.wasm$/.test(name) ? 'application/wasm' : /\.svg$/.test(name) ? 'image/svg+xml' : 'application/octet-stream';190+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Length': data.length });191+ res.end(req.method === 'HEAD' ? undefined : data);192+ }193+194+ function loginPage(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {195+ const who = gate.bySession(cookieValue(req.headers.cookie));196+ const html = render('login.njk', {197+ p: { title: `Sign in - ${cfg.name ?? 'forge'}` }, user: who?.user ?? '', csrf: who?.secret ? csrfToken(who.secret) : '',198+ signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login, host: rp?.id ?? '',199+ });200+ res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);201+ }202+203+ function listPage(req: http.IncomingMessage, res: http.ServerResponse): void {204+ const who = gate.bySession(cookieValue(req.headers.cookie));205+ if (!who) {206+ res.writeHead(303, { Location: '/login/?next=/private/', 'Cache-Control': 'no-store' }).end();207+ return;208+ }209+ const fcfg = forgeConfig({ ...process.env, FORGE_ROOT: cfg.root });210+ const repos = readable(cfg.root, who.user).map((slug) => {211+ const [owner, name] = slug.split('/');212+ const s = summarize(fcfg, owner, name);213+ return { owner, name, description: s.description, lastCommit: s.lastCommit, href: s.urls.home.href, built: fs.existsSync(path.join(cfg.priv, owner, name, 'index.html')) };214+ });215+ const html = render('private.njk', { p: { title: `Private repos - ${cfg.name ?? 'forge'}` }, user: who.user, csrf: csrfToken(who.secret!), signal: signalJson(who.user), repos });216+ res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);217+ }218+219+ const json = (res: http.ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void => {220+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }).end(JSON.stringify(body));221+ };222+223+ // A signed-in session, as the cookie that carries it.224+ function signedIn(req: http.IncomingMessage, user: string, passkey: string): Record<string, string> {225+ const { secret } = gate.id.createSession(user, passkey, agentName(req.headers['user-agent']));226+ return { 'Set-Cookie': sessionCookie(secret, SESSION_TTL) };227+ }228+229+ async function post(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {230+ // Only the forge's own pages may post here (and sign anyone in or out).231+ if (!login || req.headers.origin !== login.rp.origin) return json(res, 403, { error: 'not from this forge' });232+ const raw = await readBody(req);233+ if (url.pathname === '/login/logout') {234+ const form = new URLSearchParams(raw);235+ const secret = cookieValue(req.headers.cookie);236+ if (secret && sameSecret(form.get('csrf') ?? '', csrfToken(secret))) gate.id.endSession(secret);237+ else if (secret && gate.id.session(secret)) return json(res, 403, { error: 'stale form; reload the page and sign out again' });238+ res.writeHead(303, { Location: '/login/', 'Set-Cookie': sessionCookie('', 0), 'Cache-Control': 'no-store' }).end();239+ return;240+ }241+ let body: Record<string, unknown>;130242 try {131- const url = new URL(req.url ?? '/', 'http://x');132- page(req, res, url);243+ body = JSON.parse(raw || '{}');244+ } catch {245+ return json(res, 400, { error: 'not JSON' });246+ }247+ try {248+ if (url.pathname === '/login/options') return json(res, 200, await login.loginOptions());249+ if (url.pathname === '/login/verify') {250+ const { user, passkey } = await login.loginVerify(body);251+ return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));252+ }253+ if (url.pathname === '/login/enroll/options') return json(res, 200, await login.enrollOptions(body.code));254+ if (url.pathname === '/login/enroll/verify') {255+ const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']));256+ return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));257+ }133258 } catch (e) {259+ if (e instanceof LoginError) return json(res, 400, { error: e.message, ...e.data });260+ throw e;261+ }262+ notFound(res);263+ }264+265+ return (req, res) => {266+ const fail = (e: unknown): void => {134267 console.error(e);135268 if (!res.headersSent) res.writeHead(500).end();136269 else res.destroy();270+ };271+ try {272+ const url = new URL(req.url ?? '/', 'http://x');273+ const get = req.method === 'GET' || req.method === 'HEAD';274+ if (url.pathname.startsWith('/login/') && req.method === 'POST') post(req, res, url).catch(fail);275+ else if (url.pathname === '/login/' && get) loginPage(req, res, url);276+ else if (url.pathname === '/private/' && get) listPage(req, res);277+ else if (url.pathname === '/login' || url.pathname === '/private') res.writeHead(308, { Location: url.pathname + '/' + url.search }).end();278+ else if (url.pathname.startsWith('/assets/') && get) asset(req, res, url);279+ else page(req, res, url);280+ } catch (e) {281+ fail(e);137282 }138283 };139284 }
M package-lock.json +336 -0
@@ -8,6 +8,8 @@
88 "dependencies": {99 "@11ty/eleventy": "^3.1.7",1010 "@duckdb/node-api": "^1.5.6-r.1",11+ "@simplewebauthn/browser": "^14.0.0",12+ "@simplewebauthn/server": "^14.0.3",1113 "@sqlite.org/sqlite-wasm": "^3.53.4-build2",1214 "@typesafe-ai/sdk": "^0.7.0",1315 "highlight.js": "^11.12.0",
@@ -403,6 +405,12 @@
403405 "win32"404406 ]405407 },408+ "node_modules/@hexagon/base64": {409+ "version": "1.1.28",410+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",411+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",412+ "license": "MIT"413+ },406414 "node_modules/@iconify/types": {407415 "version": "2.0.0",408416 "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
@@ -420,6 +428,12 @@
420428 "import-meta-resolve": "^4.2.0"421429 }422430 },431+ "node_modules/@levischuck/tiny-cbor": {432+ "version": "0.2.11",433+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",434+ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",435+ "license": "MIT"436+ },423437 "node_modules/@mermaid-js/parser": {424438 "version": "2.0.1",425439 "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz",
@@ -432,6 +446,266 @@
432446 "node": ">=22.12.0"433447 }434448 },449+ "node_modules/@peculiar/asn1-android": {450+ "version": "2.11.0",451+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.11.0.tgz",452+ "integrity": "sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==",453+ "license": "MIT",454+ "dependencies": {455+ "@peculiar/asn1-schema": "^2.11.0",456+ "asn1js": "^3.0.10",457+ "tslib": "^2.8.1"458+ },459+ "engines": {460+ "node": ">=14"461+ }462+ },463+ "node_modules/@peculiar/asn1-asym-key": {464+ "version": "2.11.0",465+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.11.0.tgz",466+ "integrity": "sha512-r2957WdSZle/Zt4t9og7OMQYXNJ9/3ANXVtFwLCDJuHORlBZ4AFmDt1Qee0CzyTbHQg4ZZApBGEz5/6cFsyCRg==",467+ "license": "MIT",468+ "dependencies": {469+ "@peculiar/asn1-pkcs8": "^2.11.0",470+ "@peculiar/asn1-schema": "^2.11.0",471+ "@peculiar/asn1-x509": "^2.11.0",472+ "asn1js": "^3.0.10",473+ "tslib": "^2.8.1"474+ },475+ "engines": {476+ "node": ">=14"477+ }478+ },479+ "node_modules/@peculiar/asn1-cms": {480+ "version": "2.11.0",481+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.0.tgz",482+ "integrity": "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==",483+ "license": "MIT",484+ "dependencies": {485+ "@peculiar/asn1-schema": "^2.11.0",486+ "@peculiar/asn1-x509": "^2.11.0",487+ "@peculiar/asn1-x509-attr": "^2.11.0",488+ "asn1js": "^3.0.10",489+ "tslib": "^2.8.1"490+ },491+ "engines": {492+ "node": ">=14"493+ }494+ },495+ "node_modules/@peculiar/asn1-csr": {496+ "version": "2.11.0",497+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.11.0.tgz",498+ "integrity": "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==",499+ "license": "MIT",500+ "dependencies": {501+ "@peculiar/asn1-schema": "^2.11.0",502+ "@peculiar/asn1-x509": "^2.11.0",503+ "asn1js": "^3.0.10",504+ "tslib": "^2.8.1"505+ },506+ "engines": {507+ "node": ">=14"508+ }509+ },510+ "node_modules/@peculiar/asn1-ecc": {511+ "version": "2.11.0",512+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.11.0.tgz",513+ "integrity": "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==",514+ "license": "MIT",515+ "dependencies": {516+ "@peculiar/asn1-schema": "^2.11.0",517+ "@peculiar/asn1-x509": "^2.11.0",518+ "asn1js": "^3.0.10",519+ "tslib": "^2.8.1"520+ },521+ "engines": {522+ "node": ">=14"523+ }524+ },525+ "node_modules/@peculiar/asn1-pfx": {526+ "version": "2.11.0",527+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.11.0.tgz",528+ "integrity": "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==",529+ "license": "MIT",530+ "dependencies": {531+ "@peculiar/asn1-cms": "^2.11.0",532+ "@peculiar/asn1-pkcs8": "^2.11.0",533+ "@peculiar/asn1-rsa": "^2.11.0",534+ "@peculiar/asn1-schema": "^2.11.0",535+ "asn1js": "^3.0.10",536+ "tslib": "^2.8.1"537+ },538+ "engines": {539+ "node": ">=14"540+ }541+ },542+ "node_modules/@peculiar/asn1-pkcs8": {543+ "version": "2.11.0",544+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.0.tgz",545+ "integrity": "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==",546+ "license": "MIT",547+ "dependencies": {548+ "@peculiar/asn1-schema": "^2.11.0",549+ "@peculiar/asn1-x509": "^2.11.0",550+ "asn1js": "^3.0.10",551+ "tslib": "^2.8.1"552+ },553+ "engines": {554+ "node": ">=14"555+ }556+ },557+ "node_modules/@peculiar/asn1-pkcs9": {558+ "version": "2.11.0",559+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.0.tgz",560+ "integrity": "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==",561+ "license": "MIT",562+ "dependencies": {563+ "@peculiar/asn1-cms": "^2.11.0",564+ "@peculiar/asn1-pfx": "^2.11.0",565+ "@peculiar/asn1-pkcs8": "^2.11.0",566+ "@peculiar/asn1-schema": "^2.11.0",567+ "@peculiar/asn1-x509": "^2.11.0",568+ "@peculiar/asn1-x509-attr": "^2.11.0",569+ "asn1js": "^3.0.10",570+ "tslib": "^2.8.1"571+ },572+ "engines": {573+ "node": ">=14"574+ }575+ },576+ "node_modules/@peculiar/asn1-rsa": {577+ "version": "2.11.0",578+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.11.0.tgz",579+ "integrity": "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==",580+ "license": "MIT",581+ "dependencies": {582+ "@peculiar/asn1-schema": "^2.11.0",583+ "@peculiar/asn1-x509": "^2.11.0",584+ "asn1js": "^3.0.10",585+ "tslib": "^2.8.1"586+ },587+ "engines": {588+ "node": ">=14"589+ }590+ },591+ "node_modules/@peculiar/asn1-schema": {592+ "version": "2.11.0",593+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz",594+ "integrity": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==",595+ "license": "MIT",596+ "dependencies": {597+ "@peculiar/utils": "^2.0.2",598+ "asn1js": "^3.0.10",599+ "tslib": "^2.8.1"600+ },601+ "engines": {602+ "node": ">=14"603+ }604+ },605+ "node_modules/@peculiar/asn1-x509": {606+ "version": "2.11.0",607+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.11.0.tgz",608+ "integrity": "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==",609+ "license": "MIT",610+ "dependencies": {611+ "@peculiar/asn1-schema": "^2.11.0",612+ "@peculiar/utils": "^2.0.2",613+ "asn1js": "^3.0.10",614+ "tslib": "^2.8.1"615+ },616+ "engines": {617+ "node": ">=14"618+ }619+ },620+ "node_modules/@peculiar/asn1-x509-attr": {621+ "version": "2.11.0",622+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.0.tgz",623+ "integrity": "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==",624+ "license": "MIT",625+ "dependencies": {626+ "@peculiar/asn1-schema": "^2.11.0",627+ "@peculiar/asn1-x509": "^2.11.0",628+ "asn1js": "^3.0.10",629+ "tslib": "^2.8.1"630+ },631+ "engines": {632+ "node": ">=14"633+ }634+ },635+ "node_modules/@peculiar/asn1-x509-post-quantum": {636+ "version": "2.11.0",637+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.11.0.tgz",638+ "integrity": "sha512-K6EnquTX3xJ2G+uLPFH0jVkh7W8y6eTzoCxvSoXAj0sWS5uvVB2PEvpBPdzY4+ytaedUM3x9ZVgX0AZKb316ew==",639+ "license": "MIT",640+ "dependencies": {641+ "@peculiar/asn1-asym-key": "^2.11.0",642+ "@peculiar/asn1-schema": "^2.11.0",643+ "@peculiar/asn1-x509": "^2.11.0",644+ "asn1js": "^3.0.10",645+ "tslib": "^2.8.1"646+ },647+ "engines": {648+ "node": ">=14"649+ }650+ },651+ "node_modules/@peculiar/utils": {652+ "version": "2.0.3",653+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",654+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",655+ "license": "MIT",656+ "dependencies": {657+ "tslib": "^2.8.1"658+ }659+ },660+ "node_modules/@peculiar/x509": {661+ "version": "2.2.0",662+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.2.0.tgz",663+ "integrity": "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==",664+ "license": "MIT",665+ "dependencies": {666+ "@peculiar/asn1-cms": "^2.11.0",667+ "@peculiar/asn1-csr": "^2.11.0",668+ "@peculiar/asn1-ecc": "^2.11.0",669+ "@peculiar/asn1-pkcs9": "^2.11.0",670+ "@peculiar/asn1-rsa": "^2.11.0",671+ "@peculiar/asn1-schema": "^2.11.0",672+ "@peculiar/asn1-x509": "^2.11.0",673+ "@peculiar/asn1-x509-post-quantum": "^2.11.0",674+ "pvtsutils": "^1.3.6",675+ "tslib": "^2.8.1",676+ "tsyringe": "^4.10.0"677+ },678+ "engines": {679+ "node": ">=20.0.0"680+ }681+ },682+ "node_modules/@simplewebauthn/browser": {683+ "version": "14.0.0",684+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",685+ "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",686+ "license": "MIT"687+ },688+ "node_modules/@simplewebauthn/server": {689+ "version": "14.0.3",690+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",691+ "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==",692+ "license": "MIT",693+ "dependencies": {694+ "@hexagon/base64": "^1.1.27",695+ "@levischuck/tiny-cbor": "^0.2.2",696+ "@peculiar/asn1-android": "^2.6.0",697+ "@peculiar/asn1-ecc": "^2.6.1",698+ "@peculiar/asn1-rsa": "^2.6.1",699+ "@peculiar/asn1-schema": "^2.6.0",700+ "@peculiar/asn1-x509": "^2.6.1",701+ "@peculiar/asn1-x509-post-quantum": "^2.9.4",702+ "@peculiar/x509": "^2.1.0",703+ "reflect-metadata": "^0.2.2"704+ },705+ "engines": {706+ "node": ">=20.0.0"707+ }708+ },435709 "node_modules/@sindresorhus/slugify": {436710 "version": "2.2.1",437711 "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
@@ -1294,6 +1568,20 @@
12941568 "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",12951569 "license": "MIT"12961570 },1571+ "node_modules/asn1js": {1572+ "version": "3.1.0",1573+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",1574+ "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==",1575+ "license": "BSD-3-Clause",1576+ "dependencies": {1577+ "pvtsutils": "^1.3.6",1578+ "pvutils": "^1.2.0",1579+ "tslib": "^2.8.1"1580+ },1581+ "engines": {1582+ "node": ">=12.0.0"1583+ }1584+ },12971585 "node_modules/balanced-match": {12981586 "version": "1.0.2",12991587 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2995,6 +3283,24 @@
29953283 "node": ">=6"29963284 }29973285 },3286+ "node_modules/pvtsutils": {3287+ "version": "1.3.6",3288+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",3289+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",3290+ "license": "MIT",3291+ "dependencies": {3292+ "tslib": "^2.8.1"3293+ }3294+ },3295+ "node_modules/pvutils": {3296+ "version": "1.2.0",3297+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",3298+ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",3299+ "license": "MIT",3300+ "engines": {3301+ "node": ">=16.0.0"3302+ }3303+ },29983304 "node_modules/range-parser": {29993305 "version": "1.3.0",30003306 "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -3021,6 +3327,12 @@
30213327 "url": "https://paulmillr.com/funding/"30223328 }30233329 },3330+ "node_modules/reflect-metadata": {3331+ "version": "0.2.2",3332+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",3333+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",3334+ "license": "Apache-2.0"3335+ },30243336 "node_modules/robust-predicates": {30253337 "version": "3.0.3",30263338 "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -3350,6 +3662,30 @@
33503662 "node": ">=6.10"33513663 }33523664 },3665+ "node_modules/tslib": {3666+ "version": "2.8.1",3667+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",3668+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",3669+ "license": "0BSD"3670+ },3671+ "node_modules/tsyringe": {3672+ "version": "4.10.0",3673+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",3674+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",3675+ "license": "MIT",3676+ "dependencies": {3677+ "tslib": "^1.9.3"3678+ },3679+ "engines": {3680+ "node": ">= 6.0.0"3681+ }3682+ },3683+ "node_modules/tsyringe/node_modules/tslib": {3684+ "version": "1.14.1",3685+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",3686+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",3687+ "license": "0BSD"3688+ },33533689 "node_modules/typescript": {33543690 "version": "7.0.2",33553691 "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
M package.json +2 -0
A site/_includes/login.njk +48 -0
@@ -0,0 +1,48 @@
1+{#- /login/, which lib/private-server.ts renders on request (it isn't built):2+ sign in with a passkey, add one from the link that ssh <host> passkey3+ prints (/login/#enroll=<code>; login.ts does both), and, signed in, sign4+ out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),5+ next, ready (sign-in is set up: FORGE_BASE_URL), host. -#}6+{% extends "base.njk" %}7+{% block scripts %}8+<script src="{{ assets.webauthn }}" defer></script>9+<script src="{{ assets.login }}" defer></script>10+{% endblock %}11+{% block head %}<meta name="robots" content="noindex">12+{% endblock %}13+{% block content %}14+{% css %}15+/* the sign-in page */16+.login { padding: 16px 24px; max-width: 40em; }17+.login h1 { font-size: 1.3rem; margin: 4px 0 12px; }18+.login button.go { font-size: 1em; padding: 6px 14px; background: var(--accent); border-color: var(--accent); color: #fff; }19+.login button.go:hover { filter: brightness(1.1); background: var(--accent); }20+.login button.go:disabled { opacity: 0.6; cursor: default; }21+.login-status { min-height: 1.4em; }22+.login-status.bad { color: var(--fail); }23+.login form { display: inline; }24+{% endcss %}25+<div class="box login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}>26+{% if user %}27+<h1>Signed in as {{ user }}</h1>28+<p>You see the pages of the private repos you can read: <a href="/private/">your private repos</a>.</p>29+<form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}"><button type="submit" id="signout">Sign out</button></form>30+{% elif not ready %}31+<h1>Sign in</h1>32+<p>Sign-in isn't set up here: the forge needs <code>FORGE_BASE_URL</code>, the origin passkeys belong to.</p>33+{% else %}34+<div id="signin-part">35+<h1>Sign in</h1>36+<p>Private repos show their pages to their readers. Sign in with a passkey you have added to see them.</p>37+<p><button type="button" class="go" id="signin">Sign in with a passkey</button></p>38+</div>39+<div id="enroll-part" hidden>40+<h1>Add a passkey</h1>41+<p>For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>42+<p><button type="button" class="go" id="enroll">Add a passkey</button></p>43+</div>44+<p class="login-status" id="login-status" role="status"></p>45+<p class="desc">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>46+{% endif %}47+</div>48+{% endblock %}
A site/_includes/private.njk +35 -0
@@ -0,0 +1,35 @@
1+{#- /private/, which lib/private-server.ts renders on request: the private2+ repos the signed-in user can read, like the index lists public ones.3+ Data: forge, p (title), user, csrf, signal (JSON or ''), repos (owner,4+ name, description, lastCommit, href, built). -#}5+{% extends "base.njk" %}6+{% block scripts %}7+<script src="{{ assets.webauthn }}" defer></script>8+<script src="{{ assets.login }}" defer></script>9+{% endblock %}10+{% block head %}<meta name="robots" content="noindex">11+{% endblock %}12+{% block heading %}<b>Private repos</b>13+{% endblock %}14+{% block content %}15+{% css %}16+.signed-in { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; margin: 0 0 12px; color: var(--muted); font-size: 0.92em; }17+.signed-in form { display: inline; }18+{% endcss %}19+<div class="signed-in" id="login"{% if signal %} data-signal="{{ signal }}"{% endif %}>20+<span>Signed in as <b>{{ user }}</b>. These are the private repos you can read; only their readers see them.</span>21+<form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}"><button type="submit" id="signout">Sign out</button></form>22+</div>23+<div class="box">24+<table class="list stack nav">25+<thead><tr><th>Repository</th><th>Description</th><th>Updated</th></tr></thead>26+<tbody>27+{% for s in repos %}28+<tr><td class="nowrap">{% if s.built %}<a href="{{ s.href }}">{% endif %}<span class="desc">{{ s.owner }} /</span> <b>{{ s.name }}</b>{% if s.built %}</a>{% endif %}</td><td class="main">{{ s.description }}{% if not s.built %} <span class="desc">(no pages yet)</span>{% endif %}</td><td class="date">{{ s.lastCommit | date }}</td></tr>29+{% else %}30+<tr><td class="main" colspan="3">None yet. A private repo's owner adds readers with <code>ssh {{ forge.config.ssh }} readers &lt;owner&gt;/&lt;repo&gt; add {{ user }}</code>.</td></tr>31+{% endfor %}32+</tbody>33+</table>34+</div>35+{% endblock %}
A site/assets/login.ts +111 -0
@@ -0,0 +1,111 @@
1+// Passkeys on /login/ and /private/, with @simplewebauthn/browser (the2+// webauthn asset, which defines SimpleWebAuthnBrowser). Signs in, adds a3+// passkey from the link `ssh <host> passkey` prints (/login/#enroll=<code>),4+// and, once signed in, tells the browser or password manager which of the5+// user's passkeys the forge still accepts (the Signal API), so one removed6+// with `ssh <host> passkeys rm` stops being offered. The server's side is7+// lib/login.ts.8+interface Signal {9+ rpID: string;10+ userID: string;11+ allAcceptedCredentialIDs: string[];12+}13+interface WebAuthn {14+ browserSupportsWebAuthn(): boolean;15+ startAuthentication(o: { optionsJSON: unknown }): Promise<unknown>;16+ startRegistration(o: { optionsJSON: unknown }): Promise<unknown>;17+ sendSignal(o: ({ signalName: 'allAcceptedCredentials' } & Signal) | { signalName: 'unknownCredential'; rpID: string; credentialID: string }): Promise<void>;18+}19+20+(() => {21+ const box = document.getElementById('login');22+ const wa = (window as unknown as { SimpleWebAuthnBrowser?: WebAuthn }).SimpleWebAuthnBrowser;23+ if (!box || !wa) return;24+25+ // Best effort: browsers without the Signal API just don't hear it.26+ const signal = (s: Signal | undefined): Promise<void> =>27+ s ? wa.sendSignal({ signalName: 'allAcceptedCredentials', ...s }).catch(() => {}) : Promise.resolve();28+ if (box.dataset.signal) void signal(JSON.parse(box.dataset.signal) as Signal);29+30+ const status = document.getElementById('login-status');31+ const say = (text: string, bad = false): void => {32+ if (!status) return;33+ status.textContent = text;34+ status.classList.toggle('bad', bad);35+ };36+37+ class Failed extends Error {38+ data: Record<string, unknown>;39+ constructor(message: string, data: Record<string, unknown>) {40+ super(message);41+ this.data = data;42+ }43+ }44+ const post = async (path: string, body: unknown): Promise<Record<string, unknown>> => {45+ const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });46+ const data = (await r.json().catch(() => ({}))) as Record<string, unknown>;47+ if (!r.ok) throw new Failed(String(data.error ?? `HTTP ${r.status}`), data);48+ return data;49+ };50+ // The WebAuthn prompt was dismissed or timed out: nothing to report.51+ const dismissed = (e: unknown): boolean => (e as Error)?.name === 'NotAllowedError' || (e as Error)?.name === 'AbortError';52+ const next = (): string => box.dataset.next || '/private/';53+54+ const signin = document.getElementById('signin') as HTMLButtonElement | null;55+ if (signin && !wa.browserSupportsWebAuthn()) {56+ signin.disabled = true;57+ say("This browser can't use passkeys.", true);58+ }59+ signin?.addEventListener('click', async () => {60+ signin.disabled = true;61+ say('');62+ try {63+ const options = await post('/login/options', {});64+ const response = await wa.startAuthentication({ optionsJSON: options });65+ say('Signing in…');66+ const done = await post('/login/verify', response);67+ await signal(done.signal as Signal);68+ location.assign(next());69+ } catch (e) {70+ if (e instanceof Failed && e.data.unknown) {71+ const u = e.data.unknown as { rpID: string; credentialID: string };72+ void wa.sendSignal({ signalName: 'unknownCredential', rpID: u.rpID, credentialID: u.credentialID }).catch(() => {});73+ }74+ if (!dismissed(e)) say((e as Error).message, true);75+ signin.disabled = false;76+ }77+ });78+79+ // An enrollment link: the code is in the fragment, which never leaves the browser.80+ const code = /^#enroll=([A-Za-z0-9_-]+)$/.exec(location.hash)?.[1];81+ const enroll = document.getElementById('enroll') as HTMLButtonElement | null;82+ if (!code || !enroll) return;83+ document.getElementById('signin-part')!.hidden = true;84+ document.getElementById('enroll-part')!.hidden = false;85+ let options: Record<string, unknown> | null = null;86+ enroll.disabled = true;87+ post('/login/enroll/options', { code }).then((o) => {88+ options = o;89+ document.getElementById('enroll-user')!.textContent = (o.user as { name: string }).name;90+ enroll.disabled = false;91+ }, (e: Error) => say(e.message, true));92+ enroll.addEventListener('click', async () => {93+ if (!options) return;94+ enroll.disabled = true;95+ say('');96+ try {97+ const response = await wa.startRegistration({ optionsJSON: options });98+ say('Adding it…');99+ const done = await post('/login/enroll/verify', { code, response });100+ history.replaceState(null, '', '/login/');101+ await signal(done.signal as Signal);102+ location.assign(next());103+ } catch (e) {104+ if (!dismissed(e)) say((e as Error).message, true);105+ if (e instanceof Failed && e.data.expired) return;106+ // A challenge works once: another try needs new options.107+ options = await post('/login/enroll/options', { code }).catch((x: Error) => (say(x.message, true), null));108+ enroll.disabled = !options;109+ }110+ });111+})();
A test/authenticator.ts +129 -0
@@ -0,0 +1,129 @@
1+// A software authenticator for scenarios: what a browser and a passkey do2+// together, over HTTP, against lib/private-server.ts. It makes an ES2563+// key, attests with "none", keeps the key in a JSON file, and signs in with4+// it the way a discoverable credential does (the user handle comes along).5+// What it prints on success is the session cookie's value.6+//7+// node test/authenticator.ts enroll <server> <code> <key.json>8+// node test/authenticator.ts login <server> <key.json>9+//10+// <server> is where the requests go; the origin they claim (the Origin11+// header and the client data) is FORGE_BASE_URL. To try what must fail:12+// --origin <o> claim another origin, header and client data alike13+// --client-origin <o> only the client data says it14+// --rp <id> sign for another RP ID15+// --no-uv no user verification16+// --counter <n> the signature counter to send (default: one more than last time)17+// --replay send the same signed response a second time (prints the second answer)18+import fs from 'node:fs';19+import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';20+import { isoCBOR } from '@simplewebauthn/server/helpers';21+22+interface Key {23+ id: string;24+ privateKey: string;25+ userHandle: string | null;26+ counter: number;27+}28+29+const args = process.argv.slice(2);30+const flag = (name: string): string | null => {31+ const i = args.indexOf(name);32+ if (i < 0) return null;33+ const [, v] = args.splice(i, 2);34+ return v;35+};36+const has = (name: string): boolean => {37+ const i = args.indexOf(name);38+ if (i >= 0) args.splice(i, 1);39+ return i >= 0;40+};41+const origin = flag('--origin') ?? new URL(process.env.FORGE_BASE_URL || 'http://localhost').origin;42+const clientOrigin = flag('--client-origin') ?? origin;43+const rpOverride = flag('--rp');44+const counterArg = flag('--counter');45+const noUv = has('--no-uv');46+const replay = has('--replay');47+const [mode, server, ...rest] = args;48+49+const b64 = (b: Uint8Array): string => Buffer.from(b).toString('base64url');50+const sha256 = (b: Uint8Array | string): Buffer => createHash('sha256').update(b).digest();51+const u32 = (n: number): Buffer => {52+ const b = Buffer.alloc(4);53+ b.writeUInt32BE(n);54+ return b;55+};56+57+async function call(path: string, body: unknown): Promise<{ status: number; data: Record<string, unknown>; cookie: string | null }> {58+ const r = await fetch(server.replace(/\/$/, '') + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, 'User-Agent': 'test-authenticator' }, body: JSON.stringify(body) });59+ const text = await r.text();60+ let data: Record<string, unknown> = {};61+ try {62+ data = JSON.parse(text);63+ } catch {64+ data = { error: text };65+ }66+ const set = r.headers.get('set-cookie') ?? '';67+ const m = /__Host-forge=([^;]*)/.exec(set);68+ return { status: r.status, data, cookie: m ? m[1] : null };69+}70+71+function done(r: { status: number; data: Record<string, unknown>; cookie: string | null }): void {72+ if (r.status !== 200 || !r.cookie) {73+ console.error(`HTTP ${r.status}: ${JSON.stringify(r.data)}`);74+ process.exit(1);75+ }76+ console.log(r.cookie);77+}78+79+// Flags: user present, user verified (unless --no-uv), backup eligible and80+// backed up (a synced passkey), and attested credential data when registering.81+const flags = (attested: boolean): number => 0x01 | (noUv ? 0 : 0x04) | 0x08 | 0x10 | (attested ? 0x40 : 0);82+83+if (mode === 'enroll' && rest.length === 2) {84+ const [code, file] = rest;85+ const opts = await call('/login/enroll/options', { code });86+ if (opts.status !== 200) done(opts);87+ const o = opts.data as { challenge: string; rp: { id: string }; user: { id: string } };88+ const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });89+ const jwk = publicKey.export({ format: 'jwk' });90+ const cose = isoCBOR.encode(new Map<number, number | Uint8Array>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x!, 'base64url')], [-3, Buffer.from(jwk.y!, 'base64url')]]));91+ const id = randomBytes(16);92+ const len = Buffer.alloc(2);93+ len.writeUInt16BE(id.length);94+ const authData = Buffer.concat([sha256(rpOverride ?? o.rp.id), Buffer.from([flags(true)]), u32(0), Buffer.alloc(16), len, id, cose]);95+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));96+ const attestationObject = isoCBOR.encode(new Map<string, string | Map<string, never> | Uint8Array>([['fmt', 'none'], ['attStmt', new Map()], ['authData', authData]]));97+ const response = {98+ id: b64(id), rawId: b64(id), type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},99+ response: { clientDataJSON: b64(clientDataJSON), attestationObject: b64(attestationObject), transports: ['internal'] },100+ };101+ const r = await call('/login/enroll/verify', { code, response });102+ if (r.status === 200) {103+ const key: Key = { id: b64(id), privateKey: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, userHandle: o.user.id, counter: 0 };104+ fs.writeFileSync(file, JSON.stringify(key, null, 1) + '\n');105+ }106+ if (replay) done(await call('/login/enroll/verify', { code, response }));107+ done(r);108+} else if (mode === 'login' && rest.length === 1) {109+ const [file] = rest;110+ const key = JSON.parse(fs.readFileSync(file, 'utf8')) as Key;111+ const opts = await call('/login/options', {});112+ if (opts.status !== 200) done(opts);113+ const o = opts.data as { challenge: string; rpId: string };114+ const counter = counterArg != null ? Number(counterArg) : key.counter + 1;115+ const authData = Buffer.concat([sha256(rpOverride ?? o.rpId), Buffer.from([flags(false)]), u32(counter)]);116+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));117+ const signature = sign('sha256', Buffer.concat([authData, sha256(clientDataJSON)]), createPrivateKey(key.privateKey));118+ const response = {119+ id: key.id, rawId: key.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},120+ response: { clientDataJSON: b64(clientDataJSON), authenticatorData: b64(authData), signature: b64(signature), userHandle: key.userHandle ?? undefined },121+ };122+ const r = await call('/login/verify', response);123+ if (r.status === 200) fs.writeFileSync(file, JSON.stringify({ ...key, counter }, null, 1) + '\n');124+ if (replay) done(await call('/login/verify', response));125+ done(r);126+} else {127+ console.error('usage: node test/authenticator.ts enroll <server> <code> <key.json> | login <server> <key.json> [--origin o] [--client-origin o] [--rp id] [--no-uv] [--counter n] [--replay]');128+ process.exit(2);129+}
A test/scenarios/identity-enroll.sh +48 -0
@@ -0,0 +1,48 @@
1+# An enrollment link works once and for ten minutes: a second passkey can't2+# use it, a link nobody used is dead after ten minutes, and a link of a user3+# whose keys were removed adds nothing. Without FORGE_BASE_URL there is no4+# sign-in at all.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+export FORGE_BASE_URL=https://forge.test9+private_server10+auth() { node "$T_HOME/test/authenticator.ts" "$@"; }11+link() { ssh_as "$1" passkey | sed -n 's/^ .*#enroll=//p'; }12+13+code=$(link dami)14+ok auth enroll "$PRIV" "$code" "$T/one.key"15+fails_with 'HTTP 400: .*expired or has been used' auth enroll "$PRIV" "$code" "$T/two.key"16+missing "$T/two.key"17+# The same answer sent twice: the second finds its challenge used up.18+code=$(link dami)19+fails_with 'HTTP 400: .*challenge' auth enroll "$PRIV" "$code" "$T/three.key" --replay20+ok ssh_as dami passkeys21+eq "$(grep -c '^[0-9a-f]\{8\} ' "$T_LOG")" 222+23+# Ten minutes and a second later, an unused link is dead; at ten minutes less a second it works.24+late=$(link dami)25+early=$(link dami)26+stop_servers27+export FORGE_NOW=$((T_NOW + 599))28+private_server29+ok auth enroll "$PRIV" "$early" "$T/early.key"30+stop_servers31+export FORGE_NOW=$((T_NOW + 601))32+private_server33+fails_with 'HTTP 400: .*expired or has been used' auth enroll "$PRIV" "$late" "$T/late.key"34+export FORGE_NOW=$T_NOW35+36+# A link of a user who is gone.37+code=$(link ana)38+ok forge user rm ana39+fails_with 'HTTP 400: .*expired or has been used' auth enroll "$PRIV" "$code" "$T/ana.key"40+eq "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: https://forge.test' -d '{"code":"nonsense"}' "$PRIV/login/enroll/options")" 40041+42+# No FORGE_BASE_URL, no sign-in: the page says why, and posts are refused.43+stop_servers44+unset FORGE_BASE_URL45+private_server46+curl -s "$PRIV/login/" >"$T/page"47+has "$T/page" "Sign-in isn't set up here"48+eq "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: http://localhost' -d '{}' "$PRIV/login/options")" 403
A test/scenarios/identity-passkey.sh +58 -0
@@ -0,0 +1,58 @@
1+# Passkeys end to end, with a software authenticator (test/authenticator.ts,2+# ES256, attestation "none"): the link ssh passkey prints adds a passkey for3+# the ssh user and signs that browser in; after that it signs in by itself,4+# usernameless, for a private page of the user's. The forge checks the5+# origin, the RP ID, user verification, the signature counter, and that a6+# challenge is used once.7+. "$(dirname "$0")/../lib.sh"8+user dami9+user ana10+repo --private dami/secret "hidden thing"11+clone dami dami/secret "$T/w/dami"12+at "$T/w/dami" commit "first"13+ok push_as dami "$T/w/dami" origin main14+build15+export FORGE_BASE_URL=https://forge.test16+private_server17+auth() { node "$T_HOME/test/authenticator.ts" "$@"; }18+page() { curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$1" "$PRIV/dami/secret/"; }19+20+code=$(ssh_as dami passkey laptop | sed -n 's/^ .*#enroll=//p')21+c1=$(auth enroll "$PRIV" "$code" "$T/dami.key") || fail "enroll"22+eq "$(page "$c1")" 20023+ok ssh_as dami passkeys24+has "$T_LOG" '^[0-9a-f]{8} 2026-09-01 00:00 2026-09-01 00:00 laptop$'25+ok ssh_as dami sessions26+has "$T_LOG" ' a browser \(passkey [0-9a-f]{8}\)$'27+28+# Signing in again: a session of its own.29+c2=$(auth login "$PRIV" "$T/dami.key") || fail "login"30+[ "$c1" != "$c2" ] || fail "the same session twice"31+eq "$(page "$c2")" 20032+ok ssh_as dami sessions33+eq "$(grep -c 'passkey' "$T_LOG")" 234+# A session's cookie is no use to someone who may not read the repo.35+code=$(ssh_as ana passkey | sed -n 's/^ .*#enroll=//p')36+ca=$(auth enroll "$PRIV" "$code" "$T/ana.key") || fail "enroll ana"37+eq "$(page "$ca")" 40438+eq "$(page nonsense)" 40439+40+# What must fail.41+fails_with 'HTTP 403: .*not from this forge' auth login "$PRIV" "$T/dami.key" --origin https://evil.test42+fails_with 'HTTP 400: .*origin' auth login "$PRIV" "$T/dami.key" --client-origin https://evil.test43+fails_with 'HTTP 400: .*RP ID' auth login "$PRIV" "$T/dami.key" --rp evil.test44+fails_with 'HTTP 400: .*[Uu]ser verification' auth login "$PRIV" "$T/dami.key" --no-uv45+# A counter that didn't go up: a cloned authenticator.46+fails_with 'HTTP 400: .*counter' auth login "$PRIV" "$T/dami.key" --counter 147+# The same signed response twice: the challenge was used up.48+fails_with 'HTTP 400: .*challenge' auth login "$PRIV" "$T/dami.key" --replay49+# ana's key, claiming to be dami's: the user handle doesn't match.50+node -e '51+const fs = require("fs");52+const [a, d, out] = process.argv.slice(1);53+const k = JSON.parse(fs.readFileSync(a));54+k.userHandle = JSON.parse(fs.readFileSync(d)).userHandle;55+fs.writeFileSync(out, JSON.stringify(k));' "$T/ana.key" "$T/dami.key" "$T/mixed.key"56+fails_with "HTTP 400: .*doesn't belong" auth login "$PRIV" "$T/mixed.key"57+# Through it all, the good passkey still works.58+ok auth login "$PRIV" "$T/dami.key"
A test/scenarios/identity-sessions.sh +92 -0
@@ -0,0 +1,92 @@
1+# Sessions: the cookie is __Host-, Secure, HttpOnly, SameSite=Lax, for 302+# days, and slides: a use an hour or more after the last sends it again and3+# moves the end along; 30 days unused, it is gone. ssh sessions rm signs one4+# out, passkeys rm signs out what that passkey signed in, and sign-out is a5+# POST from the forge's own page that carries its CSRF token. Signed in, the6+# page carries what the Signal API needs to tell the password manager which7+# passkeys still work; a removed passkey is answered as unknown.8+. "$(dirname "$0")/../lib.sh"9+user dami10+repo --private dami/secret11+clone dami dami/secret "$T/w/dami"12+at "$T/w/dami" commit "first"13+ok push_as dami "$T/w/dami" origin main14+build15+export FORGE_BASE_URL=https://forge.test16+private_server17+auth() { node "$T_HOME/test/authenticator.ts" "$@"; }18+page() { curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$1" "$PRIV/dami/secret/"; }19+for k in one two; do20+ code=$(ssh_as dami passkey "$k" | sed -n 's/^ .*#enroll=//p')21+ ok auth enroll "$PRIV" "$code" "$T/$k.key"22+done23+24+c=$(auth login "$PRIV" "$T/one.key")25+curl -s -D "$T/h" -o "$T/login" -H "Cookie: __Host-forge=$c" "$PRIV/login/"26+lacks "$T/h" '^Set-Cookie'27+has "$T/h" "^Content-Security-Policy: default-src 'self'"28+has "$T/h" '^Cache-Control: private, no-store'29+has "$T/login" 'Signed in as dami'30+signal=$(sed -n 's/.*data-signal="\([^"]*\)".*/\1/p' "$T/login" | sed 's/&quot;/"/g')31+for k in one two; do32+ id=$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).id)' "$T/$k.key")33+ case $signal in *'"rpID":"forge.test"'*"\"$id\""*) ;; *) fail "the page doesn't signal passkey $k: $signal" ;; esac34+done35+grep -q 'name="csrf" value="[A-Za-z0-9_-]\{32\}"' "$T/login" || fail "no csrf token on the page"36+37+# Sliding: an hour and a minute later the cookie comes back for 30 more days.38+stop_servers39+export FORGE_NOW=$((T_NOW + 3660))40+private_server41+curl -s -D "$T/h" -o /dev/null -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/"42+has "$T/h" '^HTTP/1.1 200'43+has "$T/h" "^Set-Cookie: __Host-forge=$c; Path=/; Max-Age=2592000; Secure; HttpOnly; SameSite=Lax"44+ok ssh_as dami sessions45+has "$T_LOG" '2026-09-01 01:01 2026-10-01 01:01 '46+# 29 days on it still works (the end moved); 31 days after the last use it doesn't.47+stop_servers48+export FORGE_NOW=$((T_NOW + 3660 + 29 * 86400))49+private_server50+eq "$(page "$c")" 20051+stop_servers52+export FORGE_NOW=$((T_NOW + 3660 + 60 * 86400))53+private_server54+eq "$(page "$c")" 40455+stop_servers56+export FORGE_NOW=$T_NOW57+private_server58+59+# Sign-out: only from the forge's page, with its token.60+c=$(auth login "$PRIV" "$T/one.key")61+out() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "Cookie: __Host-forge=$c" "$@" "$PRIV/login/logout"; }62+csrf=$(curl -s -H "Cookie: __Host-forge=$c" "$PRIV/login/" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p')63+eq "$(out -H 'Origin: https://evil.test' -d "csrf=$csrf")" 40364+eq "$(out -d "csrf=$csrf")" 40365+eq "$(out -H 'Origin: https://forge.test' -d 'csrf=nope')" 40366+eq "$(page "$c")" 20067+curl -s -o /dev/null -D "$T/h" -X POST -H "Cookie: __Host-forge=$c" -H 'Origin: https://forge.test' -d "csrf=$csrf" "$PRIV/login/logout"68+has "$T/h" '^HTTP/1.1 303'69+has "$T/h" '^Location: /login/'70+has "$T/h" '^Set-Cookie: __Host-forge=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax'71+eq "$(page "$c")" 40472+73+# ssh: one session, then all of them.74+ok ssh_as dami sessions --all75+c1=$(auth login "$PRIV" "$T/one.key")76+c2=$(auth login "$PRIV" "$T/two.key")77+ok ssh_as dami sessions rm "$(ssh_as dami sessions | awk 'NR == 2 { print $1 }')"78+got="$(page "$c1") $(page "$c2")"79+case $got in "404 200" | "200 404") ;; *) fail "sessions rm: got $got" ;; esac80+ok ssh_as dami sessions --all81+has "$T_LOG" '^signed out 1 session$'82+eq "$(page "$c1") $(page "$c2")" "404 404"83+84+# passkeys rm: its sessions go, and from then on it is answered as unknown.85+c1=$(auth login "$PRIV" "$T/one.key")86+c2=$(auth login "$PRIV" "$T/two.key")87+pk=$(ssh_as dami passkeys | awk '$NF == "two" { print $1 }')88+ok ssh_as dami passkeys rm "$pk"89+has "$T_LOG" "removed passkey $pk \\(two\\), and signed out the session it signed in"90+eq "$(page "$c1") $(page "$c2")" "200 404"91+fails_with "HTTP 400: .*doesn't know this passkey.*\"unknown\":\\{\"rpID\":\"forge.test\",\"credentialID\":\"" auth login "$PRIV" "$T/two.key"92+ok auth login "$PRIV" "$T/one.key"
A test/scenarios/private-web-session.sh +67 -0
@@ -0,0 +1,67 @@
1+# Signed in with a passkey, a reader sees a private repo's pages, reads its2+# code.db in ranges with the cookie as code search's worker does, and finds3+# the private repos they can read at /private/. Signed in as someone who4+# may not read it, the repo is the missing page's 404. The sign-in pages5+# work before a build has put their assets in www.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+user cat10+repo --private dami/secret "hidden thing"11+repo --private dami/unbuilt "not yet"12+repo --private cat/own13+repo dami/open14+for r in secret open; do15+ clone dami "dami/$r" "$T/w/$r"16+ at "$T/w/$r" commit "first"17+ ok push_as dami "$T/w/$r" origin main18+done19+ok ssh_as dami readers dami/secret add ana20+ok ssh_as dami readers dami/unbuilt add ana21+build22+# As if its first build hadn't finished.23+rm -rf "$FORGE_ROOT/.forge/private/www/dami/unbuilt"24+export FORGE_BASE_URL=https://forge.test25+private_server26+signin() {27+ code=$(ssh_as "$1" passkey | sed -n 's/^ .*#enroll=//p')28+ node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/$1.key"29+}30+ana=$(signin ana) || fail "ana"31+cat=$(signin cat) || fail "cat"32+as() { u=$1; shift; curl -s -H "Cookie: __Host-forge=$u" "$@"; }33+34+eq "$(as "$ana" -o /dev/null -w '%{http_code}' "$PRIV/dami/secret/")" 20035+as "$ana" "$PRIV/dami/secret/" | grep -q 'hidden thing' || fail "ana doesn't see dami/secret"36+as "$ana" -D "$T/h" -o "$T/part" -H 'Range: bytes=0-99' "$PRIV/dami/secret/code.db"37+has "$T/h" '^HTTP/1.1 206'38+has "$T/h" '^Content-Range: bytes 0-99/'39+has "$T/h" '^Cache-Control: private, no-cache'40+eq "$(wc -c <"$T/part" | tr -d ' ')" 10041+eq "$(head -c 15 "$T/part")" "SQLite format 3"42+43+response "$PRIV/dami/nope/" >"$T/missing"44+response "$PRIV/dami/secret/" -H "Cookie: __Host-forge=$cat" >"$T/got"45+cmp -s "$T/missing" "$T/got" || fail "cat, signed in, doesn't get the missing page's 404 for dami/secret"46+47+# /private/: what you may read and nothing else.48+as "$ana" "$PRIV/private/" >"$T/list"49+has "$T/list" 'Signed in as <b>ana</b>'50+has "$T/list" '<a href="/dami/secret/"><span class="desc">dami /</span> <b>secret</b></a>'51+has "$T/list" 'not yet <span class="desc">\(no pages yet\)</span>'52+lacks "$T/list" '<b>(own|open)</b>'53+as "$cat" "$PRIV/private/" >"$T/list"54+has "$T/list" '<b>own</b>'55+lacks "$T/list" '<b>(secret|unbuilt|open)</b>'56+eq "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$PRIV/private/")" "303 $PRIV/login/?next=/private/"57+curl -s "$PRIV/login/?next=/dami/secret/" >"$T/page"58+has "$T/page" 'data-next="/dami/secret/"'59+curl -s "$PRIV/login/?next=//evil.test/" >"$T/page"60+has "$T/page" 'data-next=""'61+62+# Its scripts and styles, from the private server itself when www lacks them.63+for a in $(grep -oE '(href|src)="/assets/[^"]+"' "$T/page" | sed -E 's/^[a-z]+="//; s/"$//' | sort -u); do64+ eq "$(curl -s -o /dev/null -w '%{http_code}' "$PRIV$a")" 20065+done66+grep -q '/assets/webauthn\.[A-Za-z0-9_-]*\.js' "$T/page" || fail "no webauthn script on the sign-in page"67+grep -q '/assets/login\.[A-Za-z0-9_-]*\.js' "$T/page" || fail "no login script on the sign-in page"