Skip to content

Commit 35c47a3

Browse files
authored
Add portal playground mode: anonymous browser-pinned sessions (#38)
* Add portal playground mode: anonymous browser-pinned sessions PORTAL_PLAYGROUND=1 lets one deployment serve as a public try-it instance. An unauthenticated browser navigation mints an anonymous playground-<random> principal into the ordinary portal_session cookie, so every visitor gets their own scoped sessions, files, memory, and sandbox through the same personal-scope isolation real teammates use. Non-HTML requests without a session still get 401, so only real page loads mint. /auth/login keeps the full OIDC flow for the one admin, /admin refuses anonymous sessions outright, and signing out simply starts a fresh identity on the next visit. Minting is rate-limited per client IP through the core's durable single-use claim store, failing closed to a 429 page when the claim cannot be recorded, so restarts and blue-green deploys cannot reset the budget. The claim-store helper moves from plugins/auth to the shared chassis package now that both the auth broker and the portal consume it. * Harden playground mode after independent review Refuse to boot when playground is combined with a domain-wide cookie, an apps domain, or deployment proxying, since those surfaces never see the anon flag and would take an anonymous session at face value. Refuse out-of-range mint knobs instead of silently serving 429 to everyone: the core grants at most 64 claim slots per request and a 24-hour claim horizon, so values outside those bounds brick minting. Bucket IPv6 minting per /64 so a routed prefix cannot rotate through fresh budgets, and warn at boot when the socket address would make every visitor share one bucket behind a reverse proxy. Refuse anonymous sessions the connect and secret-drop flows so real OAuth tokens and dropped secrets cannot be attached to a throwaway principal that a cleared cookie orphans.
1 parent e00d3c0 commit 35c47a3

8 files changed

Lines changed: 388 additions & 14 deletions

File tree

plugins/auth/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
33
import { json } from "../../chassis/src/http.ts";
44
import { portFromEnv } from "../../chassis/src/env.ts";
55
import { bootProblems, readConfig } from "./config.ts";
6-
import { coreClaimStore } from "./claims.ts";
6+
import { coreClaimStore } from "../../chassis/src/claims.ts";
77
import { mailerFor } from "./email.ts";
88
import { loadSigningKey } from "./keys.ts";
99
import { TokenSigner } from "./tokens.ts";
@@ -27,7 +27,7 @@ export async function startServer(): Promise<void> {
2727
cfg: CFG,
2828
signingKey,
2929
signer: new TokenSigner(CFG.tokenSecret, CFG.issuer),
30-
claims: coreClaimStore(CFG.coreApiUrl, CFG.coreSigningSecret),
30+
claims: coreClaimStore(CFG.coreApiUrl, CFG.coreSigningSecret, "auth"),
3131
mailer: mailerFor(CFG),
3232
});
3333
const server = createServer((req, res) => {

plugins/auth/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { readBody, PayloadTooLargeError, serveEmojiFavicon } from "../../chassis
33
import { errMessage } from "../../chassis/src/errors.ts";
44
import type { AuthConfig } from "./config.ts";
55
import { validEmail } from "./config.ts";
6-
import { claimOnce, withinRateLimit, type ClaimStore } from "./claims.ts";
6+
import { claimOnce, withinRateLimit, type ClaimStore } from "../../chassis/src/claims.ts";
77
import { mintIdToken, pkceMatches, safeEqual, subjectFor, TokenSigner, type AuthRequest } from "./tokens.ts";
88
import { ID_TOKEN_ALG, type SigningKey } from "./keys.ts";
99
import { renderSignInEmail, type Mailer } from "./email.ts";

plugins/auth/test/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createServer, type Server } from "node:http";
22
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
33
import { readConfig, type AuthConfig } from "../src/config.ts";
4-
import type { ClaimStore } from "../src/claims.ts";
4+
import type { ClaimStore } from "../../chassis/src/claims.ts";
55
import type { Mailer, OutgoingEmail } from "../src/email.ts";
66
import { loadSigningKey } from "../src/keys.ts";
77
import { TokenSigner } from "../src/tokens.ts";
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createHmac } from "node:crypto";
2-
import { signedHeaders, withSourceAuthNonce } from "../../chassis/src/core-client.ts";
3-
import { errMessage } from "../../chassis/src/errors.ts";
2+
import { signedHeaders, withSourceAuthNonce } from "./core-client.ts";
3+
import { errMessage } from "./errors.ts";
44

55
export interface ClaimStore {
66
claimFirst(ids: readonly string[], expiresAtMs: number): Promise<string | null>;
@@ -9,7 +9,7 @@ export interface ClaimStore {
99
const CLAIM_PATH = "/v1/auth/broker/claim";
1010
const CLAIM_TIMEOUT_MS = 4_000;
1111

12-
export function coreClaimStore(coreApiUrl: string, signingSecret: string | undefined): ClaimStore {
12+
export function coreClaimStore(coreApiUrl: string, signingSecret: string | undefined, label = "chassis"): ClaimStore {
1313
return {
1414
async claimFirst(ids, expiresAtMs) {
1515
const path = withSourceAuthNonce(CLAIM_PATH, signingSecret);
@@ -22,13 +22,13 @@ export function coreClaimStore(coreApiUrl: string, signingSecret: string | undef
2222
signal: AbortSignal.timeout(CLAIM_TIMEOUT_MS),
2323
});
2424
if (!r.ok) {
25-
console.error(`[auth] core refused a single-use claim: HTTP ${r.status}`);
25+
console.error(`[${label}] core refused a single-use claim: HTTP ${r.status}`);
2626
return null;
2727
}
2828
const parsed = (await r.json()) as { claimed?: unknown };
2929
return typeof parsed.claimed === "string" ? parsed.claimed : null;
3030
} catch (e) {
31-
console.error(`[auth] core single-use claim failed: ${errMessage(e)}`);
31+
console.error(`[${label}] core single-use claim failed: ${errMessage(e)}`);
3232
return null;
3333
}
3434
},

plugins/portal/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,57 @@ surfaces, and it does **not** import the core.
6767
session before `exp`; the core's `canAdminister` (re-read per request) remains the live admin
6868
revocation path. Slack has no RP-initiated end-session, so SSO re-login is silent.
6969

70+
## Playground mode
71+
72+
`PORTAL_PLAYGROUND=1` turns the deployment into a public try-it instance: an
73+
unauthenticated browser navigation (a `GET` that accepts HTML) mints an anonymous
74+
principal (`playground-<random>`), seals it into the ordinary `portal_session`
75+
cookie, and continues — so each visitor's sessions, files, memory, and sandbox are
76+
pinned to their browser through the same scoping that isolates real teammates.
77+
Non-HTML requests without a session still get `401`, so the SPA's API calls ride
78+
the cookie from the first page load and bare `curl` never mints.
79+
80+
What playground mode does **not** change: `/auth/login` still runs the full OIDC
81+
flow (that's how the one admin signs in — production still demands the usual OIDC
82+
config), `/admin` refuses anonymous sessions outright, and admin identity remains
83+
the core's `ADMIN_GRANTS`. Signing out of an anonymous session just clears the
84+
cookie; the next visit starts a fresh playground identity.
85+
86+
Minting is rate-limited per client address through the core's Postgres-backed
87+
single-use claim store (the same one the sign-in broker uses), so restarts and
88+
blue-green deploys can't reset it; if the core can't record the claim the portal
89+
fails closed and answers 429. `PORTAL_PLAYGROUND_MINTS_PER_IP` (default 30, at
90+
most 64 — the core grants at most 64 claim slots per request) per
91+
`PORTAL_PLAYGROUND_MINT_WINDOW_S` (default 3600, at most 86400 — the core's
92+
claim horizon); the portal refuses to boot outside those ranges rather than
93+
silently serving 429 to everyone. IPv6 clients are bucketed per /64, not per
94+
address, so a visitor with a routed prefix can't rotate through fresh budgets.
95+
The client address comes from `clientIpOf` — on Fly that's `fly-client-ip`;
96+
elsewhere set `PORTAL_XFF_TRUSTED_HOPS` when a reverse proxy fronts the portal,
97+
or every visitor (and every crawler that accepts HTML) shares the socket
98+
address's one bucket.
99+
100+
Because playground authority must never leave this origin, the portal refuses
101+
to boot with `PORTAL_PLAYGROUND` alongside `PORTAL_COOKIE_DOMAIN`,
102+
`PORTAL_APPS_DOMAIN`, or `PORTAL_DEPLOYMENTS_ENABLED` — a domain-wide cookie or
103+
the deployment proxy would hand anonymous sessions to surfaces that never see
104+
the `anon` flag. Anonymous sessions are also refused the `/connect/*` and
105+
`/drop/*` flows, so a visitor can't attach real OAuth tokens or dropped secrets
106+
to a throwaway principal that a cleared cookie orphans.
107+
108+
The `anon` flag lives only in the portal's session cookie — it does not cross
109+
the portal identity boundary. To the core, a playground visitor is an ordinary
110+
**internal** principal of the deployment's org: they can run turns, use their
111+
sandbox, create crons, and reach anything granted or published at `org:` scope,
112+
including org-granted credentials. That is the design — visitors are members of
113+
the playground org — so a playground must be its own deployment with nothing
114+
sensitive at org scope: no org-wide credential grants, no real connector
115+
credentials, no company data. A cleared cookie mints a fresh principal, so pair
116+
this with the core's real brakes: `BUDGET_USD_PER_WINDOW`,
117+
`ORG_BUDGET_USD_PER_WINDOW`, `RATE_LIMIT_PER_WINDOW`, and a single pinned model
118+
via the admin `base-model` / `webui-models` resources. Nothing
119+
garbage-collects an abandoned visitor's scope yet.
120+
70121
## Env
71122

72123
Non-secret (`[env]`): `PORT` (8097 local / 8080 image), `PORTAL_PUBLIC_URL`, `CORE_API_URL`,

plugins/portal/src/index.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
FORWARD_BROKER_HEADERS,
3838
} from "./proxy.ts";
3939
import { signedHeaders, withSourceAuthNonce } from "../../chassis/src/core-client.ts";
40+
import { coreClaimStore, withinRateLimit } from "../../chassis/src/claims.ts";
4041
import { mintPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts";
4142
import { errMessage } from "../../chassis/src/errors.ts";
4243
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"
6970
const LOCAL_AUTH_BYPASS = LOCAL_AUTH_BYPASS_REQUESTED && !IS_PROD && isLocalPortalUrl(PUBLIC_URL);
7071
const LOCAL_AUTH_PRINCIPAL = process.env.PORTAL_DEV_PRINCIPAL || process.env.USER || "dev-admin";
7172
const DEPLOYMENTS_ENABLED = process.env.PORTAL_DEPLOYMENTS_ENABLED === "1";
73+
const PLAYGROUND = process.env.PORTAL_PLAYGROUND === "1";
74+
function playgroundIntEnv(name: string, fallback: number): number {
75+
const raw = process.env[name]?.trim();
76+
if (!raw) return fallback;
77+
const n = Number(raw);
78+
return Number.isInteger(n) ? n : NaN;
79+
}
80+
const PLAYGROUND_MINTS_PER_IP = playgroundIntEnv("PORTAL_PLAYGROUND_MINTS_PER_IP", 30);
81+
const PLAYGROUND_MINT_WINDOW_S = playgroundIntEnv("PORTAL_PLAYGROUND_MINT_WINDOW_S", 3600);
7282
const NEUTRAL_ACCENT = "#4f46e5";
7383
let brandAccent = NEUTRAL_ACCENT;
7484
let modelProviderConfigured: boolean | undefined;
@@ -713,6 +723,72 @@ function setSession(res: ServerResponse, headers: string[]): void {
713723
res.setHeader("set-cookie", headers);
714724
}
715725

726+
const playgroundClaims = PLAYGROUND ? coreClaimStore(CORE, CORE_SIGNING_SECRET, "portal") : null;
727+
728+
export function mintBucketOf(ip: string): string {
729+
if (!ip.includes(":")) return ip;
730+
const zoneless = ip.split("%")[0] ?? "";
731+
if (zoneless.toLowerCase().startsWith("::ffff:") && zoneless.includes(".")) return zoneless.slice(7);
732+
const [headRaw = "", tailRaw = ""] = zoneless.split("::", 2);
733+
const head = headRaw ? headRaw.split(":") : [];
734+
const tail = tailRaw ? tailRaw.split(":") : [];
735+
const groups = [...head, ...Array<string>(Math.max(0, 8 - head.length - tail.length)).fill("0"), ...tail];
736+
const prefix = groups
737+
.slice(0, 4)
738+
.map((g) => (g || "0").toLowerCase().replace(/^0+(?=.)/, ""))
739+
.join(":");
740+
return `${prefix}::/64`;
741+
}
742+
743+
export function playgroundBusyHtml(): string {
744+
return cardPage({
745+
title: "Playground is busy",
746+
heading: "The playground is busy",
747+
msg: "We couldn't start a fresh playground session for you right now. Waiting a little while and reloading resolves most cases.",
748+
icon: ALERT_ICON,
749+
warn: true,
750+
actions: `<a class="btn primary" href="/">Try again</a>`,
751+
help: "Playground sessions are limited per visitor to keep the demo responsive for everyone.",
752+
});
753+
}
754+
755+
export function playgroundRestrictedHtml(): string {
756+
return cardPage({
757+
title: "Not available in the playground",
758+
heading: "Not available in the playground",
759+
msg: "Connecting accounts and dropping secrets are disabled for anonymous playground sessions — clearing your cookie would orphan real credentials.",
760+
icon: LOCK_ICON,
761+
actions: `<a class="btn primary" href="/">Back to the playground</a>`,
762+
help: "Sign in with a real account at /auth/login to use this link.",
763+
});
764+
}
765+
766+
async function mintPlaygroundSession(req: IncomingMessage, res: ServerResponse): Promise<SessionClaims | null> {
767+
if (!playgroundClaims) return null;
768+
const allowed = await withinRateLimit(playgroundClaims, {
769+
secret: SESSION_SECRET ?? DEV_SECRET,
770+
kind: "playground-mint",
771+
value: mintBucketOf(clientIpOf(req)),
772+
limit: PLAYGROUND_MINTS_PER_IP,
773+
windowS: PLAYGROUND_MINT_WINDOW_S,
774+
nowMs: Date.now(),
775+
});
776+
if (!allowed) return null;
777+
const now = Math.floor(Date.now() / 1000);
778+
const session: SessionClaims = {
779+
k: "session",
780+
sub: `playground-${randomToken(8)}`,
781+
org: ORG,
782+
name: "Guest",
783+
anon: true,
784+
auth: now,
785+
iat: now,
786+
exp: now + SESSION_TTL_S,
787+
};
788+
setSession(res, sessionCookieSet(seal(session, sessionKey)));
789+
return session;
790+
}
791+
716792
function renewSessionCookie(req: IncomingMessage, res: ServerResponse): void {
717793
const session = openSession(
718794
readCookie(req.headers.cookie, "portal_session"),
@@ -793,7 +869,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
793869
);
794870
}
795871

796-
const session = currentSession(req);
872+
let session = currentSession(req);
797873
if (session) renewSessionCookie(req, res);
798874

799875
if (pathname === "/auth/impersonate" && method === "POST") {
@@ -854,6 +930,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
854930
const redeem = /^\/connect\/redeem\/([^/]+)$/.exec(pathname);
855931
if (method === "GET" && redeem) {
856932
if (!session) return consentBounce();
933+
if (session.anon) return sendHtml(res, 403, playgroundRestrictedHtml());
857934
return handleConsentRedeem(res, {
858935
corePath: `/v1/connectors/oauth/consent/redeem/${redeem[1]}${url.search}`,
859936
session,
@@ -862,6 +939,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
862939
const selfConnect = /^\/connect\/([^/]+)\/self-connect$/.exec(pathname);
863940
if (method === "GET" && selfConnect) {
864941
if (!session) return consentBounce();
942+
if (session.anon) return sendHtml(res, 403, playgroundRestrictedHtml());
865943
return handleSelfConnect(res, { provider: decodeURIComponent(selfConnect[1] ?? ""), session });
866944
}
867945

@@ -872,6 +950,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
872950
const dropForm = /^\/drop\/([^/]+)\/form$/.exec(pathname);
873951
if (method === "GET" && dropForm) {
874952
if (!session) return consentBounce();
953+
if (session.anon) return sendHtml(res, 403, playgroundRestrictedHtml());
875954
return handleSecretDrop(req, res, {
876955
method,
877956
corePath: `/v1/keychain/drops/${dropForm[1]}/form${url.search}`,
@@ -886,6 +965,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
886965
message: "your session expired — re-open the link, sign in, and paste again",
887966
});
888967
if (!sameOriginRequest(req)) return json(res, 403, { error: "forbidden", message: "cross-origin request refused" });
968+
if (session.anon)
969+
return json(res, 403, { error: "forbidden", message: "secret drops are disabled for playground sessions" });
889970
return handleSecretDrop(req, res, {
890971
method,
891972
corePath: `/v1/keychain/drops/${dropSubmit[1]}${url.search}`,
@@ -920,11 +1001,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
9201001

9211002
if (!session) {
9221003
if (method === "GET" && wantsHtml(req)) {
923-
const returnTo = encodeURIComponent(`${pathname}${url.search}`);
924-
res.writeHead(302, { location: `/auth/login?returnTo=${returnTo}` });
925-
return void res.end();
1004+
if (PLAYGROUND) {
1005+
session = await mintPlaygroundSession(req, res);
1006+
if (!session) return sendHtml(res, 429, playgroundBusyHtml());
1007+
} else {
1008+
const returnTo = encodeURIComponent(`${pathname}${url.search}`);
1009+
res.writeHead(302, { location: `/auth/login?returnTo=${returnTo}` });
1010+
return void res.end();
1011+
}
1012+
} else {
1013+
return json(res, 401, { error: "sign in" });
9261014
}
927-
return json(res, 401, { error: "sign in" });
9281015
}
9291016

9301017
if (method !== "GET" && method !== "HEAD" && !sameOriginRequest(req)) {
@@ -950,6 +1037,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
9501037

9511038
const key = surfaceKey as string;
9521039
if (key === "admin") {
1040+
if (session.anon) {
1041+
if (wantsHtml(req)) return sendHtml(res, 403, nonAdminDeniedHtml({ sub: session.sub, org: session.org }));
1042+
return json(res, 403, { error: "forbidden", message: "admin access required" });
1043+
}
9531044
const probe = await adminProbe(session.sub);
9541045
if (!probe.isAdmin) {
9551046
if (wantsHtml(req)) {
@@ -1083,6 +1174,32 @@ export function bootChecks(): void {
10831174
if (LOCAL_AUTH_BYPASS_REQUESTED && !isLocalPortalUrl(PUBLIC_URL)) {
10841175
problems.push("PORTAL_LOCAL_AUTH_BYPASS requires a localhost, 127.0.0.1, or ::1 PORTAL_PUBLIC_URL");
10851176
}
1177+
if (PLAYGROUND) {
1178+
if (!Number.isInteger(PLAYGROUND_MINTS_PER_IP) || PLAYGROUND_MINTS_PER_IP < 1 || PLAYGROUND_MINTS_PER_IP > 64) {
1179+
problems.push(
1180+
"PORTAL_PLAYGROUND_MINTS_PER_IP must be an integer between 1 and 64 (the core grants at most 64 claim slots per request)",
1181+
);
1182+
}
1183+
if (
1184+
!Number.isInteger(PLAYGROUND_MINT_WINDOW_S) ||
1185+
PLAYGROUND_MINT_WINDOW_S < 60 ||
1186+
PLAYGROUND_MINT_WINDOW_S > 86400
1187+
) {
1188+
problems.push(
1189+
"PORTAL_PLAYGROUND_MINT_WINDOW_S must be an integer between 60 and 86400 (the core's claim horizon is 24 hours)",
1190+
);
1191+
}
1192+
if (COOKIE_DOMAIN || APPS_DOMAIN) {
1193+
problems.push(
1194+
"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",
1195+
);
1196+
}
1197+
if (DEPLOYMENTS_ENABLED) {
1198+
problems.push(
1199+
"PORTAL_PLAYGROUND requires PORTAL_DEPLOYMENTS_ENABLED unset — anonymous visitors must not reach deployed apps",
1200+
);
1201+
}
1202+
}
10861203
if (APPS_DOMAIN && !COOKIE_DOMAIN) {
10871204
problems.push(
10881205
"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 {
12081325
console.warn(
12091326
`[portal] PORTAL_LOCAL_AUTH_BYPASS=1 -- using ${LOCAL_AUTH_PRINCIPAL} as the local session principal (dev/test only)`,
12101327
);
1328+
if (PLAYGROUND)
1329+
console.warn(
1330+
`[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`,
1331+
);
1332+
if (PLAYGROUND && !ON_FLY && XFF_TRUSTED_HOPS === 0)
1333+
console.warn(
1334+
"[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",
1335+
);
12111336
console.log(
12121337
"[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",
12131338
);

plugins/portal/src/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface SessionClaims {
3232
org: string;
3333
name?: string;
3434
auth?: number;
35+
anon?: boolean;
3536
iat: number;
3637
exp: number;
3738
}

0 commit comments

Comments
 (0)