diff --git a/admin/src/hooks/use-session.ts b/admin/src/hooks/use-session.ts index aa29058..44d11ec 100644 --- a/admin/src/hooks/use-session.ts +++ b/admin/src/hooks/use-session.ts @@ -12,6 +12,7 @@ export function useSession() { const [status, setStatus] = useState(allowDemo ? "local demo data loaded" : "loading"); const [lastUpdatedAt, setLastUpdatedAt] = useState(allowDemo ? Date.now() : null); const [demoMode, setDemoMode] = useState(allowDemo); + const [loginRequired, setLoginRequired] = useState(false); const statusPresentation = useMemo(() => consoleStatusPresentation(status, demoMode), [demoMode, status]); const busy = statusPresentation.tone === "pending"; @@ -45,6 +46,8 @@ export function useSession() { setLastUpdatedAt, demoMode, setDemoMode, + loginRequired, + setLoginRequired, statusPresentation, statusTone: statusPresentation.tone, busy, diff --git a/admin/src/main.tsx b/admin/src/main.tsx index a010868..4ff04c0 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,10 +1,16 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { AppShell } from "./app-shell"; -import { ConsoleControllerProvider } from "./console-controller-context"; +import { ConsoleControllerProvider, useConsole } from "./console-controller-context"; +import { LoginScreen } from "./screens/login"; import "@fontsource-variable/archivo/standard.css"; import "@fontsource-variable/spline-sans-mono"; import "./style.css"; -function App() { return ; } +function Gate() { + const { session, refresh } = useConsole(); + if (session.loginRequired) return { session.setLoginRequired(false); void refresh(); }} />; + return ; +} +function App() { return ; } createRoot(document.getElementById("root")!).render(); diff --git a/admin/src/screens/login.tsx b/admin/src/screens/login.tsx new file mode 100644 index 0000000..7e7e111 --- /dev/null +++ b/admin/src/screens/login.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { LogIn, Route } from "lucide-react"; +import { InlineError } from "../components"; +import { localLogin } from "../ui-helpers"; + +export function LoginScreen({ gatewayOrigin, onSuccess }: { gatewayOrigin: string; onSuccess: () => void }) { + const [token, setToken] = React.useState(""); + const [error, setError] = React.useState(""); + const [busy, setBusy] = React.useState(false); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + const failure = await localLogin(gatewayOrigin, token.trim()); + if (failure) setError(failure); + else onSuccess(); + } catch { + setError("sign-in request failed; gateway unreachable"); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +
+ ClawRouter + access gateway +
+
+

Sign in

+

This self-hosted console uses local sign-in. Paste the admin token configured for this deployment.

+ {error ? : null} + + + +
+ ); +} diff --git a/admin/src/styles/shell.css b/admin/src/styles/shell.css index 9a9128f..a9a4b57 100644 --- a/admin/src/styles/shell.css +++ b/admin/src/styles/shell.css @@ -537,3 +537,55 @@ margin-left: 0; } } + +/* Local sign-in screen (self-host profile). */ + +.loginShell { + display: grid; + min-height: 100vh; + place-items: center; + background: var(--canvas); + padding: 24px; +} + +.loginCard { + display: grid; + gap: 14px; + width: min(360px, 100%); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + padding: 0 22px 22px; +} + +.loginCard .brandBlock { + margin: 0 -22px; + padding: 0 22px; +} + +.loginCard h1 { + margin: 6px 0 0; + color: var(--ink); + font-size: 19px; + letter-spacing: -0.01em; +} + +.loginCard p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.loginCard label { + display: grid; + gap: 5px; +} + +.loginCard label span { + color: var(--muted); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-transform: uppercase; +} diff --git a/admin/src/ui-helpers.ts b/admin/src/ui-helpers.ts index 438a265..e79f3a5 100644 --- a/admin/src/ui-helpers.ts +++ b/admin/src/ui-helpers.ts @@ -33,6 +33,28 @@ export async function request(baseUrl: string, path: string, init: RequestIni return response.json() as Promise; } +export async function localLoginAvailable(baseUrl: string): Promise { + try { + const index = await request<{ endpoints?: { sessionLogin?: unknown } }>(baseUrl, "/v1"); + return typeof index.endpoints?.sessionLogin === "string"; + } catch { + return false; + } +} + +export async function localLogin(baseUrl: string, token: string): Promise { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/session/login`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (response.ok) return null; + if (response.status === 401) return "invalid admin token"; + if (response.status === 429) return "too many sign-in attempts; wait a minute and retry"; + return `sign-in failed with status ${response.status}`; +} + export async function playgroundRequest(baseUrl: string, path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, credentials: "same-origin", headers }); diff --git a/admin/src/use-console-controller.ts b/admin/src/use-console-controller.ts index 5986d99..7048173 100644 --- a/admin/src/use-console-controller.ts +++ b/admin/src/use-console-controller.ts @@ -8,7 +8,7 @@ import { useUsage } from "./hooks/use-usage"; import { useSelfServiceKeys } from "./hooks/use-self-service-keys"; import { installAutoRefresh } from "./auto-refresh"; import { demo } from "./ui-config"; -import { localDemoRole, oauthCallbackStatus, request, settled, usagePolicyId } from "./ui-helpers"; +import { localDemoRole, localLoginAvailable, oauthCallbackStatus, request, settled, usagePolicyId } from "./ui-helpers"; import { syntheticUsageTimeline } from "./usage-analytics"; import type { AccessUser, @@ -126,6 +126,7 @@ export function useConsoleController() { staticCatalog, ]); session.setValue(sessionData); + session.setLoginRequired(false); selfServiceKeys.setPrincipal(sessionData.email ?? ""); catalog.setProviders(providerData.providers); catalog.setRoutes(routeData); @@ -154,6 +155,11 @@ export function useConsoleController() { if (!background) session.setStatus(warnings.length ? warnings.join("; ") : oauthCallbackStatus() ?? "connected"); } catch (caught) { const message = errorMessage(caught); + if (message.includes("access_session_required") && await localLoginAvailable(session.gatewayOrigin)) { + session.setLoginRequired(true); + if (!background) session.setStatus("sign-in required"); + return; + } if (session.allowDemo) { loadAdminDemo(); return; diff --git a/deploy/self-host/.env.example b/deploy/self-host/.env.example index a92a2de..c3c0fed 100644 --- a/deploy/self-host/.env.example +++ b/deploy/self-host/.env.example @@ -1,6 +1,14 @@ # Required. SHA-256 digest only; keep the raw admin token in your secret manager. CLAWROUTER_ADMIN_TOKEN_SHA256= +# Local console sign-in, opt-in: set to "enabled" to serve the dashboard +# sign-in form; sign in with the raw admin token. Without it the console +# stays API-only. +# CLAWROUTER_LOCAL_AUTH=enabled + +# Identity recorded for local console sign-ins. +# CLAWROUTER_LOCAL_ADMIN_EMAIL=admin@local + # Provider bindings are discovered from the compiled provider snapshot. # OPENAI_API_KEY= # ANTHROPIC_API_KEY= diff --git a/deploy/self-host/entrypoint.mjs b/deploy/self-host/entrypoint.mjs index 693f525..470e110 100644 --- a/deploy/self-host/entrypoint.mjs +++ b/deploy/self-host/entrypoint.mjs @@ -42,11 +42,29 @@ export function selfHostVariableNames(providerSnapshot, env) { names.add(trimmed); } names.delete("CLAWROUTER_ADMIN_TOKEN_SHA256"); + names.delete("CLAWROUTER_LOCAL_AUTH"); + names.delete("CLAWROUTER_LOCAL_ADMIN_EMAIL"); return [...names] .filter((name) => env[name] !== undefined && env[name] !== "") .sort(); } +export function localAuthMode(env) { + const value = (env.CLAWROUTER_LOCAL_AUTH ?? "disabled").trim().toLowerCase(); + if (!["enabled", "disabled"].includes(value)) { + throw new Error('CLAWROUTER_LOCAL_AUTH must be "enabled" or "disabled"'); + } + return value; +} + +export function localAdminEmail(env) { + const value = env.CLAWROUTER_LOCAL_ADMIN_EMAIL?.trim(); + if (value && !(value.length <= 320 && /^[^\s@]+@[^\s@]+$/.test(value))) { + throw new Error("CLAWROUTER_LOCAL_ADMIN_EMAIL must be a valid email address"); + } + return value || null; +} + function main() { const adminTokenSha256 = process.env.CLAWROUTER_ADMIN_TOKEN_SHA256?.trim(); if (!adminTokenSha256) { @@ -56,6 +74,15 @@ function main() { fail("CLAWROUTER_ADMIN_TOKEN_SHA256 must be a 64-character hexadecimal SHA-256 digest"); } + let localAuth; + let adminEmail; + try { + localAuth = localAuthMode(process.env); + adminEmail = localAdminEmail(process.env); + } catch (error) { + fail(error.message); + } + const sourceConfig = readFileSync(join(root, "wrangler.toml"), "utf8"); writeFileSync(configPath, renderSelfHostConfig(sourceConfig), { mode: 0o600 }); @@ -76,7 +103,12 @@ function main() { configPath, "--var", `CLAWROUTER_ADMIN_TOKEN_SHA256:${adminTokenSha256}`, + "--var", + `CLAWROUTER_LOCAL_AUTH:${localAuth}`, ]; + if (adminEmail) { + args.push("--var", `CLAWROUTER_LOCAL_ADMIN_EMAIL:${adminEmail}`); + } // Wrangler redacts secret-shaped bindings; --var makes Docker env explicit to local workerd. for (const name of variableNames) { args.push("--var", `${name}:${process.env[name]}`); diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 56a8fcd..930ddcd 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -38,6 +38,30 @@ For a custom manifest or another intentional Worker variable, add its name to the comma-separated `CLAWROUTER_SELF_HOST_VARS` value. Never add the raw `CLAWROUTER_ADMIN_TOKEN`; the Worker receives only its digest. +## Console sign-in + +Local console sign-in is opt-in. Add `CLAWROUTER_LOCAL_AUTH=enabled` to +`deploy/self-host/.env`, recreate the container, then open +`http://localhost:8787/dashboard` and paste the raw admin token into the +sign-in form; the browser receives a 12-hour session cookie. Without the +flag the console stays API-only. Scripts can obtain the same cookie +directly: + +```sh +curl --fail -c cookies.txt http://localhost:8787/v1/session/login \ + -H 'content-type: application/json' \ + --data "{\"token\": \"$CLAWROUTER_ADMIN_TOKEN\"}" +curl --fail -b cookies.txt http://localhost:8787/v1/session +``` + +The session authenticates the dashboard, the playground, and the +`/v1/session/*` endpoints (including self-service maintainer keys) as an +administrator identified by `CLAWROUTER_LOCAL_ADMIN_EMAIL` (default +`admin@local`). `POST /v1/session/logout` revokes the session. Sign-in +attempts are rate limited. Local sign-in is refused whenever Cloudflare +Access variables are configured, so it cannot be enabled on a managed +deployment. + ## Create a proxy key The normal key helper uses the running Worker's admin bearer-token API. It does @@ -88,6 +112,12 @@ credentials, grants, budgets, settled usage records, and retained content. Pending, delayed, or retrying local queue messages are memory-only and are lost on a crash or restart; drain request traffic before planned maintenance. +Upgrading past 0.1.0 does not change the console posture: local sign-in is +opt-in, so the dashboard keeps failing closed until the operator sets +`CLAWROUTER_LOCAL_AUTH=enabled`. With the flag set, the dashboard shell and +`/v1/session/login` become reachable; the login still requires the admin +token, and every API behind the shell stays session-gated. + To upgrade a source checkout, back up `/data`, pull the new source, review the release notes, then pull fresh base layers, rebuild, and restart: @@ -98,10 +128,11 @@ docker compose -f deploy/self-host/docker-compose.yml up -d ## Version 1 limitations -Cloudflare Access is absent. Console sign-in, browser OAuth, and GitHub -maintainer auto-provisioning are unavailable. Manage the service through the -admin bearer-token API and repository scripts; clients use normal proxy keys. -The dashboard and Access-session endpoints remain fail-closed without an Access -identity. This profile is one local workerd process and does not provide -Cloudflare's distributed availability, durable queue delivery, or managed -backups. +Cloudflare Access is absent. GitHub maintainer auto-provisioning is +unavailable, browser OAuth connect flows are untested in this profile, and +local console sign-in currently supports a single admin-token identity +rather than per-user passwords. +Manage the service through the console session or the admin bearer-token API +and repository scripts; clients use normal proxy keys. This profile is one +local workerd process and does not provide Cloudflare's distributed +availability, durable queue delivery, or managed backups. diff --git a/test/self-host-entrypoint.test.mjs b/test/self-host-entrypoint.test.mjs index 40d63b7..e5b6d26 100644 --- a/test/self-host-entrypoint.test.mjs +++ b/test/self-host-entrypoint.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + localAdminEmail, + localAuthMode, renderSelfHostConfig, selfHostVariableNames, } from "../deploy/self-host/entrypoint.mjs"; @@ -53,3 +55,26 @@ test("self-host vars include configured provider and explicit custom bindings", /cannot be passed to the Worker/, ); }); + +test("self-host vars exclude local-auth bindings owned by the entrypoint", () => { + const names = selfHostVariableNames({ providers: [] }, { + CLAWROUTER_LOCAL_AUTH: "enabled", + CLAWROUTER_LOCAL_ADMIN_EMAIL: "ops@example.com", + CUSTOM_BINDING: "custom", + CLAWROUTER_SELF_HOST_VARS: "CUSTOM_BINDING,CLAWROUTER_LOCAL_AUTH,CLAWROUTER_LOCAL_ADMIN_EMAIL", + }); + assert.deepEqual(names, ["CUSTOM_BINDING"]); +}); + +test("local auth mode defaults to disabled and rejects unknown values", () => { + assert.equal(localAuthMode({}), "disabled"); + assert.equal(localAuthMode({ CLAWROUTER_LOCAL_AUTH: " Enabled " }), "enabled"); + assert.throws(() => localAuthMode({ CLAWROUTER_LOCAL_AUTH: "maybe" }), /must be "enabled" or "disabled"/); +}); + +test("local admin email is validated at startup instead of first sign-in", () => { + assert.equal(localAdminEmail({}), null); + assert.equal(localAdminEmail({ CLAWROUTER_LOCAL_ADMIN_EMAIL: " ops@example.com " }), "ops@example.com"); + assert.throws(() => localAdminEmail({ CLAWROUTER_LOCAL_ADMIN_EMAIL: "admin local" }), /valid email address/); + assert.throws(() => localAdminEmail({ CLAWROUTER_LOCAL_ADMIN_EMAIL: "admin@" }), /valid email address/); +}); diff --git a/worker/access.ts b/worker/access.ts index fea51d2..6b68698 100644 --- a/worker/access.ts +++ b/worker/access.ts @@ -2,6 +2,7 @@ import { authorityCall, resolveBindings, resolvePolicies, resolveUsers } from ". import { assignmentEvidenceFromAccessIdentity } from "./assignment-evaluator"; import { listAssignmentRules, reconcileUserAssignments } from "./assignments"; import { selectProviderPolicy } from "./grant-selection"; +import { localSession } from "./local-auth"; import type { AccessControlUser, AccessPolicyEntry, AccessSession, AuthorizedIdentity, Env } from "./types"; import { commaSet, errorResponse, normalizeEmail, parseBearer, safeEqual, sha256Hex } from "./utils"; @@ -18,6 +19,10 @@ interface AccessJwtPayload { interface Jwk { kid?: string; kty?: string; n?: string; e?: string; alg?: string; use?: string } export async function verifiedAccessSession(request: Request, env: Env): Promise { + return (await cloudflareAccessSession(request, env)) ?? localSession(request, env); +} + +async function cloudflareAccessSession(request: Request, env: Env): Promise { const headers = request.headers; const assertion = headers.get("cf-access-jwt-assertion"); if (!assertion || !env.CLAWROUTER_ACCESS_TEAM_DOMAIN || !env.CLAWROUTER_ACCESS_AUD) return null; @@ -91,7 +96,7 @@ export async function authorizeAdmin(request: Request, env: Env): Promise { - const session = await verifiedAccessSession(request, env); - if (!session) return errorResponse("access_session_required", "a verified Cloudflare Access session is required", 401); + // Local-auth mode serves the shell unauthenticated so the SPA can present sign-in; every API behind it stays session-gated. + if (!localAuthEnabled(env)) { + const session = await verifiedAccessSession(request, env); + if (!session) return errorResponse("access_session_required", "a verified Cloudflare Access session is required", 401); + } const url = new URL(request.url); url.pathname = "/"; const response = await env.ASSETS.fetch(new Request(url, request)); const headers = dashboardSecurityHeaders(response.headers); @@ -109,7 +115,7 @@ function sameOrigin(request: Request): boolean { const url = new URL(request.url function openAiPath(path: string): boolean { return ["/v1/chat/completions", "/v1/responses", "/v1/embeddings"].includes(path); } function serviceIndex(env: Env) { - return { + const index = { ...healthStatus(env), contract: "clawrouter.openai-compatible.v1", interface: { root: "/", dashboard: "/dashboard", playground: "/dashboard/playground", admin: "/dashboard/access", account: "/dashboard/users" }, endpoints: { @@ -122,6 +128,8 @@ function serviceIndex(env: Env) { openaiCompatible: ["/v1/chat/completions", "/v1/responses", "/v1/embeddings"], manifestProxy: "/v1/proxy/{provider}/{endpoint}", nativeProxy: "/v1/native/{provider}/{provider-native-path}", }, }; + if (localAuthEnabled(env)) Object.assign(index.endpoints, { sessionLogin: "/v1/session/login", sessionLogout: "/v1/session/logout" }); + return index; } function healthStatus(env: Env) { diff --git a/worker/local-auth.ts b/worker/local-auth.ts new file mode 100644 index 0000000..d7b51ec --- /dev/null +++ b/worker/local-auth.ts @@ -0,0 +1,141 @@ +import { publicSession, sameOrigin } from "./access"; +import { authorityCall, resolveUsers } from "./authority"; +import type { AccessControlUser, AccessSession, Env } from "./types"; +import { errorResponse, json, normalizeEmail, nowIso, readJson, safeEqual, sha256Hex } from "./utils"; + +const sessionCookieName = "clawrouter_session"; +const sessionTtlSeconds = 12 * 60 * 60; +const sessionKeyPrefix = "local/sessions/"; +const loginWindowMs = 60_000; +const loginAttemptLimit = 10; +// cf-connecting-ip is client-supplied on a bare workerd host, so per-client buckets are advisory; the global bucket bounds spoofed-header bypass. +const loginGlobalKey = "*global*"; +const loginGlobalLimit = 50; +const loginAttemptClientCap = 10_000; +const loginAttempts = new Map(); + +interface LocalSessionRecord { email: string; role: "admin" | "user"; createdAt: string; expiresAtMs: number } + +export function localAuthEnabled(env: Env): boolean { + // Cloudflare Access configuration always wins so a stray flag cannot open a login form on a managed deployment. + if (env.CLAWROUTER_ACCESS_TEAM_DOMAIN || env.CLAWROUTER_ACCESS_AUD) return false; + return ["enabled", "true", "1"].includes((env.CLAWROUTER_LOCAL_AUTH ?? "").trim().toLowerCase()); +} + +export async function localSession(request: Request, env: Env): Promise { + if (!localAuthEnabled(env)) return null; + const token = sessionCookieValue(request); + if (!token) return null; + const record = await env.POLICY_KV.get(sessionKey(await sha256Hex(token)), "json"); + // KV TTL eviction can lag; the stored expiry keeps the 12h boundary exact. + if (!record?.email || record.expiresAtMs <= Date.now()) return null; + const user = (await resolveUsers(env, [record.email]))[0]; + // Deleting or disabling the user record revokes live sessions; role edits apply on the next request. + if (!user || user.record.enabled === false) return null; + return localAccessSession(record, user, env); +} + +export async function localLogin(request: Request, env: Env): Promise { + if (!localAuthEnabled(env)) return errorResponse("route_not_found", "route not found", 404); + if (!sameOrigin(request)) return errorResponse("access_csrf_required", "same-origin browser request required", 403); + const client = request.headers.get("cf-connecting-ip") ?? "local"; + const nowMs = Date.now(); + if (loginThrottled(client, nowMs)) return errorResponse("login_rate_limited", "too many sign-in attempts; retry in a minute", 429); + const body = await readJson<{ token?: unknown }>(request); + const submitted = typeof body.token === "string" ? body.token.trim() : ""; + const expected = env.CLAWROUTER_ADMIN_TOKEN_SHA256?.trim().toLowerCase(); + if (!submitted || !expected || !safeEqual(await sha256Hex(submitted), expected)) { + recordLoginFailure(client, nowMs); + return errorResponse("login_invalid", "invalid sign-in token", 401); + } + const email = normalizeEmail(env.CLAWROUTER_LOCAL_ADMIN_EMAIL ?? "admin@local"); + if (!email) return errorResponse("local_auth_misconfigured", "CLAWROUTER_LOCAL_ADMIN_EMAIL must be a valid email address", 500); + let user = (await resolveUsers(env, [email]))[0]; + if (!user) { + user = { email, record: { role: "admin", tenantId: env.CLAWROUTER_ACCESS_DEFAULT_TENANT ?? "default", enabled: true, groups: [], contentRetentionDisabled: false } }; + await authorityCall(env, "/users/put", user); + } + if (user.record.enabled === false) { + recordLoginFailure(client, nowMs); + return errorResponse("login_invalid", "invalid sign-in token", 401); + } + const sessionToken = randomSessionToken(); + const record: LocalSessionRecord = { email, role: "admin", createdAt: nowIso(), expiresAtMs: nowMs + sessionTtlSeconds * 1000 }; + await env.POLICY_KV.put(sessionKey(await sha256Hex(sessionToken)), JSON.stringify(record), { expirationTtl: sessionTtlSeconds }); + return json({ ok: true, session: publicSession(localAccessSession(record, user, env)) }, 200, { + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "set-cookie": sessionCookieHeader(request, sessionToken, sessionTtlSeconds), + }); +} + +export async function localLogout(request: Request, env: Env): Promise { + if (!localAuthEnabled(env)) return errorResponse("route_not_found", "route not found", 404); + if (!sameOrigin(request)) return errorResponse("access_csrf_required", "same-origin browser request required", 403); + const token = sessionCookieValue(request); + if (token) await env.POLICY_KV.delete(sessionKey(await sha256Hex(token))); + return json({ ok: true }, 200, { "cache-control": "no-store", "set-cookie": sessionCookieHeader(request, "", 0) }); +} + +function localAccessSession(record: LocalSessionRecord, user: AccessControlUser, env: Env): AccessSession { + return { + authenticated: true, + auth: "local", + role: user.record.role ?? record.role, + email: record.email, + subject: null, + tenantId: user.record.tenantId ?? env.CLAWROUTER_ACCESS_DEFAULT_TENANT ?? "default", + groups: [...new Set(user.record.groups ?? [])].sort(), + contentRetentionDisabled: user.record.contentRetentionDisabled ?? false, + }; +} + +function sessionKey(tokenSha256: string): string { return `${sessionKeyPrefix}${tokenSha256}`; } + +function randomSessionToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function sessionCookieValue(request: Request): string | null { + const header = request.headers.get("cookie"); + if (!header) return null; + for (const part of header.split(";")) { + const separator = part.indexOf("="); + if (separator < 0 || part.slice(0, separator).trim() !== sessionCookieName) continue; + const value = part.slice(separator + 1).trim(); + return /^[a-f0-9]{64}$/.test(value) ? value : null; + } + return null; +} + +function sessionCookieHeader(request: Request, value: string, maxAgeSeconds: number): string { + // Secure is omitted on plain-http origins so the loopback-only self-host default still receives the cookie. + // x-forwarded-proto covers TLS-terminating reverse proxies; a client forging it only breaks its own cookie. + const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0].trim().toLowerCase(); + const secure = new URL(request.url).protocol === "https:" || forwardedProto === "https" ? "; Secure" : ""; + return `${sessionCookieName}=${value}; Max-Age=${maxAgeSeconds}; Path=/; HttpOnly; SameSite=Lax${secure}`; +} + +function loginThrottled(client: string, nowMs: number): boolean { + return bucketFull(client, loginAttemptLimit, nowMs) || bucketFull(loginGlobalKey, loginGlobalLimit, nowMs); +} + +function bucketFull(key: string, limit: number, nowMs: number): boolean { + const entry = loginAttempts.get(key); + if (entry && entry.resetAtMs <= nowMs) loginAttempts.delete(key); + const current = loginAttempts.get(key); + return !!current && current.count >= limit; +} + +function recordLoginFailure(client: string, nowMs: number): void { + if (loginAttempts.size >= loginAttemptClientCap) { + for (const [key, entry] of loginAttempts) if (entry.resetAtMs <= nowMs) loginAttempts.delete(key); + if (loginAttempts.size >= loginAttemptClientCap) loginAttempts.clear(); + } + for (const key of [client, loginGlobalKey]) { + const entry = loginAttempts.get(key); + if (!entry || entry.resetAtMs <= nowMs) loginAttempts.set(key, { count: 1, resetAtMs: nowMs + loginWindowMs }); + else entry.count += 1; + } +} diff --git a/worker/test/local-auth.test.mjs b/worker/test/local-auth.test.mjs new file mode 100644 index 0000000..f2bc7cd --- /dev/null +++ b/worker/test/local-auth.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { extname } from "node:path"; +import { registerHooks } from "node:module"; +import test from "node:test"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith(".") && context.parentURL && !extname(new URL(specifier, context.parentURL).pathname)) return nextResolve(`${specifier}.ts`, context); + return nextResolve(specifier, context); + }, +}); + +const { authorizeAdmin, verifiedAccessSession } = await import("../access.ts"); +const { localAuthEnabled, localLogin, localLogout, localSession } = await import("../local-auth.ts"); + +const adminKeyMaterial = "self-host-console-key"; +const adminKeyMaterialSha256 = createHash("sha256").update(adminKeyMaterial).digest("hex"); +const origin = "http://localhost:8787"; + +function fixture(overrides = {}) { + const kv = new Map(); + const users = new Map(); + return { + kv, + users, + POLICY_KV: { + get: async (key, type) => { + const value = kv.get(key); + return value === undefined ? null : type === "json" ? JSON.parse(value) : value; + }, + put: async (key, value) => { kv.set(key, value); }, + delete: async (key) => { kv.delete(key); }, + list: async () => ({ keys: [], list_complete: true }), + }, + ACCESS_CONTROL: { + idFromName: (name) => name, + get: () => ({ + fetch: async (url, init) => { + const path = new URL(url).pathname; + const body = init?.body ? JSON.parse(init.body) : {}; + if (path === "/users/resolve") { + const found = body.emails.filter((email) => users.has(email)).map((email) => ({ email, record: users.get(email) })); + return Response.json({ initialized: true, users: found, missingEmails: body.emails.filter((email) => !users.has(email)) }); + } + if (path === "/users/put") { users.set(body.email, body.record); return new Response("updated"); } + return Response.json({ error: { code: "route_not_found" } }, { status: 404 }); + }, + }), + }, + CLAWROUTER_LOCAL_AUTH: "enabled", + CLAWROUTER_ADMIN_TOKEN_SHA256: adminKeyMaterialSha256, + ...overrides, + }; +} + +function loginRequest(token, { ip, base = origin, from } = {}) { + return new Request(`${base}/v1/session/login`, { + method: "POST", + body: JSON.stringify({ token }), + headers: { origin: from ?? new URL(base).origin, "content-type": "application/json", ...(ip ? { "cf-connecting-ip": ip } : {}) }, + }); +} + +function cookieFrom(response) { + const header = response.headers.get("set-cookie"); + assert.ok(header, "expected a set-cookie header"); + return header.split(";")[0]; +} + +test("local login is refused when local auth is disabled or Access is configured", async () => { + const disabled = fixture({ CLAWROUTER_LOCAL_AUTH: undefined }); + assert.equal(localAuthEnabled(disabled), false); + assert.equal((await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.1" }), disabled)).status, 404); + + const managed = fixture({ CLAWROUTER_ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", CLAWROUTER_ACCESS_AUD: "aud" }); + assert.equal(localAuthEnabled(managed), false); + assert.equal((await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.2" }), managed)).status, 404); +}); + +test("local login rejects cross-origin requests and wrong tokens", async () => { + const env = fixture(); + const csrf = await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.3", from: "https://evil.example" }), env); + assert.equal(csrf.status, 403); + assert.equal((await csrf.json()).error.code, "access_csrf_required"); + + const wrong = await localLogin(loginRequest("not-the-token", { ip: "198.51.100.4" }), env); + assert.equal(wrong.status, 401); + assert.equal((await wrong.json()).error.code, "login_invalid"); + assert.equal(env.kv.size, 0); +}); + +test("local login mints a session cookie that authenticates as an admin", async () => { + const env = fixture(); + const response = await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.5" }), env); + assert.equal(response.status, 200); + const setCookie = response.headers.get("set-cookie"); + assert.match(setCookie, /^clawrouter_session=[a-f0-9]{64}; Max-Age=43200; Path=\/; HttpOnly; SameSite=Lax$/); + assert.doesNotMatch(setCookie, /Secure/); + const body = await response.json(); + assert.equal(body.session.auth, "local"); + assert.equal(body.session.role, "admin"); + assert.equal(body.session.email, "admin@local"); + assert.equal(env.users.get("admin@local").role, "admin"); + + const authed = new Request(`${origin}/v1/session`, { headers: { cookie: cookieFrom(response) } }); + const session = await verifiedAccessSession(authed, env); + assert.equal(session?.auth, "local"); + assert.equal(session?.role, "admin"); + assert.equal(session?.email, "admin@local"); + + const admin = await authorizeAdmin(new Request(`${origin}/v1/admin/overview`, { headers: { cookie: cookieFrom(response) } }), env); + assert.ok(!(admin instanceof Response)); + assert.equal(admin.role, "admin"); +}); + +test("local login marks the cookie Secure on https origins and honors the configured admin email", async () => { + const env = fixture({ CLAWROUTER_LOCAL_ADMIN_EMAIL: "ops@example.com" }); + const response = await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.6", base: "https://router.example" }), env); + assert.equal(response.status, 200); + assert.match(response.headers.get("set-cookie"), /; Secure$/); + assert.equal((await response.json()).session.email, "ops@example.com"); +}); + +test("local login marks the cookie Secure behind a TLS-terminating proxy", async () => { + const env = fixture(); + const request = loginRequest(adminKeyMaterial, { ip: "198.51.100.12" }); + request.headers.set("x-forwarded-proto", "https"); + const response = await localLogin(request, env); + assert.equal(response.status, 200); + assert.match(response.headers.get("set-cookie"), /; Secure$/); +}); + +test("local sessions respect disabled, deleted, or demoted user records and expiry", async () => { + const env = fixture(); + const response = await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.7" }), env); + const request = new Request(`${origin}/v1/session`, { headers: { cookie: cookieFrom(response) } }); + assert.ok(await localSession(request, env)); + + const provisioned = env.users.get("admin@local"); + env.users.set("admin@local", { ...provisioned, enabled: false }); + assert.equal(await localSession(request, env), null); + + env.users.delete("admin@local"); + assert.equal(await localSession(request, env), null); + + env.users.set("admin@local", { ...provisioned, role: "user" }); + assert.equal((await localSession(request, env))?.role, "user"); + env.users.set("admin@local", provisioned); + + const [key, value] = [...env.kv.entries()][0]; + env.kv.set(key, JSON.stringify({ ...JSON.parse(value), expiresAtMs: Date.now() - 1 })); + assert.equal(await localSession(request, env), null); +}); + +test("local logout revokes the stored session and clears the cookie", async () => { + const env = fixture(); + const login = await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.8" }), env); + const cookie = cookieFrom(login); + const logout = await localLogout(new Request(`${origin}/v1/session/logout`, { method: "POST", headers: { origin, cookie } }), env); + assert.equal(logout.status, 200); + assert.match(logout.headers.get("set-cookie"), /^clawrouter_session=; Max-Age=0/); + assert.equal(env.kv.size, 0); + assert.equal(await verifiedAccessSession(new Request(`${origin}/v1/session`, { headers: { cookie } }), env), null); +}); + +test("repeated sign-in failures from one client are rate limited", async () => { + const env = fixture(); + const ip = "203.0.113.9"; + for (let attempt = 0; attempt < 10; attempt += 1) { + assert.equal((await localLogin(loginRequest("wrong", { ip }), env)).status, 401); + } + const throttled = await localLogin(loginRequest(adminKeyMaterial, { ip }), env); + assert.equal(throttled.status, 429); + assert.equal((await throttled.json()).error.code, "login_rate_limited"); + + const other = await localLogin(loginRequest(adminKeyMaterial, { ip: "203.0.113.10" }), env); + assert.equal(other.status, 200); +}); + +test("session cookies are ignored outside local-auth mode", async () => { + const env = fixture(); + const login = await localLogin(loginRequest(adminKeyMaterial, { ip: "198.51.100.11" }), env); + const cookie = cookieFrom(login); + const managed = { ...env, CLAWROUTER_ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", CLAWROUTER_ACCESS_AUD: "aud" }; + assert.equal(await localSession(new Request(`${origin}/v1/session`, { headers: { cookie } }), managed), null); + const unset = { ...env, CLAWROUTER_LOCAL_AUTH: undefined }; + assert.equal(await verifiedAccessSession(new Request(`${origin}/v1/session`, { headers: { cookie } }), unset), null); +}); + +test("spoofed per-request client addresses cannot bypass the global sign-in cap", async () => { + const env = fixture(); + let throttled = null; + for (let attempt = 0; attempt < 60 && !throttled; attempt += 1) { + const response = await localLogin(loginRequest("wrong", { ip: `192.0.2.${attempt + 1}` }), env); + if (response.status === 429) throttled = response; + else assert.equal(response.status, 401); + } + assert.ok(throttled, "expected the global cap to throttle spoofed clients"); + const fresh = await localLogin(loginRequest(adminKeyMaterial, { ip: "192.0.2.200" }), env); + assert.equal(fresh.status, 429); +}); diff --git a/worker/types.ts b/worker/types.ts index bf82334..41f1082 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -76,6 +76,8 @@ export interface Env { CLAWROUTER_ACCESS_ADMIN_EMAILS?: string; CLAWROUTER_ACCESS_ADMIN_DOMAINS?: string; CLAWROUTER_ACCESS_DEFAULT_TENANT?: string; + CLAWROUTER_LOCAL_AUTH?: string; + CLAWROUTER_LOCAL_ADMIN_EMAIL?: string; CLAWROUTER_CONTENT_RETENTION_DEFAULT?: string; CLAWROUTER_DEPLOY_ENV?: string; [name: string]: unknown; @@ -148,7 +150,7 @@ export interface AccessControlUser { email: string; record: AccessUserRecord } export interface AccessSession { authenticated: true; - auth: "cloudflare_access"; + auth: "cloudflare_access" | "local" | "admin_token"; role: "admin" | "user"; email: string; subject: string | null;