From e32bac9cdfd78359815d4741f5bb677eebad62ff Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Thu, 23 Jul 2026 00:29:13 +0400 Subject: [PATCH 1/3] feat: add local OAuth dashboard --- README.md | 12 +- docs/architecture.md | 4 +- docs/cli.md | 11 +- docs/console-api.md | 23 +- docs/oauth-console-threat-model.md | 10 +- docs/oauth-support.md | 2 +- docs/security.md | 2 +- src/cli/exit-codes.ts | 3 + src/cli/main.ts | 52 +- src/cli/migrate-config.ts | 5 + src/cli/parse.ts | 14 +- src/console/console-application-service.ts | 141 ++++- src/console/console-assets.ts | 586 ++++++++++++++++++ src/console/console-server.ts | 131 +++- src/console/open-browser.ts | 78 +++ src/utils/errors.ts | 5 +- tests/cli-exit-codes.test.ts | 3 + tests/cli-parse.test.ts | 15 + tests/console-application-service.test.ts | 72 +++ tests/console-open-browser.test.ts | 48 ++ tests/console-server.test.ts | 204 +++++- ...console-threat-model-docs-contract.test.ts | 9 +- tests/package-contract.test.ts | 18 + 23 files changed, 1407 insertions(+), 41 deletions(-) create mode 100644 src/console/console-assets.ts create mode 100644 src/console/open-browser.ts create mode 100644 tests/console-open-browser.test.ts diff --git a/README.md b/README.md index 989cd649..35409912 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,14 @@ Claude Desktop is a GUI app and does not inherit terminal startup files such as Continue with the step-by-step [Claude Desktop setup](docs/claude-desktop.md), or start from the [GitHub example](docs/examples/github.md) or [Sentry example](docs/examples/sentry.md). +Prefer a browser for OAuth setup? Run: + +```bash +miftah dashboard +``` + +The optional local Console opens on `127.0.0.1`, creates a validated first native-OAuth profile without hand-written JSON, shows redacted connection/audit state, and generates client JSON for you to review and copy. It stays in the foreground, never edits Claude or another client configuration, and does not accept provider passwords, browser cookies, or raw tokens. Use `--config ` to manage another Miftah configuration or `--no-open` when you only want the local URL. + ## A real multi-account setup Imagine you use Sentry for two products. Instead of adding two separate Sentry servers to Claude Desktop, configure one `miftah-sentry` connector with two profiles: @@ -101,7 +109,7 @@ For credential-file workflows, see [profile credential isolation](docs/config.md When an operator enables profile locking, `miftah_lock_profile` and `miftah_unlock_profile` expose that control to the MCP client. For the complete security scope and future work, read the linked designs below. -The optional local Console control API is started explicitly with `miftah console --config `. It binds only to loopback, uses a one-time terminal bootstrap plus browser session/CSRF protection, and exposes redacted metadata and audited connection operations under `/api/v1`. It is not a daemon and cannot change an already-running MCP client's in-memory session. The browser UI is a separate roadmap layer; see the [Console API contract](docs/console-api.md). +The optional local Console is started explicitly with `miftah dashboard`; `miftah console --config ` remains the API-only form. It binds only to loopback, uses a one-time terminal bootstrap plus browser session/CSRF protection, and exposes redacted metadata and audited connection operations under `/api/v1`. It is not a daemon and cannot change an already-running MCP client's in-memory session. See the [Console contract](docs/console-api.md). ## Is Miftah right for you? @@ -118,7 +126,7 @@ If you use one account with one direct MCP server and do not need profile, routi - [Security boundary](docs/security.md), [Threat model](docs/threat-model.md), and [OAuth and Console security design](docs/oauth-console-threat-model.md) - [OAuth support](docs/oauth-support.md) - [Provider adapters and Google Search Console pilot](docs/provider-adapters.md) -- [Local Console control API](docs/console-api.md) +- [Local Console dashboard and control API](docs/console-api.md) - [CLI reference](docs/cli.md) - [Architecture](docs/architecture.md) - [Changelog and release policy](CHANGELOG.md) diff --git a/docs/architecture.md b/docs/architecture.md index 5151c9aa..60eb9e68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -The [threat model](threat-model.md) maps these components and their boundaries to public security controls and residual risks. The [OAuth and Console security design delta](oauth-console-threat-model.md) describes the control-plane and credential-lifecycle gates. Version 3 adds a standards-compatible remote OAuth runtime, in-process operator lifecycle services, and a separately launched local Console control API for exact HTTPS Streamable HTTP connections. A separate typed built-in [provider-adapter catalog](provider-adapters.md) can launch and describe upstream-owned local authentication without entering the native OAuth engine; provider-side revocation and the browser UI remain separate milestones. +The [threat model](threat-model.md) maps these components and their boundaries to public security controls and residual risks. The [OAuth and Console security design delta](oauth-console-threat-model.md) describes the control-plane and credential-lifecycle gates. Version 3 adds a standards-compatible remote OAuth runtime, in-process operator lifecycle services, and a separately launched local Console UI/control API for exact HTTPS Streamable HTTP connections. A separate typed built-in [provider-adapter catalog](provider-adapters.md) can launch and describe upstream-owned local authentication without entering the native OAuth engine; provider-side revocation remains a separate boundary. Miftah is an MCP-aware proxy, not a byte-level reverse proxy: @@ -16,7 +16,7 @@ MCP client -> upstream STDIO server ``` -The Console control plane is a sibling process boundary, not another MCP transport. `miftah console` binds literal loopback only and routes `/api/v1` browser requests through one-use bootstrap, a bounded HttpOnly session, exact Host/Origin checks, CSRF validation, typed application services, and a separate fail-closed mutation journal. It has no `/mcp` route and cannot mutate another process's active STDIO session. +The Console control plane is a sibling process boundary, not another MCP transport. `miftah dashboard` (or API-only `miftah console`) binds literal loopback only and routes `/api/v1` browser requests through one-use bootstrap, a bounded HttpOnly session, exact Host checks, exact Origin plus CSRF for mutations, typed application services, and a separate fail-closed mutation journal. It has no `/mcp` route and cannot mutate another process's active STDIO session. The STDIO public server is built with the official `@modelcontextprotocol/sdk` `Server` and `StdioServerTransport`. Each profile/upstream pair gets an SDK `Client` and its configured transport on first use: local processes use `StdioClientTransport`; remote upstreams use Streamable HTTP by default (the historical `"http"` alias is accepted only by version 1) or the deprecated legacy SSE client. Resolved profile headers override upstream headers case-insensitively before either remote transport is constructed. An exact version-3 OAuth binding gives only that Streamable HTTP session a profile-bound SDK OAuth provider; static Authorization headers on the same target are rejected. The provider validates protected-resource and authorization-server discovery, drives PKCE/browser/callback exchange, and loads or refreshes credentials through the exact OS-vault binding before reconnecting with bearer authorization. Streamable HTTP session shutdown sends DELETE while its session ID is still available, then closes the local client transport and any pending OAuth handoff; legacy SSE has no corresponding server-session teardown request. Request cancellation and requested progress notifications retain their MCP request context across both STDIO and Streamable HTTP upstreams. Startup HTTP failures and operation HTTP/MCP/OAuth failures are normalized before crossing the proxy boundary, retaining only stable category/code metadata rather than server response text. See [OAuth support](oauth-support.md) for the supported native OAuth boundary. diff --git a/docs/cli.md b/docs/cli.md index 18b54c4d..9b7590cc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI reference -`miftah` is an MCP wrapper with STDIO as its default transport, an opt-in local Streamable HTTP server, and a separately launched local Console control API. Run `miftah --help` for the generated command list, or `miftah --help` for the options accepted by one command. The help text is the authoritative grammar for the installed version. +`miftah` is an MCP wrapper with STDIO as its default transport, an opt-in local Streamable HTTP server, and a separately launched local Console dashboard/control API. Run `miftah --help` for the generated command list, or `miftah --help` for the options accepted by one command. The help text is the authoritative grammar for the installed version. ## Help @@ -8,7 +8,7 @@ Usage: miftah [command] [options] ``` -The root command list includes `serve`, `console`, `validate`, `doctor`, `schema`, `init`, `migrate-config`, `connection add|list|status|test`, `auth connect|reauth|disconnect`, `list-tools`, `test-profile`, `logs`, `audit-export`, `audit-verify`, and `version`. With no command, Miftah runs `serve`. +The root command list includes `serve`, `dashboard`, `console`, `validate`, `doctor`, `schema`, `init`, `migrate-config`, `connection add|list|status|test`, `auth connect|reauth|disconnect`, `list-tools`, `test-profile`, `logs`, `audit-export`, `audit-verify`, and `version`. With no command, Miftah runs `serve`. Documented command names, options, JSON success forms, and exit categories are compatibility contracts. An incompatible CLI removal, rename, required-option change, or semantic output change requires the pre-1.0 deprecation/removal process in the [public compatibility policy](library-api.md#compatibility-policy). @@ -20,6 +20,7 @@ Documented command names, options, JSON success forms, and exit categories are c | --- | --- | --- | --- | | `miftah serve --config ` | `--config` | `--config `, `--transport ` | Runs the STDIO MCP wrapper by default, or the configured local Streamable HTTP endpoint with `--transport http`. `miftah --config ` is the equivalent default-command STDIO form. | | `miftah console --config ` | `--config` | `--config `, `--port ` | Explicitly starts the separate literal-loopback Console control API. It prints the URL and a one-use terminal bootstrap code; no daemon or MCP session is started. | +| `miftah dashboard` | none | `--config `, `--port `, `--no-open` | Starts the optional foreground browser Console on literal loopback. It defaults to `~/.config/miftah/miftah.json`, permits a missing file for first-run setup, opens the system browser unless `--no-open` is set, and never starts a daemon. | | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | @@ -47,11 +48,13 @@ Every command also accepts `--help` and `-h`; those generated per-command help s HTTP bearer authentication is configured only through `server.http.authToken` as a secret reference. The CLI never accepts a bearer token option and never writes one to its listener or error output. See [HTTP server transport](config.md#http-server-transport) for loopback, non-loopback, Host, Origin, session, and request-limit requirements. -### Local Console control API +### Local Console dashboard and control API + +`miftah dashboard` opens the optional browser-local Console and prints its exact URL, resolved configuration location, and one-use bootstrap code. Without `--config`, it uses `~/.config/miftah/miftah.json`. A missing file is not created until the operator submits a strict first-run native-OAuth profile; an existing file is loaded and is never silently replaced. `--no-open` leaves browser launch to the operator while keeping the same foreground server. `miftah console --config ` 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. -The Console API is versioned under `/api/v1` and uses exact Host/Origin checks, a short-lived HttpOnly same-site session, CSRF proof for every mutation, bounded JSON, fail-closed mutation audit, and metadata-only responses. 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 control API](console-api.md) for the full endpoint and bootstrap contract. +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. ### `init` presets and paths diff --git a/docs/console-api.md b/docs/console-api.md index c12620ec..0702961d 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -1,12 +1,18 @@ -# Local Console control API +# Local Console dashboard and control API -Miftah includes an optional, local-only control API for the Console UI. It is a separate process and listener from the MCP `/mcp` transport. It starts only when an operator runs: +Miftah includes an optional, local-only browser Console over its control API. It is a separate foreground process and listener from the MCP `/mcp` transport. Start the dashboard with: + +```sh +miftah dashboard +``` + +By default this reviews or creates `~/.config/miftah/miftah.json`, opens the system browser, and prints both the exact loopback URL and a one-time code. Use `--config ` for another configuration, `--port ` for a fixed loopback port, or `--no-open` to print the URL without launching a browser. The API-only compatibility command remains: ```sh miftah console --config ~/.config/miftah/service.json ``` -The command binds literal `127.0.0.1` on an ephemeral port by default. `--port ` selects a fixed loopback port. There is no host option, LAN mode, background daemon, or automatic startup. +Both commands bind literal `127.0.0.1` on an ephemeral port by default. There is no host option, LAN mode, background daemon, or automatic startup. Closing the foreground command stops the dashboard. ## First browser session @@ -17,19 +23,20 @@ Startup prints the loopback URL and one CSPRNG-backed bootstrap code to the laun - is never placed in a URL, cookie, browser storage, browser response, audit record, or diagnostic; and - is unrelated to MCP HTTP authentication and OAuth access or refresh tokens. -The Console page introduced separately 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 must remain in page memory and accompany every later mutation as `X-Miftah-CSRF`; the UI must not persist it in localStorage or sessionStorage. A bootstrap cannot be replayed. +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. 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 -Every request must use the exact listener `Host` and `Origin`. Except for the bootstrap exchange, every endpoint requires the session cookie. JSON request bodies are capped at 64 KiB and must use `Content-Type: application/json`. Headers are capped at 16 KiB. The process admits at most 240 trusted-origin requests per minute and only eight bootstrap attempts per minute; excess requests receive `429` plus `Retry-After`. +Every request must use the exact listener `Host`. Browser mutations, including bootstrap, must also use the exact listener `Origin`. Normal same-origin navigation and authenticated `GET` and `HEAD` requests may omit `Origin`, because browsers do not consistently attach it to read requests; hostile or duplicate Origin values are still rejected, cross-site cookies remain blocked by `SameSite=Strict`, and every mutation still requires exact Origin plus CSRF. Except for the bootstrap exchange and static application assets, every API endpoint requires the session cookie. JSON request bodies are capped at 64 KiB and must use `Content-Type: application/json`. Headers are capped at 16 KiB. The process admits at most 240 trusted requests per minute and only eight bootstrap attempts per minute; excess requests receive `429` plus `Retry-After`. -`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. +`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. The first-run endpoint accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes; token, password, cookie, secret, callback, and arbitrary extra fields are rejected. | Method and path | Purpose | | --- | --- | | `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. | +| `POST /api/v1/onboarding/native-oauth` | Exclusively create the first validated v3 native-OAuth profile, upstream, and connection. Requires CSRF and refuses an existing file. | | `GET /api/v1/health` | Return safe config identity, Console audit health, and restart-required guidance. | | `GET /api/v1/config` | Return allowlisted configuration metadata only. | | `GET /api/v1/profiles` | Return profile names, descriptions, tags, policy names, and named-upstream keys only. | @@ -37,14 +44,16 @@ Every request must use the exact listener `Host` and `Origin`. Except for the bo | `GET /api/v1/connections/:ref` | Return redacted credential and identity status for one exact connection. | | `POST /api/v1/connections` | Atomically add one schema-valid OAuth connection binding. Requires CSRF. | | `POST /api/v1/connections/:ref/connect` | Run the approved system-browser authorization for one exact connection. Requires CSRF. | +| `POST /api/v1/connections/:ref/test` | Test one exact configured connection without starting a new interactive authorization. Requires CSRF. | | `POST /api/v1/connections/:ref/reauth` | Replace one exact credential without deleting the old credential before success. Requires CSRF. | | `DELETE /api/v1/connections/:ref/credential` | Delete one exact local vault credential. Requires CSRF. Provider-side revocation is not claimed. | | `GET /api/v1/audit?limit=1..200` | Return allowlisted metadata from the owner-restricted Console mutation journal, never raw JSONL or arguments. | +| `GET /api/v1/client-snippets?client=` | Generate review-and-copy JSON for Claude Desktop, Claude Code, Cursor, VS Code, or `all`; never edit client files. | Success responses use `{ "data": ... }`. Errors use `{ "error": { "code": "...", "message": "..." } }` with semantic HTTP status codes. Responses are non-cacheable and carry restrictive content-type, framing, referrer, and content-security headers. Internal paths, raw configuration, secret references, environment maps, command arguments, headers, tokens, authorization URLs, raw provider errors, and raw audit bytes are not part of the browser contract. ## Mutation and process boundary -Connection creation reuses the same typed application service as the CLI: the candidate is schema-validated, applied from an exact source snapshot, backed up uniquely, and published through the guarded atomic replacement. Console mutations use a separate owner-restricted, fail-closed journal under `.miftah/audit/console.jsonl` beside the configuration. If that journal cannot be prepared, the mutation is refused before its side effect. +Connection creation reuses the same typed application service as the CLI: an existing candidate is schema-validated, applied from an exact source snapshot, backed up uniquely, and published through the guarded atomic replacement. First-run onboarding constructs and validates the complete v3 candidate before an exclusive, non-overwriting create. Console mutations use a separate owner-restricted, fail-closed journal under `.miftah/audit/console.jsonl` beside the configuration. If that journal cannot be prepared, the mutation is refused before its side effect. The control API manages durable configuration and the local OAuth vault for future connections. It cannot inspect or take over another Miftah process, replace an active Claude Desktop STDIO session, or change that process's in-memory profile selection. Restart or reconnect the MCP client after a durable Console change. A future broker or IPC design would require a separate authenticated threat-model review. diff --git a/docs/oauth-console-threat-model.md b/docs/oauth-console-threat-model.md index fe03b86e..3b63c058 100644 --- a/docs/oauth-console-threat-model.md +++ b/docs/oauth-console-threat-model.md @@ -2,14 +2,14 @@ > **Status:** Approved security decision from [#80](https://github.com/mohanagy/miftah/issues/80), with the standards-compatible remote-OAuth portion implemented under [#82](https://github.com/mohanagy/miftah/issues/82). It remains a maintainer-authored delta to the [threat model](threat-model.md), not evidence that the independent review in [#37](https://github.com/mohanagy/miftah/issues/37) is complete. -Version 3 can run the approved standards-compatible remote OAuth flow for an exact HTTPS Streamable HTTP connection: protected-resource and authorization-server discovery, reviewed client registration, system-browser authorization, a single-use literal-loopback callback, PKCE exchange, OS-vault credential storage, refresh, and profile-bound bearer injection. The local CLI can plan bindings, report redacted state, connect, reauthenticate, and delete an exact local credential; it does not claim provider-side revocation. Issue #85 adds a separately launched local Console control API over those typed services; no browser Console UI, provider-specific adapter, provider-revocation client, hosted broker, or background daemon exists. Static headers and provider-owned/upstream-owned authentication remain supported as described in [OAuth support](oauth-support.md). +Version 3 can run the approved standards-compatible remote OAuth flow for an exact HTTPS Streamable HTTP connection: protected-resource and authorization-server discovery, reviewed client registration, system-browser authorization, a single-use literal-loopback callback, PKCE exchange, OS-vault credential storage, refresh, and profile-bound bearer injection. The local CLI can plan bindings, report redacted state, connect, reauthenticate, and delete an exact local credential; it does not claim provider-side revocation. Issue #85 adds a separately launched local Console control API over those typed services. Issue #86 adds the optional browser-local Console UI after the explicit go decision recorded under #35. A typed provider-adapter catalog also exists for bounded upstream-owned flows, but no provider-revocation client, hosted broker, or background daemon exists. Static headers and provider-owned/upstream-owned authentication remain supported as described in [OAuth support](oauth-support.md). The design follows the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), [RFC 8707 resource indicators](https://www.rfc-editor.org/rfc/rfc8707), [RFC 9728 protected-resource metadata](https://www.rfc-editor.org/rfc/rfc9728), [RFC 8414 authorization-server metadata](https://www.rfc-editor.org/rfc/rfc8414), [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252) for native-app browser flows, [RFC 9207 `iss`](https://www.rfc-editor.org/rfc/rfc9207) authorization-response issuer identification, and [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700) for OAuth security practice. If a provider or SDK cannot meet the required properties below, Miftah must not approximate them with a provider-specific shortcut. ## Scope and hard boundaries - Native OAuth is a profile-bound connection capability for standards-compatible remote HTTPS Streamable HTTP MCP resources only. -- The optional local Console control API is an explicitly launched operator surface over the same typed connection and configuration services. It is not a hosted service, cloud token sync system, embedded login browser, or background daemon. +- The optional local Console UI and control API are an explicitly launched foreground operator surface over the same typed connection and configuration services. The UI uses the system browser, serves no third-party assets, and is not a hosted service, cloud token sync system, embedded login browser, or background daemon. - The Console control API is distinct from the MCP `/mcp` endpoint. It has a different listener, route namespace, session model, control credential, and authorization decision from every client-facing MCP transport. - A Console operation cannot mutate an already-running Claude Desktop or other STDIO client session. Durable changes apply to new or explicitly restarted sessions until a separately designed broker or IPC capability exists. - This design does not make upstream-owned local OAuth, such as Google Search Console-style local MCP processes, Miftah-managed. Their documented login, callback, and cache remain upstream private state. @@ -67,7 +67,7 @@ An authorization code can arrive only at the bounded callback and must be exchan ## Local control-plane separation -The Streamable HTTP host accepts protocol clients at its fixed MCP endpoint and deliberately permits a missing Origin for non-browser MCP clients. The Console control API does not reuse that permissive rule or listener. It binds literal `127.0.0.1`, requires the exact Host and Origin for every browser request, requires CSRF protection for every state-changing request after bootstrap, expires sessions after bounded idle and absolute lifetimes, and applies body, header, and session limits before application work. +The Streamable HTTP host accepts protocol clients at its fixed MCP endpoint and deliberately permits a missing Origin for non-browser MCP clients. The Console control API does not reuse that listener. It binds literal `127.0.0.1`, requires the exact Host for every request, permits an absent Origin only for static navigation and authenticated `GET`/`HEAD` reads, requires the exact loopback Origin plus CSRF for every state-changing request after bootstrap, expires sessions after bounded idle and absolute lifetimes, and applies body, header, and session limits before application work. The approved control credential is not an OAuth token. `miftah console` generates it in memory and prints it once to the launching terminal. The operator enters it into the separately delivered same-origin UI, which sends it only as `Authorization: Bootstrap ` to `POST /api/v1/sessions`. It expires after five minutes, is consumed once, and never appears in a query string, browser storage, cookie, response, log, audit event, or diagnostic. A successful exchange creates a different opaque HttpOnly same-site session handle and returns a different CSRF proof for page memory; neither is an MCP bearer. Restart or explicit rotation invalidates every session and creates a new one-use bootstrap. @@ -96,9 +96,9 @@ All tests must use fixture-only values and local fakes. They must not invoke a l ## Implementation gates -Issue #82 carries the explicit maintainer authorization for the standards-compatible remote-OAuth runtime, issue #84 authorizes the local connection and credential-lifecycle application services, and issue #85 authorizes the separate local Console control API and its bootstrap design. They do not authorize a browser UI, proprietary provider adapters, a hosted broker, provider-side revocation, or publication before release gates. Those later production changes still require: +Issue #82 carries the explicit maintainer authorization for the standards-compatible remote-OAuth runtime, issue #84 authorizes the local connection and credential-lifecycle application services, issue #85 authorizes the separate local Console control API and its bootstrap design, and the recorded #35 decision plus issue #86 authorize the optional browser-local UI. They do not authorize a hosted broker, provider-side revocation, or publication before release gates. Later production changes still require: -1. the external design-partner evidence required by [#25](https://github.com/mohanagy/miftah/issues/25) and the deliberate Console/TUI/no-UI decision in [#35](https://github.com/mohanagy/miftah/issues/35); +1. the external design-partner evidence required by [#25](https://github.com/mohanagy/miftah/issues/25) for broader product claims; the deliberate Console/TUI/no-UI decision is recorded in [#35](https://github.com/mohanagy/miftah/issues/35); 2. an independent-review process and public status under [#37](https://github.com/mohanagy/miftah/issues/37), with this delta reviewed and linked; 3. the versioned, profile-bound connection-record and secure-store contract to remain intact; and 4. the focused test plan above to be implemented before each relevant runtime surface is enabled. diff --git a/docs/oauth-support.md b/docs/oauth-support.md index 2acc9e6a..6b79c9fd 100644 --- a/docs/oauth-support.md +++ b/docs/oauth-support.md @@ -6,7 +6,7 @@ Miftah performs protected-resource and authorization-server discovery, browser a Miftah does not support OAuth for every MCP server or provider. It does not guess private endpoints, scrape provider caches, automate local STDIO providers' custom login flows, accept passwords or browser cookies as OAuth state, or treat a valid token as proof that the correct account was selected. Operator lifecycle commands manage only Miftah's exact local binding and vault credential; `auth disconnect` does not claim provider-side token revocation. -The [OAuth and local Console design delta](oauth-console-threat-model.md) records the enforced OAuth security controls, residual risks, and the separately launched local Console control-plane boundary. The API is available through `miftah console`; the browser UI remains a separate layer. +The [OAuth and local Console design delta](oauth-console-threat-model.md) records the enforced OAuth security controls, residual risks, and the separately launched local Console control-plane boundary. Run `miftah dashboard` for the optional browser-local UI, or `miftah console --config ` for the API-only compatibility surface. ## Support matrix diff --git a/docs/security.md b/docs/security.md index 657ff5dd..8b4e8ae8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -38,7 +38,7 @@ The server rejects duplicate or malformed `Host`, `Origin`, authorization, and M Before it allocates a session, the host validates the exact `/mcp` endpoint, method, Host, Origin, authentication, JSON content type, body size, and one initialize request. Session capacity includes in-progress initializations and closing runtimes. Each session receives a distinct Miftah runtime, profile/session state, approval/lock/lease/routing state, and upstream manager. Idle expiry, DELETE, and graceful shutdown detach the session immediately and close its runtime, which closes retained upstream transports and their ordinary descendants; detached cleanup retains capacity until it succeeds, and a failure remains capacity-consuming and is reported. An HTTP reconnect reuses only its existing session ID; it cannot create or access another client's runtime. -The Console control API is not this MCP HTTP server. `miftah console` creates a separate listener with no `/mcp` route, MCP transport, upstream session registry, or MCP bearer. It requires an exact browser Origin even for reads. Its CSPRNG bootstrap code appears only in the launching terminal, expires after five minutes, and is consumed once; the resulting opaque HttpOnly same-site session and in-memory CSRF proof are bounded by idle and absolute lifetimes. Every durable mutation prepares a separate fail-closed owner-restricted Console audit journal first. Browser responses contain allowlisted metadata and redacted status only. See the [Console API contract](console-api.md). +The Console control plane is not this MCP HTTP server. `miftah dashboard` or the API-only `miftah console` command creates a separate listener with no `/mcp` route, MCP transport, upstream session registry, or MCP bearer. It requires the exact listener Host for all traffic. State-changing browser requests require the exact loopback Origin and in-memory CSRF proof; authenticated `GET`/`HEAD` reads may omit Origin because normal same-origin browsers do not consistently send it. Its CSPRNG bootstrap code appears only in the launching terminal, expires after five minutes, and is consumed once; the resulting opaque HttpOnly same-site session and in-memory CSRF proof are bounded by idle and absolute lifetimes. Every durable mutation prepares a separate fail-closed owner-restricted Console audit journal first. Browser responses contain allowlisted metadata and redacted status only. See the [Console API contract](console-api.md). Audit writes default to fail-closed: Miftah verifies the configured sink before dispatch and refuses a request when the sink cannot be prepared. A terminal write can fail after an upstream side effect has completed, so a post-dispatch `AUDIT_WRITE_FAILED` has an indeterminate outcome and must not prompt a blind retry of a non-idempotent operation. An operator can set `audit.failureMode` to `"fail-open"` for availability-sensitive deployments; Miftah then preserves the request outcome but exposes a redacted `AUDIT_WRITE_FAILED` health entry. This mode trades complete auditability for availability. diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index c2685f85..afaad6d7 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -23,6 +23,8 @@ export const ERROR_EXIT_CODES = { CONFIG_MIGRATION_CONFLICT: CLI_EXIT_CODES.config, CONFIG_MIGRATION_BACKUP_EXISTS: CLI_EXIT_CODES.config, CONFIG_MIGRATION_WRITE_FAILED: CLI_EXIT_CODES.config, + CONFIG_ALREADY_EXISTS: CLI_EXIT_CODES.config, + CONFIG_CREATE_FAILED: CLI_EXIT_CODES.config, DEFAULT_PROFILE_NOT_FOUND: CLI_EXIT_CODES.config, POLICY_NOT_FOUND: CLI_EXIT_CODES.config, ROUTING_PROFILE_NOT_FOUND: CLI_EXIT_CODES.config, @@ -98,6 +100,7 @@ export const ERROR_EXIT_CODES = { OAUTH_AUTHORIZATION_FAILED: CLI_EXIT_CODES.secret, OAUTH_INTERACTIVE_REQUIRED: CLI_EXIT_CODES.config, OAUTH_AUTHORIZATION_NOT_ENABLED: CLI_EXIT_CODES.config, + CONSOLE_LAUNCHER_UNAVAILABLE: CLI_EXIT_CODES.operation, TOOL_COLLISION: CLI_EXIT_CODES.operation, TOOL_NOT_FOUND: CLI_EXIT_CODES.operation, TOOL_SCHEMA_MISMATCH: CLI_EXIT_CODES.operation, diff --git a/src/cli/main.ts b/src/cli/main.ts index 4fd06804..d985d92e 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,4 +1,6 @@ import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { loadConfig } from "../config/load-config.js"; import { generateConfigSchema } from "../config/generate-json-schema.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -21,6 +23,7 @@ import { OAuthConnectionCommandService } from "../oauth/connection-command-servi import { CLIENT_NAMES, renderClientSnippets, type ClientSelection } from "./client-snippets.js"; import { resolvePath } from "../config/path-resolve.js"; import { startConsoleServer } from "../console/console-server.js"; +import { openSystemBrowser } from "../console/open-browser.js"; function oauthSelector(args: { readonly connection?: string; readonly profile?: string; readonly upstream?: string }) { return { @@ -77,7 +80,10 @@ function consolePort(value: string | undefined): number | undefined { } async function consoleServe(configPath: string, port: string | undefined): Promise { - const server = await startConsoleServer(configPath, { port: consolePort(port) }); + const server = await startConsoleServer(configPath, { + port: consolePort(port), + launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] } + }); process.stdout.write( [ `Miftah Console control API listening on ${server.url.toString()}`, @@ -100,6 +106,46 @@ async function consoleServe(configPath: string, port: string | undefined): Promi } } +function defaultDashboardConfigPath(): string { + return join(homedir(), ".config", "miftah", "miftah.json"); +} + +async function dashboardServe( + configPath: string, + port: string | undefined, + openBrowser: boolean +): Promise { + const server = await startConsoleServer(configPath, { + port: consolePort(port), + allowMissingConfig: true, + launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] } + }); + process.stdout.write( + [ + `Miftah Console listening on ${server.url.toString()}`, + `Configuration: ${resolvePath(configPath)}`, + `One-time bootstrap code: ${server.bootstrapCredential}`, + "Enter this code only in the local Miftah Console. It expires after first use or shutdown." + ].join("\n") + "\n" + ); + if (openBrowser && !(await openSystemBrowser(server.url))) { + process.stderr.write(`Miftah could not open the system browser. Open ${server.url.toString()} manually.\n`); + } + const shutdown = (): void => { + void server.close().catch(() => { + process.stderr.write("Miftah Console shutdown failed.\n"); + process.exitCode = 1; + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + if (process.platform !== "win32") { + process.on("SIGHUP", () => { + process.stdout.write(`Replacement one-time bootstrap code: ${server.rotateCredential()}\n`); + }); + } +} + async function main(argv = process.argv.slice(2)): Promise { const invocation = parseCli(argv); if (invocation.kind === "help") { @@ -127,6 +173,10 @@ async function main(argv = process.argv.slice(2)): Promise { }); return; } + if (command === "dashboard") { + await dashboardServe(args.config ?? defaultDashboardConfigPath(), args.port, args.noOpen !== true); + return; + } if (!args.config) { throw new CliUsageError( `Command '${command}' requires '--config '. Use 'miftah ${command} --help' for usage.` diff --git a/src/cli/migrate-config.ts b/src/cli/migrate-config.ts index 38c458d7..cdc62f38 100644 --- a/src/cli/migrate-config.ts +++ b/src/cli/migrate-config.ts @@ -187,6 +187,11 @@ async function writeSyncedExclusive( } } +/** Creates one synced owner-only configuration file without replacing any existing path. */ +export async function writeNewConfigFile(path: string, content: string): Promise { + await writeSyncedExclusive(path, content, 0o600); +} + async function writeMigrationFile( path: string, content: string | Uint8Array, diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 69d2c40b..d17cef09 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -19,7 +19,7 @@ type ValueOptionName = | "clientRegistration" | "scopes" | "port"; -type BooleanOptionName = "follow" | "json" | "interactive" | "includeArguments" | "write" | "nonInteractive"; +type BooleanOptionName = "follow" | "json" | "interactive" | "includeArguments" | "write" | "nonInteractive" | "noOpen"; type CliOptionName = ValueOptionName | BooleanOptionName; export interface CliOptions { @@ -49,6 +49,7 @@ export interface CliOptions { readonly includeArguments?: true; readonly write?: true; readonly nonInteractive?: true; + readonly noOpen?: true; } interface CliCommandMetadata { @@ -66,6 +67,10 @@ export const CLI_COMMANDS = { description: "Start the optional local Console control API.", options: ["config", "port"] }, + dashboard: { + description: "Open the optional local Miftah Console in a browser.", + options: ["config", "port", "noOpen"] + }, validate: { description: "Validate a Miftah configuration.", options: ["config"] @@ -333,6 +338,12 @@ const OPTION_DEFINITIONS: Record = { takesValue: false, usage: "--non-interactive", description: "Return a typed diagnostic instead of opening a browser." + }, + noOpen: { + name: "noOpen", + takesValue: false, + usage: "--no-open", + description: "Print the local dashboard URL without opening a browser." } }; @@ -363,6 +374,7 @@ const FLAG_DEFINITIONS: Record "--interactive": OPTION_DEFINITIONS.interactive, "--write": OPTION_DEFINITIONS.write, "--non-interactive": OPTION_DEFINITIONS.nonInteractive, + "--no-open": OPTION_DEFINITIONS.noOpen, "--help": "help", "-h": "help", "--version": "version", diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index 29de30ec..caadc6ee 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -1,6 +1,10 @@ +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { readAuditJsonl } from "../cli/audit-jsonl.js"; import { resolvePath } from "../config/path-resolve.js"; +import type { MiftahConfig } from "../config/types.js"; +import { validateConfig } from "../config/validate-config.js"; import { AuditLogger } from "../audit/audit-logger.js"; import { AuditTrail } from "../audit/audit-trail.js"; import type { @@ -18,6 +22,13 @@ import { SecretRedactor } from "../secrets/redact.js"; import { loadConfig } from "../config/load-config.js"; import { MiftahError } from "../utils/errors.js"; import { parseOAuthConnectionRef } from "../oauth/connection-types.js"; +import { writeNewConfigFile } from "../cli/migrate-config.js"; +import { + renderClientSnippets, + type ClientLauncher, + type ClientSelection, + type ClientSnippet +} from "../cli/client-snippets.js"; export interface ConsoleConnectionAddRequest extends OAuthConnectionAddRequest { readonly connectionRef?: string; @@ -25,6 +36,16 @@ export interface ConsoleConnectionAddRequest extends OAuthConnectionAddRequest { export type ConsoleConnectionAddReport = Omit; +export interface ConsoleNativeOAuthOnboardingRequest { + readonly name: string; + readonly profile: string; + readonly description?: string; + readonly resource: string; + readonly issuer: string; + readonly clientRegistration: string; + readonly scopes: readonly string[]; +} + export interface ConsoleAuditRecord { readonly timestamp?: string; readonly kind?: string; @@ -36,7 +57,8 @@ export interface ConsoleAuditRecord { readonly errorCode?: string; } -export interface ConsoleConfigMetadata { +export interface ConsoleInitializedConfigMetadata { + readonly initialized: true; readonly name: string; readonly version: string; readonly defaultProfile: string; @@ -52,6 +74,13 @@ export interface ConsoleConfigMetadata { readonly restartRequiredForExistingClients: true; } +export interface ConsoleUninitializedConfigMetadata { + readonly initialized: false; + readonly restartRequiredForExistingClients: true; +} + +export type ConsoleConfigMetadata = ConsoleInitializedConfigMetadata | ConsoleUninitializedConfigMetadata; + export interface ConsoleHealth { readonly status: "ok"; readonly config: { readonly name: string; readonly version: string }; @@ -66,11 +95,14 @@ export interface ConsoleHealth { export interface ConsoleControlApplication { health(): Promise; configMetadata(): Promise; + onboardNativeOAuth(request: ConsoleNativeOAuthOnboardingRequest): Promise; + clientSnippets(selection: ClientSelection): Promise; listConnections(): Promise; connectionStatus(connectionRef: string): Promise; addConnection(request: ConsoleConnectionAddRequest): Promise; connect(connectionRef: string): Promise; reauth(connectionRef: string): Promise; + testConnection(connectionRef: string): Promise; disconnect(connectionRef: string): Promise; auditRecords(limit: number): Promise; } @@ -80,11 +112,20 @@ interface ConsoleOAuthCommandService { status(selector: { readonly connectionRef: string }): Promise; connect(selector: { readonly connectionRef: string }): Promise; reauth(selector: { readonly connectionRef: string }): Promise; + test(selector: { readonly connectionRef: string }): Promise; disconnect(selector: { readonly connectionRef: string }): Promise; } export interface ConsoleApplicationDependencies { readonly commandService?: ConsoleOAuthCommandService; + readonly generateConnectionRef?: () => string; + readonly launcher?: ClientLauncher; +} + +function fileErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; } function consoleAuditPath(configPath: string): string { @@ -125,6 +166,8 @@ export class ConsoleApplicationService implements ConsoleControlApplication { private readonly audit: AuditTrail; private readonly commandService: ConsoleOAuthCommandService; + private readonly generateConnectionRef: () => string; + private readonly launcher: ClientLauncher | undefined; constructor( private readonly configPath: string, @@ -133,6 +176,8 @@ export class ConsoleApplicationService implements ConsoleControlApplication { this.auditPath = consoleAuditPath(configPath); this.audit = new AuditTrail("miftah-console", new AuditLogger(this.auditPath, { failureMode: "fail-closed" })); this.commandService = dependencies.commandService ?? new OAuthConnectionCommandService(configPath); + this.generateConnectionRef = dependencies.generateConnectionRef ?? randomUUID; + this.launcher = dependencies.launcher; } async health(): Promise { @@ -153,7 +198,15 @@ export class ConsoleApplicationService implements ConsoleControlApplication { } async configMetadata(): Promise { - const config = await loadConfig(this.configPath); + let config: Awaited>; + try { + config = await loadConfig(this.configPath); + } catch (error) { + if (error instanceof MiftahError && error.code === "CONFIG_NOT_FOUND") { + return { initialized: false, restartRequiredForExistingClients: true }; + } + throw error; + } const upstreams = config.upstreams === undefined ? config.upstream === undefined ? [] @@ -162,6 +215,7 @@ export class ConsoleApplicationService implements ConsoleControlApplication { .map(([name, upstream]) => ({ name, transport: upstream.transport })) .sort((left, right) => left.name.localeCompare(right.name)); return { + initialized: true, name: config.name, version: config.version, defaultProfile: config.defaultProfile, @@ -180,6 +234,83 @@ export class ConsoleApplicationService implements ConsoleControlApplication { }; } + async onboardNativeOAuth(request: ConsoleNativeOAuthOnboardingRequest): Promise { + const connectionRef = parseOAuthConnectionRef(`oauthconn:${this.generateConnectionRef()}`); + const profile = { + ...(request.description === undefined || request.description.length === 0 + ? {} + : { description: request.description }) + }; + const config: MiftahConfig = { + version: "3", + name: request.name, + defaultProfile: request.profile, + upstream: { transport: "streamable-http", url: request.resource }, + profiles: { [request.profile]: profile }, + oauth: { + connections: { + [connectionRef]: { + profile: request.profile, + upstream: "default", + resource: request.resource, + issuer: request.issuer, + clientRegistration: request.clientRegistration, + scopes: [...request.scopes] + } + } + } + }; + validateConfig(config); + await this.audit.ensureWritable(); + const path = resolvePath(this.configPath); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + try { + await writeNewConfigFile(path, `${JSON.stringify(config, null, 2)}\n`); + } catch (error) { + if (fileErrorCode(error) === "EEXIST") { + throw new MiftahError( + "CONFIG_ALREADY_EXISTS", + "CONFIG_ALREADY_EXISTS: refusing to replace an existing configuration" + ); + } + throw new MiftahError( + "CONFIG_CREATE_FAILED", + "CONFIG_CREATE_FAILED: unable to create the initial configuration" + ); + } + await this.audit.writeRequiredLifecycle({ + operation: "console/onboard-native-oauth", + name: "connection", + profile: request.profile, + upstream: "default", + status: "success" + }); + return { + changed: true, + write: true, + connectionRef, + profile: request.profile, + upstream: "default", + resource: request.resource, + actions: [ + `Created profile '${request.profile}'.`, + `Added OAuth connection for profile '${request.profile}' and upstream 'default'.` + ] + }; + } + + async clientSnippets(selection: ClientSelection): Promise { + if (this.launcher === undefined) { + throw new MiftahError("CONSOLE_LAUNCHER_UNAVAILABLE", "CONSOLE_LAUNCHER_UNAVAILABLE: client snippets are unavailable"); + } + const config = await loadConfig(this.configPath); + return renderClientSnippets(selection, { + serverName: config.name, + configPath: resolvePath(this.configPath), + launcher: this.launcher + }); + } + listConnections(): Promise { return new NativeOAuthCommandRuntimeFactory().connections(this.configPath); } @@ -218,13 +349,17 @@ export class ConsoleApplicationService implements ConsoleControlApplication { return this.runConnectionMutation(connectionRef, "reauth", (service) => service.reauth({ connectionRef })); } + testConnection(connectionRef: string): Promise { + return this.runConnectionMutation(connectionRef, "test", (service) => service.test({ connectionRef })); + } + disconnect(connectionRef: string): Promise { return this.runConnectionMutation(connectionRef, "disconnect", (service) => service.disconnect({ connectionRef })); } private async runConnectionMutation( connectionRef: string, - action: "connect" | "reauth" | "disconnect", + action: "connect" | "reauth" | "test" | "disconnect", operation: (service: ConsoleOAuthCommandService) => Promise ): Promise { const reference = parseOAuthConnectionRef(connectionRef); diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts new file mode 100644 index 00000000..afb0ebdb --- /dev/null +++ b/src/console/console-assets.ts @@ -0,0 +1,586 @@ +export interface ConsoleAsset { + readonly contentType: string; + readonly body: string; +} + +const page = ` + + + + + + Miftah Console + + + + +
+
+
+

Local control plane

+

Miftah Console

+
+

127.0.0.1 only

+
+ +
+
+

01 / Unlock

+

Use the one-time code from your terminal

+

The code stays in this page's memory, works once, and is never written to browser storage.

+
+
+ +
+ + +
+

This is not a provider password. Miftah never asks for provider passwords.

+
+
+ + +

+
+ + + +`; + +const styles = `:root { + color-scheme: dark; + --ink: #f4f0e7; + --muted: #aaa498; + --line: #343a35; + --panel: #151a17; + --panel-raised: #1b211d; + --ground: #0b0e0c; + --key: #efb44d; + --safe: #75c99a; + --danger: #e08a77; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px), + radial-gradient(circle at 85% 8%, rgb(239 180 77 / 11%), transparent 32rem), + linear-gradient(135deg, #0d110f, var(--ground)); + background-size: 4rem 4rem, 4rem 4rem, auto, auto; +} +.skip-link { position: fixed; left: 1rem; top: -4rem; z-index: 10; padding: .7rem 1rem; color: #111; background: var(--key); } +.skip-link:focus { top: 1rem; } +.shell { width: min(76rem, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2.5rem, 7vw, 6rem) 0; } +.masthead { display: flex; align-items: flex-start; justify-content: space-between; gap: 2rem; margin-bottom: clamp(3rem, 7vw, 6rem); } +.eyebrow, .step, .mode-tag, .summary-label { color: var(--key); font: 700 .72rem/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; } +h1 { margin: .45rem 0 0; font: 500 clamp(3.4rem, 9vw, 7.5rem)/.88 Georgia, serif; letter-spacing: -.065em; } +h1 span { color: var(--muted); } +h2 { margin: .45rem 0 1rem; font: 500 clamp(1.9rem, 4vw, 3.5rem)/1.03 Georgia, serif; letter-spacing: -.035em; } +h3 { margin: .4rem 0 .7rem; font: 650 1.05rem/1.2 ui-sans-serif, system-ui, sans-serif; } +p { color: var(--muted); line-height: 1.6; } +.local-mark { display: flex; align-items: center; gap: .55rem; margin: .3rem 0; font: 650 .78rem/1 ui-monospace, monospace; } +.local-mark span { width: .6rem; height: .6rem; border-radius: 50%; background: var(--safe); box-shadow: 0 0 0 .3rem rgb(117 201 154 / 12%); } +.gate { display: grid; grid-template-columns: minmax(0, 1fr) minmax(18rem, 1fr); gap: clamp(2rem, 7vw, 6rem); border-top: 1px solid var(--line); padding: 2rem 0 4rem; } +.gate h2 { max-width: 31rem; } +.field-note { margin: .65rem 0 0; font-size: .8rem; } +.intro, .work-section { border-top: 1px solid var(--line); padding: 2rem 0 clamp(3rem, 7vw, 6rem); } +.mode-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin-top: 2rem; background: var(--line); border: 1px solid var(--line); } +.mode { min-height: 14rem; padding: 1.3rem; background: var(--panel); } +.mode-native { box-shadow: inset 0 .2rem 0 var(--key); } +.mode-unsupported { box-shadow: inset 0 .2rem 0 var(--danger); } +.mode p:last-child { font-size: .88rem; } +.section-heading { display: grid; grid-template-columns: minmax(0, 1fr) minmax(17rem, .75fr); gap: 3rem; align-items: end; margin-bottom: 2rem; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; padding: clamp(1.2rem, 3vw, 2rem); border: 1px solid var(--line); background: rgb(21 26 23 / 88%); } +.form-grid.compact { margin-top: 1.2rem; } +.wide { grid-column: 1 / -1; } +label { display: block; color: var(--muted); font-size: .82rem; } +input, select, textarea, button { border-radius: .28rem; font: inherit; } +input, select, textarea { width: 100%; margin-top: .55rem; border: 1px solid var(--line); padding: .78rem .85rem; color: var(--ink); background: var(--ground); } +textarea { resize: vertical; font: .78rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } +input:focus, select:focus, textarea:focus, button:focus-visible, summary:focus-visible { outline: 2px solid var(--key); outline-offset: 2px; } +.input-row { display: flex; align-items: flex-end; gap: .65rem; } +.grow { flex: 1; } +button { min-height: 2.85rem; border: 0; padding: 0 1rem; color: #19150d; background: var(--key); font-weight: 760; cursor: pointer; } +button:hover { filter: brightness(1.08); } +button:disabled { cursor: wait; opacity: .55; } +button.secondary { color: var(--ink); background: var(--panel-raised); border: 1px solid var(--line); } +button.danger { color: #ffd7cf; background: transparent; border: 1px solid #70433a; } +.form-action { display: flex; justify-content: flex-end; } +.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); } +.summary article { display: flex; min-height: 9rem; flex-direction: column; gap: .45rem; padding: 1.25rem; background: var(--panel); } +.summary strong { font: 500 1.5rem/1.15 Georgia, serif; } +.summary span { color: var(--muted); font-size: .8rem; line-height: 1.45; } +.restart-note { margin: 1rem 0 4rem; padding: 1rem 1.2rem; border-left: .2rem solid var(--key); background: rgb(239 180 77 / 7%); } +.connection-list { display: grid; gap: .8rem; margin-bottom: 1.2rem; } +.connection { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; align-items: center; padding: 1rem 1.2rem; border: 1px solid var(--line); background: var(--panel); } +.connection p { margin: .25rem 0 0; font: .77rem/1.5 ui-monospace, monospace; } +.connection-actions { display: flex; flex-wrap: wrap; gap: .45rem; justify-content: flex-end; } +.connection-actions button { min-height: 2.35rem; font-size: .76rem; } +details { border: 1px solid var(--line); padding: 1rem; } +summary { cursor: pointer; font-weight: 700; } +.split { display: grid; grid-template-columns: minmax(0, .8fr) minmax(20rem, 1.2fr); gap: clamp(2rem, 6vw, 5rem); } +.audit-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); } +.audit-list li { display: grid; grid-template-columns: 10rem 1fr auto; gap: 1rem; padding: .8rem 0; border-bottom: 1px solid var(--line); color: var(--muted); font: .76rem/1.45 ui-monospace, monospace; } +.status { position: sticky; bottom: 1rem; min-height: 1.5rem; width: fit-content; max-width: 100%; margin: 1rem 0 0; padding: .7rem 1rem; color: var(--ink); background: #222923; border: 1px solid var(--line); box-shadow: 0 .7rem 2rem rgb(0 0 0 / 35%); } +.status:empty { visibility: hidden; } +@media (max-width: 850px) { .mode-grid, .summary { grid-template-columns: repeat(2, 1fr); } .section-heading, .split { grid-template-columns: 1fr; gap: 1rem; } } +@media (max-width: 620px) { .gate, .form-grid, .mode-grid, .summary { grid-template-columns: 1fr; } .wide { grid-column: 1; } .masthead { flex-direction: column; } .input-row, .connection { align-items: stretch; flex-direction: column; grid-template-columns: 1fr; } .connection-actions { justify-content: flex-start; } .audit-list li { grid-template-columns: 1fr; gap: .2rem; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } } +`; + +const script = `(() => { + "use strict"; + const byId = (id) => document.getElementById(id); + const status = byId("status"); + const unlockForm = byId("unlock-form"); + const bootstrapInput = byId("bootstrap"); + const dashboardView = byId("dashboard-view"); + const unlockView = byId("unlock-view"); + const onboardingView = byId("onboarding-view"); + const workspaceView = byId("workspace-view"); + let csrfToken = ""; + + function message(text) { + if (status) status.textContent = text; + } + + function errorMessage(error) { + return error instanceof Error ? error.message : "The Console request failed."; + } + + function restoreUnlock() { + csrfToken = ""; + if (dashboardView) dashboardView.hidden = true; + if (onboardingView) onboardingView.hidden = true; + if (workspaceView) workspaceView.hidden = true; + if (unlockView) unlockView.hidden = false; + if (bootstrapInput instanceof HTMLInputElement) bootstrapInput.focus(); + } + + 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 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 publicMessage = payload && payload.error && typeof payload.error.message === "string" + ? payload.error.message + : "The Console request failed."; + throw new Error(publicMessage); + } + return payload ? payload.data : undefined; + } + + function registration(form) { + const data = new FormData(form); + const mode = String(data.get("registrationMode") || "dynamic"); + const value = String(data.get("registrationValue") || "").trim(); + if (mode === "dynamic") return "dynamic"; + if (!value) throw new Error("Enter the reviewed client ID or metadata URL for this registration mode."); + return mode + ":" + value; + } + + function scopes(form) { + const value = String(new FormData(form).get("scopes") || ""); + return value.split(/[\\s,]+/u).map((scope) => scope.trim()).filter(Boolean); + } + + function setOptions(select, values) { + if (!(select instanceof HTMLSelectElement)) return; + select.replaceChildren(); + values.forEach((value) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + select.append(option); + }); + } + + function record(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + } + + function renderConnections(value) { + const list = byId("connection-list"); + if (!list) return; + list.replaceChildren(); + const connections = Array.isArray(value) ? value : []; + if (connections.length === 0) { + const empty = document.createElement("p"); + empty.textContent = "No native OAuth connections are configured yet."; + list.append(empty); + return; + } + connections.forEach((item) => { + const connection = record(item); + const reference = typeof connection.connectionRef === "string" ? connection.connectionRef : ""; + const card = document.createElement("article"); + card.className = "connection"; + const details = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = (typeof connection.profile === "string" ? connection.profile : "Unknown profile") + + " / " + (typeof connection.upstream === "string" ? connection.upstream : "unknown upstream"); + const state = document.createElement("p"); + const credential = typeof connection.credentialState === "string" ? connection.credentialState : "unknown"; + const identity = typeof connection.identityState === "string" ? connection.identityState : "not verified"; + state.textContent = "credential: " + credential + " · identity: " + identity; + details.append(title, state); + const actions = document.createElement("div"); + actions.className = "connection-actions"; + [ + ["connect", "Connect", ""], + ["test", "Test", "secondary"], + ["reauth", "Reauthorize", "secondary"], + ["credential", "Remove local credential", "danger"] + ].forEach((definition) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = definition[1]; + button.className = definition[2]; + button.dataset.connection = reference; + button.dataset.action = definition[0]; + button.disabled = !reference; + actions.append(button); + }); + card.append(details, actions); + list.append(card); + }); + } + + function renderAudit(value) { + const list = byId("audit-list"); + if (!list) return; + list.replaceChildren(); + const entries = Array.isArray(value) ? value : []; + if (entries.length === 0) { + const empty = document.createElement("li"); + empty.textContent = "No Console lifecycle events yet."; + list.append(empty); + return; + } + entries.slice().reverse().forEach((item) => { + const event = record(item); + const row = document.createElement("li"); + [event.timestamp || "—", event.operation || "unknown operation", event.status || "unknown"].forEach((value) => { + const part = document.createElement("span"); + part.textContent = String(value); + row.append(part); + }); + list.append(row); + }); + } + + async function refresh() { + const metadata = record(await api("/api/v1/config")); + if (unlockView) unlockView.hidden = true; + if (dashboardView) dashboardView.hidden = false; + if (metadata.initialized !== true) { + if (onboardingView) onboardingView.hidden = false; + if (workspaceView) workspaceView.hidden = true; + message("No configuration exists yet. Create the first native OAuth profile below."); + return; + } + if (onboardingView) onboardingView.hidden = true; + if (workspaceView) workspaceView.hidden = false; + const configName = byId("config-name"); + const configVersion = byId("config-version"); + const defaultProfile = byId("default-profile"); + if (configName) configName.textContent = String(metadata.name || "—"); + if (configVersion) configVersion.textContent = "Config v" + String(metadata.version || "—"); + if (defaultProfile) defaultProfile.textContent = String(metadata.defaultProfile || "—"); + const profiles = Array.isArray(metadata.profiles) ? metadata.profiles.map((item) => String(record(item).name || "")).filter(Boolean) : []; + const upstreams = Array.isArray(metadata.upstreams) ? metadata.upstreams.map((item) => String(record(item).name || "")).filter(Boolean) : []; + setOptions(byId("connection-profile"), profiles); + setOptions(byId("connection-upstream"), upstreams); + const results = await Promise.all([ + api("/api/v1/health"), + api("/api/v1/connections"), + api("/api/v1/audit?limit=50") + ]); + const health = record(results[0]); + const audit = record(health.audit); + const auditState = byId("audit-state"); + if (auditState) auditState.textContent = String(audit.state || "unknown"); + renderConnections(results[1]); + renderAudit(results[2]); + message("Console data refreshed. Existing MCP clients still need a restart for durable changes."); + } + + if (unlockForm instanceof HTMLFormElement && bootstrapInput instanceof HTMLInputElement) { + unlockForm.addEventListener("submit", async (event) => { + event.preventDefault(); + message("Opening the local Console…"); + try { + const response = await fetch("/api/v1/sessions", { + method: "POST", + headers: { "Authorization": "Bootstrap " + bootstrapInput.value, "Content-Type": "application/json" }, + body: "{}" + }); + 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."); + } + csrfToken = payload.data.csrfToken; + await refresh(); + } catch (error) { + message(errorMessage(error)); + bootstrapInput.focus(); + } + }); + } + + const onboardingForm = byId("onboarding-form"); + if (onboardingForm instanceof HTMLFormElement) { + onboardingForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(onboardingForm); + message("Creating the validated profile and OAuth connection…"); + try { + await api("/api/v1/onboarding/native-oauth", { + method: "POST", + body: { + name: String(data.get("name") || "").trim(), + profile: String(data.get("profile") || "").trim(), + description: String(data.get("description") || "").trim() || undefined, + resource: String(data.get("resource") || "").trim(), + issuer: String(data.get("issuer") || "").trim(), + clientRegistration: registration(onboardingForm), + scopes: scopes(onboardingForm) + } + }); + onboardingForm.reset(); + await refresh(); + } catch (error) { message(errorMessage(error)); } + }); + } + + const connectionForm = byId("connection-form"); + if (connectionForm instanceof HTMLFormElement) { + connectionForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(connectionForm); + message("Adding the reviewed OAuth binding…"); + try { + await api("/api/v1/connections", { + method: "POST", + body: { + profile: String(data.get("profile") || ""), + upstream: String(data.get("upstream") || ""), + issuer: String(data.get("issuer") || "").trim(), + clientRegistration: registration(connectionForm), + scopes: scopes(connectionForm) + } + }); + connectionForm.reset(); + await refresh(); + } catch (error) { message(errorMessage(error)); } + }); + } + + const connectionList = byId("connection-list"); + if (connectionList) { + connectionList.addEventListener("click", async (event) => { + const button = event.target instanceof Element ? event.target.closest("button[data-action]") : null; + if (!(button instanceof HTMLButtonElement)) return; + const reference = button.dataset.connection || ""; + const action = button.dataset.action || ""; + if (!reference || !action) return; + button.disabled = true; + message(action === "credential" ? "Removing the exact local vault credential…" : "Running " + action + "…"); + try { + await api("/api/v1/connections/" + encodeURIComponent(reference) + "/" + action, { + method: action === "credential" ? "DELETE" : "POST", + body: {} + }); + await refresh(); + } catch (error) { message(errorMessage(error)); } + finally { button.disabled = false; } + }); + } + + const generateSnippet = byId("generate-snippet"); + if (generateSnippet instanceof HTMLButtonElement) { + generateSnippet.addEventListener("click", async () => { + const select = byId("client-select"); + const output = byId("snippet-output"); + if (!(select instanceof HTMLSelectElement) || !(output instanceof HTMLTextAreaElement)) return; + try { + const snippets = await api("/api/v1/client-snippets?client=" + encodeURIComponent(select.value)); + const first = Array.isArray(snippets) ? record(snippets[0]) : {}; + output.value = typeof first.json === "string" ? first.json : ""; + message("Generated copy-only client configuration. Review it before merging."); + } catch (error) { message(errorMessage(error)); } + }); + } + + const copySnippet = byId("copy-snippet"); + if (copySnippet instanceof HTMLButtonElement) { + copySnippet.addEventListener("click", async () => { + const output = byId("snippet-output"); + if (!(output instanceof HTMLTextAreaElement) || !output.value) return; + try { + await navigator.clipboard.writeText(output.value); + message("Client JSON copied. Miftah did not modify any client file."); + } catch { + output.focus(); + output.select(); + message("Clipboard access was unavailable. The JSON is selected for manual copy."); + } + }); + } + + const refreshButton = byId("refresh-dashboard"); + if (refreshButton instanceof HTMLButtonElement) { + refreshButton.addEventListener("click", () => void refresh().catch((error) => message(errorMessage(error)))); + } +})(); +`; + +const assets: Readonly> = Object.freeze({ + "/": { contentType: "text/html; charset=utf-8", body: page }, + "/app.css": { contentType: "text/css; charset=utf-8", body: styles }, + "/app.js": { contentType: "text/javascript; charset=utf-8", body: script } +}); + +export function consoleAsset(path: string): ConsoleAsset | undefined { + return Object.hasOwn(assets, path) ? assets[path] : undefined; +} diff --git a/src/console/console-server.ts b/src/console/console-server.ts index 814c7eae..eb239fc9 100644 --- a/src/console/console-server.ts +++ b/src/console/console-server.ts @@ -4,6 +4,8 @@ import type { Socket } from "node:net"; import { z } from "zod"; import { loadConfig } from "../config/load-config.js"; import { MiftahError } from "../utils/errors.js"; +import { CLIENT_NAMES, type ClientLauncher, type ClientSelection } from "../cli/client-snippets.js"; +import { consoleAsset, type ConsoleAsset } from "./console-assets.js"; import { ConsoleApplicationService, type ConsoleControlApplication @@ -34,6 +36,15 @@ const connectionAddSchema = z.object({ clientRegistration: z.string().min(1).max(2_048), scopes: z.array(z.string().min(1).max(512)).max(128) }).strict(); +const nativeOAuthOnboardingSchema = z.object({ + name: z.string().min(1).max(256), + profile: z.string().min(1).max(256), + description: z.string().max(1_024).optional(), + resource: z.string().url().max(2_048), + issuer: z.string().url().max(2_048), + clientRegistration: z.string().min(1).max(2_048), + scopes: z.array(z.string().min(1).max(512)).max(128) +}).strict(); interface BrowserSession { readonly id: string; @@ -62,6 +73,10 @@ export interface ConsoleServerOptions { readonly idleSessionMs?: number; readonly absoluteSessionMs?: number; readonly now?: () => number; + /** Allows the dashboard to start before its first configuration is created. */ + readonly allowMissingConfig?: boolean; + /** Exact installed CLI launcher used only to generate copyable client snippets. */ + readonly launcher?: ClientLauncher; /** Internal embedding/test seam; production CLI uses the native in-process application service. */ readonly application?: ConsoleControlApplication; } @@ -121,6 +136,25 @@ function writeJson(response: ServerResponse, status: number, body: unknown, head response.end(JSON.stringify(body)); } +function writeAsset(response: ServerResponse, requestMethod: string | undefined, asset: ConsoleAsset): void { + if (response.headersSent || response.writableEnded) { + response.destroy(); + return; + } + response.statusCode = 200; + response.setHeader("cache-control", "no-store"); + response.setHeader("content-type", asset.contentType); + response.setHeader("x-content-type-options", "nosniff"); + response.setHeader("x-frame-options", "DENY"); + response.setHeader("referrer-policy", "no-referrer"); + response.setHeader( + "content-security-policy", + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; " + + "img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ); + response.end(requestMethod === "HEAD" ? undefined : asset.body); +} + function writeError(response: ServerResponse, error: ConsoleHttpError): void { writeJson(response, error.status, { error: { code: error.code, message: error.message } }, error.headers); } @@ -135,6 +169,12 @@ function publicApplicationError(error: unknown): ConsoleHttpError { if (error.code === "OAUTH_CONNECTION_NOT_FOUND") { return new ConsoleHttpError(404, "oauth_connection_not_found", "The OAuth connection does not exist."); } + if (error.code === "CONFIG_ALREADY_EXISTS") { + return new ConsoleHttpError(409, "config_already_exists", "A configuration already exists at this location."); + } + if (error.code === "CONFIG_CREATE_FAILED") { + return new ConsoleHttpError(503, "config_create_failed", "The initial configuration could not be created."); + } if ( error.code.startsWith("CONFIG_") || error.code.startsWith("OAUTH_CONNECTION_") || @@ -249,6 +289,16 @@ class LocalConsoleServer implements ConsoleServer { private async route(request: IncomingMessage, response: ServerResponse): Promise { if (this.closed) throw new ConsoleHttpError(503, "service_unavailable", "The Console is shutting down."); + const asset = request.url === undefined ? undefined : consoleAsset(request.url); + if (asset !== undefined) { + this.requireTrustedNavigation(request); + this.admitRequest(false); + if (request.method !== "GET" && request.method !== "HEAD") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET, HEAD" }); + } + writeAsset(response, request.method, asset); + return; + } this.requireTrustedBrowser(request); this.admitRequest(request.url === "/api/v1/sessions"); @@ -280,7 +330,55 @@ class LocalConsoleServer implements ConsoleServer { try { const metadata = await this.application.configMetadata(); session.lastUsedAt = this.options.now(); - writeJson(response, 200, { data: request.url.endsWith("/profiles") ? metadata.profiles : metadata }); + writeJson(response, 200, { + data: request.url.endsWith("/profiles") + ? metadata.initialized + ? metadata.profiles + : [] + : metadata + }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.url === "/api/v1/onboarding/native-oauth") { + if (request.method !== "POST") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "POST" }); + } + this.requireCsrf(request, session); + const parsed = nativeOAuthOnboardingSchema.safeParse(await readJsonBody(request, this.options.maximumRequestBytes)); + if (!parsed.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid."); + try { + const result = await this.application.onboardNativeOAuth(parsed.data); + session.lastUsedAt = this.options.now(); + writeJson(response, 201, { data: result }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.url?.startsWith("/api/v1/client-snippets")) { + if (request.method !== "GET") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" }); + } + let url: URL; + try { + url = new URL(request.url, this.url); + } catch { + throw new ConsoleHttpError(400, "invalid_request", "The request URL is invalid."); + } + if (url.pathname !== "/api/v1/client-snippets" || [...url.searchParams.keys()].some((key) => key !== "client")) { + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + const client = url.searchParams.get("client") ?? "all"; + if (client !== "all" && !(CLIENT_NAMES as readonly string[]).includes(client)) { + throw new ConsoleHttpError(422, "validation_error", "The requested MCP client is not supported."); + } + try { + const snippets = await this.application.clientSnippets(client as ClientSelection); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: snippets }); } catch (error) { throw publicApplicationError(error); } @@ -327,9 +425,9 @@ class LocalConsoleServer implements ConsoleServer { } return; } - const connectionAction = /^\/api\/v1\/connections\/([^/]+)\/(connect|reauth|credential)$/u.exec(request.url ?? ""); + const connectionAction = /^\/api\/v1\/connections\/([^/]+)\/(connect|reauth|test|credential)$/u.exec(request.url ?? ""); if (connectionAction !== null) { - const action = connectionAction[2] as "connect" | "reauth" | "credential"; + const action = connectionAction[2] as "connect" | "reauth" | "test" | "credential"; const requiredMethod = action === "credential" ? "DELETE" : "POST"; if (request.method !== requiredMethod) { throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: requiredMethod }); @@ -343,7 +441,9 @@ class LocalConsoleServer implements ConsoleServer { ? await this.application.connect(connectionRef) : action === "reauth" ? await this.application.reauth(connectionRef) - : await this.application.disconnect(connectionRef); + : action === "test" + ? await this.application.testConnection(connectionRef) + : await this.application.disconnect(connectionRef); session.lastUsedAt = this.options.now(); writeJson(response, 200, { data: result }); } catch (error) { @@ -383,7 +483,16 @@ class LocalConsoleServer implements ConsoleServer { private requireTrustedBrowser(request: IncomingMessage): void { const host = singleHeader(request, "host"); const origin = singleHeader(request, "origin"); - if (host !== this.url.host || origin !== this.url.origin) { + const readWithoutOrigin = origin === undefined && (request.method === "GET" || request.method === "HEAD"); + if (host !== this.url.host || origin === null || (!readWithoutOrigin && origin !== this.url.origin)) { + throw new ConsoleHttpError(403, "forbidden", "The request origin is not trusted."); + } + } + + private requireTrustedNavigation(request: IncomingMessage): void { + const host = singleHeader(request, "host"); + const origin = singleHeader(request, "origin"); + if (host !== this.url.host || origin === null || (origin !== undefined && origin !== this.url.origin)) { throw new ConsoleHttpError(403, "forbidden", "The request origin is not trusted."); } } @@ -519,7 +628,13 @@ export async function startConsoleServer( configPath: string, options: ConsoleServerOptions = {} ): Promise { - await loadConfig(configPath); + try { + await loadConfig(configPath); + } catch (error) { + if (!(options.allowMissingConfig === true && error instanceof MiftahError && error.code === "CONFIG_NOT_FOUND")) { + throw error; + } + } const bootstrapCredential = options.bootstrapCredential ?? randomCredential(); if (bootstrapCredential.length < 16 || bootstrapCredential.length > 4_096) { throw new Error("Unable to start the Miftah Console server."); @@ -556,7 +671,9 @@ export async function startConsoleServer( url, bootstrapCredential, listener, - options.application ?? new ConsoleApplicationService(configPath), + options.application ?? new ConsoleApplicationService(configPath, { + ...(options.launcher === undefined ? {} : { launcher: options.launcher }) + }), { maximumRequestBytes: options.maximumRequestBytes ?? defaultMaximumRequestBytes, maximumSessions: options.maximumSessions ?? defaultMaximumSessions, diff --git a/src/console/open-browser.ts b/src/console/open-browser.ts new file mode 100644 index 00000000..16d963bf --- /dev/null +++ b/src/console/open-browser.ts @@ -0,0 +1,78 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; + +export interface BrowserLaunchCommand { + readonly command: string; + readonly arguments: readonly string[]; +} + +interface BrowserLaunchDependencies { + readonly platform?: NodeJS.Platform; + readonly exists?: (path: string) => boolean; + readonly spawn?: typeof spawn; +} + +function isTrustedDashboardUrl(url: URL): boolean { + return ( + url.protocol === "http:" && + url.hostname === "127.0.0.1" && + url.username.length === 0 && + url.password.length === 0 + ); +} + +/** Selects only fixed OS launchers for a literal-loopback dashboard URL. */ +export function browserLaunchCommand( + url: URL, + dependencies: Pick = {} +): BrowserLaunchCommand | undefined { + if (!isTrustedDashboardUrl(url)) return undefined; + const platform = dependencies.platform ?? process.platform; + const target = url.toString(); + if (platform === "darwin") return { command: "/usr/bin/open", arguments: [target] }; + if (platform === "win32") { + return { + command: "C:\\Windows\\System32\\rundll32.exe", + arguments: ["url.dll,FileProtocolHandler", target] + }; + } + if (platform === "linux") { + const exists = dependencies.exists ?? existsSync; + const command = ["/usr/bin/xdg-open", "/bin/xdg-open"].find((candidate) => exists(candidate)); + return command === undefined ? undefined : { command, arguments: [target] }; + } + return undefined; +} + +/** Opens the optional dashboard without a command shell; failure leaves the printed URL usable. */ +export async function openSystemBrowser( + url: URL, + dependencies: BrowserLaunchDependencies = {} +): Promise { + const launch = browserLaunchCommand(url, dependencies); + if (launch === undefined) return false; + const spawnProcess = dependencies.spawn ?? spawn; + return new Promise((resolve) => { + let settled = false; + const finish = (opened: boolean): void => { + if (settled) return; + settled = true; + resolve(opened); + }; + try { + const child = spawnProcess(launch.command, [...launch.arguments], { + shell: false, + stdio: "ignore", + detached: true, + windowsHide: true + }); + child.once("error", () => finish(false)); + child.once("spawn", () => { + child.unref(); + finish(true); + }); + } catch { + finish(false); + } + }); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 2de8f893..a628aecc 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -10,6 +10,8 @@ export type MiftahErrorCode = | "CONFIG_MIGRATION_CONFLICT" | "CONFIG_MIGRATION_BACKUP_EXISTS" | "CONFIG_MIGRATION_WRITE_FAILED" + | "CONFIG_ALREADY_EXISTS" + | "CONFIG_CREATE_FAILED" | "DEFAULT_PROFILE_NOT_FOUND" | "POLICY_NOT_FOUND" | "ROUTING_PROFILE_NOT_FOUND" @@ -102,7 +104,8 @@ export type MiftahErrorCode = | "OAUTH_CLIENT_REGISTRATION_UNSUPPORTED" | "OAUTH_AUTHORIZATION_FAILED" | "OAUTH_INTERACTIVE_REQUIRED" - | "OAUTH_AUTHORIZATION_NOT_ENABLED"; + | "OAUTH_AUTHORIZATION_NOT_ENABLED" + | "CONSOLE_LAUNCHER_UNAVAILABLE"; export interface MiftahErrorDetails { readonly diagnostics?: readonly ConfigDiagnostic[]; diff --git a/tests/cli-exit-codes.test.ts b/tests/cli-exit-codes.test.ts index d5df86e3..5500cd9b 100644 --- a/tests/cli-exit-codes.test.ts +++ b/tests/cli-exit-codes.test.ts @@ -17,6 +17,8 @@ const expectedErrorExitCodes: Record = { CONFIG_MIGRATION_CONFLICT: CLI_EXIT_CODES.config, CONFIG_MIGRATION_BACKUP_EXISTS: CLI_EXIT_CODES.config, CONFIG_MIGRATION_WRITE_FAILED: CLI_EXIT_CODES.config, + CONFIG_ALREADY_EXISTS: CLI_EXIT_CODES.config, + CONFIG_CREATE_FAILED: CLI_EXIT_CODES.config, DEFAULT_PROFILE_NOT_FOUND: CLI_EXIT_CODES.config, POLICY_NOT_FOUND: CLI_EXIT_CODES.config, ROUTING_PROFILE_NOT_FOUND: CLI_EXIT_CODES.config, @@ -92,6 +94,7 @@ const expectedErrorExitCodes: Record = { OAUTH_AUTHORIZATION_FAILED: CLI_EXIT_CODES.secret, OAUTH_INTERACTIVE_REQUIRED: CLI_EXIT_CODES.config, OAUTH_AUTHORIZATION_NOT_ENABLED: CLI_EXIT_CODES.config, + CONSOLE_LAUNCHER_UNAVAILABLE: CLI_EXIT_CODES.operation, TOOL_COLLISION: CLI_EXIT_CODES.operation, TOOL_NOT_FOUND: CLI_EXIT_CODES.operation, TOOL_SCHEMA_MISMATCH: CLI_EXIT_CODES.operation, diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index 4db2893e..9a24f045 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -44,6 +44,21 @@ describe("CLI parser", () => { expectUsageError(["serve", "--port", "43127"]); }); + it("parses the optional browser dashboard without requiring a configuration path", () => { + expect(parseCli(["dashboard"])).toEqual({ + kind: "run", + command: "dashboard", + options: {} + }); + expect(parseCli(["dashboard", "--config", "wrapper.json", "--port", "43127", "--no-open"])).toEqual({ + kind: "run", + command: "dashboard", + options: { config: "wrapper.json", port: "43127", noOpen: true } + }); + expect(renderCommandHelp("dashboard")).toContain("--no-open"); + expectUsageError(["console", "--no-open"]); + }); + it("accepts command options before and after commands, including equals values", () => { expect(parseCli(["--config=wrapper.json", "doctor", "--json"])).toEqual({ kind: "run", diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index d71ba011..b5ae17a1 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -47,6 +47,77 @@ async function writeConfig(): Promise { } describe("Console application service", () => { + it("creates a validated first-run native OAuth profile and connection without accepting secret material", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-first-run-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "miftah.json"); + const service = new ConsoleApplicationService(configPath, { + generateConnectionRef: () => "31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c", + launcher: { command: process.execPath, args: [join(process.cwd(), "dist", "cli", "main.js"), "serve"] } + }); + + await expect(service.configMetadata()).resolves.toEqual({ + initialized: false, + restartRequiredForExistingClients: true + }); + + const created = await service.onboardNativeOAuth({ + name: "posthog-work", + profile: "production", + description: "Production account", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid", "analytics:read"] + }); + expect(created).toMatchObject({ + connectionRef, + profile: "production", + upstream: "default", + resource: "https://mcp.example.test/mcp" + }); + + const config = JSON.parse(await readFile(configPath, "utf8")); + expect(config).toEqual({ + version: "3", + name: "posthog-work", + defaultProfile: "production", + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { production: { description: "Production account" } }, + oauth: { + connections: { + [connectionRef]: { + profile: "production", + upstream: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid", "analytics:read"] + } + } + } + }); + expect(JSON.stringify(config)).not.toMatch(/token|secret|password/iu); + + const snippets = await service.clientSnippets("claude-desktop"); + expect(snippets).toEqual([ + expect.objectContaining({ + client: "claude-desktop", + json: expect.stringContaining(configPath) + }) + ]); + expect(JSON.stringify(snippets)).not.toContain("auth.example.test"); + + await expect(service.onboardNativeOAuth({ + name: "replacement", + profile: "other", + resource: "https://other.example.test/mcp", + issuer: "https://auth.other.example.test", + clientRegistration: "dynamic", + scopes: [] + })).rejects.toMatchObject({ code: "CONFIG_ALREADY_EXISTS" }); + }); + it("returns allowlisted metadata and audit-records each exact OAuth lifecycle mutation", async () => { const calls: string[] = []; const configPath = await writeConfig(); @@ -62,6 +133,7 @@ describe("Console application service", () => { calls.push(`reauth:${selected}`); return { ok: true }; }, + test: async ({ connectionRef: selected }) => ({ connectionRef: selected, ok: true }), disconnect: async ({ connectionRef: selected }) => { calls.push(`disconnect:${selected}`); return { credentialState: "missing" }; diff --git a/tests/console-open-browser.test.ts b/tests/console-open-browser.test.ts new file mode 100644 index 00000000..bb3eadf2 --- /dev/null +++ b/tests/console-open-browser.test.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { browserLaunchCommand, openSystemBrowser } from "../src/console/open-browser.js"; + +const dashboard = new URL("http://127.0.0.1:43127/"); + +describe("Console system-browser launch", () => { + it("uses only fixed platform launchers and rejects non-loopback destinations", () => { + expect(browserLaunchCommand(dashboard, { platform: "darwin" })).toEqual({ + command: "/usr/bin/open", + arguments: [dashboard.toString()] + }); + expect(browserLaunchCommand(dashboard, { platform: "win32" })).toEqual({ + command: "C:\\Windows\\System32\\rundll32.exe", + arguments: ["url.dll,FileProtocolHandler", dashboard.toString()] + }); + expect(browserLaunchCommand(dashboard, { + platform: "linux", + exists: (path) => path === "/bin/xdg-open" + })).toEqual({ command: "/bin/xdg-open", arguments: [dashboard.toString()] }); + expect(browserLaunchCommand(new URL("https://example.test/"), { platform: "darwin" })).toBeUndefined(); + }); + + it("spawns with an argument array and shell disabled", async () => { + const calls: Array<{ command: string; arguments: readonly string[]; options: Record }> = []; + class FakeChild extends EventEmitter { + unrefCalled = false; + unref(): void { this.unrefCalled = true; } + } + const child = new FakeChild(); + const result = openSystemBrowser(dashboard, { + platform: "darwin", + spawn: ((command: string, arguments_: readonly string[], options: Record) => { + calls.push({ command, arguments: arguments_, options }); + queueMicrotask(() => child.emit("spawn")); + return child; + }) as never + }); + + await expect(result).resolves.toBe(true); + expect(calls).toEqual([{ + command: "/usr/bin/open", + arguments: [dashboard.toString()], + options: expect.objectContaining({ shell: false, stdio: "ignore", detached: true }) + }]); + expect(child.unrefCalled).toBe(true); + }); +}); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index 47e10a9a..81700397 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -7,6 +7,7 @@ import { startConsoleServer, type ConsoleControlApplication } from "../src/console/console-server.js"; +import { MiftahError } from "../src/utils/errors.js"; const temporaryDirectories: string[] = []; @@ -93,6 +94,157 @@ async function bootstrapSession(server: Awaited { + it("serves a navigation-safe local dashboard shell without exposing bootstrap credentials", async () => { + const server = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "test-only-bootstrap-credential" + }); + + try { + const page = await fetch(server.url); + expect(page.status).toBe(200); + expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(page.headers.get("cache-control")).toBe("no-store"); + expect(page.headers.get("content-security-policy")).toBe( + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; " + + "img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ); + const html = await page.text(); + expect(html).toContain("Miftah Console"); + expect(html).toContain('src="/app.js"'); + expect(html).toContain('href="/app.css"'); + expect(html).toContain("Remote native OAuth"); + expect(html).toContain("Provider adapter"); + expect(html).toContain("Upstream-owned auth"); + expect(html).toContain("Unsupported state"); + expect(html).toContain("Active vs durable:"); + expect(html).not.toContain("test-only-bootstrap-credential"); + expect(html).not.toContain("localStorage"); + + const script = await fetch(new URL("/app.js", server.url)); + expect(script.status).toBe(200); + expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + const javascript = await script.text(); + expect(javascript).toContain("/api/v1/sessions"); + expect(javascript).toContain("/api/v1/onboarding/native-oauth"); + expect(javascript).toContain("/api/v1/client-snippets"); + expect(javascript).toContain('action === "credential" ? "DELETE" : "POST"'); + expect(javascript).toContain("restoreUnlock"); + expect(javascript).not.toMatch(/innerHTML|localStorage|sessionStorage|\beval\s*\(/u); + + const stylesheet = await fetch(new URL("/app.css", server.url)); + expect(stylesheet.status).toBe(200); + expect(stylesheet.headers.get("content-type")).toBe("text/css; charset=utf-8"); + expect(await stylesheet.text()).toContain("prefers-reduced-motion"); + + const hostileHost = await new Promise((resolve, reject) => { + const request = httpRequest( + { + hostname: server.url.hostname, + port: server.url.port, + path: "/", + method: "GET", + headers: { host: "attacker.example.test" } + }, + (response) => { + response.resume(); + response.once("end", () => resolve(response.statusCode ?? 0)); + } + ); + request.once("error", reject); + request.end(); + }); + expect(hostileHost).toBe(403); + + const mutation = await fetch(server.url, { method: "POST" }); + expect(mutation.status).toBe(405); + } finally { + await server.close(); + } + }); + + it("supports a CSRF-protected first-run native OAuth setup and copy-only client snippets", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "miftah.json"); + const server = await startConsoleServer(configPath, { + bootstrapCredential: "test-only-bootstrap-credential", + allowMissingConfig: true, + launcher: { command: process.execPath, args: [join(process.cwd(), "dist", "cli", "main.js"), "serve"] } + }); + + try { + const session = await bootstrapSession(server); + const metadata = await fetch(new URL("/api/v1/config", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(metadata.status).toBe(200); + expect(await metadata.json()).toEqual({ + data: { initialized: false, restartRequiredForExistingClients: true } + }); + + const endpoint = new URL("/api/v1/onboarding/native-oauth", server.url); + const request = { + name: "posthog-work", + profile: "production", + description: "Production account", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["openid", "analytics:read"] + }; + const missingCsrf = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "content-type": "application/json" + }, + body: JSON.stringify(request) + }); + expect(missingCsrf.status).toBe(403); + await expect(readFile(configPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + + const secretBearing = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ ...request, accessToken: "must-not-be-accepted" }) + }); + expect(secretBearing.status).toBe(422); + await expect(readFile(configPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + + const created = await fetch(endpoint, { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify(request) + }); + expect(created.status).toBe(201); + expect(await created.json()).toMatchObject({ + data: { profile: "production", upstream: "default", resource: "https://mcp.example.test/mcp" } + }); + + const snippets = await fetch(new URL("/api/v1/client-snippets?client=claude-desktop", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(snippets.status).toBe(200); + const snippetBody = await snippets.json(); + expect(snippetBody).toMatchObject({ data: [{ client: "claude-desktop" }] }); + expect(JSON.stringify(snippetBody)).toContain(configPath); + expect(JSON.stringify(snippetBody)).not.toContain("auth.example.test"); + } finally { + await server.close(); + } + }); + it("requires an invocation-bound bootstrap before returning redacted control metadata", async () => { const server = await startConsoleServer(await writeConfig(), { bootstrapCredential: "test-only-bootstrap-credential" @@ -108,7 +260,7 @@ describe("local Console control server", () => { expect(unauthenticated.status).toBe(401); const missingOrigin = await fetch(new URL("/api/v1/health", server.url)); - expect(missingOrigin.status).toBe(403); + expect(missingOrigin.status).toBe(401); const bootstrapUrl = new URL("/api/v1/sessions", server.url); const hostileHost = await rawPost( @@ -164,7 +316,7 @@ describe("local Console control server", () => { expect(bootstrap.headers.get("x-frame-options")).toBe("DENY"); const health = await fetch(new URL("/api/v1/health", server.url), { - headers: { origin: server.url.origin, cookie: cookie!.split(";", 1)[0]! } + headers: { cookie: cookie!.split(";", 1)[0]! } }); expect(health.status).toBe(200); expect(await health.json()).toEqual({ @@ -310,6 +462,7 @@ describe("local Console control server", () => { restartRequiredForExistingClients: true }), configMetadata: async () => ({ + initialized: true, name: "console-test", version: "1", defaultProfile: "personal", @@ -319,6 +472,10 @@ describe("local Console control server", () => { restartRequiredForExistingClients: true }), listConnections: async () => [], + onboardNativeOAuth: async () => { + throw new MiftahError("CONFIG_CREATE_FAILED", "CONFIG_CREATE_FAILED: test fixture"); + }, + clientSnippets: async () => [], connectionStatus: async (connectionRef) => ({ connectionRef, credentialState: "missing" }), addConnection: async () => { throw new Error("not used"); }, connect: async (connectionRef) => { @@ -333,6 +490,10 @@ describe("local Console control server", () => { calls.push(`disconnect:${connectionRef}`); return { connectionRef, credentialState: "missing" }; }, + testConnection: async (connectionRef) => { + calls.push(`test:${connectionRef}`); + return { ok: true, connectionRef }; + }, auditRecords: async () => [] }; const server = await startConsoleServer(await writeConfig(), { @@ -342,6 +503,31 @@ describe("local Console control server", () => { try { const session = await bootstrapSession(server); + const createFailure = await fetch(new URL("/api/v1/onboarding/native-oauth", server.url), { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ + name: "service", + profile: "work", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: [] + }) + }); + expect(createFailure.status).toBe(503); + expect(await createFailure.json()).toEqual({ + error: { + code: "config_create_failed", + message: "The initial configuration could not be created." + } + }); + const reference = "oauthconn:31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c"; const status = await fetch( new URL(`/api/v1/connections/${encodeURIComponent(reference)}`, server.url), @@ -358,7 +544,12 @@ describe("local Console control server", () => { expect(rejected.status).toBe(403); expect(calls).toEqual([]); - for (const [action, method] of [["connect", "POST"], ["reauth", "POST"], ["credential", "DELETE"]] as const) { + for (const [action, method] of [ + ["connect", "POST"], + ["reauth", "POST"], + ["test", "POST"], + ["credential", "DELETE"] + ] as const) { const response = await fetch( new URL(`/api/v1/connections/${encodeURIComponent(reference)}/${action}`, server.url), { @@ -374,7 +565,12 @@ describe("local Console control server", () => { ); expect(response.status).toBe(200); } - expect(calls).toEqual([`connect:${reference}`, `reauth:${reference}`, `disconnect:${reference}`]); + expect(calls).toEqual([ + `connect:${reference}`, + `reauth:${reference}`, + `test:${reference}`, + `disconnect:${reference}` + ]); } finally { await server.close(); } diff --git a/tests/oauth-console-threat-model-docs-contract.test.ts b/tests/oauth-console-threat-model-docs-contract.test.ts index a40d0086..97638ece 100644 --- a/tests/oauth-console-threat-model-docs-contract.test.ts +++ b/tests/oauth-console-threat-model-docs-contract.test.ts @@ -26,19 +26,24 @@ describe("OAuth and Console threat-model documentation contract", () => { expect(delta).toContain("# OAuth broker and local Console design delta"); expect(delta).toContain("Version 3 can run the approved standards-compatible remote OAuth flow"); expect(delta).toContain("Issue #85 adds a separately launched local Console control API"); - expect(delta).toContain("no browser Console UI, provider-specific adapter, provider-revocation client, hosted broker, or background daemon exists"); + expect(delta).toContain("Issue #86 adds the optional browser-local Console UI"); + expect(delta).toContain("no provider-revocation client, hosted broker, or background daemon exists"); expect(delta).toContain("The local CLI can plan bindings, report redacted state, connect, reauthenticate, and delete an exact local credential"); expect(delta).toContain("The Console control API is distinct from the MCP `/mcp` endpoint."); expect(delta).toContain("OAuth access tokens and refresh tokens must not appear in configuration, audit events, diagnostics, logs, query strings, browser storage, or Console UI responses."); expect(delta).toContain("An authorization code can arrive only at the bounded callback and must be exchanged without being persisted, logged, audited, or rendered."); - expect(consoleApi).toContain("# Local Console control API"); + expect(consoleApi).toContain("# Local Console dashboard and control API"); expect(consoleApi).toContain("There is no host option, LAN mode, background daemon, or automatic startup."); expect(consoleApi).toContain("`POST /api/v1/sessions`"); + expect(consoleApi).toContain("`POST /api/v1/onboarding/native-oauth`"); + expect(consoleApi).toContain("`GET /api/v1/client-snippets?client=`"); + expect(consoleApi).toContain("`POST /api/v1/connections/:ref/test`"); expect(consoleApi).toContain("`POST /api/v1/connections/:ref/connect`"); expect(consoleApi).toContain("`POST /api/v1/connections/:ref/reauth`"); expect(consoleApi).toContain("`DELETE /api/v1/connections/:ref/credential`"); expect(consoleApi).toContain("must send `Content-Type: application/json` with the JSON body `{}`"); expect(consoleApi).toContain("It cannot inspect or take over another Miftah process"); + expect(consoleApi).toContain("authenticated `GET` and `HEAD` requests may omit `Origin`"); }); it("defines protocol go/no-go, abuse cases, residual risks, and implementation evidence", async () => { diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index d0012696..6bc0c838 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -1028,6 +1028,23 @@ describe("packed artifact contract", () => { await consoleServe.stop(); } + const dashboardConfigPath = join(cliContractDirectory, "first dashboard config.json"); + const dashboardServe = await startInstalledCli( + installedCliEntry, + ["dashboard", "--config", dashboardConfigPath, "--no-open"], + cliContractDirectory, + "Miftah Console listening on " + ); + try { + expect(dashboardServe.stdout).toMatch( + /^Miftah Console listening on http:\/\/127\.0\.0\.1:\d+\/\nConfiguration: .+first dashboard config\.json\nOne-time bootstrap code: [A-Za-z0-9_-]{32,}\nEnter this code only in the local Miftah Console\. It expires after first use or shutdown\.\n$/u + ); + expect(dashboardServe.stderr).toBe(""); + await expect(readFile(dashboardConfigPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await dashboardServe.stop(); + } + const rootHelp = runInstalledBinary(binary, ["--help"], cliContractDirectory); expect(rootHelp.status, rootHelp.stderr || rootHelp.stdout).toBe(0); expect(rootHelp.stderr).toBe(""); @@ -1035,6 +1052,7 @@ describe("packed artifact contract", () => { const commandOptions = { serve: ["--config "], console: ["--config ", "--port "], + dashboard: ["--config ", "--port ", "--no-open"], validate: ["--config "], doctor: ["--config ", "--json"], schema: [], From c074c94703523482564b6ebc11e24f2fc202ca27 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Thu, 23 Jul 2026 01:51:19 +0400 Subject: [PATCH 2/3] fix: expose dashboard connection state --- src/console/console-application-service.ts | 15 ++++- src/console/console-assets.ts | 14 ++++- tests/console-application-service.test.ts | 64 ++++++++++++++++++++++ tests/console-server.test.ts | 1 + 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index caadc6ee..1fcb6338 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -311,8 +311,19 @@ export class ConsoleApplicationService implements ConsoleControlApplication { }); } - listConnections(): Promise { - return new NativeOAuthCommandRuntimeFactory().connections(this.configPath); + async listConnections(): Promise { + try { + return await this.commandService.list(); + } catch (error) { + if (!(error instanceof MiftahError)) throw error; + const configured = await new NativeOAuthCommandRuntimeFactory().connections(this.configPath); + return configured.map((connection) => ({ + ...connection, + credentialState: "unsupported", + identityState: "unavailable", + statusErrorCode: error.code + })); + } } connectionStatus(connectionRef: string): Promise { diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index afb0ebdb..2f9a842f 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -240,7 +240,7 @@ button.danger { color: #ffd7cf; background: transparent; border: 1px solid #7043 .restart-note { margin: 1rem 0 4rem; padding: 1rem 1.2rem; border-left: .2rem solid var(--key); background: rgb(239 180 77 / 7%); } .connection-list { display: grid; gap: .8rem; margin-bottom: 1.2rem; } .connection { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; align-items: center; padding: 1rem 1.2rem; border: 1px solid var(--line); background: var(--panel); } -.connection p { margin: .25rem 0 0; font: .77rem/1.5 ui-monospace, monospace; } +.connection p { margin: .25rem 0 0; overflow-wrap: anywhere; font: .77rem/1.5 ui-monospace, monospace; } .connection-actions { display: flex; flex-wrap: wrap; gap: .45rem; justify-content: flex-end; } .connection-actions button { min-height: 2.35rem; font-size: .76rem; } details { border: 1px solid var(--line); padding: 1rem; } @@ -361,8 +361,16 @@ const script = `(() => { const state = document.createElement("p"); const credential = typeof connection.credentialState === "string" ? connection.credentialState : "unknown"; const identity = typeof connection.identityState === "string" ? connection.identityState : "not verified"; - state.textContent = "credential: " + credential + " · identity: " + identity; - details.append(title, state); + const statusErrorCode = typeof connection.statusErrorCode === "string" ? connection.statusErrorCode : ""; + state.textContent = statusErrorCode + ? "status unavailable: " + statusErrorCode + : "credential: " + credential + " · identity: " + identity; + const binding = document.createElement("p"); + const resource = typeof connection.resource === "string" ? connection.resource : "unknown resource"; + const issuer = typeof connection.issuer === "string" ? connection.issuer : "unknown issuer"; + const grantedScopes = Array.isArray(connection.scopes) ? connection.scopes.map(String).join(" ") : "none"; + binding.textContent = "resource: " + resource + " · issuer: " + issuer + " · scopes: " + (grantedScopes || "none"); + details.append(title, binding, state); const actions = document.createElement("div"); actions.className = "connection-actions"; [ diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index b5ae17a1..52a90c91 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ConsoleApplicationService } from "../src/console/console-application-service.js"; +import { MiftahError } from "../src/utils/errors.js"; const temporaryDirectories: string[] = []; const connectionRef = "oauthconn:31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c"; @@ -174,6 +175,69 @@ describe("Console application service", () => { expect(JSON.stringify(records)).not.toContain("auth.example.test"); }); + it("returns live redacted connection state for dashboard connection cards", async () => { + const configPath = await writeConfig(); + const status = { + connectionRef, + profile: "personal", + upstream: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["read"], + credentialState: "disconnected", + identityState: "unavailable" + }; + let listCalls = 0; + const service = new ConsoleApplicationService(configPath, { + commandService: { + list: async () => { + listCalls += 1; + return [status]; + }, + status: async () => status, + connect: async () => status, + reauth: async () => status, + test: async () => ({ ok: true }), + disconnect: async () => status + } + }); + + await expect(service.listConnections()).resolves.toEqual([status]); + expect(listCalls).toBe(1); + }); + + it("surfaces a stable diagnostic when live connection state is unavailable", async () => { + const configPath = await writeConfig(); + const unavailable = new MiftahError( + "OAUTH_CONNECTION_STORE_UNAVAILABLE", + "sensitive provider detail that must not cross the Console boundary" + ); + const service = new ConsoleApplicationService(configPath, { + commandService: { + list: async () => Promise.reject(unavailable), + status: async () => Promise.reject(unavailable), + connect: async () => Promise.reject(unavailable), + reauth: async () => Promise.reject(unavailable), + test: async () => Promise.reject(unavailable), + disconnect: async () => Promise.reject(unavailable) + } + }); + + const connections = await service.listConnections(); + expect(connections).toEqual([ + expect.objectContaining({ + connectionRef, + profile: "personal", + upstream: "default", + credentialState: "unsupported", + identityState: "unavailable", + statusErrorCode: "OAUTH_CONNECTION_STORE_UNAVAILABLE" + }) + ]); + expect(JSON.stringify(connections)).not.toContain("sensitive provider detail"); + }); + it("refuses a configuration mutation before side effects when the required Console audit is unavailable", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-audit-failure-")); temporaryDirectories.push(directory); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index 81700397..f63e32f1 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -128,6 +128,7 @@ describe("local Console control server", () => { expect(javascript).toContain("/api/v1/onboarding/native-oauth"); expect(javascript).toContain("/api/v1/client-snippets"); expect(javascript).toContain('action === "credential" ? "DELETE" : "POST"'); + expect(javascript).toContain("statusErrorCode"); expect(javascript).toContain("restoreUnlock"); expect(javascript).not.toMatch(/innerHTML|localStorage|sessionStorage|\beval\s*\(/u); From d2c93b9e980e2e637b5dd0dc287e998268143c55 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Thu, 23 Jul 2026 02:06:16 +0400 Subject: [PATCH 3/3] fix: address console review findings --- docs/oauth-console-threat-model.md | 2 +- docs/security.md | 2 +- src/cli/main.ts | 42 ++++++++----------- src/console/console-server.ts | 7 ++++ tests/console-server.test.ts | 23 ++++++++++ ...console-threat-model-docs-contract.test.ts | 7 ++++ 6 files changed, 56 insertions(+), 27 deletions(-) diff --git a/docs/oauth-console-threat-model.md b/docs/oauth-console-threat-model.md index 3b63c058..73d7a61a 100644 --- a/docs/oauth-console-threat-model.md +++ b/docs/oauth-console-threat-model.md @@ -67,7 +67,7 @@ An authorization code can arrive only at the bounded callback and must be exchan ## Local control-plane separation -The Streamable HTTP host accepts protocol clients at its fixed MCP endpoint and deliberately permits a missing Origin for non-browser MCP clients. The Console control API does not reuse that listener. It binds literal `127.0.0.1`, requires the exact Host for every request, permits an absent Origin only for static navigation and authenticated `GET`/`HEAD` reads, requires the exact loopback Origin plus CSRF for every state-changing request after bootstrap, expires sessions after bounded idle and absolute lifetimes, and applies body, header, and session limits before application work. +The Streamable HTTP host accepts protocol clients at its fixed MCP endpoint and deliberately permits a missing Origin for non-browser MCP clients. The Console control API does not reuse that listener. It binds literal `127.0.0.1`, requires the exact Host for every request, permits an absent Origin only for static navigation and authenticated `GET`/`HEAD` reads, and requires the exact listener Origin, including scheme, host, and port, plus CSRF for every state-changing request after bootstrap. It expires sessions after bounded idle and absolute lifetimes and applies body, header, and session limits before application work. The approved control credential is not an OAuth token. `miftah console` generates it in memory and prints it once to the launching terminal. The operator enters it into the separately delivered same-origin UI, which sends it only as `Authorization: Bootstrap ` to `POST /api/v1/sessions`. It expires after five minutes, is consumed once, and never appears in a query string, browser storage, cookie, response, log, audit event, or diagnostic. A successful exchange creates a different opaque HttpOnly same-site session handle and returns a different CSRF proof for page memory; neither is an MCP bearer. Restart or explicit rotation invalidates every session and creates a new one-use bootstrap. diff --git a/docs/security.md b/docs/security.md index 8b4e8ae8..8457f1bf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -38,7 +38,7 @@ The server rejects duplicate or malformed `Host`, `Origin`, authorization, and M Before it allocates a session, the host validates the exact `/mcp` endpoint, method, Host, Origin, authentication, JSON content type, body size, and one initialize request. Session capacity includes in-progress initializations and closing runtimes. Each session receives a distinct Miftah runtime, profile/session state, approval/lock/lease/routing state, and upstream manager. Idle expiry, DELETE, and graceful shutdown detach the session immediately and close its runtime, which closes retained upstream transports and their ordinary descendants; detached cleanup retains capacity until it succeeds, and a failure remains capacity-consuming and is reported. An HTTP reconnect reuses only its existing session ID; it cannot create or access another client's runtime. -The Console control plane is not this MCP HTTP server. `miftah dashboard` or the API-only `miftah console` command creates a separate listener with no `/mcp` route, MCP transport, upstream session registry, or MCP bearer. It requires the exact listener Host for all traffic. State-changing browser requests require the exact loopback Origin and in-memory CSRF proof; authenticated `GET`/`HEAD` reads may omit Origin because normal same-origin browsers do not consistently send it. Its CSPRNG bootstrap code appears only in the launching terminal, expires after five minutes, and is consumed once; the resulting opaque HttpOnly same-site session and in-memory CSRF proof are bounded by idle and absolute lifetimes. Every durable mutation prepares a separate fail-closed owner-restricted Console audit journal first. Browser responses contain allowlisted metadata and redacted status only. See the [Console API contract](console-api.md). +The Console control plane is not this MCP HTTP server. `miftah dashboard` or the API-only `miftah console` command creates a separate listener with no `/mcp` route, MCP transport, upstream session registry, or MCP bearer. It requires the exact listener Host for all traffic. State-changing browser requests require the exact listener Origin, including scheme, host, and port, plus in-memory CSRF proof; authenticated `GET`/`HEAD` reads may omit Origin because normal same-origin browsers do not consistently send it. Its CSPRNG bootstrap code appears only in the launching terminal, expires after five minutes, and is consumed once; the resulting opaque HttpOnly same-site session and in-memory CSRF proof are bounded by idle and absolute lifetimes. Every durable mutation prepares a separate fail-closed owner-restricted Console audit journal first. Browser responses contain allowlisted metadata and redacted status only. See the [Console API contract](console-api.md). Audit writes default to fail-closed: Miftah verifies the configured sink before dispatch and refuses a request when the sink cannot be prepared. A terminal write can fail after an upstream side effect has completed, so a post-dispatch `AUDIT_WRITE_FAILED` has an indeterminate outcome and must not prompt a blind retry of a non-idempotent operation. An operator can set `audit.failureMode` to `"fail-open"` for availability-sensitive deployments; Miftah then preserves the request outcome but exposes a redacted `AUDIT_WRITE_FAILED` health entry. This mode trades complete auditability for availability. diff --git a/src/cli/main.ts b/src/cli/main.ts index d985d92e..770d7e62 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -79,18 +79,7 @@ function consolePort(value: string | undefined): number | undefined { return Number(value); } -async function consoleServe(configPath: string, port: string | undefined): Promise { - const server = await startConsoleServer(configPath, { - port: consolePort(port), - launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] } - }); - process.stdout.write( - [ - `Miftah Console control API listening on ${server.url.toString()}`, - `One-time bootstrap code: ${server.bootstrapCredential}`, - "Enter this code only in the local Miftah Console. It expires after first use or shutdown." - ].join("\n") + "\n" - ); +function registerConsoleServerLifecycle(server: Awaited>): void { const shutdown = (): void => { void server.close().catch(() => { process.stderr.write("Miftah Console shutdown failed.\n"); @@ -106,6 +95,21 @@ async function consoleServe(configPath: string, port: string | undefined): Promi } } +async function consoleServe(configPath: string, port: string | undefined): Promise { + const server = await startConsoleServer(configPath, { + port: consolePort(port), + launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] } + }); + process.stdout.write( + [ + `Miftah Console control API listening on ${server.url.toString()}`, + `One-time bootstrap code: ${server.bootstrapCredential}`, + "Enter this code only in the local Miftah Console. It expires after first use or shutdown." + ].join("\n") + "\n" + ); + registerConsoleServerLifecycle(server); +} + function defaultDashboardConfigPath(): string { return join(homedir(), ".config", "miftah", "miftah.json"); } @@ -131,19 +135,7 @@ async function dashboardServe( if (openBrowser && !(await openSystemBrowser(server.url))) { process.stderr.write(`Miftah could not open the system browser. Open ${server.url.toString()} manually.\n`); } - const shutdown = (): void => { - void server.close().catch(() => { - process.stderr.write("Miftah Console shutdown failed.\n"); - process.exitCode = 1; - }); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - if (process.platform !== "win32") { - process.on("SIGHUP", () => { - process.stdout.write(`Replacement one-time bootstrap code: ${server.rotateCredential()}\n`); - }); - } + registerConsoleServerLifecycle(server); } async function main(argv = process.argv.slice(2)): Promise { diff --git a/src/console/console-server.ts b/src/console/console-server.ts index eb239fc9..e937fd09 100644 --- a/src/console/console-server.ts +++ b/src/console/console-server.ts @@ -175,6 +175,13 @@ function publicApplicationError(error: unknown): ConsoleHttpError { if (error.code === "CONFIG_CREATE_FAILED") { return new ConsoleHttpError(503, "config_create_failed", "The initial configuration could not be created."); } + if (error.code === "CONSOLE_LAUNCHER_UNAVAILABLE") { + return new ConsoleHttpError( + 503, + "console_launcher_unavailable", + "Client snippets are unavailable because the Console launcher is not configured." + ); + } if ( error.code.startsWith("CONFIG_") || error.code.startsWith("OAUTH_CONNECTION_") || diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index f63e32f1..5687bb8b 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -246,6 +246,29 @@ describe("local Console control server", () => { } }); + it("reports an unavailable local client launcher as a stable service-availability error", async () => { + const server = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "test-only-bootstrap-credential" + }); + + try { + const session = await bootstrapSession(server); + const response = await fetch(new URL("/api/v1/client-snippets?client=claude-desktop", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: { + code: "console_launcher_unavailable", + message: "Client snippets are unavailable because the Console launcher is not configured." + } + }); + } finally { + await server.close(); + } + }); + it("requires an invocation-bound bootstrap before returning redacted control metadata", async () => { const server = await startConsoleServer(await writeConfig(), { bootstrapCredential: "test-only-bootstrap-credential" diff --git a/tests/oauth-console-threat-model-docs-contract.test.ts b/tests/oauth-console-threat-model-docs-contract.test.ts index 97638ece..1e1380b5 100644 --- a/tests/oauth-console-threat-model-docs-contract.test.ts +++ b/tests/oauth-console-threat-model-docs-contract.test.ts @@ -44,6 +44,13 @@ describe("OAuth and Console threat-model documentation contract", () => { expect(consoleApi).toContain("must send `Content-Type: application/json` with the JSON body `{}`"); expect(consoleApi).toContain("It cannot inspect or take over another Miftah process"); expect(consoleApi).toContain("authenticated `GET` and `HEAD` requests may omit `Origin`"); + expect(consoleApi).toContain("Every request must use the exact listener `Host`"); + expect(consoleApi).toContain("Browser mutations, including bootstrap, must also use the exact listener `Origin`"); + expect(consoleApi).toContain("every mutation still requires exact Origin plus CSRF"); + expect(delta).toContain("requires the exact listener Origin, including scheme, host, and port"); + expect(security).toContain( + "State-changing browser requests require the exact listener Origin, including scheme, host, and port" + ); }); it("defines protocol go/no-go, abuse cases, residual risks, and implementation evidence", async () => {