diff --git a/plugins/auth/src/index.ts b/plugins/auth/src/index.ts index 4069521b9..ee2533206 100644 --- a/plugins/auth/src/index.ts +++ b/plugins/auth/src/index.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"; import { json } from "../../chassis/src/http.ts"; import { portFromEnv } from "../../chassis/src/env.ts"; import { bootProblems, readConfig } from "./config.ts"; -import { coreClaimStore } from "./claims.ts"; +import { coreClaimStore } from "../../chassis/src/claims.ts"; import { mailerFor } from "./email.ts"; import { loadSigningKey } from "./keys.ts"; import { TokenSigner } from "./tokens.ts"; @@ -27,7 +27,7 @@ export async function startServer(): Promise { cfg: CFG, signingKey, signer: new TokenSigner(CFG.tokenSecret, CFG.issuer), - claims: coreClaimStore(CFG.coreApiUrl, CFG.coreSigningSecret), + claims: coreClaimStore(CFG.coreApiUrl, CFG.coreSigningSecret, "auth"), mailer: mailerFor(CFG), }); const server = createServer((req, res) => { diff --git a/plugins/auth/src/server.ts b/plugins/auth/src/server.ts index 08c4d7c86..40528672b 100644 --- a/plugins/auth/src/server.ts +++ b/plugins/auth/src/server.ts @@ -3,7 +3,7 @@ import { readBody, PayloadTooLargeError, serveEmojiFavicon } from "../../chassis import { errMessage } from "../../chassis/src/errors.ts"; import type { AuthConfig } from "./config.ts"; import { validEmail } from "./config.ts"; -import { claimOnce, withinRateLimit, type ClaimStore } from "./claims.ts"; +import { claimOnce, withinRateLimit, type ClaimStore } from "../../chassis/src/claims.ts"; import { mintIdToken, pkceMatches, safeEqual, subjectFor, TokenSigner, type AuthRequest } from "./tokens.ts"; import { ID_TOKEN_ALG, type SigningKey } from "./keys.ts"; import { renderSignInEmail, type Mailer } from "./email.ts"; diff --git a/plugins/auth/test/helpers.ts b/plugins/auth/test/helpers.ts index 274c6ebca..faf878cc2 100644 --- a/plugins/auth/test/helpers.ts +++ b/plugins/auth/test/helpers.ts @@ -1,7 +1,7 @@ import { createServer, type Server } from "node:http"; import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; import { readConfig, type AuthConfig } from "../src/config.ts"; -import type { ClaimStore } from "../src/claims.ts"; +import type { ClaimStore } from "../../chassis/src/claims.ts"; import type { Mailer, OutgoingEmail } from "../src/email.ts"; import { loadSigningKey } from "../src/keys.ts"; import { TokenSigner } from "../src/tokens.ts"; diff --git a/plugins/auth/src/claims.ts b/plugins/chassis/src/claims.ts similarity index 83% rename from plugins/auth/src/claims.ts rename to plugins/chassis/src/claims.ts index 91fb95a86..667baf62f 100644 --- a/plugins/auth/src/claims.ts +++ b/plugins/chassis/src/claims.ts @@ -1,6 +1,6 @@ import { createHmac } from "node:crypto"; -import { signedHeaders, withSourceAuthNonce } from "../../chassis/src/core-client.ts"; -import { errMessage } from "../../chassis/src/errors.ts"; +import { signedHeaders, withSourceAuthNonce } from "./core-client.ts"; +import { errMessage } from "./errors.ts"; export interface ClaimStore { claimFirst(ids: readonly string[], expiresAtMs: number): Promise; @@ -9,7 +9,7 @@ export interface ClaimStore { const CLAIM_PATH = "/v1/auth/broker/claim"; const CLAIM_TIMEOUT_MS = 4_000; -export function coreClaimStore(coreApiUrl: string, signingSecret: string | undefined): ClaimStore { +export function coreClaimStore(coreApiUrl: string, signingSecret: string | undefined, label = "chassis"): ClaimStore { return { async claimFirst(ids, expiresAtMs) { const path = withSourceAuthNonce(CLAIM_PATH, signingSecret); @@ -22,13 +22,13 @@ export function coreClaimStore(coreApiUrl: string, signingSecret: string | undef signal: AbortSignal.timeout(CLAIM_TIMEOUT_MS), }); if (!r.ok) { - console.error(`[auth] core refused a single-use claim: HTTP ${r.status}`); + console.error(`[${label}] core refused a single-use claim: HTTP ${r.status}`); return null; } const parsed = (await r.json()) as { claimed?: unknown }; return typeof parsed.claimed === "string" ? parsed.claimed : null; } catch (e) { - console.error(`[auth] core single-use claim failed: ${errMessage(e)}`); + console.error(`[${label}] core single-use claim failed: ${errMessage(e)}`); return null; } }, diff --git a/plugins/portal/README.md b/plugins/portal/README.md index ab6bd2e6a..39c3ccbad 100644 --- a/plugins/portal/README.md +++ b/plugins/portal/README.md @@ -67,6 +67,57 @@ surfaces, and it does **not** import the core. session before `exp`; the core's `canAdminister` (re-read per request) remains the live admin revocation path. Slack has no RP-initiated end-session, so SSO re-login is silent. +## Playground mode + +`PORTAL_PLAYGROUND=1` turns the deployment into a public try-it instance: an +unauthenticated browser navigation (a `GET` that accepts HTML) mints an anonymous +principal (`playground-`), seals it into the ordinary `portal_session` +cookie, and continues — so each visitor's sessions, files, memory, and sandbox are +pinned to their browser through the same scoping that isolates real teammates. +Non-HTML requests without a session still get `401`, so the SPA's API calls ride +the cookie from the first page load and bare `curl` never mints. + +What playground mode does **not** change: `/auth/login` still runs the full OIDC +flow (that's how the one admin signs in — production still demands the usual OIDC +config), `/admin` refuses anonymous sessions outright, and admin identity remains +the core's `ADMIN_GRANTS`. Signing out of an anonymous session just clears the +cookie; the next visit starts a fresh playground identity. + +Minting is rate-limited per client address through the core's Postgres-backed +single-use claim store (the same one the sign-in broker uses), so restarts and +blue-green deploys can't reset it; if the core can't record the claim the portal +fails closed and answers 429. `PORTAL_PLAYGROUND_MINTS_PER_IP` (default 30, at +most 64 — the core grants at most 64 claim slots per request) per +`PORTAL_PLAYGROUND_MINT_WINDOW_S` (default 3600, at most 86400 — the core's +claim horizon); the portal refuses to boot outside those ranges rather than +silently serving 429 to everyone. IPv6 clients are bucketed per /64, not per +address, so a visitor with a routed prefix can't rotate through fresh budgets. +The client address comes from `clientIpOf` — on Fly that's `fly-client-ip`; +elsewhere set `PORTAL_XFF_TRUSTED_HOPS` when a reverse proxy fronts the portal, +or every visitor (and every crawler that accepts HTML) shares the socket +address's one bucket. + +Because playground authority must never leave this origin, the portal refuses +to boot with `PORTAL_PLAYGROUND` alongside `PORTAL_COOKIE_DOMAIN`, +`PORTAL_APPS_DOMAIN`, or `PORTAL_DEPLOYMENTS_ENABLED` — a domain-wide cookie or +the deployment proxy would hand anonymous sessions to surfaces that never see +the `anon` flag. Anonymous sessions are also refused the `/connect/*` and +`/drop/*` flows, so a visitor can't attach real OAuth tokens or dropped secrets +to a throwaway principal that a cleared cookie orphans. + +The `anon` flag lives only in the portal's session cookie — it does not cross +the portal identity boundary. To the core, a playground visitor is an ordinary +**internal** principal of the deployment's org: they can run turns, use their +sandbox, create crons, and reach anything granted or published at `org:` scope, +including org-granted credentials. That is the design — visitors are members of +the playground org — so a playground must be its own deployment with nothing +sensitive at org scope: no org-wide credential grants, no real connector +credentials, no company data. A cleared cookie mints a fresh principal, so pair +this with the core's real brakes: `BUDGET_USD_PER_WINDOW`, +`ORG_BUDGET_USD_PER_WINDOW`, `RATE_LIMIT_PER_WINDOW`, and a single pinned model +via the admin `base-model` / `webui-models` resources. Nothing +garbage-collects an abandoned visitor's scope yet. + ## Env Non-secret (`[env]`): `PORT` (8097 local / 8080 image), `PORTAL_PUBLIC_URL`, `CORE_API_URL`, diff --git a/plugins/portal/src/index.ts b/plugins/portal/src/index.ts index ab5832b5d..2ecde29cd 100644 --- a/plugins/portal/src/index.ts +++ b/plugins/portal/src/index.ts @@ -37,6 +37,7 @@ import { FORWARD_BROKER_HEADERS, } from "./proxy.ts"; import { signedHeaders, withSourceAuthNonce } from "../../chassis/src/core-client.ts"; +import { coreClaimStore, withinRateLimit } from "../../chassis/src/claims.ts"; import { mintPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts"; import { errMessage } from "../../chassis/src/errors.ts"; import { json, escapeHtml, serveEmojiFavicon } from "../../chassis/src/http.ts"; @@ -69,6 +70,15 @@ const LOCAL_AUTH_BYPASS_REQUESTED = process.env.PORTAL_LOCAL_AUTH_BYPASS === "1" const LOCAL_AUTH_BYPASS = LOCAL_AUTH_BYPASS_REQUESTED && !IS_PROD && isLocalPortalUrl(PUBLIC_URL); const LOCAL_AUTH_PRINCIPAL = process.env.PORTAL_DEV_PRINCIPAL || process.env.USER || "dev-admin"; const DEPLOYMENTS_ENABLED = process.env.PORTAL_DEPLOYMENTS_ENABLED === "1"; +const PLAYGROUND = process.env.PORTAL_PLAYGROUND === "1"; +function playgroundIntEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const n = Number(raw); + return Number.isInteger(n) ? n : NaN; +} +const PLAYGROUND_MINTS_PER_IP = playgroundIntEnv("PORTAL_PLAYGROUND_MINTS_PER_IP", 30); +const PLAYGROUND_MINT_WINDOW_S = playgroundIntEnv("PORTAL_PLAYGROUND_MINT_WINDOW_S", 3600); const NEUTRAL_ACCENT = "#4f46e5"; let brandAccent = NEUTRAL_ACCENT; let modelProviderConfigured: boolean | undefined; @@ -713,6 +723,72 @@ function setSession(res: ServerResponse, headers: string[]): void { res.setHeader("set-cookie", headers); } +const playgroundClaims = PLAYGROUND ? coreClaimStore(CORE, CORE_SIGNING_SECRET, "portal") : null; + +export function mintBucketOf(ip: string): string { + if (!ip.includes(":")) return ip; + const zoneless = ip.split("%")[0] ?? ""; + if (zoneless.toLowerCase().startsWith("::ffff:") && zoneless.includes(".")) return zoneless.slice(7); + const [headRaw = "", tailRaw = ""] = zoneless.split("::", 2); + const head = headRaw ? headRaw.split(":") : []; + const tail = tailRaw ? tailRaw.split(":") : []; + const groups = [...head, ...Array(Math.max(0, 8 - head.length - tail.length)).fill("0"), ...tail]; + const prefix = groups + .slice(0, 4) + .map((g) => (g || "0").toLowerCase().replace(/^0+(?=.)/, "")) + .join(":"); + return `${prefix}::/64`; +} + +export function playgroundBusyHtml(): string { + return cardPage({ + title: "Playground is busy", + heading: "The playground is busy", + msg: "We couldn't start a fresh playground session for you right now. Waiting a little while and reloading resolves most cases.", + icon: ALERT_ICON, + warn: true, + actions: `Try again`, + help: "Playground sessions are limited per visitor to keep the demo responsive for everyone.", + }); +} + +export function playgroundRestrictedHtml(): string { + return cardPage({ + title: "Not available in the playground", + heading: "Not available in the playground", + msg: "Connecting accounts and dropping secrets are disabled for anonymous playground sessions — clearing your cookie would orphan real credentials.", + icon: LOCK_ICON, + actions: `Back to the playground`, + help: "Sign in with a real account at /auth/login to use this link.", + }); +} + +async function mintPlaygroundSession(req: IncomingMessage, res: ServerResponse): Promise { + if (!playgroundClaims) return null; + const allowed = await withinRateLimit(playgroundClaims, { + secret: SESSION_SECRET ?? DEV_SECRET, + kind: "playground-mint", + value: mintBucketOf(clientIpOf(req)), + limit: PLAYGROUND_MINTS_PER_IP, + windowS: PLAYGROUND_MINT_WINDOW_S, + nowMs: Date.now(), + }); + if (!allowed) return null; + const now = Math.floor(Date.now() / 1000); + const session: SessionClaims = { + k: "session", + sub: `playground-${randomToken(8)}`, + org: ORG, + name: "Guest", + anon: true, + auth: now, + iat: now, + exp: now + SESSION_TTL_S, + }; + setSession(res, sessionCookieSet(seal(session, sessionKey))); + return session; +} + function renewSessionCookie(req: IncomingMessage, res: ServerResponse): void { const session = openSession( readCookie(req.headers.cookie, "portal_session"), @@ -793,7 +869,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise ); } - const session = currentSession(req); + let session = currentSession(req); if (session) renewSessionCookie(req, res); if (pathname === "/auth/impersonate" && method === "POST") { @@ -854,6 +930,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const redeem = /^\/connect\/redeem\/([^/]+)$/.exec(pathname); if (method === "GET" && redeem) { if (!session) return consentBounce(); + if (session.anon) return sendHtml(res, 403, playgroundRestrictedHtml()); return handleConsentRedeem(res, { corePath: `/v1/connectors/oauth/consent/redeem/${redeem[1]}${url.search}`, session, @@ -862,6 +939,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const selfConnect = /^\/connect\/([^/]+)\/self-connect$/.exec(pathname); if (method === "GET" && selfConnect) { if (!session) return consentBounce(); + if (session.anon) return sendHtml(res, 403, playgroundRestrictedHtml()); return handleSelfConnect(res, { provider: decodeURIComponent(selfConnect[1] ?? ""), session }); } @@ -872,6 +950,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const dropForm = /^\/drop\/([^/]+)\/form$/.exec(pathname); if (method === "GET" && dropForm) { if (!session) return consentBounce(); + if (session.anon) return sendHtml(res, 403, playgroundRestrictedHtml()); return handleSecretDrop(req, res, { method, corePath: `/v1/keychain/drops/${dropForm[1]}/form${url.search}`, @@ -886,6 +965,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise message: "your session expired — re-open the link, sign in, and paste again", }); if (!sameOriginRequest(req)) return json(res, 403, { error: "forbidden", message: "cross-origin request refused" }); + if (session.anon) + return json(res, 403, { error: "forbidden", message: "secret drops are disabled for playground sessions" }); return handleSecretDrop(req, res, { method, corePath: `/v1/keychain/drops/${dropSubmit[1]}${url.search}`, @@ -920,11 +1001,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (!session) { if (method === "GET" && wantsHtml(req)) { - const returnTo = encodeURIComponent(`${pathname}${url.search}`); - res.writeHead(302, { location: `/auth/login?returnTo=${returnTo}` }); - return void res.end(); + if (PLAYGROUND) { + session = await mintPlaygroundSession(req, res); + if (!session) return sendHtml(res, 429, playgroundBusyHtml()); + } else { + const returnTo = encodeURIComponent(`${pathname}${url.search}`); + res.writeHead(302, { location: `/auth/login?returnTo=${returnTo}` }); + return void res.end(); + } + } else { + return json(res, 401, { error: "sign in" }); } - return json(res, 401, { error: "sign in" }); } if (method !== "GET" && method !== "HEAD" && !sameOriginRequest(req)) { @@ -950,6 +1037,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const key = surfaceKey as string; if (key === "admin") { + if (session.anon) { + if (wantsHtml(req)) return sendHtml(res, 403, nonAdminDeniedHtml({ sub: session.sub, org: session.org })); + return json(res, 403, { error: "forbidden", message: "admin access required" }); + } const probe = await adminProbe(session.sub); if (!probe.isAdmin) { if (wantsHtml(req)) { @@ -1083,6 +1174,32 @@ export function bootChecks(): void { if (LOCAL_AUTH_BYPASS_REQUESTED && !isLocalPortalUrl(PUBLIC_URL)) { problems.push("PORTAL_LOCAL_AUTH_BYPASS requires a localhost, 127.0.0.1, or ::1 PORTAL_PUBLIC_URL"); } + if (PLAYGROUND) { + if (!Number.isInteger(PLAYGROUND_MINTS_PER_IP) || PLAYGROUND_MINTS_PER_IP < 1 || PLAYGROUND_MINTS_PER_IP > 64) { + problems.push( + "PORTAL_PLAYGROUND_MINTS_PER_IP must be an integer between 1 and 64 (the core grants at most 64 claim slots per request)", + ); + } + if ( + !Number.isInteger(PLAYGROUND_MINT_WINDOW_S) || + PLAYGROUND_MINT_WINDOW_S < 60 || + PLAYGROUND_MINT_WINDOW_S > 86400 + ) { + problems.push( + "PORTAL_PLAYGROUND_MINT_WINDOW_S must be an integer between 60 and 86400 (the core's claim horizon is 24 hours)", + ); + } + if (COOKIE_DOMAIN || APPS_DOMAIN) { + problems.push( + "PORTAL_PLAYGROUND requires PORTAL_COOKIE_DOMAIN and PORTAL_APPS_DOMAIN unset — a domain-wide cookie would carry anonymous sessions to app subdomains, which never see the anon flag", + ); + } + if (DEPLOYMENTS_ENABLED) { + problems.push( + "PORTAL_PLAYGROUND requires PORTAL_DEPLOYMENTS_ENABLED unset — anonymous visitors must not reach deployed apps", + ); + } + } if (APPS_DOMAIN && !COOKIE_DOMAIN) { problems.push( "PORTAL_APPS_DOMAIN requires PORTAL_COOKIE_DOMAIN (app returnTo without a domain-wide session cookie loops sign-in forever)", @@ -1208,6 +1325,14 @@ export function startServer(): void { console.warn( `[portal] PORTAL_LOCAL_AUTH_BYPASS=1 -- using ${LOCAL_AUTH_PRINCIPAL} as the local session principal (dev/test only)`, ); + if (PLAYGROUND) + console.warn( + `[portal] PORTAL_PLAYGROUND=1 -- unauthenticated visitors get anonymous browser-pinned sessions (${PLAYGROUND_MINTS_PER_IP} mints per IP per ${PLAYGROUND_MINT_WINDOW_S}s); admin sign-in stays on /auth/login`, + ); + if (PLAYGROUND && !ON_FLY && XFF_TRUSTED_HOPS === 0) + console.warn( + "[portal] playground mint limits key on the socket address — set PORTAL_XFF_TRUSTED_HOPS when behind a reverse proxy, or every visitor shares one bucket", + ); console.log( "[portal] /admin access is derived (portal → admin surface /api/whoami over 6PN → core canAdminister); the core's ADMIN_GRANTS is the one source of admin identity", ); diff --git a/plugins/portal/src/session.ts b/plugins/portal/src/session.ts index 0cc285493..1c06922a3 100644 --- a/plugins/portal/src/session.ts +++ b/plugins/portal/src/session.ts @@ -32,6 +32,7 @@ export interface SessionClaims { org: string; name?: string; auth?: number; + anon?: boolean; iat: number; exp: number; } diff --git a/plugins/portal/test/playground.test.ts b/plugins/portal/test/playground.test.ts new file mode 100644 index 000000000..cd3a4ae90 --- /dev/null +++ b/plugins/portal/test/playground.test.ts @@ -0,0 +1,197 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage } from "node:http"; +import type { AddressInfo } from "node:net"; +import { spawnSync } from "node:child_process"; +import { deriveKey, seal, openSession, type SessionClaims } from "../src/session.ts"; + +const claimed = new Set(); +let claimCalls = 0; +let refuseClaims = false; + +const upstream = createServer((req: IncomingMessage, res) => { + if (req.method === "POST" && req.url?.startsWith("/v1/auth/broker/claim")) { + claimCalls++; + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + if (refuseClaims) return void res.end(JSON.stringify({ claimed: null })); + const { ids } = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { ids: string[] }; + const winner = ids.find((id) => !claimed.has(id)) ?? null; + if (winner) claimed.add(winner); + res.end(JSON.stringify({ claimed: winner })); + }); + return; + } + if (req.url === "/api/whoami") { + res.writeHead(200, { "content-type": "application/json" }); + return void res.end(JSON.stringify({ isAdmin: false })); + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ url: req.url, cookie: req.headers.cookie ?? null })); +}); +await new Promise((r) => upstream.listen(0, r)); +const upstreamUrl = `http://localhost:${(upstream.address() as AddressInfo).port}`; + +process.env.PORTAL_PUBLIC_URL = "http://localhost:18196"; +process.env.PORTAL_SESSION_SECRET = "playground-test-portal-secret"; +process.env.CORE_SIGNING_SECRET = "playground-test-core-secret"; +process.env.WEB_UI_UPSTREAM = upstreamUrl; +process.env.ADMIN_UPSTREAM = upstreamUrl; +process.env.CORE_API_URL = upstreamUrl; +process.env.PORTAL_PLAYGROUND = "1"; +process.env.PORTAL_PLAYGROUND_MINTS_PER_IP = "3"; +delete process.env.PORTAL_LOCAL_AUTH_BYPASS; + +const { server, mintBucketOf } = await import("../src/index.ts"); +await new Promise((r) => server.listen(0, r)); +const base = `http://localhost:${(server.address() as AddressInfo).port}`; + +test.after(() => { + server.close(); + upstream.close(); +}); + +const HTML = { accept: "text/html" }; + +function sessionCookieOf(res: Response): string { + const raw = res.headers.getSetCookie().find((c) => c.startsWith("portal_session=") && !/portal_session=;/.test(c)); + assert.ok(raw, "expected a portal_session cookie"); + return raw.split(";")[0]!; +} + +test("an unauthenticated browser visit mints an anonymous session pinned to the cookie", async () => { + const first = await fetch(`${base}/`, { headers: HTML, redirect: "manual" }); + assert.equal(first.status, 200); + const cookie = sessionCookieOf(first); + const body = (await first.json()) as { cookie: string }; + const principal = /webuiuser=(playground-[0-9a-f]+)/.exec(body.cookie)?.[1]; + assert.ok(principal, `expected a playground principal, got: ${body.cookie}`); + + const callsBefore = claimCalls; + const again = await fetch(`${base}/`, { headers: { ...HTML, cookie }, redirect: "manual" }); + assert.equal(again.status, 200); + const reuse = (await again.json()) as { cookie: string }; + assert.match(reuse.cookie, new RegExp(`webuiuser=${principal}`)); + assert.equal(claimCalls, callsBefore, "a returning session must not mint again"); +}); + +test("API requests without a session still require sign-in", async () => { + const api = await fetch(`${base}/api/state`, { headers: { accept: "application/json" } }); + assert.equal(api.status, 401); + const post = await fetch(`${base}/api/turn`, { method: "POST", headers: HTML }); + assert.equal(post.status, 401); +}); + +test("anonymous sessions are refused the admin surface", async () => { + const visit = await fetch(`${base}/`, { headers: HTML, redirect: "manual" }); + const cookie = sessionCookieOf(visit); + const admin = await fetch(`${base}/admin/`, { headers: { ...HTML, cookie } }); + assert.equal(admin.status, 403); + const adminApi = await fetch(`${base}/admin/api/me`, { headers: { accept: "application/json", cookie } }); + assert.equal(adminApi.status, 403); +}); + +test("explicit sign-in still goes to the identity provider", async () => { + const login = await fetch(`${base}/auth/login`, { redirect: "manual" }); + assert.equal(login.status, 302); + assert.match(login.headers.get("location") ?? "", /^https:\/\/slack\.com\/openid\/connect\/authorize/); +}); + +test("sliding renewal preserves the anon flag", async () => { + const key = deriveKey("playground-test-portal-secret", "portal.session.v1"); + const now = Math.floor(Date.now() / 1000); + const aged: SessionClaims = { + k: "session", + sub: "playground-deadbeef", + org: process.env.CORE_ORG_ID ?? "acme", + name: "Guest", + anon: true, + auth: now - 15000, + iat: now - 15000, + exp: now + 13800, + }; + const res = await fetch(`${base}/`, { + headers: { ...HTML, cookie: `portal_session=${encodeURIComponent(seal(aged, key))}` }, + redirect: "manual", + }); + assert.equal(res.status, 200); + const renewed = openSession(decodeURIComponent(sessionCookieOf(res).split("=")[1]!), key, Date.now()); + assert.ok(renewed, "expected a renewed session"); + assert.equal(renewed.anon, true); + assert.equal(renewed.sub, "playground-deadbeef"); + assert.ok(renewed.iat > aged.iat, "expected a re-stamped iat"); +}); + +test("anonymous sessions are refused the connect and secret-drop flows", async () => { + const visit = await fetch(`${base}/`, { headers: HTML, redirect: "manual" }); + const cookie = sessionCookieOf(visit); + for (const path of ["/connect/redeem/tok123", "/connect/google/self-connect", "/drop/tok123/form"]) { + const r = await fetch(`${base}${path}`, { headers: { ...HTML, cookie } }); + assert.equal(r.status, 403, `${path} must refuse anon sessions`); + } + const drop = await fetch(`${base}/drop/tok123`, { + method: "POST", + headers: { cookie, origin: "http://localhost:18196" }, + }); + assert.equal(drop.status, 403); + assert.match(((await drop.json()) as { message: string }).message, /playground/); +}); + +test("mintBucketOf keys IPv4 per address and IPv6 per /64", () => { + assert.equal(mintBucketOf("203.0.113.9"), "203.0.113.9"); + assert.equal(mintBucketOf("::ffff:203.0.113.9"), "203.0.113.9"); + assert.equal(mintBucketOf("2001:db8:1:2:3:4:5:6"), "2001:db8:1:2::/64"); + assert.equal(mintBucketOf("2001:db8:1:2:ffff::1"), mintBucketOf("2001:db8:1:2:3:4:5:6")); + assert.notEqual(mintBucketOf("2001:db8:1:3::1"), mintBucketOf("2001:db8:1:2::1")); + assert.equal(mintBucketOf("2001:db8::1"), "2001:db8:0:0::/64"); + assert.equal(mintBucketOf("fe80::1%en0"), "fe80:0:0:0::/64"); +}); + +test("boot refuses playground configurations that leak or brick", () => { + const command = "import('./src/index.ts').then(m => m.bootChecks())"; + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + NODE_ENV: "test", + PORTAL_PUBLIC_URL: "http://localhost:18196", + PORTAL_PLAYGROUND: "1", + }; + delete baseEnv.PORTAL_COOKIE_DOMAIN; + delete baseEnv.PORTAL_APPS_DOMAIN; + delete baseEnv.PORTAL_DEPLOYMENTS_ENABLED; + delete baseEnv.PORTAL_PLAYGROUND_MINTS_PER_IP; + delete baseEnv.PORTAL_PLAYGROUND_MINT_WINDOW_S; + const boot = (env: NodeJS.ProcessEnv) => + spawnSync(process.execPath, ["--input-type=module", "-e", command], { cwd: process.cwd(), env, encoding: "utf8" }); + assert.equal(boot(baseEnv).status, 0); + const bad: Array<[NodeJS.ProcessEnv, RegExp]> = [ + [{ PORTAL_PLAYGROUND_MINTS_PER_IP: "0" }, /between 1 and 64/], + [{ PORTAL_PLAYGROUND_MINTS_PER_IP: "65" }, /between 1 and 64/], + [{ PORTAL_PLAYGROUND_MINTS_PER_IP: "lots" }, /between 1 and 64/], + [{ PORTAL_PLAYGROUND_MINT_WINDOW_S: "30" }, /between 60 and 86400/], + [{ PORTAL_PLAYGROUND_MINT_WINDOW_S: "172800" }, /between 60 and 86400/], + [{ PORTAL_COOKIE_DOMAIN: "qm.example.com" }, /PORTAL_COOKIE_DOMAIN and PORTAL_APPS_DOMAIN unset/], + [{ PORTAL_DEPLOYMENTS_ENABLED: "1" }, /PORTAL_DEPLOYMENTS_ENABLED unset/], + ]; + for (const [extra, pattern] of bad) { + const r = boot({ ...baseEnv, ...extra }); + assert.notEqual(r.status, 0, `expected boot failure for ${JSON.stringify(extra)}`); + assert.match(r.stderr, pattern); + } +}); + +test("mints beyond the per-IP budget are refused, and refusal sets no cookie", async () => { + let last: Response | null = null; + for (let i = 0; i < 10; i++) last = await fetch(`${base}/`, { headers: HTML, redirect: "manual" }); + assert.equal(last!.status, 429); + assert.equal(last!.headers.getSetCookie().length, 0); + + refuseClaims = true; + try { + const down = await fetch(`${base}/`, { headers: HTML, redirect: "manual" }); + assert.equal(down.status, 429, "a failed claim must fail closed"); + } finally { + refuseClaims = false; + } +});