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
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ HTTP bearer authentication is configured only through `server.http.authToken` as

`miftah dashboard` opens the optional browser-local Console and prints its exact URL, resolved first-run location, and one-use bootstrap code. Without `--config`, it inspects only direct, safe JSON configs in `~/.config/miftah` and requires the operator to choose one; it never scans client settings, process arguments, or arbitrary directories. A missing catalog permits strict first-run known-preset or native-OAuth setup at `~/.config/miftah/miftah.json`; an existing file is never silently replaced. `miftah dashboard --config <file>` stays authoritative for one exact configuration and skips catalog discovery. `--no-open` leaves browser launch to the operator while keeping the same foreground server.

`miftah console --config <file>` binds only literal `127.0.0.1`, uses an ephemeral port unless `--port` is supplied, and prints an invocation-bound one-use bootstrap code to the launching terminal. The code is not an OAuth token or MCP bearer. Enter it only in the local Console bootstrap screen; never paste it into a URL, client configuration, log, or support ticket. Stopping the process closes the listener and invalidates every browser session. Restarting produces a fresh bootstrap credential.
`miftah console --config <file>` binds only literal `127.0.0.1`, uses an ephemeral port unless `--port` is supplied, and prints an invocation-bound one-use bootstrap code to the launching terminal. The code is not an OAuth token or MCP bearer. Enter it only in the local Console bootstrap screen; never paste it into a URL, client configuration, log, or support ticket. A reload resumes a still-valid cookie-authenticated session without storing its code or CSRF proof. If the page says the session expired or belongs to an earlier process, run `miftah dashboard` in the terminal and use the newly printed URL and one-time code. Stopping the process closes the listener and invalidates every browser session.

The Console API is versioned under `/api/v1` and uses exact Host checks, exact loopback Origin plus CSRF for every mutation, a short-lived HttpOnly same-site session, bounded JSON, fail-closed mutation audit, and metadata-only responses. Authenticated reads may omit Origin because normal same-origin browser GETs do not consistently send it. It modifies durable configuration and exact local OAuth credentials for future client connections; it cannot take over or silently change another process's active Claude Desktop session. See the [local Console dashboard and control API](console-api.md) for the full endpoint and bootstrap contract.

Expand Down
3 changes: 3 additions & 0 deletions docs/console-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Startup prints the loopback URL and one CSPRNG-backed bootstrap code to the laun

The Console page asks the operator to type this terminal code. A successful same-origin exchange returns an in-memory CSRF proof and sets an opaque `HttpOnly; SameSite=Strict` session cookie scoped to `/api/v1`. The cookie is a session handle, not the bootstrap credential. The CSRF proof remains in page memory and accompanies every later mutation as `X-Miftah-CSRF`; the UI does not persist it in localStorage or sessionStorage. A bootstrap cannot be replayed.

A normal page reload resumes a still-valid session through `GET /api/v1/session`: the HttpOnly cookie authenticates the read and the response restores the CSRF proof only to page memory. Expired, reused, malformed, superseded, and wrong-process codes receive distinct redacted states. A missing, expired, or earlier-process session gives one contextual recovery action; when a new foreground process is required, run `miftah dashboard` in the terminal and use its new URL and code. Recovery does not use `localStorage`, `sessionStorage`, a URL, a log, configuration, or an audit argument for the bootstrap code, session handle, or CSRF proof.

Browser sessions have a 15-minute idle limit and a one-hour absolute limit. Restarting, stopping, or rotating the control host invalidates them. Loopback HTTP cannot provide a meaningful `Secure` cookie flag, so exact Host and Origin validation, SameSite, HttpOnly, one-use bootstrap, CSRF, and short lifetime are all mandatory controls. A hostile process running as the same OS user remains outside this boundary.

## Version 1 endpoints
Expand All @@ -46,6 +48,7 @@ The unconfigured first-run dashboard exposes **Save connector choice**, **Contin
| Method and path | Purpose |
| --- | --- |
| `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. |
| `GET /api/v1/session` | Resume a still-valid cookie-authenticated browser session and return its CSRF proof to page memory. |
| `GET /api/v1/setup-draft` | Return the current first-run safe connector checkpoint, or `null`. Requires a Console session and returns no connection details. |
| `PUT /api/v1/setup-draft` | Save one strict non-secret connector name/preset/stage checkpoint. Requires CSRF and optional exact expected revision. |
| `DELETE /api/v1/setup-draft` | Discard one exact-revision first-run checkpoint. Requires CSRF and never creates or changes a configuration. |
Expand Down
91 changes: 81 additions & 10 deletions src/console/console-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ const script = `(() => {
return error instanceof Error ? error.message : "The Console request failed.";
}

function restoreUnlock() {
function restoreUnlock(recoveryMessage) {
csrfToken = "";
setupCompletion = undefined;
activeSetupDraft = undefined;
Expand All @@ -608,24 +608,74 @@ const script = `(() => {
if (workspaceView) workspaceView.hidden = true;
if (unlockView) unlockView.hidden = false;
if (bootstrapInput instanceof HTMLInputElement) bootstrapInput.focus();
if (typeof recoveryMessage === "string" && recoveryMessage.length > 0) message(recoveryMessage);
}

function sessionRecoveryMessage(code) {
if (code === "session_missing") return "Enter the one-time code printed by the running Console process.";
if (code === "session_expired") {
return "This Console session expired before your next action. Run \`miftah dashboard\` in the terminal for a new one-time code.";
}
return "This page belongs to an earlier or different Console process. Run \`miftah dashboard\` in the terminal for a new URL and one-time code.";
}

function bootstrapRecoveryMessage(code) {
if (code === "bootstrap_expired") {
return "That one-time code expired. Run \`miftah dashboard\` in the terminal for a new code.";
}
if (code === "bootstrap_used") {
return "That code already opened a browser session. Reload the original Console tab, or run \`miftah dashboard\` in the terminal for a new code.";
}
if (code === "bootstrap_superseded") {
return "A newer one-time code was issued. Enter the latest code shown by the running Console process.";
}
if (code === "bootstrap_wrong_process") {
return "That code belongs to another or stopped Console process. Run \`miftah dashboard\` in the terminal and use its new URL and code.";
}
return "Enter the complete one-time code exactly as printed by the running Console process.";
}

function bootstrapResponseError(response, payload) {
const code = payload && payload.error && typeof payload.error.code === "string" ? payload.error.code : "";
if (response.status === 401) return new Error(bootstrapRecoveryMessage(code));
if (response.status === 429 && code === "rate_limit_exceeded") {
const retryAfter = response.headers.get("retry-after");
if (typeof retryAfter === "string" && /^[1-9][0-9]{0,2}$/.test(retryAfter)) {
return new Error("Too many unlock attempts. Wait " + retryAfter + " seconds before trying again; keep this Console process running.");
}
return new Error("Too many unlock attempts. Wait briefly before trying again; keep this Console process running.");
}
const publicMessage = payload && payload.error && typeof payload.error.message === "string"
? payload.error.message
: "The Console unlock request failed.";
return new Error(publicMessage);
}

async function api(path, options) {
const request = options || {};
const headers = { "Accept": "application/json" };
if (request.body !== undefined) headers["Content-Type"] = "application/json";
if (request.method && request.method !== "GET" && request.method !== "HEAD") headers["X-Miftah-CSRF"] = csrfToken;
const response = await fetch(path, {
method: request.method || "GET",
headers,
body: request.body === undefined ? undefined : JSON.stringify(request.body)
});
let response;
try {
response = await fetch(path, {
method: request.method || "GET",
headers,
body: request.body === undefined ? undefined : JSON.stringify(request.body)
});
} catch {
const recovery = "This Console process is no longer reachable. Run \`miftah dashboard\` in the terminal for a new URL and one-time code.";
restoreUnlock(recovery);
throw new Error(recovery);
}
let payload;
try { payload = await response.json(); } catch { payload = undefined; }
if (!response.ok) {
if (response.status === 401) {
restoreUnlock();
throw new Error("The Console session expired. Restart miftah dashboard to get a new one-time code.");
const code = payload && payload.error && typeof payload.error.code === "string" ? payload.error.code : "";
const recovery = sessionRecoveryMessage(code);
restoreUnlock(recovery);
throw new Error(recovery);
}
const publicMessage = payload && payload.error && typeof payload.error.message === "string"
? payload.error.message
Expand All @@ -635,6 +685,20 @@ const script = `(() => {
return payload ? payload.data : undefined;
}

async function resumeSession() {
message("Checking this browser session…");
try {
const resumed = record(await api("/api/v1/session"));
if (typeof resumed.csrfToken !== "string" || resumed.csrfToken.length < 32) {
throw new Error("Miftah did not return a valid session proof.");
}
csrfToken = resumed.csrfToken;
await refresh();
} catch (error) {
message(errorMessage(error));
}
}

function registration(form) {
const data = new FormData(form);
const mode = String(data.get("registrationMode") || "dynamic");
Expand Down Expand Up @@ -1579,8 +1643,9 @@ const script = `(() => {
});
bootstrapInput.value = "";
const payload = await response.json();
if (!response.ok || !payload || !payload.data || typeof payload.data.csrfToken !== "string") {
throw new Error("The one-time code was rejected or expired.");
if (!response.ok) throw bootstrapResponseError(response, payload);
if (!payload || !payload.data || typeof payload.data.csrfToken !== "string") {
throw new Error("Miftah did not return a valid session proof.");
}
csrfToken = payload.data.csrfToken;
await refresh();
Expand Down Expand Up @@ -2172,6 +2237,12 @@ const script = `(() => {
if (refreshButton instanceof HTMLButtonElement) {
refreshButton.addEventListener("click", () => void refresh().catch((error) => message(errorMessage(error))));
}
if (typeof window !== "undefined") {
window.addEventListener("pageshow", (event) => {
if (event.persisted) void resumeSession();
});
void resumeSession();
}
})();
`;

Expand Down
89 changes: 76 additions & 13 deletions src/console/console-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { Socket } from "node:net";
import { z } from "zod";
Expand Down Expand Up @@ -31,6 +31,7 @@ const headersTimeoutMs = 10_000;
const connectionsCheckingIntervalMs = 5_000;
const maximumHeaderBytes = 16 * 1024;
const sessionCookieName = "miftah_console_session";
const maximumSupersededBootstrapDigests = 8;
const bootstrapSchema = z.object({}).strict();
const httpsUrlSchema = z.string().url().max(2_048).refine((value) => new URL(value).protocol === "https:");
const connectionAddSchema = z.object({
Expand Down Expand Up @@ -232,6 +233,10 @@ function randomCredential(): string {
return randomBytes(32).toString("base64url");
}

function credentialDigest(value: string): string {
return createHash("sha256").update(value, "utf8").digest("base64url");
}

function rawHeaderValues(request: IncomingMessage, name: string): string[] {
const values: string[] = [];
const expected = name.toLowerCase();
Expand Down Expand Up @@ -521,6 +526,7 @@ class LocalConsoleServer implements ConsoleServer {
private bootstrap: string;
private bootstrapIssuedAt: number;
private bootstrapUsed = false;
private readonly supersededBootstrapDigests: string[] = [];
private rateWindowStartedAt: number;
private requestCount = 0;
private bootstrapAttemptCount = 0;
Expand Down Expand Up @@ -580,6 +586,20 @@ class LocalConsoleServer implements ConsoleServer {
}

const session = this.requireSession(request);
if (request.url === "/api/v1/session") {
if (request.method !== "GET") {
throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" });
}
const now = this.options.now();
session.lastUsedAt = now;
writeJson(response, 200, {
data: {
csrfToken: session.csrfToken,
expiresInMs: Math.max(0, this.options.absoluteSessionMs - (now - session.createdAt))
}
});
return;
}
if (request.url === "/api/v1/health") {
if (request.method !== "GET") {
throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" });
Expand Down Expand Up @@ -1147,15 +1167,40 @@ class LocalConsoleServer implements ConsoleServer {

private async bootstrapSession(request: IncomingMessage, response: ServerResponse): Promise<void> {
const authorization = singleHeader(request, "authorization");
if (
this.bootstrapUsed ||
this.options.now() - this.bootstrapIssuedAt >= this.options.bootstrapTtlMs ||
authorization === undefined ||
authorization === null ||
!authorization.startsWith("Bootstrap ") ||
!safeEqual(authorization.slice("Bootstrap ".length), this.bootstrap)
) {
throw new ConsoleHttpError(401, "unauthorized", "Console authentication failed.");
if (authorization === undefined || authorization === null || !authorization.startsWith("Bootstrap ")) {
throw new ConsoleHttpError(
401,
"bootstrap_malformed",
"Enter the complete one-time Console code from this process."
);
}
const receivedBootstrap = authorization.slice("Bootstrap ".length);
if (receivedBootstrap.length < 16 || receivedBootstrap.length > 4_096) {
throw new ConsoleHttpError(
401,
"bootstrap_malformed",
"Enter the complete one-time Console code from this process."
);
}
if (!safeEqual(receivedBootstrap, this.bootstrap)) {
if (this.supersededBootstrapDigests.includes(credentialDigest(receivedBootstrap))) {
throw new ConsoleHttpError(
401,
"bootstrap_superseded",
"This one-time Console code was replaced by a newer code."
);
}
throw new ConsoleHttpError(
401,
"bootstrap_wrong_process",
"This code does not belong to the running Console process."
);
}
if (this.bootstrapUsed) {
throw new ConsoleHttpError(401, "bootstrap_used", "This one-time Console code was already used.");
}
if (this.options.now() - this.bootstrapIssuedAt >= this.options.bootstrapTtlMs) {
throw new ConsoleHttpError(401, "bootstrap_expired", "This one-time Console code expired.");
}
const parsed = bootstrapSchema.safeParse(await readJsonBody(request, this.options.maximumRequestBytes));
if (!parsed.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid.");
Expand Down Expand Up @@ -1185,11 +1230,25 @@ class LocalConsoleServer implements ConsoleServer {
}

private requireSession(request: IncomingMessage): BrowserSession {
this.pruneExpiredSessions();
const id = cookieValue(request, sessionCookieName);
const session = id === undefined ? undefined : this.sessions.get(id);
if (id === undefined) {
throw new ConsoleHttpError(401, "session_missing", "Enter the one-time Console code from this process.");
}
const session = this.sessions.get(id);
if (session === undefined) {
throw new ConsoleHttpError(401, "unauthorized", "A valid Console session is required.");
throw new ConsoleHttpError(
401,
"session_unavailable",
"This Console session belongs to an earlier or different process."
);
}
const now = this.options.now();
if (
now - session.lastUsedAt >= this.options.idleSessionMs ||
now - session.createdAt >= this.options.absoluteSessionMs
) {
this.sessions.delete(id);
throw new ConsoleHttpError(401, "session_expired", "This Console session expired.");
}
return session;
}
Expand Down Expand Up @@ -1228,6 +1287,10 @@ class LocalConsoleServer implements ConsoleServer {

rotateCredential(): string {
this.sessions.clear();
if (this.bootstrap.length > 0) {
this.supersededBootstrapDigests.unshift(credentialDigest(this.bootstrap));
this.supersededBootstrapDigests.splice(maximumSupersededBootstrapDigests);
}
this.bootstrap = randomCredential();
this.bootstrapIssuedAt = this.options.now();
this.bootstrapUsed = false;
Expand Down
Loading