Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,7 +27,7 @@ export async function startServer(): Promise<void> {
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) => {
Expand Down
2 changes: 1 addition & 1 deletion plugins/auth/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion plugins/auth/test/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
10 changes: 5 additions & 5 deletions plugins/auth/src/claims.ts → plugins/chassis/src/claims.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;
Expand All @@ -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);
Expand All @@ -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;
}
},
Expand Down
51 changes: 51 additions & 0 deletions plugins/portal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<random>`), 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`,
Expand Down
135 changes: 130 additions & 5 deletions plugins/portal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>(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: `<a class="btn primary" href="/">Try again</a>`,
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: `<a class="btn primary" href="/">Back to the playground</a>`,
help: "Sign in with a real account at /auth/login to use this link.",
});
}

async function mintPlaygroundSession(req: IncomingMessage, res: ServerResponse): Promise<SessionClaims | null> {
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"),
Expand Down Expand Up @@ -793,7 +869,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
);
}

const session = currentSession(req);
let session = currentSession(req);
if (session) renewSessionCookie(req, res);

if (pathname === "/auth/impersonate" && method === "POST") {
Expand Down Expand Up @@ -854,6 +930,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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,
Expand All @@ -862,6 +939,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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 });
}

Expand All @@ -872,6 +950,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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}`,
Expand All @@ -886,6 +965,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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}`,
Expand Down Expand Up @@ -920,11 +1001,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>

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)) {
Expand All @@ -950,6 +1037,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>

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)) {
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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",
);
Expand Down
1 change: 1 addition & 0 deletions plugins/portal/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface SessionClaims {
org: string;
name?: string;
auth?: number;
anon?: boolean;
iat: number;
exp: number;
}
Expand Down
Loading