diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e5066e..0df5dadf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. The format - [#81](https://github.com/mohanagy/miftah/issues/81) Added configuration format v3 and a strict, non-secret OAuth connection core: opaque profile/upstream/resource/issuer bindings, canonical HTTPS Streamable HTTP validation, static-Authorization collision refusal, OS-vault credential isolation, crash-released binding-scoped transaction coordination, redacted lifecycle primitives, and v1/v2-to-v3 migration with no credential synthesis. - [#82](https://github.com/mohanagy/miftah/issues/82) Enabled standards-compatible OAuth for exact v3 HTTPS Streamable HTTP connections: protected-resource plus authorization-server/OpenID discovery, explicit pre-registered, Client ID Metadata, or Dynamic Client Registration, system-browser PKCE authorization through a single-use literal-loopback callback, RFC 9207 issuer validation, profile-bound OS-vault credentials, refresh and reconnect, typed redacted failures, and unchanged non-OAuth transport behavior. Provider-specific/local OAuth, revocation, and operator lifecycle commands remain outside this release surface. - [#83](https://github.com/mohanagy/miftah/issues/83) Added durable bounded profile/account identity bindings, visible per-profile binding states and evidence, exact OAuth connection identity-state updates, and opt-in explicit or confirmed current-session selection for protected multi-profile operations. Persisted evidence never replaces live verification, and external state changes never silently override an active client's in-memory profile. +- [#84](https://github.com/mohanagy/miftah/issues/84) Added dry-run-first OAuth connection setup, redacted connection list/status/test reports, safe connect and reauthentication, exact local disconnect, client snippets, and headless diagnostics through shared typed application services. +- [#85](https://github.com/mohanagy/miftah/issues/85) Added the explicitly launched, literal-loopback Console control API with a separate `/api/v1` listener, one-use terminal bootstrap, bounded HttpOnly browser sessions, strict Host/Origin and CSRF enforcement, metadata-only configuration/profile/connection health, atomic audited connection changes, redacted Console audit queries, credential rotation, and clean shutdown. The browser UI remains a separate roadmap layer. ## [0.3.3] - 2026-07-22 diff --git a/README.md b/README.md index 76c9f97c..74d1d502 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ 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). + ## Is Miftah right for you? Miftah is a good fit when account context matters: you work across clients, organizations, environments, or credentials; you want a deliberate boundary before write/destructive actions; or you want one local place to inspect safe, redacted connection activity. @@ -115,6 +117,7 @@ If you use one account with one direct MCP server and do not need profile, routi - [Configuration reference](docs/config.md) - [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) +- [Local Console 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 fe47c27b..883499b3 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 and in-process operator lifecycle services for exact HTTPS Streamable HTTP connections; provider adapters, provider-side revocation, and the local Console 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 control API for exact HTTPS Streamable HTTP connections; provider adapters, provider-side revocation, and the browser UI remain separate milestones. Miftah is an MCP-aware proxy, not a byte-level reverse proxy: @@ -16,6 +16,8 @@ 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 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. The optional HTTP host owns the listener and no shared Miftah session state. It validates the exact `/mcp` target, Host, Origin, bearer authentication, a bounded JSON initialize request, and capacity before creating a `StreamableHTTPServerTransport`. That transport and a new `MiftahServer`, `ProfileManager`, and upstream manager belong to one HTTP session only. The host forces the session runtime to in-memory `session` profile state, even if normal STDIO configuration uses workspace/global persistence. Its session registry owns only opaque MCP session IDs, idle timers, and cleanup tasks; DELETE, expiry, connection shutdown, and wrapper shutdown detach the record before asynchronous runtime cleanup so no client can reuse a closing runtime, while retaining that capacity reservation until cleanup succeeds. A rejected cleanup is recorded, retains capacity, and makes graceful shutdown fail rather than silently admitting unlimited replacement runtimes. A reconnect is routed to the existing transport by its session ID, while the SDK permits only one active standalone SSE stream for that session. diff --git a/docs/cli.md b/docs/cli.md index 8a100914..3abe328f 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 and an opt-in local Streamable HTTP server. 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 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`, `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`, `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). @@ -19,6 +19,7 @@ Documented command names, options, JSON success forms, and exit categories are c | Command | Required input | Options | Output and behavior | | --- | --- | --- | --- | | `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 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. | @@ -46,6 +47,12 @@ 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 + +`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. + ### `init` presets and paths `--preset` defaults to `generic`. The strict catalog accepts `generic`, `github`, `sentry`, `generic-npx`, `generic-docker`, and `streamable-http`; an unrecognized preset is a usage error. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: @@ -92,7 +99,7 @@ When identity verification is unconfigured, doctor records `DOCTOR_IDENTITY` as `miftah_verify_identity` is an MCP management tool, not a shell subcommand. It accepts optional `profile` and `upstream` strings. `profile` defaults to the active profile. Supplying a named `upstream` verifies only that target; `upstream: "default"` is an alias only for a single unnamed upstream. With `upstream` omitted, Miftah verifies every configured target in deterministic upstream order. The response always contains safe structured identity results, including nonverified states, and its audit event contains only safe evidence and a failure outcome when verification did not succeed. -`miftah_list_profiles` and `miftah_profile_info` show each profile's configured and persisted binding evidence; `miftah_current_profile`, `miftah_health`, and `miftah_route_preview` expose the same configured, persisted, or cached identity status. None starts an upstream or runs a probe. A newly started client reloads persisted evidence and durable profile selection, but another process or future Console action cannot silently replace an already active client's in-memory selection; restart that client when applying an external configuration or durable-selection change. +`miftah_list_profiles` and `miftah_profile_info` show each profile's configured and persisted binding evidence; `miftah_current_profile`, `miftah_health`, and `miftah_route_preview` expose the same configured, persisted, or cached identity status. None starts an upstream or runs a probe. A newly started client reloads persisted evidence and durable profile selection, but another process or Console action cannot silently replace an already active client's in-memory selection; restart that client when applying an external configuration or durable-selection change. ### MCP profile management diff --git a/docs/console-api.md b/docs/console-api.md new file mode 100644 index 00000000..c12620ec --- /dev/null +++ b/docs/console-api.md @@ -0,0 +1,50 @@ +# Local Console 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: + +```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. + +## First browser session + +Startup prints the loopback URL and one CSPRNG-backed bootstrap code to the launching terminal. The code: + +- is accepted once at `POST /api/v1/sessions` as `Authorization: Bootstrap `; +- expires after five minutes and can be replaced by restarting the command or rotating the running host; +- 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. + +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`. + +`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. + +| Method and path | Purpose | +| --- | --- | +| `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. | +| `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. | +| `GET /api/v1/connections` | Return configured non-secret OAuth connection bindings without opening the vault. | +| `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/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. | + +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. + +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 de5acb2e..fe03b86e 100644 --- a/docs/oauth-console-threat-model.md +++ b/docs/oauth-console-threat-model.md @@ -2,16 +2,16 @@ > **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. No local Console, provider-specific adapter, provider-revocation client, or hosted broker 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; 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). 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. -- An optional local Console is a future operator experience over the same connection and configuration services. It is not a hosted service, cloud token sync system, embedded login browser, or background daemon. -- The future Console control API is distinct from the MCP /mcp endpoint. It cannot share a listener, session model, bearer, or authorization decision with a client-facing MCP transport without a separately reviewed design. -- A Console cannot mutate an already-running Claude Desktop or other STDIO client session. Changes apply to new or explicitly restarted sessions until a separately designed broker or IPC capability exists. +- 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 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. ## Canonical resource comparison @@ -62,16 +62,16 @@ An authorization code can arrive only at the bounded callback and must be exchan | **Authorization-code or token replay** — reuse an intercepted code, callback, access token, or refresh token. | Use PKCE S256; keep a one-time, bounded transaction with single-use state and callback; exchange a code once; keep credentials in secure storage; rotate/clear a failed or disconnected connection. | PKCE does not protect a compromised host or provider. Tests must prove a mismatched PKCE verifier, duplicate callback, reused code, expired state, and stale token are refused without output leakage. | | **Cross-profile or cross-resource credential leakage** — use one account or token for another profile, upstream, issuer, or MCP resource. | Use a versioned, unambiguous key for the exact profile/upstream/issuer/`canonicalResource` tuple; reject ambiguous provider display names; keep refresh/revoke/health operations scoped to that key; require the later identity verifier to make account claims explicit. | A valid token is not account authorization. Tests must cover cross-profile refresh, resource substitution, profile switching during a flow, secure-store key collision attempts, and an identity mismatch after connection. | | **Browser CSRF or hostile local origin** — drive a local browser callback or Console mutation from an untrusted page. | Use high-entropy state, exact callback validation, a system browser, strict Host and Origin checks, explicit CSRF protection for every Console mutation, and bounded browser session lifetime. The Console must not use a query-string bearer or browser storage. | A same OS user is not a strong isolation boundary. Tests must reject missing/duplicate/mismatched state, hostile Origin/Host, cross-origin mutation, and stale browser sessions. | -| **Local network exposure and same-user control** — expose a loopback control service to LAN callers or let a local process reuse its control credential. | No Console listener may start until a separately approved bootstrap protocol can establish an invocation-bound, CSPRNG-backed, one-time browser session without query strings or browser storage. That future protocol must bind literal loopback, a short lifetime, exact Host/Origin validation, CSRF protection, bounded bodies/sessions/callback listeners/concurrency, and a control credential separate from the MCP bearer. | Loopback plus a future random credential does not defend against a hostile process running as the same OS user or a host administrator. Tests must demonstrate refusal of non-loopback binds, absent/forged Host/Origin, bootstrap replay, exhausted capacity, and use after expiry. | +| **Local network exposure and same-user control** — expose a loopback control service to LAN callers or let a local process reuse its control credential. | Start only through `miftah console`; bind literal `127.0.0.1`; generate an invocation-bound CSPRNG bootstrap code with a five-minute lifetime; accept it once from the exact Origin as an authorization header; never put it in a URL, cookie, browser storage, response, audit, or diagnostic; then use a short HttpOnly same-site session and in-memory CSRF proof. Bound bodies, sessions, headers, and shutdown, and keep the control credential separate from the MCP bearer. | Loopback plus a random credential does not defend against a hostile process running as the same OS user or a host administrator. Tests demonstrate absent/forged Host/Origin refusal, bootstrap expiry and replay refusal, request limits, session expiry, credential rotation, and shutdown. | | **Broker or IPC confusion** — claim that a Console can reconfigure or authorize an already-running client process. | Keep connection/configuration services explicit and transactional; require a separately designed, authenticated broker or IPC protocol before controlling another process; expose only safe restart-required guidance until then. | An operator still has to restart or reconnect a client for a changed connection to take effect. Tests must prove a Console change cannot alter an existing independent STDIO session. | ## Local control-plane separation -The current Streamable HTTP host accepts protocol clients at its fixed MCP endpoint and deliberately permits a missing Origin for non-browser MCP clients. A future Console is a browser-facing operator interface and therefore cannot reuse that permissive rule. Its design must require literal-loopback binding, exact Host and Origin validation for browser traffic, CSRF protection for every state-changing request, a short idle and absolute browser-session lifetime, and request and session limits before work is allocated. +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. -A future control credential is not an OAuth token and must still never be placed in a query string, browser storage, log, audit event, diagnostic, or rendered response. Console implementation is NO-GO until its initial browser bootstrap has a separately approved security design. That design must specify how an invocation-bound, CSPRNG-backed, one-time browser session is established and expired without a cookie, URL token, localStorage value, or an unreviewed same-user IPC shortcut; it must then satisfy the focused tests below. This delta deliberately does not approve a bootstrap mechanism. +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. -The Console may create or inspect a future connection record only through a typed local control service. It must not parse upstream-owned token caches, browser cookies, or provider-specific files, and it must not assume that the MCP serving endpoint can act as a control API. The UI can display only redacted connection state, selected profile/upstream, safe scope summaries, and restart-required guidance. +The Console creates or inspects a connection record only through the typed local control service shared with the CLI. Atomic configuration writes and exact OAuth lifecycle actions prepare a separate fail-closed owner-restricted Console audit journal before mutation. The service does not parse upstream-owned token caches, browser cookies, or provider-specific files, and the MCP serving endpoint cannot act as a control API. Browser responses allowlist only configuration/profile metadata, redacted connection state, safe scope summaries, safe audit event fields, and restart-required guidance. ## Focused security test plan and implementation evidence @@ -86,7 +86,7 @@ The Console may create or inspect a future connection record only through a type | cross-profile and resource isolation | Concurrent profiles, profile switching, same-provider different resources, and account identity mismatch must never select or reveal another connection. | | CSRF, Host and Origin | Browser mutation tests reject missing/forged state, hostile Origin, malformed/duplicate Host, absent CSRF proof, and requests that attempt to reuse the MCP bearer. | | request and session limits | Callback and Console tests prove bounded bodies, transaction count, session count, idle/absolute expiry, shutdown cleanup, and no listener remains after completion. | -| Console bootstrap | No Console listener may start before an approved bootstrap design exists. Once approved, fixtures must prove one-time invocation binding, CSPRNG generation, expiry, bootstrap replay refusal, no URL/cookie/localStorage/control-token delivery, and no use of the MCP bearer. | +| Console bootstrap | Cross-platform fixtures prove explicit-only startup, literal-loopback binding, one-time CSPRNG invocation binding, five-minute expiry, replay refusal, credential rotation, no URL/cookie/localStorage/response delivery, and no reuse of the MCP bearer. | | redaction | Assertions cover configuration, audit, diagnostics, logs, callback pages, browser-visible responses, support export, and exception paths for tokens, refresh tokens, codes, and control credentials. | | static bearer collision | Configuration and runtime negative tests prove native OAuth is rejected while effective profile-and-upstream headers contain `Authorization` under case-insensitive merging, including a profile-level lowercase `authorization` or duplicate case variant, and succeeds only after an explicit migration/removal with no ambiguous bearer precedence. | | unsupported provider fallback | A Google Search Console-style local/upstream-owned flow and malformed remote metadata prove that Miftah leaves provider private state untouched and returns the documented no-go/manual fallback. | @@ -96,7 +96,7 @@ 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, and issue #84 authorizes the local connection and credential-lifecycle application services within this design. Neither authorizes the Console, 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, 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: 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); 2. an independent-review process and public status under [#37](https://github.com/mohanagy/miftah/issues/37), with this delta reviewed and linked; diff --git a/docs/oauth-support.md b/docs/oauth-support.md index fc9604ce..126b1d58 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 separate no-go gates for the future local Console. +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. ## Support matrix diff --git a/docs/security.md b/docs/security.md index a7624eed..657ff5dd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Security model -For the public mapping of assets, trust boundaries, threats, controls, and residual risks, see the [threat model](threat-model.md). The [OAuth and Console security design delta](oauth-console-threat-model.md) records the implemented remote-OAuth gates and the future Console gates; this page is the detailed operating-security reference for implemented controls. +For the public mapping of assets, trust boundaries, threats, controls, and residual risks, see the [threat model](threat-model.md). The [OAuth and Console security design delta](oauth-console-threat-model.md) records the implemented remote-OAuth and local Console control-plane gates; this page is the detailed operating-security reference for implemented controls. Miftah is a credential broker, so safe defaults are part of the product contract: @@ -16,6 +16,7 @@ Miftah is a credential broker, so safe defaults are part of the product contract - audit export is explicit and local-only; it applies redaction again and omits stored arguments unless an operator opts in; - durable active-profile state is opt-in, uses derived owner-restricted paths, and stores no credentials; - the optional Streamable HTTP server binds literal loopback by default and bounds sessions and request bodies before runtime allocation; +- the separately launched Console control API binds only literal loopback, requires one-use bootstrap, exact Host/Origin, short browser sessions, and CSRF for mutations; - MCP tool annotations are ignored for risk downgrades unless the operator explicitly trusts the configured upstream that supplied them; - provider tokens should be separate, least-privilege tokens per account and risk level. @@ -37,6 +38,8 @@ 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). + 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. When configured, audit rotation occurs only at completed JSONL batch boundaries and retention acts only on Miftah-managed, single-link regular archive names with stable file identities within the configured directory. Its kernel-released coordination is local to one host, so a managed journal must not be concurrently shared across machines through a network filesystem. The optional `sha256-chain` integrity mode hashes already-redacted records and tracks the retained segment set so `audit-verify` can identify the first safe broken record. It is tamper evidence, not a cryptographic signature, nonrepudiation mechanism, or remotely anchored immutable log: a party able to replace every local journal and its integrity metadata can defeat the evidence. Preserve compliance or incident evidence in an independently protected destination. `audit-export` is a user-invoked local transformation, not telemetry; it repeats redaction and drops stored `arguments` unless `--include-arguments` is explicitly requested. diff --git a/docs/threat-model.md b/docs/threat-model.md index 41394e4a..5bf94b33 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -22,7 +22,7 @@ The method is intentionally practical: describe the system and its trust boundar This is a model of Miftah's wrapper and its documented configuration surface, not a claim that every configured upstream, plugin, operating system, dependency, or deployment is safe. Changes to a boundary, secret flow, configuration default, dependency trust decision, or externally exposed transport require this document and its targeted tests to be reviewed together. -The forward-looking [OAuth and local Console design delta](oauth-console-threat-model.md) records the additional protocol, callback, credential, browser, and local-control-plane decisions required before those capabilities can be implemented. It does not change the implemented-control claims in this current model or mark the independent review complete. +The [OAuth and local Console design delta](oauth-console-threat-model.md) records the implemented protocol, callback, credential, browser-session, and local-control-plane decisions plus the remaining browser-UI and provider-adapter boundaries. It does not mark the independent review complete. ## Protected assets diff --git a/src/cli/main.ts b/src/cli/main.ts index aaa465ba..4fd06804 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -20,6 +20,7 @@ import { runConnectionAddCommand } from "../oauth/connection-application-service import { OAuthConnectionCommandService } from "../oauth/connection-command-service.js"; import { CLIENT_NAMES, renderClientSnippets, type ClientSelection } from "./client-snippets.js"; import { resolvePath } from "../config/path-resolve.js"; +import { startConsoleServer } from "../console/console-server.js"; function oauthSelector(args: { readonly connection?: string; readonly profile?: string; readonly upstream?: string }) { return { @@ -67,6 +68,38 @@ async function serve(configPath: string, transportKind = "stdio"): Promise await runtime.connect(transport); } +function consolePort(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + if (!/^\d{1,5}$/u.test(value) || Number(value) < 1 || Number(value) > 65_535) { + throw new CliUsageError("Option '--port' must be an integer from 1 to 65535."); + } + return Number(value); +} + +async function consoleServe(configPath: string, port: string | undefined): Promise { + const server = await startConsoleServer(configPath, { port: consolePort(port) }); + 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" + ); + 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") { @@ -159,6 +192,10 @@ async function main(argv = process.argv.slice(2)): Promise { await serve(args.config, args.transport); return; } + if (command === "console") { + await consoleServe(args.config, args.port); + return; + } if (command === "validate") { const config = await loadConfig(args.config); process.stdout.write(`${JSON.stringify({ ok: true, name: config.name, profiles: Object.keys(config.profiles) }, null, 2)}\n`); diff --git a/src/cli/parse.ts b/src/cli/parse.ts index a566f510..4d1df8a9 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -16,7 +16,8 @@ type ValueOptionName = | "upstream" | "issuer" | "clientRegistration" - | "scopes"; + | "scopes" + | "port"; type BooleanOptionName = "follow" | "json" | "interactive" | "includeArguments" | "write" | "nonInteractive"; type CliOptionName = ValueOptionName | BooleanOptionName; @@ -39,6 +40,7 @@ export interface CliOptions { readonly issuer?: string; readonly clientRegistration?: string; readonly scopes?: readonly string[]; + readonly port?: string; readonly follow?: true; readonly json?: true; readonly interactive?: true; @@ -58,6 +60,10 @@ export const CLI_COMMANDS = { description: "Start the MCP wrapper server.", options: ["config", "transport"] }, + console: { + description: "Start the optional local Console control API.", + options: ["config", "port"] + }, validate: { description: "Validate a Miftah configuration.", options: ["config"] @@ -277,6 +283,12 @@ const OPTION_DEFINITIONS: Record = { usage: "--scope ", description: "Least-privilege OAuth scope; repeat for multiple scopes." }, + port: { + name: "port", + takesValue: true, + usage: "--port ", + description: "Literal-loopback Console port; defaults to an ephemeral port." + }, follow: { name: "follow", takesValue: false, @@ -334,6 +346,7 @@ const FLAG_DEFINITIONS: Record "--issuer": OPTION_DEFINITIONS.issuer, "--client-registration": OPTION_DEFINITIONS.clientRegistration, "--scope": OPTION_DEFINITIONS.scopes, + "--port": OPTION_DEFINITIONS.port, "--follow": OPTION_DEFINITIONS.follow, "--include-arguments": OPTION_DEFINITIONS.includeArguments, "--json": OPTION_DEFINITIONS.json, diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts new file mode 100644 index 00000000..29de30ec --- /dev/null +++ b/src/console/console-application-service.ts @@ -0,0 +1,292 @@ +import { dirname, join } from "node:path"; +import { readAuditJsonl } from "../cli/audit-jsonl.js"; +import { resolvePath } from "../config/path-resolve.js"; +import { AuditLogger } from "../audit/audit-logger.js"; +import { AuditTrail } from "../audit/audit-trail.js"; +import type { + ConnectionAddCommandReport, + ConnectionApplicationAuditEvent, + ConnectionApplicationAuditSink, + OAuthConnectionAddRequest +} from "../oauth/connection-application-service.js"; +import { runConnectionAddCommand } from "../oauth/connection-application-service.js"; +import { + NativeOAuthCommandRuntimeFactory, + OAuthConnectionCommandService +} from "../oauth/connection-command-service.js"; +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"; + +export interface ConsoleConnectionAddRequest extends OAuthConnectionAddRequest { + readonly connectionRef?: string; +} + +export type ConsoleConnectionAddReport = Omit; + +export interface ConsoleAuditRecord { + readonly timestamp?: string; + readonly kind?: string; + readonly operation?: string; + readonly name?: string; + readonly profile?: string; + readonly upstream?: string; + readonly status?: string; + readonly errorCode?: string; +} + +export interface ConsoleConfigMetadata { + readonly name: string; + readonly version: string; + readonly defaultProfile: string; + readonly profiles: readonly { + readonly name: string; + readonly description?: string; + readonly tags?: readonly string[]; + readonly policy?: string; + readonly upstreams?: readonly string[]; + }[]; + readonly upstreams: readonly { readonly name: string; readonly transport: string }[]; + readonly oauthConnectionCount: number; + readonly restartRequiredForExistingClients: true; +} + +export interface ConsoleHealth { + readonly status: "ok"; + readonly config: { readonly name: string; readonly version: string }; + readonly audit: { + readonly enabled: boolean; + readonly state?: "healthy" | "failed"; + readonly lastFailure?: { readonly timestamp: string; readonly errorCode: "AUDIT_WRITE_FAILED" }; + }; + readonly restartRequiredForExistingClients: true; +} + +export interface ConsoleControlApplication { + health(): Promise; + configMetadata(): Promise; + listConnections(): Promise; + connectionStatus(connectionRef: string): Promise; + addConnection(request: ConsoleConnectionAddRequest): Promise; + connect(connectionRef: string): Promise; + reauth(connectionRef: string): Promise; + disconnect(connectionRef: string): Promise; + auditRecords(limit: number): Promise; +} + +interface ConsoleOAuthCommandService { + list(): Promise; + status(selector: { readonly connectionRef: string }): Promise; + connect(selector: { readonly connectionRef: string }): Promise; + reauth(selector: { readonly connectionRef: string }): Promise; + disconnect(selector: { readonly connectionRef: string }): Promise; +} + +export interface ConsoleApplicationDependencies { + readonly commandService?: ConsoleOAuthCommandService; +} + +function consoleAuditPath(configPath: string): string { + return join(dirname(resolvePath(configPath)), ".miftah", "audit", "console.jsonl"); +} + +function safeAuditRecord(value: unknown): ConsoleAuditRecord | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const input = value as Record; + const output: Record = {}; + for (const key of ["timestamp", "kind", "operation", "name", "profile", "upstream", "status", "errorCode"] as const) { + if (typeof input[key] === "string") output[key] = input[key]; + } + return output; +} + +class ConsoleConnectionAuditSink implements ConnectionApplicationAuditSink { + constructor(private readonly trail: AuditTrail) {} + + ensureWritable(): Promise { + return this.trail.ensureWritable(); + } + + record(event: ConnectionApplicationAuditEvent): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "console/oauth-connection-add", + name: "connection", + profile: event.profile, + upstream: event.upstream, + status: event.status + }); + } +} + +/** Shared, in-process Console application layer. It never invokes the Miftah CLI. */ +export class ConsoleApplicationService implements ConsoleControlApplication { + private readonly auditPath: string; + private readonly audit: AuditTrail; + + private readonly commandService: ConsoleOAuthCommandService; + + constructor( + private readonly configPath: string, + dependencies: ConsoleApplicationDependencies = {} + ) { + this.auditPath = consoleAuditPath(configPath); + this.audit = new AuditTrail("miftah-console", new AuditLogger(this.auditPath, { failureMode: "fail-closed" })); + this.commandService = dependencies.commandService ?? new OAuthConnectionCommandService(configPath); + } + + async health(): Promise { + const config = await loadConfig(this.configPath); + const audit = this.audit.health(); + return { + status: "ok", + config: { name: config.name, version: config.version }, + audit: { + enabled: audit.enabled, + ...(audit.state === undefined ? {} : { state: audit.state }), + ...(audit.lastFailure === undefined + ? {} + : { lastFailure: { timestamp: audit.lastFailure.timestamp, errorCode: audit.lastFailure.errorCode } }) + }, + restartRequiredForExistingClients: true + }; + } + + async configMetadata(): Promise { + const config = await loadConfig(this.configPath); + const upstreams = config.upstreams === undefined + ? config.upstream === undefined + ? [] + : [{ name: "default", transport: config.upstream.transport }] + : Object.entries(config.upstreams) + .map(([name, upstream]) => ({ name, transport: upstream.transport })) + .sort((left, right) => left.name.localeCompare(right.name)); + return { + name: config.name, + version: config.version, + defaultProfile: config.defaultProfile, + profiles: Object.entries(config.profiles) + .map(([name, profile]) => ({ + name, + ...(profile.description === undefined ? {} : { description: profile.description }), + ...(profile.tags === undefined ? {} : { tags: [...profile.tags] }), + ...(profile.policy === undefined ? {} : { policy: profile.policy }), + ...(profile.upstreams === undefined ? {} : { upstreams: Object.keys(profile.upstreams).sort() }) + })) + .sort((left, right) => left.name.localeCompare(right.name)), + upstreams, + oauthConnectionCount: config.version === "3" ? Object.keys(config.oauth?.connections ?? {}).length : 0, + restartRequiredForExistingClients: true + }; + } + + listConnections(): Promise { + return new NativeOAuthCommandRuntimeFactory().connections(this.configPath); + } + + connectionStatus(connectionRef: string): Promise { + return this.commandService.status({ connectionRef }); + } + + async addConnection(request: ConsoleConnectionAddRequest): Promise { + const result = await runConnectionAddCommand({ + configPath: this.configPath, + connectionRef: request.connectionRef, + profile: request.profile, + upstream: request.upstream, + issuer: request.issuer, + clientRegistration: request.clientRegistration, + scopes: request.scopes, + write: true + }, { audit: new ConsoleConnectionAuditSink(this.audit) }); + return { + changed: result.changed, + write: result.write, + connectionRef: result.connectionRef, + profile: result.profile, + upstream: result.upstream, + resource: result.resource, + actions: result.actions + }; + } + + connect(connectionRef: string): Promise { + return this.runConnectionMutation(connectionRef, "connect", (service) => service.connect({ connectionRef })); + } + + reauth(connectionRef: string): Promise { + return this.runConnectionMutation(connectionRef, "reauth", (service) => service.reauth({ connectionRef })); + } + + disconnect(connectionRef: string): Promise { + return this.runConnectionMutation(connectionRef, "disconnect", (service) => service.disconnect({ connectionRef })); + } + + private async runConnectionMutation( + connectionRef: string, + action: "connect" | "reauth" | "disconnect", + operation: (service: ConsoleOAuthCommandService) => Promise + ): Promise { + const reference = parseOAuthConnectionRef(connectionRef); + const config = await loadConfig(this.configPath); + const target = config.version === "3" ? config.oauth?.connections[reference] : undefined; + if (target === undefined) { + throw new MiftahError("OAUTH_CONNECTION_NOT_FOUND", "OAUTH_CONNECTION_NOT_FOUND: OAuth connection does not exist"); + } + await this.audit.ensureWritable(); + try { + const result = await operation(this.commandService); + await this.audit.writeRequiredLifecycle({ + operation: `console/oauth-${action}`, + name: "connection", + profile: target.profile, + upstream: target.upstream, + status: "success" + }); + return result; + } catch (error) { + const errorCode = error instanceof MiftahError ? error.code : "OAUTH_AUTHORIZATION_FAILED"; + await this.audit.writeRequiredLifecycle({ + operation: `console/oauth-${action}`, + name: "connection", + profile: target.profile, + upstream: target.upstream, + status: "failure", + errorCode + }); + throw error; + } + } + + async auditRecords(limit: number): Promise { + let pending = ""; + const records: ConsoleAuditRecord[] = []; + try { + await readAuditJsonl({ + path: this.auditPath, + redactor: new SecretRedactor(), + includeArguments: false, + write: (chunk) => { + pending += chunk; + let newline = pending.indexOf("\n"); + while (newline !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (line.length > 0) { + try { + const record = safeAuditRecord(JSON.parse(line)); + if (record !== undefined) records.push(record); + } catch { + // The hardened reader emits a fixed malformed-record marker; it has no metadata to expose. + } + } + newline = pending.indexOf("\n"); + } + } + }); + } catch (error) { + if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) throw error; + } + return records.slice(-limit); + } +} diff --git a/src/console/console-server.ts b/src/console/console-server.ts new file mode 100644 index 00000000..814c7eae --- /dev/null +++ b/src/console/console-server.ts @@ -0,0 +1,574 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import { z } from "zod"; +import { loadConfig } from "../config/load-config.js"; +import { MiftahError } from "../utils/errors.js"; +import { + ConsoleApplicationService, + type ConsoleControlApplication +} from "./console-application-service.js"; + +export type { ConsoleControlApplication } from "./console-application-service.js"; + +const loopbackHost = "127.0.0.1"; +const defaultMaximumRequestBytes = 64 * 1024; +const defaultMaximumSessions = 8; +const defaultBootstrapTtlMs = 5 * 60_000; +const defaultMaximumRequestsPerMinute = 240; +const defaultMaximumBootstrapAttemptsPerMinute = 8; +const rateWindowMs = 60_000; +const defaultIdleSessionMs = 15 * 60_000; +const defaultAbsoluteSessionMs = 60 * 60_000; +const requestTimeoutMs = 30_000; +const headersTimeoutMs = 10_000; +const connectionsCheckingIntervalMs = 5_000; +const maximumHeaderBytes = 16 * 1024; +const sessionCookieName = "miftah_console_session"; +const bootstrapSchema = z.object({}).strict(); +const connectionAddSchema = z.object({ + connectionRef: z.string().min(1).max(512).optional(), + profile: z.string().min(1).max(256), + upstream: z.string().min(1).max(256).optional(), + 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; + readonly csrfToken: string; + readonly createdAt: number; + lastUsedAt: number; +} + +export interface ConsoleServer { + readonly url: URL; + /** Invocation-bound bootstrap credential. Print it only to the launching terminal. */ + readonly bootstrapCredential: string; + /** Invalidates every browser session and returns a fresh one-use bootstrap credential. */ + rotateCredential(): string; + close(): Promise; +} + +export interface ConsoleServerOptions { + readonly bootstrapCredential?: string; + readonly port?: number; + readonly maximumRequestBytes?: number; + readonly maximumSessions?: number; + readonly bootstrapTtlMs?: number; + readonly maximumRequestsPerMinute?: number; + readonly maximumBootstrapAttemptsPerMinute?: number; + readonly idleSessionMs?: number; + readonly absoluteSessionMs?: number; + readonly now?: () => number; + /** Internal embedding/test seam; production CLI uses the native in-process application service. */ + readonly application?: ConsoleControlApplication; +} + +class ConsoleHttpError extends Error { + constructor( + readonly status: number, + readonly code: string, + readonly message: string, + readonly headers: Readonly> = {} + ) { + super(message); + } +} + +function randomCredential(): string { + return randomBytes(32).toString("base64url"); +} + +function rawHeaderValues(request: IncomingMessage, name: string): string[] { + const values: string[] = []; + const expected = name.toLowerCase(); + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (request.rawHeaders[index]?.toLowerCase() === expected) { + const value = request.rawHeaders[index + 1]; + if (value !== undefined) values.push(value); + } + } + return values; +} + +function singleHeader(request: IncomingMessage, name: string): string | undefined | null { + const values = rawHeaderValues(request, name); + if (values.length === 0) return undefined; + return values.length === 1 ? values[0] : null; +} + +function safeEqual(received: string, expected: string): boolean { + const receivedBytes = Buffer.from(received, "utf8"); + const expectedBytes = Buffer.from(expected, "utf8"); + return receivedBytes.length === expectedBytes.length && timingSafeEqual(receivedBytes, expectedBytes); +} + +function writeJson(response: ServerResponse, status: number, body: unknown, headers: Readonly> = {}): void { + if (response.headersSent || response.writableEnded) { + response.destroy(); + return; + } + response.statusCode = status; + response.setHeader("cache-control", "no-store"); + response.setHeader("content-type", "application/json; charset=utf-8"); + 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'; frame-ancestors 'none'"); + for (const [name, value] of Object.entries(headers)) response.setHeader(name, value); + response.end(JSON.stringify(body)); +} + +function writeError(response: ServerResponse, error: ConsoleHttpError): void { + writeJson(response, error.status, { error: { code: error.code, message: error.message } }, error.headers); +} + +function publicApplicationError(error: unknown): ConsoleHttpError { + if (!(error instanceof MiftahError)) { + return new ConsoleHttpError(500, "internal_error", "The Console request failed."); + } + if (error.code === "AUDIT_WRITE_FAILED") { + return new ConsoleHttpError(503, "audit_unavailable", "The required Console audit journal is unavailable."); + } + if (error.code === "OAUTH_CONNECTION_NOT_FOUND") { + return new ConsoleHttpError(404, "oauth_connection_not_found", "The OAuth connection does not exist."); + } + if ( + error.code.startsWith("CONFIG_") || + error.code.startsWith("OAUTH_CONNECTION_") || + error.code === "DEFAULT_PROFILE_NOT_FOUND" || + error.code === "PROFILE_NOT_FOUND" || + error.code === "UPSTREAM_NOT_FOUND" + ) { + return new ConsoleHttpError(422, error.code.toLowerCase(), "The requested change is not valid."); + } + return new ConsoleHttpError(502, error.code.toLowerCase(), "The requested operation could not be completed."); +} + +async function readJsonBody(request: IncomingMessage, maximumBytes: number): Promise { + const contentType = singleHeader(request, "content-type"); + if ( + contentType === undefined || + contentType === null || + contentType.split(";", 1)[0]?.trim().toLowerCase() !== "application/json" + ) { + throw new ConsoleHttpError(415, "unsupported_media_type", "Content-Type must be application/json."); + } + const length = singleHeader(request, "content-length"); + if (length === null || (length !== undefined && !/^\d+$/u.test(length))) { + throw new ConsoleHttpError(400, "invalid_request", "The request is invalid."); + } + if (length !== undefined && Number(length) > maximumBytes) { + throw new ConsoleHttpError(413, "payload_too_large", "The request body is too large."); + } + + const chunks: Buffer[] = []; + let size = 0; + try { + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += bytes.length; + if (size > maximumBytes) { + request.resume(); + throw new ConsoleHttpError(413, "payload_too_large", "The request body is too large."); + } + chunks.push(bytes); + } + } catch (error) { + if (error instanceof ConsoleHttpError) throw error; + throw new ConsoleHttpError(400, "invalid_request", "The request is invalid."); + } + if (size === 0) throw new ConsoleHttpError(400, "invalid_json", "A JSON body is required."); + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new ConsoleHttpError(400, "invalid_json", "The request body is not valid JSON."); + } +} + +function cookieValue(request: IncomingMessage, name: string): string | undefined { + const cookie = singleHeader(request, "cookie"); + if (cookie === undefined || cookie === null || cookie.length > 4_096) return undefined; + const matches = cookie + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith(`${name}=`)); + if (matches.length !== 1) return undefined; + const value = matches[0]!.slice(name.length + 1); + return /^[A-Za-z0-9_-]{32,}$/u.test(value) ? value : undefined; +} + +function closeListener(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error === undefined ? resolve() : reject(error)); + }); +} + +class LocalConsoleServer implements ConsoleServer { + private readonly sessions = new Map(); + private readonly sockets = new Set(); + private bootstrap: string; + private bootstrapIssuedAt: number; + private bootstrapUsed = false; + private rateWindowStartedAt: number; + private requestCount = 0; + private bootstrapAttemptCount = 0; + private closed = false; + private closePromise: Promise | undefined; + + constructor( + readonly url: URL, + readonly bootstrapCredential: string, + private readonly listener: Server, + private readonly application: ConsoleControlApplication, + private readonly options: Required> + ) { + this.bootstrap = bootstrapCredential; + this.bootstrapIssuedAt = options.now(); + this.rateWindowStartedAt = options.now(); + listener.on("connection", (socket) => { + this.sockets.add(socket); + socket.once("close", () => this.sockets.delete(socket)); + }); + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + try { + await this.route(request, response); + } catch (error) { + writeError( + response, + error instanceof ConsoleHttpError + ? error + : new ConsoleHttpError(500, "internal_error", "The Console request failed.") + ); + } + } + + private async route(request: IncomingMessage, response: ServerResponse): Promise { + if (this.closed) throw new ConsoleHttpError(503, "service_unavailable", "The Console is shutting down."); + this.requireTrustedBrowser(request); + this.admitRequest(request.url === "/api/v1/sessions"); + + if (request.url === "/api/v1/sessions") { + if (request.method !== "POST") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "POST" }); + } + await this.bootstrapSession(request, response); + return; + } + + const session = this.requireSession(request); + if (request.url === "/api/v1/health") { + if (request.method !== "GET") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" }); + } + try { + writeJson(response, 200, { data: await this.application.health() }); + } catch (error) { + throw publicApplicationError(error); + } + session.lastUsedAt = this.options.now(); + return; + } + if (request.url === "/api/v1/config" || request.url === "/api/v1/profiles") { + if (request.method !== "GET") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" }); + } + try { + const metadata = await this.application.configMetadata(); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: request.url.endsWith("/profiles") ? metadata.profiles : metadata }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.url === "/api/v1/connections") { + if (request.method === "GET") { + try { + const connections = await this.application.listConnections(); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: connections }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.method !== "POST") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET, POST" }); + } + this.requireCsrf(request, session); + const parsed = connectionAddSchema.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.addConnection(parsed.data); + session.lastUsedAt = this.options.now(); + writeJson(response, 201, { data: result }, { location: `/api/v1/connections/${encodeURIComponent(result.connectionRef)}` }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + const connectionStatus = /^\/api\/v1\/connections\/([^/]+)$/u.exec(request.url ?? ""); + if (connectionStatus !== null) { + if (request.method !== "GET") { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: "GET" }); + } + const connectionRef = this.decodeConnectionReference(connectionStatus[1]!); + try { + const result = await this.application.connectionStatus(connectionRef); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: result }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + const connectionAction = /^\/api\/v1\/connections\/([^/]+)\/(connect|reauth|credential)$/u.exec(request.url ?? ""); + if (connectionAction !== null) { + const action = connectionAction[2] as "connect" | "reauth" | "credential"; + const requiredMethod = action === "credential" ? "DELETE" : "POST"; + if (request.method !== requiredMethod) { + throw new ConsoleHttpError(405, "method_not_allowed", "Method not allowed.", { allow: requiredMethod }); + } + this.requireCsrf(request, session); + const parsedBody = bootstrapSchema.safeParse(await readJsonBody(request, this.options.maximumRequestBytes)); + if (!parsedBody.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid."); + const connectionRef = this.decodeConnectionReference(connectionAction[1]!); + try { + const result = action === "connect" + ? await this.application.connect(connectionRef) + : action === "reauth" + ? await this.application.reauth(connectionRef) + : await this.application.disconnect(connectionRef); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: result }); + } catch (error) { + throw publicApplicationError(error); + } + return; + } + if (request.url?.startsWith("/api/v1/audit")) { + 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/audit" || [...url.searchParams.keys()].some((key) => key !== "limit")) { + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + const rawLimit = url.searchParams.get("limit") ?? "50"; + if (!/^\d{1,3}$/u.test(rawLimit) || Number(rawLimit) < 1 || Number(rawLimit) > 200) { + throw new ConsoleHttpError(422, "validation_error", "The audit limit must be between 1 and 200."); + } + try { + const records = await this.application.auditRecords(Number(rawLimit)); + session.lastUsedAt = this.options.now(); + writeJson(response, 200, { data: records, meta: { limit: Number(rawLimit), returned: records.length } }); + } catch { + throw new ConsoleHttpError(503, "audit_unavailable", "The Console audit journal is unavailable."); + } + return; + } + throw new ConsoleHttpError(404, "not_found", "The requested resource does not exist."); + } + + private requireTrustedBrowser(request: IncomingMessage): void { + const host = singleHeader(request, "host"); + const origin = singleHeader(request, "origin"); + if (host !== this.url.host || origin !== this.url.origin) { + throw new ConsoleHttpError(403, "forbidden", "The request origin is not trusted."); + } + } + + private admitRequest(bootstrap: boolean): void { + const now = this.options.now(); + if (now - this.rateWindowStartedAt >= rateWindowMs) { + this.rateWindowStartedAt = now; + this.requestCount = 0; + this.bootstrapAttemptCount = 0; + } + this.requestCount += 1; + if (bootstrap) this.bootstrapAttemptCount += 1; + if ( + this.requestCount > this.options.maximumRequestsPerMinute || + this.bootstrapAttemptCount > this.options.maximumBootstrapAttemptsPerMinute + ) { + const retryAfter = Math.max(1, Math.ceil((this.rateWindowStartedAt + rateWindowMs - now) / 1_000)); + throw new ConsoleHttpError( + 429, + "rate_limit_exceeded", + "The local Console request limit was reached.", + { "retry-after": String(retryAfter) } + ); + } + } + + private async bootstrapSession(request: IncomingMessage, response: ServerResponse): Promise { + const authorization = singleHeader(request, "authorization"); + if ( + this.bootstrapUsed || + this.options.now() - this.bootstrapIssuedAt >= this.options.bootstrapTtlMs || + authorization === undefined || + authorization === null || + !authorization.startsWith("Bootstrap ") || + !safeEqual(authorization.slice("Bootstrap ".length), this.bootstrap) + ) { + throw new ConsoleHttpError(401, "unauthorized", "Console authentication failed."); + } + const parsed = bootstrapSchema.safeParse(await readJsonBody(request, this.options.maximumRequestBytes)); + if (!parsed.success) throw new ConsoleHttpError(422, "validation_error", "The request body is invalid."); + this.pruneExpiredSessions(); + if (this.sessions.size >= this.options.maximumSessions) { + throw new ConsoleHttpError(429, "session_limit_exceeded", "The Console session limit was reached."); + } + + this.bootstrapUsed = true; + const now = this.options.now(); + const session: BrowserSession = { + id: randomCredential(), + csrfToken: randomCredential(), + createdAt: now, + lastUsedAt: now + }; + this.sessions.set(session.id, session); + writeJson( + response, + 201, + { data: { csrfToken: session.csrfToken, expiresInMs: this.options.absoluteSessionMs } }, + { + "set-cookie": `${sessionCookieName}=${session.id}; HttpOnly; SameSite=Strict; Path=/api/v1`, + location: "/api/v1/health" + } + ); + } + + private requireSession(request: IncomingMessage): BrowserSession { + this.pruneExpiredSessions(); + const id = cookieValue(request, sessionCookieName); + const session = id === undefined ? undefined : this.sessions.get(id); + if (session === undefined) { + throw new ConsoleHttpError(401, "unauthorized", "A valid Console session is required."); + } + return session; + } + + private requireCsrf(request: IncomingMessage, session: BrowserSession): void { + const token = singleHeader(request, "x-miftah-csrf"); + if (token === undefined || token === null || !safeEqual(token, session.csrfToken)) { + throw new ConsoleHttpError(403, "csrf_invalid", "A valid CSRF proof is required."); + } + } + + private decodeConnectionReference(segment: string): string { + let connectionRef: string; + try { + connectionRef = decodeURIComponent(segment); + } catch { + throw new ConsoleHttpError(400, "invalid_request", "The connection reference is invalid."); + } + if (connectionRef.length === 0 || connectionRef.length > 512 || connectionRef.includes("/")) { + throw new ConsoleHttpError(422, "validation_error", "The connection reference is invalid."); + } + return connectionRef; + } + + private pruneExpiredSessions(): void { + const now = this.options.now(); + for (const [id, session] of this.sessions) { + if ( + now - session.lastUsedAt >= this.options.idleSessionMs || + now - session.createdAt >= this.options.absoluteSessionMs + ) { + this.sessions.delete(id); + } + } + } + + rotateCredential(): string { + this.sessions.clear(); + this.bootstrap = randomCredential(); + this.bootstrapIssuedAt = this.options.now(); + this.bootstrapUsed = false; + return this.bootstrap; + } + + close(): Promise { + if (this.closePromise !== undefined) return this.closePromise; + this.closed = true; + this.sessions.clear(); + this.bootstrap = ""; + this.closePromise = (async () => { + const closing = closeListener(this.listener); + for (const socket of this.sockets) socket.destroy(); + await closing; + })(); + return this.closePromise; + } +} + +/** Starts the separate Console control plane only when explicitly called. */ +export async function startConsoleServer( + configPath: string, + options: ConsoleServerOptions = {} +): Promise { + await loadConfig(configPath); + const bootstrapCredential = options.bootstrapCredential ?? randomCredential(); + if (bootstrapCredential.length < 16 || bootstrapCredential.length > 4_096) { + throw new Error("Unable to start the Miftah Console server."); + } + const listener = createServer({ + requestTimeout: requestTimeoutMs, + headersTimeout: headersTimeoutMs, + connectionsCheckingInterval: connectionsCheckingIntervalMs, + maxHeaderSize: maximumHeaderBytes + }); + const port = options.port ?? 0; + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + listener.off("listening", onListening); + reject(error); + }; + const onListening = (): void => { + listener.off("error", onError); + resolve(); + }; + listener.once("error", onError); + listener.once("listening", onListening); + listener.listen(port, loopbackHost); + }).catch((error: unknown) => { + throw new Error("Unable to start the Miftah Console server.", { cause: error }); + }); + const address = listener.address(); + if (address === null || typeof address === "string" || address.address !== loopbackHost) { + await closeListener(listener).catch(() => undefined); + throw new Error("Unable to start the Miftah Console server."); + } + const url = new URL(`http://${loopbackHost}:${address.port}/`); + const server = new LocalConsoleServer( + url, + bootstrapCredential, + listener, + options.application ?? new ConsoleApplicationService(configPath), + { + maximumRequestBytes: options.maximumRequestBytes ?? defaultMaximumRequestBytes, + maximumSessions: options.maximumSessions ?? defaultMaximumSessions, + bootstrapTtlMs: options.bootstrapTtlMs ?? defaultBootstrapTtlMs, + maximumRequestsPerMinute: options.maximumRequestsPerMinute ?? defaultMaximumRequestsPerMinute, + maximumBootstrapAttemptsPerMinute: + options.maximumBootstrapAttemptsPerMinute ?? defaultMaximumBootstrapAttemptsPerMinute, + idleSessionMs: options.idleSessionMs ?? defaultIdleSessionMs, + absoluteSessionMs: options.absoluteSessionMs ?? defaultAbsoluteSessionMs, + now: options.now ?? Date.now + } + ); + listener.on("request", (request, response) => void server.handle(request, response)); + return server; +} diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index ead1465d..28cc53ee 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -34,6 +34,16 @@ describe("CLI parser", () => { expectUsageError(["serve", "--transport", "websocket"]); }); + it("parses only an explicit loopback Console launch with an optional port", () => { + expect(parseCli(["console", "--config", "wrapper.json", "--port", "43127"])).toEqual({ + kind: "run", + command: "console", + options: { config: "wrapper.json", port: "43127" } + }); + expect(renderCommandHelp("console")).toContain("--port "); + expectUsageError(["serve", "--port", "43127"]); + }); + 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 new file mode 100644 index 00000000..d71ba011 --- /dev/null +++ b/tests/console-application-service.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +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"; + +const temporaryDirectories: string[] = []; +const connectionRef = "oauthconn:31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c"; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function writeConfig(): Promise { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-application-")); + temporaryDirectories.push(directory); + const path = join(directory, "miftah.json"); + await writeFile(path, JSON.stringify({ + version: "3", + name: "console-application-test", + defaultProfile: "personal", + upstream: { + transport: "streamable-http", + url: "https://mcp.example.test/mcp", + headers: { "X-Private": "secretref:env://PRIVATE_HEADER" } + }, + profiles: { + personal: { + description: "Personal account", + env: { PRIVATE_TOKEN: "secretref:env://PRIVATE_PROFILE_TOKEN" } + } + }, + oauth: { + connections: { + [connectionRef]: { + profile: "personal", + upstream: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["read"] + } + } + } + })); + return path; +} + +describe("Console application service", () => { + it("returns allowlisted metadata and audit-records each exact OAuth lifecycle mutation", async () => { + const calls: string[] = []; + const configPath = await writeConfig(); + const service = new ConsoleApplicationService(configPath, { + commandService: { + list: async () => [], + status: async ({ connectionRef: selected }) => ({ connectionRef: selected, credentialState: "missing" }), + connect: async ({ connectionRef: selected }) => { + calls.push(`connect:${selected}`); + return { ok: true }; + }, + reauth: async ({ connectionRef: selected }) => { + calls.push(`reauth:${selected}`); + return { ok: true }; + }, + disconnect: async ({ connectionRef: selected }) => { + calls.push(`disconnect:${selected}`); + return { credentialState: "missing" }; + } + } + }); + + const metadata = await service.configMetadata(); + expect(metadata).toMatchObject({ + name: "console-application-test", + profiles: [{ name: "personal", description: "Personal account" }], + upstreams: [{ name: "default", transport: "streamable-http" }], + oauthConnectionCount: 1 + }); + expect(JSON.stringify(metadata)).not.toContain("PRIVATE_HEADER"); + expect(JSON.stringify(metadata)).not.toContain("PRIVATE_PROFILE_TOKEN"); + + await expect(service.connect("__proto__")).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + expect(calls).toEqual([]); + expect(await service.auditRecords(10)).toEqual([]); + + await service.connect(connectionRef); + await service.reauth(connectionRef); + await service.disconnect(connectionRef); + expect(calls).toEqual([ + `connect:${connectionRef}`, + `reauth:${connectionRef}`, + `disconnect:${connectionRef}` + ]); + + const records = await service.auditRecords(10); + expect(records).toEqual([ + expect.objectContaining({ operation: "console/oauth-connect", status: "success", profile: "personal" }), + expect.objectContaining({ operation: "console/oauth-reauth", status: "success", profile: "personal" }), + expect.objectContaining({ operation: "console/oauth-disconnect", status: "success", profile: "personal" }) + ]); + expect(JSON.stringify(records)).not.toContain(connectionRef); + expect(JSON.stringify(records)).not.toContain("auth.example.test"); + }); + + 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); + const configPath = join(directory, "miftah.json"); + const original = JSON.stringify({ + version: "2", + name: "console-audit-failure", + defaultProfile: "personal", + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { personal: {} } + }); + await writeFile(configPath, original); + await writeFile(join(directory, ".miftah"), "blocks the required audit directory"); + + const service = new ConsoleApplicationService(configPath); + await expect(service.addConnection({ + connectionRef, + profile: "personal", + upstream: "default", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["read"] + })).rejects.toMatchObject({ code: "AUDIT_WRITE_FAILED" }); + + expect(await readFile(configPath, "utf8")).toBe(original); + const health = await service.health(); + expect(health).toMatchObject({ + audit: { + enabled: true, + state: "failed", + lastFailure: { errorCode: "AUDIT_WRITE_FAILED" } + } + }); + expect(JSON.stringify(health)).not.toContain(directory); + }); +}); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts new file mode 100644 index 00000000..47e10a9a --- /dev/null +++ b/tests/console-server.test.ts @@ -0,0 +1,537 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + startConsoleServer, + type ConsoleControlApplication +} from "../src/console/console-server.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function writeConfig(): Promise { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-server-")); + temporaryDirectories.push(directory); + const path = join(directory, "miftah.json"); + await writeFile( + path, + JSON.stringify({ + version: "1", + name: "console-test", + defaultProfile: "personal", + upstream: { transport: "stdio", command: process.execPath, args: ["provider.mjs"] }, + profiles: { personal: { description: "Personal account" }, work: {} } + }) + ); + return path; +} + +async function writeOAuthConfig(): Promise { + const directory = await mkdtemp(join(tmpdir(), "miftah-console-oauth-")); + temporaryDirectories.push(directory); + const path = join(directory, "miftah.json"); + await writeFile( + path, + JSON.stringify({ + version: "2", + name: "console-oauth-test", + defaultProfile: "personal", + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { personal: { description: "Personal account" }, work: {} } + }, null, 2) + ); + return path; +} + +async function rawPost( + url: URL, + headers: Readonly>, + body: string +): Promise<{ readonly status: number; readonly body: string; readonly headers: NodeJS.Dict }> { + return new Promise((resolve, reject) => { + const request = httpRequest( + { hostname: url.hostname, port: url.port, path: url.pathname, method: "POST", headers }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("error", reject); + response.once("end", () => resolve({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + headers: response.headers + })); + } + ); + request.once("error", reject); + request.end(body); + }); +} + +async function bootstrapSession(server: Awaited>): Promise<{ + readonly cookie: string; + readonly csrfToken: string; +}> { + const response = await fetch(new URL("/api/v1/sessions", server.url), { + method: "POST", + headers: { + origin: server.url.origin, + authorization: `Bootstrap ${server.bootstrapCredential}`, + "content-type": "application/json" + }, + body: "{}" + }); + expect(response.status).toBe(201); + const cookie = response.headers.get("set-cookie")?.split(";", 1)[0]; + const body = await response.json() as { readonly data: { readonly csrfToken: string } }; + if (cookie === undefined) throw new Error("Expected a Console session cookie."); + return { cookie, csrfToken: body.data.csrfToken }; +} + +describe("local Console control server", () => { + it("requires an invocation-bound bootstrap before returning redacted control metadata", async () => { + const server = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "test-only-bootstrap-credential" + }); + + try { + expect(server.url.hostname).toBe("127.0.0.1"); + expect(server.url.pathname).toBe("/"); + + const unauthenticated = await fetch(new URL("/api/v1/health", server.url), { + headers: { origin: server.url.origin } + }); + expect(unauthenticated.status).toBe(401); + + const missingOrigin = await fetch(new URL("/api/v1/health", server.url)); + expect(missingOrigin.status).toBe(403); + + const bootstrapUrl = new URL("/api/v1/sessions", server.url); + const hostileHost = await rawPost( + bootstrapUrl, + { + host: "attacker.example.test", + origin: server.url.origin, + authorization: "Bootstrap test-only-bootstrap-credential", + "content-type": "application/json" + }, + "{}" + ); + expect(hostileHost.status).toBe(403); + + const hostileOrigin = await fetch(bootstrapUrl, { + method: "POST", + headers: { + origin: "https://attacker.example.test", + authorization: "Bootstrap test-only-bootstrap-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(hostileOrigin.status).toBe(403); + + const mcpBearer = await fetch(bootstrapUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: "Bearer test-only-bootstrap-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(mcpBearer.status).toBe(401); + + const bootstrap = await fetch(bootstrapUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: "Bootstrap test-only-bootstrap-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(bootstrap.status).toBe(201); + const cookie = bootstrap.headers.get("set-cookie"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + const bootstrapBody = await bootstrap.json() as { readonly data: { readonly csrfToken: string } }; + expect(bootstrapBody.data.csrfToken).toMatch(/^[A-Za-z0-9_-]{32,}$/u); + expect(JSON.stringify(bootstrapBody)).not.toContain("test-only-bootstrap-credential"); + 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]! } + }); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ + data: { + status: "ok", + config: { name: "console-test", version: "1" }, + audit: { enabled: true, state: "healthy" }, + restartRequiredForExistingClients: true + } + }); + } finally { + await server.close(); + } + }); + + it("requires CSRF proof and schema validation before an atomic audited connection mutation", async () => { + const configPath = await writeOAuthConfig(); + const server = await startConsoleServer(configPath, { bootstrapCredential: "test-only-bootstrap-credential" }); + const connectionRef = "oauthconn:31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c"; + + try { + const session = await bootstrapSession(server); + const endpoint = new URL("/api/v1/connections", server.url); + const request = { + connectionRef, + profile: "personal", + upstream: "default", + issuer: "https://auth.example.test", + clientRegistration: "dynamic", + scopes: ["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); + expect(await readFile(configPath, "utf8")).not.toContain(connectionRef); + + const invalid = 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, scopes: "read", unexpected: true }) + }); + expect(invalid.status).toBe(422); + expect(await readFile(configPath, "utf8")).not.toContain(connectionRef); + + 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); + const createdBody = await created.json(); + expect(createdBody).toMatchObject({ + data: { changed: true, write: true, connectionRef } + }); + expect(JSON.stringify(createdBody)).not.toContain(configPath); + expect(JSON.stringify(createdBody)).not.toContain("miftah-backup"); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + oauth: { connections: { [connectionRef]: { profile: "personal", scopes: ["read"] } } } + }); + + const audit = await fetch(new URL("/api/v1/audit?limit=10", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(audit.status).toBe(200); + const auditBody = await audit.json() as { readonly data: readonly Record[] }; + expect(auditBody.data).toContainEqual(expect.objectContaining({ + operation: "console/oauth-connection-add", + status: "success", + profile: "personal", + upstream: "default" + })); + expect(JSON.stringify(auditBody)).not.toContain("dynamic"); + expect(JSON.stringify(auditBody)).not.toContain("auth.example.test"); + + 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()).toMatchObject({ + data: { + name: "console-oauth-test", + version: "3", + defaultProfile: "personal", + profiles: [ + { name: "personal", description: "Personal account" }, + { name: "work" } + ], + upstreams: [{ name: "default", transport: "streamable-http" }], + restartRequiredForExistingClients: true + } + }); + + const profiles = await fetch(new URL("/api/v1/profiles", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(profiles.status).toBe(200); + expect(await profiles.json()).toMatchObject({ data: [{ name: "personal" }, { name: "work" }] }); + + const connections = await fetch(new URL("/api/v1/connections", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(connections.status).toBe(200); + const connectionsBody = await connections.json() as { readonly data: unknown }; + expect(connectionsBody.data).toEqual([ + expect.objectContaining({ connectionRef, profile: "personal", upstream: "default" }) + ]); + expect(JSON.stringify(connectionsBody)).not.toContain("accessToken"); + expect(JSON.stringify(connectionsBody)).not.toContain("refreshToken"); + + const mcpRoute = await fetch(new URL("/mcp", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(mcpRoute.status).toBe(404); + } finally { + await server.close(); + } + }); + + it("audits exact connection lifecycle mutations only after CSRF validation", async () => { + const calls: string[] = []; + const application: ConsoleControlApplication = { + health: async () => ({ + status: "ok", + config: { name: "console-test", version: "1" }, + audit: { enabled: true, state: "healthy" }, + restartRequiredForExistingClients: true + }), + configMetadata: async () => ({ + name: "console-test", + version: "1", + defaultProfile: "personal", + profiles: [], + upstreams: [], + oauthConnectionCount: 0, + restartRequiredForExistingClients: true + }), + listConnections: async () => [], + connectionStatus: async (connectionRef) => ({ connectionRef, credentialState: "missing" }), + addConnection: async () => { throw new Error("not used"); }, + connect: async (connectionRef) => { + calls.push(`connect:${connectionRef}`); + return { ok: true, connectionRef }; + }, + reauth: async (connectionRef) => { + calls.push(`reauth:${connectionRef}`); + return { ok: true, connectionRef }; + }, + disconnect: async (connectionRef) => { + calls.push(`disconnect:${connectionRef}`); + return { connectionRef, credentialState: "missing" }; + }, + auditRecords: async () => [] + }; + const server = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "test-only-bootstrap-credential", + application + }); + + try { + const session = await bootstrapSession(server); + const reference = "oauthconn:31cb3ef5-22cb-4bf7-9ebf-e4a2d32bf18c"; + const status = await fetch( + new URL(`/api/v1/connections/${encodeURIComponent(reference)}`, server.url), + { headers: { origin: server.url.origin, cookie: session.cookie } } + ); + expect(status.status).toBe(200); + expect(await status.json()).toEqual({ data: { connectionRef: reference, credentialState: "missing" } }); + const connectUrl = new URL(`/api/v1/connections/${encodeURIComponent(reference)}/connect`, server.url); + const rejected = await fetch(connectUrl, { + method: "POST", + headers: { origin: server.url.origin, cookie: session.cookie, "content-type": "application/json" }, + body: "{}" + }); + expect(rejected.status).toBe(403); + expect(calls).toEqual([]); + + for (const [action, method] of [["connect", "POST"], ["reauth", "POST"], ["credential", "DELETE"]] as const) { + const response = await fetch( + new URL(`/api/v1/connections/${encodeURIComponent(reference)}/${action}`, server.url), + { + method, + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: "{}" + } + ); + expect(response.status).toBe(200); + } + expect(calls).toEqual([`connect:${reference}`, `reauth:${reference}`, `disconnect:${reference}`]); + } finally { + await server.close(); + } + }); + + it("bounds requests, expires sessions, rotates local credentials, and shuts down cleanly", async () => { + let now = 10_000; + const server = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "first-test-bootstrap-credential", + maximumRequestBytes: 32, + bootstrapTtlMs: 100, + idleSessionMs: 100, + absoluteSessionMs: 1_000, + now: () => now + }); + + const firstUrl = new URL("/api/v1/sessions", server.url); + const oversized = await fetch(firstUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: "Bootstrap first-test-bootstrap-credential", + "content-type": "application/json" + }, + body: JSON.stringify({ padding: "x".repeat(64) }) + }); + expect(oversized.status).toBe(413); + + now += 101; + const staleBootstrap = await fetch(firstUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: "Bootstrap first-test-bootstrap-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(staleBootstrap.status).toBe(401); + + const activeBootstrap = server.rotateCredential(); + const activeBootstrapResponse = await fetch(firstUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: `Bootstrap ${activeBootstrap}`, + "content-type": "application/json" + }, + body: "{}" + }); + expect(activeBootstrapResponse.status).toBe(201); + const activeBody = await activeBootstrapResponse.json() as { readonly data: { readonly csrfToken: string } }; + const activeCookie = activeBootstrapResponse.headers.get("set-cookie")?.split(";", 1)[0]; + if (activeCookie === undefined) throw new Error("Expected an active Console session cookie."); + const session = { cookie: activeCookie, csrfToken: activeBody.data.csrfToken }; + const replay = await fetch(firstUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: `Bootstrap ${activeBootstrap}`, + "content-type": "application/json" + }, + body: "{}" + }); + expect(replay.status).toBe(401); + + now += 101; + const expired = await fetch(new URL("/api/v1/health", server.url), { + headers: { origin: server.url.origin, cookie: session.cookie } + }); + expect(expired.status).toBe(401); + + const replacement = server.rotateCredential(); + expect(replacement).not.toBe(activeBootstrap); + const replacementSession = await fetch(firstUrl, { + method: "POST", + headers: { + origin: server.url.origin, + authorization: `Bootstrap ${replacement}`, + "content-type": "application/json" + }, + body: "{}" + }); + expect(replacementSession.status).toBe(201); + + await server.close(); + await expect(fetch(new URL("/api/v1/health", server.url), { + headers: { origin: server.url.origin } + })).rejects.toThrow(); + }); + + it("rate-limits the local API and applies a stricter bootstrap-attempt budget", async () => { + let now = 50_000; + const requestLimited = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "request-rate-bootstrap-credential", + maximumRequestsPerMinute: 2, + now: () => now + }); + try { + const session = await bootstrapSession(requestLimited); + const first = await fetch(new URL("/api/v1/health", requestLimited.url), { + headers: { origin: requestLimited.url.origin, cookie: session.cookie } + }); + expect(first.status).toBe(200); + const limited = await fetch(new URL("/api/v1/health", requestLimited.url), { + headers: { origin: requestLimited.url.origin, cookie: session.cookie } + }); + expect(limited.status).toBe(429); + expect(limited.headers.get("retry-after")).toBe("60"); + } finally { + await requestLimited.close(); + } + + const bootstrapLimited = await startConsoleServer(await writeConfig(), { + bootstrapCredential: "bootstrap-rate-test-credential", + maximumRequestsPerMinute: 100, + maximumBootstrapAttemptsPerMinute: 2, + now: () => now + }); + try { + const url = new URL("/api/v1/sessions", bootstrapLimited.url); + for (let attempt = 0; attempt < 2; attempt += 1) { + const rejected = await fetch(url, { + method: "POST", + headers: { + origin: bootstrapLimited.url.origin, + authorization: "Bootstrap invalid-bootstrap-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(rejected.status).toBe(401); + } + const limited = await fetch(url, { + method: "POST", + headers: { + origin: bootstrapLimited.url.origin, + authorization: "Bootstrap bootstrap-rate-test-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(limited.status).toBe(429); + + now += 60_000; + const recovered = await fetch(url, { + method: "POST", + headers: { + origin: bootstrapLimited.url.origin, + authorization: "Bootstrap bootstrap-rate-test-credential", + "content-type": "application/json" + }, + body: "{}" + }); + expect(recovered.status).toBe(201); + } finally { + await bootstrapLimited.close(); + } + }); +}); diff --git a/tests/oauth-console-threat-model-docs-contract.test.ts b/tests/oauth-console-threat-model-docs-contract.test.ts index 2e7d04ec..a40d0086 100644 --- a/tests/oauth-console-threat-model-docs-contract.test.ts +++ b/tests/oauth-console-threat-model-docs-contract.test.ts @@ -6,13 +6,14 @@ async function document(path: string): Promise { } describe("OAuth and Console threat-model documentation contract", () => { - it("publishes implemented remote OAuth and the future Console boundary without overstating either", async () => { - const [delta, threatModel, oauthSupport, security, architecture, readme] = await Promise.all([ + it("publishes implemented remote OAuth and the separate Console control boundary without overstating either", async () => { + const [delta, threatModel, oauthSupport, security, architecture, consoleApi, readme] = await Promise.all([ document("docs/oauth-console-threat-model.md"), document("docs/threat-model.md"), document("docs/oauth-support.md"), document("docs/security.md"), document("docs/architecture.md"), + document("docs/console-api.md"), document("README.md") ]); @@ -24,11 +25,20 @@ 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("No local Console, provider-specific adapter, provider-revocation client, or hosted broker exists."); + 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("The local CLI can plan bindings, report redacted state, connect, reauthenticate, and delete an exact local credential"); - expect(delta).toContain("The future Console control API is distinct from the MCP /mcp endpoint."); + 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("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/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"); }); it("defines protocol go/no-go, abuse cases, residual risks, and implementation evidence", async () => { @@ -79,7 +89,7 @@ describe("OAuth and Console threat-model documentation contract", () => { } }); - it("fails closed on issuer provenance, unsupported MCP metadata, Console bootstrap, and static bearer collisions", async () => { + it("fails closed on issuer provenance and static bearer collisions while specifying the implemented Console bootstrap", async () => { const delta = await document("docs/oauth-console-threat-model.md"); expect(delta).toContain("RFC 9207 `iss`"); @@ -97,7 +107,9 @@ describe("OAuth and Console threat-model documentation contract", () => { expect(delta).toContain("The Client ID Metadata path requires `client_id_metadata_document_supported` to be `true`"); expect(delta).toContain("The Dynamic Client Registration path requires `registration_endpoint` to be advertised"); - expect(delta).toContain("Console implementation is NO-GO until its initial browser bootstrap has a separately approved security design"); + expect(delta).toContain("The approved control credential is not an OAuth token."); + expect(delta).toContain("It expires after five minutes, is consumed once"); + expect(delta).toContain("Restart or explicit rotation invalidates every session"); expect(delta).toContain("A profile cannot enable native OAuth while the effective headers for that exact upstream contain an `Authorization` header after profile and upstream headers are merged case-insensitively"); expect(delta).toContain("Any header whose normalized name is `authorization`, including a profile-level lowercase `authorization` entry or a duplicate case variant, blocks native OAuth until explicitly removed or migrated."); expect(delta).toContain("Configuration validation must form that effective header set from profile and upstream headers using case-insensitive header names."); diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index 52349897..0cf3073b 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -360,7 +360,12 @@ interface StartedInstalledCli { stop(): Promise; } -async function startInstalledCli(entry: string, args: readonly string[], cwd: string): Promise { +async function startInstalledCli( + entry: string, + args: readonly string[], + cwd: string, + startupMarker = "Miftah HTTP server listening on " +): Promise { const child = spawn(process.execPath, [entry, ...args], { cwd, shell: false, @@ -373,14 +378,14 @@ async function startInstalledCli(entry: string, args: readonly string[], cwd: st const waitForStartup = new Promise((resolve, reject) => { const timeout = setTimeout(() => { - reject(new Error(`Installed CLI did not report HTTP startup.${npmDiagnostics(stdout, stderr)}`)); + reject(new Error(`Installed CLI did not report startup.${npmDiagnostics(stdout, stderr)}`)); }, npmCommandTimeoutMs); const settle = (outcome: () => void): void => { clearTimeout(timeout); outcome(); }; const reportStartup = (): void => { - if (!stdout.includes("Miftah HTTP server listening on ") && !stderr.includes("Miftah HTTP server listening on ")) return; + if (!stdout.includes(startupMarker) && !stderr.includes(startupMarker)) return; settle(resolve); }; child.stdout.setEncoding("utf8"); @@ -399,7 +404,7 @@ async function startInstalledCli(entry: string, args: readonly string[], cwd: st child.once("close", (status, signal) => { settle(() => { const outcome = status === null ? `terminated by ${signal ?? "an unknown signal"}` : `exited with status ${status}`; - reject(new Error(`Installed CLI ${outcome} before HTTP startup.${npmDiagnostics(stdout, stderr)}`)); + reject(new Error(`Installed CLI ${outcome} before startup.${npmDiagnostics(stdout, stderr)}`)); }); }); }); @@ -977,12 +982,28 @@ describe("packed artifact contract", () => { await httpServe.stop(); } + const consoleServe = await startInstalledCli( + installedCliEntry, + ["console", "--config", httpServeConfigPath], + cliContractDirectory, + "Miftah Console control API listening on " + ); + try { + expect(consoleServe.stdout).toMatch( + /^Miftah Console control API listening on http:\/\/127\.0\.0\.1:\d+\/\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(consoleServe.stderr).toBe(""); + } finally { + await consoleServe.stop(); + } + const rootHelp = runInstalledBinary(binary, ["--help"], cliContractDirectory); expect(rootHelp.status, rootHelp.stderr || rootHelp.stdout).toBe(0); expect(rootHelp.stderr).toBe(""); expect(rootHelp.stdout).toContain("Usage: miftah [command] [options]"); const commandOptions = { serve: ["--config "], + console: ["--config ", "--port "], validate: ["--config "], doctor: ["--config ", "--json"], schema: [],