From 888e2f0a6b2ee0390800bd4e8ec7cd528e04c4a7 Mon Sep 17 00:00:00 2001 From: Ilia Manolov Date: Fri, 17 Jul 2026 13:11:54 +0100 Subject: [PATCH] Add browser-based device authorization --- Cargo.lock | 190 +++++ Cargo.toml | 4 + README.md | 49 +- SECURITY.md | 24 +- crates/edison-stdiod/Cargo.toml | 4 + crates/edison-stdiod/src/auth.rs | 693 ++++++++++++++++++ crates/edison-stdiod/src/cli/login.rs | 330 +++++++-- crates/edison-stdiod/src/cli/logout.rs | 98 +++ crates/edison-stdiod/src/cli/mod.rs | 1 + crates/edison-stdiod/src/cli/server.rs | 558 ++++++++++---- crates/edison-stdiod/src/config.rs | 615 +++++++++++++++- .../edison-stdiod/src/config_extra_tests.rs | 53 ++ crates/edison-stdiod/src/daemon.rs | 361 ++++++--- crates/edison-stdiod/src/daemon_auth.rs | 266 +++++++ crates/edison-stdiod/src/env_store.rs | 103 ++- crates/edison-stdiod/src/http.rs | 216 +++++- crates/edison-stdiod/src/main.rs | 11 +- crates/edison-stdiod/src/platform/linux.rs | 8 +- crates/edison-stdiod/src/platform/macos.rs | 8 +- crates/edison-stdiod/src/platform/windows.rs | 8 +- crates/edison-stdiod/src/proc.rs | 20 + crates/edison-stdiod/src/process_shutdown.rs | 18 + crates/edison-stdiod/src/secure_file.rs | 147 ++++ crates/edison-stdiod/src/tunnel.rs | 222 ++++-- 24 files changed, 3524 insertions(+), 483 deletions(-) create mode 100644 crates/edison-stdiod/src/auth.rs create mode 100644 crates/edison-stdiod/src/cli/logout.rs create mode 100644 crates/edison-stdiod/src/config_extra_tests.rs create mode 100644 crates/edison-stdiod/src/daemon_auth.rs create mode 100644 crates/edison-stdiod/src/process_shutdown.rs create mode 100644 crates/edison-stdiod/src/secure_file.rs diff --git a/Cargo.lock b/Cargo.lock index d091332..de4f9ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,26 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -279,15 +299,18 @@ name = "edison-stdiod" version = "0.0.1" dependencies = [ "anyhow", + "base64", "chrono", "clap", "dirs", "edison-tunnel-protocol", "futures-util", + "getrandom 0.3.4", "once_cell", "reqwest", "serde", "serde_json", + "sha2", "thiserror", "tokio", "tokio-tungstenite", @@ -295,6 +318,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "webbrowser", ] [[package]] @@ -690,6 +714,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -776,6 +849,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -794,6 +873,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1068,6 +1172,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1115,12 +1228,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1196,6 +1324,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1221,6 +1360,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1623,6 +1778,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1722,6 +1887,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1740,6 +1921,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 1e88875..2efd992 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,20 +15,24 @@ homepage = "https://github.com/Edison-Watch/stdiod" [workspace.dependencies] anyhow = "1" +base64 = "0.22" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4", features = ["derive", "env"] } dirs = "6" futures-util = "0.3" +getrandom = "0.3" once_cell = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } toml = "1.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +webbrowser = "1" # Workspace member crates edison-tunnel-protocol = { path = "crates/edison-tunnel-protocol", version = "0.0.1" } diff --git a/README.md b/README.md index 7512892..ac06e01 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ ## How it works -- **Outbound-only.** The daemon opens one WebSocket to `/api/v1/stdio-tunnel/ws` and authenticates with a Bearer API key. There are no inbound listening ports. +- **Outbound-only.** The daemon opens one WebSocket to `/api/v1/stdio-tunnel/ws` and authenticates with a scoped Bearer client credential. There are no inbound listening ports. - **Reverse RPC.** A single symmetric `mcp_frame` envelope carries every MCP interaction (requests, responses, server-initiated sampling, notifications, errors) in both directions over the one connection. - **Child supervision.** The backend pushes a desired set of servers; the daemon spawns/stops the matching subprocesses and pumps their stdio. - **Survival.** It reconnects with backoff across network blips and machine sleep/resume, and reconciles desired state on every (re)connect. @@ -65,10 +65,9 @@ cargo build --release # binary at target/release/edison-stdiod ## Quickstart ```sh -# 1. Store credentials + backend URL in ~/.config/edison-stdiod/config.toml (mode 0600). -edison-stdiod login \ - --backend https://dashboard.edison.watch \ - --api-key +# 1. Authorize this installation in a browser. The credential and backend URL +# are stored in ~/.config/edison-stdiod/config.toml (mode 0600). +edison-stdiod login --backend https://dashboard.edison.watch # 2. Register the OS supervisor unit (macOS LaunchAgent) so the daemon # starts at login and is restarted on crash. Requires `login` first. @@ -81,6 +80,11 @@ edison-stdiod status edison-stdiod logs -f ``` +On a headless machine, pass `--no-open` and open the printed HTTP(S) URL on +another device. Run `edison-stdiod logout` to remove the local credential +immediately and then best-effort revoke it remotely. The backend URL and local +display preferences are retained. + To run the daemon in the foreground without installing a service unit (useful for development): ```sh @@ -92,8 +96,8 @@ edison-stdiod run ### Registering a local MCP server ```sh -# Expose a local stdio MCP server through the tunnel. Tool calls appear in -# the gateway namespaced as `_`. +# Submit a local stdio MCP server for admin approval. Once approved, tool calls +# appear in the gateway namespaced as `_`. edison-stdiod server add filesystem \ --command npx \ --arg -y --arg @modelcontextprotocol/server-filesystem --arg "$HOME" @@ -111,15 +115,16 @@ TLDR: `edison-stdiod --help` (and `edison-stdiod --help` for any subco | Command | What it does | | --- | --- | -| `login` | Persist credentials + backend URL to `~/.config/edison-stdiod/config.toml` (mode `0600`). Merges on re-run, so you can rotate the API key without re-supplying the backend URL. | +| `login` | Start browser/device authorization and persist the resulting scoped client credential in `~/.config/edison-stdiod/config.toml` (mode `0600`). Use `--no-open` for headless login. The deprecated `--api-key` path remains for existing desktop clients. | +| `logout` | Atomically remove local credentials and account/device bindings, then best-effort revoke the prior client credential. Retains the backend URL and unrelated preferences. | | `install` | Register the OS supervisor unit (macOS LaunchAgent) so the daemon starts at login and restarts on crash. Requires `login` first. | | `uninstall` | Stop and remove the supervisor unit. Pass `--purge` to also delete the persisted config and logs. | -| `run` | Run the daemon in the foreground (normally invoked by the service unit). Reads config or accepts `--backend` / `--api-key` / `--device-id` / `--label` flags (also via `EDISON_*` env vars). | +| `run` | Run the daemon in the foreground (normally invoked by the service unit). Reads the client credential from config. `--backend` may only canonically match the saved credential's backend unless an explicit legacy `--api-key` is supplied. Legacy device/secret overrides are not inherited across that boundary. | | `status` | Print a one-shot summary of supervisor-unit status, connection state, and currently-running child servers. | | `logs` | Print the daemon log. `-f`/`--follow` to tail in real time; `-n`/`--lines N` to set the backscroll (default 200). | -| `server add ` | Register a stdio_tunnel server. `--command `, repeatable `--arg `, optional `--working-dir` and `--display-name`. The prefix name must be alphanumeric (plus hyphens). | -| `server list` | List stdio_tunnel servers registered for this device. `--json` for raw output. | -| `server remove ` | Delete a server by name. Idempotent - a missing name is reported as a no-op. | +| `server add ` | Submit a stdio request and print its pending or auto-approved status. Browser auth uses the exact-device-scoped client request endpoint; legacy API keys use `/api/v1/mcp-requests` with the local hostname. `--command `, repeatable `--arg `, and optional `--display-name` are supported. `--working-dir` is rejected because requests cannot persist it. | +| `server list` | With browser auth, list only approved stdio_tunnel servers bound to this exact client device. `--json` for raw output. Legacy API keys retain the compatibility listing flow. | +| `server remove ` | With browser auth, withdraw your pending request by name. Approved server removal requires dashboard/admin action. Legacy API keys retain direct server deletion. | @@ -138,21 +143,29 @@ Settings resolve in two layers, highest precedence first: ```toml # ~/.config/edison-stdiod/config.toml (mode 0600) backend_url = "https://dashboard.edison.watch" # Backend base URL (http://localhost:3001 for dev) -api_key = "ew_live_…" # Bearer API key issued by the backend (plaintext, 0600) +client_access_token = "…" # Long-lived opaque Bearer client token (plaintext, 0600) +client_installation_id = "…" # Account/install namespace issued by the backend +authenticated_user_id = "…" +authenticated_org_id = "…" +scopes = ["tunnel:connect", "mcp_requests:create", "mcp_requests:read", "servers:self:read"] edison_secret_key = "…" # Optional X-Edison-Secret-Key for per-user secret decryption -device_id = "my-laptop" # Stable device identifier; defaults to the machine hostname +device_id = "…" # Server-issued device identifier device_label = "My Laptop" # Human-readable label shown in the dashboard ``` | Field (`config.toml`) | Env var | Description | | --- | --- | --- | | `backend_url` | `EDISON_BACKEND_URL` | Backend base URL (`http://localhost:3001` for dev, `https://dashboard.edison.watch` for prod). | -| `api_key` | `EDISON_API_KEY` | Bearer API key issued by the backend. Stored in plaintext at mode `0600`. | +| `client_access_token` | - | Opaque client Bearer token issued by browser/device authorization. Stored in plaintext at mode `0600`; no refresh token is used in the MVP. | +| `client_installation_id` | - | Backend-issued installation/account namespace. Local per-server environment values are isolated by this ID. | +| `api_key` | `EDISON_API_KEY` | Deprecated legacy API key. Explicit flag/env values still override persisted client auth. | | `edison_secret_key` | `EDISON_SECRET_KEY` | Optional `X-Edison-Secret-Key` for per-user secret decryption. | | `device_id` | `EDISON_DEVICE_ID` | Stable device identifier; defaults to the machine hostname. | | `device_label` | `EDISON_DEVICE_LABEL` | Human-readable label shown in the dashboard. | -Rotate the API key by re-running `edison-stdiod login --api-key …`. To remove everything, run `edison-stdiod uninstall --purge`. +Re-run `edison-stdiod login` to switch accounts or replace an invalid client +credential. `logout` removes authentication while retaining preferences; +`uninstall --purge` removes all stdiod files. @@ -165,7 +178,9 @@ TLDR: the daemon keeps almost nothing durable - the backend is the source of tru ``` ~/.config/edison-stdiod/ - config.toml # backend URL, device_id, api_key, secret (mode 0600) + config.toml # backend URL, account IDs, token, device ID, secret (mode 0600) + server_envs.json # legacy-auth local server values (mode 0600) + server_envs/.json # client-installation-isolated server values (mode 0600) state.json # atomic writes; snapshot consumed by the desktop tray UI ~/Library/Logs/edison-stdiod/ # macOS - platform-equivalent paths elsewhere daemon.log # rotated daily diff --git a/SECURITY.md b/SECURITY.md index 6f76394..df10917 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,15 +31,23 @@ coordinated disclosure and are happy to credit reporters. stdiod runs as a long-lived daemon on a user's machine and handles credentials, so a few properties are worth understanding: -- **Credentials at rest.** The API key (and optional secret key) are written in - plaintext to `~/.config/edison-stdiod/config.toml` with file mode `0600`. They - are not encrypted on disk. Protect the host account accordingly; rotate a key - by re-running `edison-stdiod login --api-key …`, and remove all persisted state - with `edison-stdiod uninstall --purge`. +- **Credentials at rest.** The long-lived opaque client access token (and + optional secret key) are written in plaintext to + `~/.config/edison-stdiod/config.toml` with file mode `0600`. The MVP has no + refresh token. Protect the host account accordingly. `edison-stdiod logout` + best-effort revokes the client token and always removes the local credential; + `edison-stdiod uninstall --purge` removes all persisted state. Deprecated + legacy API keys use the same storage protections. +- **Account isolation.** Per-server environment values may contain secrets. + Browser-auth installations store them in separate files namespaced by a hash + of the backend-issued `client_installation_id`; switching accounts does not + load the previous account's values. Legacy API-key auth continues to use its + original `server_envs.json` store. - **Outbound-only transport.** The daemon makes a single outbound TLS WebSocket - connection to the configured backend and authenticates with a Bearer token. It - opens no inbound listening ports. Always use an `https://`/`wss://` backend URL - outside of local development. + connection to the configured backend and authenticates with a scoped Bearer + client token. It + opens no inbound listening ports. Backend configuration accepts HTTPS origins; + cleartext HTTP is restricted to localhost and loopback IPs for development. - **Child processes.** The daemon spawns local MCP server subprocesses as instructed by the authenticated backend. Those processes run with the privileges of the user running the daemon and can access that user's files and diff --git a/crates/edison-stdiod/Cargo.toml b/crates/edison-stdiod/Cargo.toml index d9c42f1..17eaf07 100644 --- a/crates/edison-stdiod/Cargo.toml +++ b/crates/edison-stdiod/Cargo.toml @@ -13,14 +13,17 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } +base64 = { workspace = true } chrono = { workspace = true } clap = { workspace = true } dirs = { workspace = true } futures-util = { workspace = true } +getrandom = { workspace = true } once_cell = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } @@ -29,3 +32,4 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } edison-tunnel-protocol = { workspace = true } url = "2" +webbrowser = { workspace = true } diff --git a/crates/edison-stdiod/src/auth.rs b/crates/edison-stdiod/src/auth.rs new file mode 100644 index 0000000..92481e8 --- /dev/null +++ b/crates/edison-stdiod/src/auth.rs @@ -0,0 +1,693 @@ +//! OAuth-style browser/device authorization with PKCE for this stdiod install. + +use std::future::Future; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::config; +use crate::http::{self, HttpError}; + +pub const CLIENT_ID: &str = "stdiod"; +pub const CLIENT_SCOPES: &[&str] = &[ + "tunnel:connect", + "mcp_requests:create", + "mcp_requests:read", + "servers:self:read", +]; +const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5); +const MIN_POLL_INTERVAL: Duration = Duration::from_secs(5); + +pub struct Pkce { + verifier: String, + challenge: String, +} + +impl Pkce { + pub fn generate() -> Result { + let mut entropy = [0_u8; 32]; + getrandom::fill(&mut entropy) + .map_err(|_| AuthError::Protocol("secure random generation failed".into()))?; + let verifier = URL_SAFE_NO_PAD.encode(entropy); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + Ok(Self { + verifier, + challenge, + }) + } + + pub fn verifier(&self) -> &str { + &self.verifier + } + + pub fn challenge(&self) -> &str { + &self.challenge + } +} + +#[derive(Serialize)] +struct DeviceCodeRequest<'a> { + client_id: &'static str, + scope: &'static [&'static str], + code_challenge: &'a str, + code_challenge_method: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + device_label: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + client_installation_id: Option<&'a str>, + platform: &'static str, + client_version: &'static str, +} + +#[derive(Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Serialize)] +struct DeviceTokenRequest<'a> { + client_id: &'static str, + device_code: &'a str, + code_verifier: &'a str, +} + +#[derive(Deserialize)] +struct DeviceTokenError { + error: String, +} + +#[derive(Deserialize)] +pub struct DeviceTokenResponse { + pub access_token: String, + pub token_type: String, + pub client_installation_id: String, + pub device_id: String, + #[serde(deserialize_with = "deserialize_scopes")] + pub scope: Vec, + pub user_id: String, + pub org_id: String, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ScopeValue { + List(Vec), + SpaceDelimited(String), +} + +fn deserialize_scopes<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(match ScopeValue::deserialize(deserializer)? { + ScopeValue::List(scopes) => scopes, + ScopeValue::SpaceDelimited(scopes) => scopes + .split_ascii_whitespace() + .map(str::to_string) + .collect(), + }) +} + +#[derive(Debug, Error)] +pub enum AuthError { + #[error(transparent)] + Http(#[from] HttpError), + #[error("device authorization was denied")] + AccessDenied, + #[error("device authorization expired; run `edison-stdiod login` again")] + Expired, + #[error("device authorization failed with an unrecognized backend error")] + BackendRejected, + #[error("invalid device authorization response: {0}")] + Protocol(String), +} + +pub struct AuthClient { + base: String, + http: Client, +} + +impl AuthClient { + pub fn new(base: impl Into) -> Result { + let base = config::normalize_backend_url(&base.into()) + .map_err(|_| AuthError::Protocol("backend URL was invalid".into()))?; + let http = Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|source| HttpError::Request { + operation: "building auth HTTP client".into(), + source, + })?; + Ok(Self { base, http }) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base, path) + } + + pub async fn initiate( + &self, + code_challenge: &str, + device_label: Option<&str>, + client_installation_id: Option<&str>, + ) -> Result { + let path = "/api/v1/auth/device/code"; + let response = self + .http + .post(self.url(path)) + .json(&DeviceCodeRequest { + client_id: CLIENT_ID, + scope: CLIENT_SCOPES, + code_challenge, + code_challenge_method: "S256", + device_label, + client_installation_id, + platform: std::env::consts::OS, + client_version: env!("CARGO_PKG_VERSION"), + }) + .send() + .await + .map_err(|source| HttpError::Request { + operation: format!("POST {path}"), + source, + })?; + let code: DeviceCodeResponse = http::decode_json(response, format!("POST {path}")).await?; + validate_device_code(&code)?; + Ok(code) + } + + pub async fn poll( + &self, + code: &DeviceCodeResponse, + code_verifier: &str, + ) -> Result { + self.poll_with_sleep(code, code_verifier, tokio::time::sleep) + .await + } + + async fn poll_with_sleep( + &self, + code: &DeviceCodeResponse, + code_verifier: &str, + mut sleep: F, + ) -> Result + where + F: FnMut(Duration) -> Fut, + Fut: Future, + { + let deadline = Instant::now() + .checked_add(Duration::from_secs(code.expires_in)) + .ok_or(AuthError::Expired)?; + let mut interval = Duration::from_secs(code.interval).max(MIN_POLL_INTERVAL); + let path = "/api/v1/auth/device/token"; + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(AuthError::Expired); + } + let remaining = deadline.saturating_duration_since(now); + if interval >= remaining { + sleep(remaining).await; + return Err(AuthError::Expired); + } + sleep(interval).await; + + let response = self + .http + .post(self.url(path)) + .json(&DeviceTokenRequest { + client_id: CLIENT_ID, + device_code: &code.device_code, + code_verifier, + }) + .send() + .await + .map_err(|source| HttpError::Request { + operation: format!("POST {path}"), + source, + })?; + let status = response.status(); + if status == StatusCode::TOO_MANY_REQUESTS { + interval = retry_after(&response) + .unwrap_or(interval) + .max(interval) + .max(MIN_POLL_INTERVAL); + continue; + } + let body = response.text().await.unwrap_or_default(); + if Instant::now() >= deadline { + return Err(AuthError::Expired); + } + + if status.is_success() { + let token: DeviceTokenResponse = + serde_json::from_str(&body).map_err(|source| HttpError::Decode { + operation: format!("POST {path}"), + status, + source, + })?; + validate_token(&token)?; + return Ok(token); + } + + if status != StatusCode::BAD_REQUEST { + return Err(HttpError::Status { + operation: format!("POST {path}"), + status, + body, + } + .into()); + } + + let error: DeviceTokenError = + serde_json::from_str(&body).map_err(|source| HttpError::Decode { + operation: format!("POST {path}"), + status, + source, + })?; + match error.error.as_str() { + "authorization_pending" => {} + "slow_down" => interval = interval.saturating_add(SLOW_DOWN_INCREMENT), + "access_denied" => return Err(AuthError::AccessDenied), + "expired_token" => return Err(AuthError::Expired), + _ => return Err(AuthError::BackendRejected), + } + } + } + + /// Revocation is intentionally a separate best-effort operation at the + /// call site: local logout must proceed when the backend is unavailable. + pub async fn revoke(&self, token: &str) -> Result<(), HttpError> { + let path = "/api/v1/auth/device/revoke"; + let response = self + .http + .post(self.url(path)) + .bearer_auth(token) + .send() + .await + .map_err(|source| HttpError::Request { + operation: format!("POST {path}"), + source, + })?; + http::expect_success(response, format!("POST {path}")).await + } +} + +fn retry_after(response: &reqwest::Response) -> Option { + let value = response + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()?; + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + let retry_at = chrono::DateTime::parse_from_rfc2822(value).ok()?; + (retry_at.with_timezone(&chrono::Utc) - chrono::Utc::now()) + .to_std() + .ok() +} + +fn validate_device_code(code: &DeviceCodeResponse) -> Result<(), AuthError> { + if code.device_code.is_empty() || code.user_code.is_empty() || code.expires_in == 0 { + return Err(AuthError::Protocol( + "missing device code, user code, or expiry".into(), + )); + } + validate_http_url(&code.verification_uri)?; + validate_http_url(&code.verification_uri_complete)?; + Ok(()) +} + +fn validate_token(token: &DeviceTokenResponse) -> Result<(), AuthError> { + if token.token_type != "Bearer" { + return Err(AuthError::Protocol("token_type was not Bearer".into())); + } + if token.access_token.is_empty() + || token.client_installation_id.is_empty() + || token.device_id.is_empty() + || token.user_id.is_empty() + || token.org_id.is_empty() + { + return Err(AuthError::Protocol( + "token response omitted required identity fields".into(), + )); + } + Ok(()) +} + +pub fn validate_http_url(raw: &str) -> Result<(), AuthError> { + let parsed = url::Url::parse(raw) + .map_err(|_| AuthError::Protocol("verification URL was invalid".into()))?; + let loopback = match parsed.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + None => { + return Err(AuthError::Protocol( + "verification URL must include a hostname".into(), + )); + } + }; + if parsed.scheme() != "https" && !(parsed.scheme() == "http" && loopback) { + return Err(AuthError::Protocol( + "verification URL must use https (or http on loopback)".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use sha2::{Digest, Sha256}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + #[test] + fn pkce_has_rfc7636_shape_and_matching_s256_challenge() { + let pkce = Pkce::generate().unwrap(); + assert_eq!(pkce.verifier().len(), 43); + assert!(pkce + .verifier() + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))); + let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(pkce.verifier().as_bytes())); + assert_eq!(pkce.challenge(), expected); + assert_eq!(pkce.challenge().len(), 43); + } + + #[test] + fn verification_url_rejects_non_http_schemes() { + assert!(validate_http_url("https://example.test/activate").is_ok()); + assert!(validate_http_url("http://localhost:3001/device").is_ok()); + assert!(validate_http_url("http://127.0.0.1:3001/device").is_ok()); + assert!(validate_http_url("http://[::1]:3001/device").is_ok()); + assert!(validate_http_url("http://example.test/activate").is_err()); + assert!(validate_http_url("javascript:alert(1)").is_err()); + assert!(validate_http_url("file:///tmp/code").is_err()); + } + + #[tokio::test] + async fn initiation_sends_device_contract() { + let response = r#"{ + "device_code":"device-code", + "user_code":"ABCD-EFGH", + "verification_uri":"https://example.test/activate", + "verification_uri_complete":"https://example.test/activate?code=ABCD-EFGH", + "expires_in":600, + "interval":5 + }"#; + let (base, bodies, server) = mock_server(vec![(200, response)]).await; + let client = AuthClient::new(base).unwrap(); + let code = client + .initiate("pkce-challenge", Some("Laptop"), Some("install-existing")) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!(code.user_code, "ABCD-EFGH"); + let bodies = bodies.lock().unwrap(); + let request: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap(); + assert_eq!(request["client_id"], CLIENT_ID); + assert_eq!(request["code_challenge"], "pkce-challenge"); + assert_eq!(request["code_challenge_method"], "S256"); + assert_eq!(request["device_label"], "Laptop"); + assert_eq!(request["client_installation_id"], "install-existing"); + assert_eq!(request["platform"], std::env::consts::OS); + assert_eq!(request["client_version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + request["scope"], + serde_json::json!([ + "tunnel:connect", + "mcp_requests:create", + "mcp_requests:read", + "servers:self:read" + ]) + ); + } + + #[test] + fn initiation_omits_missing_installation_identity() { + let request = DeviceCodeRequest { + client_id: CLIENT_ID, + scope: CLIENT_SCOPES, + code_challenge: "challenge", + code_challenge_method: "S256", + device_label: None, + client_installation_id: None, + platform: "linux", + client_version: "test", + }; + let json = serde_json::to_value(request).unwrap(); + assert!(json.get("client_installation_id").is_none()); + } + + #[tokio::test] + async fn polling_handles_pending_slow_down_and_success() { + let success = r#"{ + "access_token":"opaque-client-token", + "token_type":"Bearer", + "client_installation_id":"install-1", + "device_id":"device-1", + "scope":["tunnel:connect"], + "user_id":"user-1", + "org_id":"org-1" + }"#; + let (base, bodies, server) = mock_server(vec![ + (400, r#"{"error":"authorization_pending"}"#), + (400, r#"{"error":"slow_down"}"#), + (200, success), + ]) + .await; + let client = AuthClient::new(base).unwrap(); + let code = sample_code(2, 60); + let waits = Arc::new(Mutex::new(Vec::new())); + let observed = waits.clone(); + let token = client + .poll_with_sleep(&code, "pkce-verifier", move |duration| { + observed.lock().unwrap().push(duration); + std::future::ready(()) + }) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!(token.client_installation_id, "install-1"); + assert_eq!( + *waits.lock().unwrap(), + vec![ + Duration::from_secs(5), + Duration::from_secs(5), + Duration::from_secs(10) + ] + ); + assert!(bodies + .lock() + .unwrap() + .iter() + .all(|body| body.contains("pkce-verifier") && body.contains("device-code"))); + } + + #[tokio::test] + async fn polling_surfaces_denied_and_expired() { + for (body, expected_denied) in [ + (r#"{"error":"access_denied"}"#, true), + (r#"{"error":"expired_token"}"#, false), + ] { + let (base, _, server) = mock_server(vec![(400, body)]).await; + let client = AuthClient::new(base).unwrap(); + let error = client + .poll_with_sleep(&sample_code(0, 60), "verifier", |_| std::future::ready(())) + .await + .err() + .unwrap(); + server.await.unwrap(); + assert_eq!(matches!(error, AuthError::AccessDenied), expected_denied); + assert_eq!(matches!(error, AuthError::Expired), !expected_denied); + } + } + + #[tokio::test] + async fn polling_honors_retry_after_without_decoding_the_429_body() { + let success = r#"{ + "access_token":"opaque-client-token", + "token_type":"Bearer", + "client_installation_id":"install-1", + "device_id":"device-1", + "scope":["tunnel:connect"], + "user_id":"user-1", + "org_id":"org-1" + }"#; + let (base, _, server) = mock_server(vec![ + (429, "body containing opaque-client-token is ignored"), + (200, success), + ]) + .await; + let client = AuthClient::new(base).unwrap(); + let waits = Arc::new(Mutex::new(Vec::new())); + let observed = waits.clone(); + let token = client + .poll_with_sleep(&sample_code(1, 60), "verifier", move |duration| { + observed.lock().unwrap().push(duration); + std::future::ready(()) + }) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!(token.access_token, "opaque-client-token"); + assert_eq!( + *waits.lock().unwrap(), + vec![Duration::from_secs(5), Duration::from_secs(9)] + ); + } + + #[tokio::test] + async fn retry_after_cannot_reduce_a_slowed_down_interval() { + let success = r#"{ + "access_token":"opaque-client-token", + "token_type":"Bearer", + "client_installation_id":"install-1", + "device_id":"device-1", + "scope":["tunnel:connect"], + "user_id":"user-1", + "org_id":"org-1" + }"#; + let (base, _, server) = mock_server(vec![ + (400, r#"{"error":"slow_down"}"#), + (429, "rate limited"), + (200, success), + ]) + .await; + let client = AuthClient::new(base).unwrap(); + let waits = Arc::new(Mutex::new(Vec::new())); + let observed = waits.clone(); + client + .poll_with_sleep(&sample_code(1, 60), "verifier", move |duration| { + observed.lock().unwrap().push(duration); + std::future::ready(()) + }) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!( + *waits.lock().unwrap(), + vec![ + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(10) + ] + ); + } + + #[tokio::test] + async fn polling_does_not_request_when_minimum_interval_reaches_expiry() { + let client = AuthClient::new("http://127.0.0.1:1").unwrap(); + let waits = Arc::new(Mutex::new(Vec::new())); + let observed = waits.clone(); + let result = client + .poll_with_sleep(&sample_code(0, 5), "verifier", move |duration| { + observed.lock().unwrap().push(duration); + std::future::ready(()) + }) + .await; + let error = match result { + Ok(_) => panic!("poll unexpectedly succeeded"), + Err(error) => error, + }; + assert!(matches!(error, AuthError::Expired)); + assert_eq!(waits.lock().unwrap().len(), 1); + } + + fn sample_code(interval: u64, expires_in: u64) -> DeviceCodeResponse { + DeviceCodeResponse { + device_code: "device-code".into(), + user_code: "ABCD-EFGH".into(), + verification_uri: "https://example.test/activate".into(), + verification_uri_complete: "https://example.test/activate?code=ABCD-EFGH".into(), + expires_in, + interval, + } + } + + type MockBodies = Arc>>; + + async fn mock_server( + responses: Vec<(u16, &'static str)>, + ) -> (String, MockBodies, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let bodies = Arc::new(Mutex::new(Vec::new())); + let captured = bodies.clone(); + let handle = tokio::spawn(async move { + for (status, body) in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + let header_end = loop { + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&chunk[..read]); + if let Some(index) = request.windows(4).position(|part| part == b"\r\n\r\n") { + break index + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while request.len() < header_end + content_length { + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&chunk[..read]); + } + captured.lock().unwrap().push( + String::from_utf8_lossy( + &request[header_end..header_end.saturating_add(content_length)], + ) + .into_owned(), + ); + + let reason = match status { + 200 => "OK", + 429 => "Too Many Requests", + _ => "Bad Request", + }; + let retry_after = if status == 429 { + "Retry-After: 9\r\n" + } else { + "" + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\n{retry_after}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.shutdown().await.unwrap(); + } + }); + (format!("http://{address}"), bodies, handle) + } +} diff --git a/crates/edison-stdiod/src/cli/login.rs b/crates/edison-stdiod/src/cli/login.rs index dbe8170..3880066 100644 --- a/crates/edison-stdiod/src/cli/login.rs +++ b/crates/edison-stdiod/src/cli/login.rs @@ -1,34 +1,33 @@ -//! `edison-stdiod login` - persist credentials + backend URL to -//! `~/.config/edison-stdiod/config.toml`. -//! -//! The supervisor unit (`launchctl`/`systemctl`) cannot reliably carry -//! secrets in its environment, so a one-shot login step writes them to a -//! 0600 file the daemon reads at startup. Re-running `login` merges: -//! existing fields are preserved unless explicitly overridden, so a user -//! can rotate their API key without re-supplying the backend URL. +//! `edison-stdiod login` - browser/device authorization or the deprecated +//! API-key persistence path used by existing desktop releases. use anyhow::{anyhow, Result}; use clap::Args; -use tracing::info; +use tracing::{info, warn}; -use crate::config::PersistedConfig; +use crate::auth::{AuthClient, Pkce}; +use crate::config::{self, PersistedConfig}; use crate::paths; #[derive(Debug, Args)] pub struct LoginArgs { - /// Backend base URL - `http://localhost:3001` for `make dev`, - /// `https://dashboard.edison.watch` for prod. + /// Backend base URL - `http://localhost:3001` for development or the + /// Edison Watch dashboard URL for production. #[arg(long)] pub backend: Option, - /// Bearer API key issued by the backend. Stored in plaintext under - /// mode 0600; rotate by re-running `login --api-key …`. + /// Deprecated legacy Bearer API key. When omitted, login uses browser + /// authorization and stores a scoped client access token. #[arg(long)] pub api_key: Option, + /// Do not try to open a browser during interactive login. The + /// verification URL and user code are still printed. + #[arg(long)] + pub no_open: bool, /// Optional `X-Edison-Secret-Key` for per-user secret decryption. #[arg(long)] pub edison_secret_key: Option, - /// Stable device identifier. Defaults to the machine's hostname when - /// neither stored nor passed. + /// Legacy device identifier override. Interactive login always uses the + /// server-issued device ID. #[arg(long)] pub device_id: Option, /// Human-readable label shown in the admin Devices page. @@ -36,87 +35,294 @@ pub struct LoginArgs { pub device_label: Option, } -pub fn run(args: LoginArgs) -> Result<()> { +pub async fn run(args: LoginArgs) -> Result<()> { let mut cfg = PersistedConfig::load()?; + let backend = match args.backend.as_deref() { + Some(value) => config::normalize_backend_url(value)?, + None => cfg + .backend_url + .as_deref() + .filter(|value| !value.is_empty()) + .map(config::normalize_backend_url) + .transpose()? + .ok_or_else(|| anyhow!("missing backend URL. Pass --backend on first login."))?, + }; + let same_issuer = config::same_backend(cfg.backend_url.as_deref(), &backend); - if let Some(v) = args.backend { - cfg.backend_url = Some(normalize_backend_url(&v)?); + if let Some(api_key) = args.api_key.clone() { + return legacy_login(cfg, backend, same_issuer, api_key, args).await; } - if let Some(v) = args.api_key { - cfg.api_key = Some(v); + let previous_credential = capture_client_revocation(&cfg); + + let pkce = Pkce::generate()?; + let auth = AuthClient::new(backend.clone())?; + let label = args.device_label.as_deref().or(cfg.device_label.as_deref()); + let existing_installation = reusable_installation(&cfg, same_issuer); + let code = auth + .initiate(pkce.challenge(), label, existing_installation) + .await?; + + println!("Open this URL to authorize stdiod:"); + println!("{}", code.verification_uri_complete); + println!("User code: {}", code.user_code); + if !try_open_browser(&code.verification_uri_complete, args.no_open, |url| { + webbrowser::open(url).is_ok() + })? && !args.no_open + { + warn!("could not open the default browser; continuing with printed instructions"); + eprintln!("Could not open a browser. Open the URL above manually."); } - if let Some(v) = args.edison_secret_key { - cfg.edison_secret_key = Some(v); + println!("Waiting for authorization..."); + + let token = auth.poll(&code, pkce.verifier()).await?; + let account_changed = !same_issuer + || cfg.client_installation_id.as_deref() != Some(token.client_installation_id.as_str()); + + cfg.backend_url = Some(backend); + cfg.api_key = None; + cfg.client_access_token = Some(token.access_token); + cfg.client_installation_id = Some(token.client_installation_id); + cfg.authenticated_user_id = Some(token.user_id); + cfg.authenticated_org_id = Some(token.org_id); + cfg.device_id = Some(token.device_id); + cfg.scopes = token.scope; + if let Some(label) = args.device_label { + cfg.device_label = Some(label); } - if let Some(v) = args.device_id { - cfg.device_id = Some(v); + if account_changed { + cfg.edison_secret_key = args.edison_secret_key; + } else if let Some(secret) = args.edison_secret_key { + cfg.edison_secret_key = Some(secret); } - if let Some(v) = args.device_label { - cfg.device_label = Some(v); + + save_login(&cfg)?; + if account_changed { + revoke_previous(previous_credential).await; + } + println!("Authorization complete."); + Ok(()) +} + +async fn legacy_login( + cfg: PersistedConfig, + backend: String, + same_issuer: bool, + api_key: String, + args: LoginArgs, +) -> Result<()> { + let previous_credential = capture_client_revocation(&cfg); + let cfg = configure_legacy_login(cfg, backend, same_issuer, api_key, args)?; + save_login(&cfg)?; + revoke_previous(previous_credential).await; + Ok(()) +} + +fn capture_client_revocation(cfg: &PersistedConfig) -> Option<(String, String)> { + Some(( + cfg.backend_url.as_ref()?.clone(), + cfg.client_access_token + .as_ref() + .filter(|token| !token.is_empty())? + .clone(), + )) +} + +async fn revoke_previous(revocation: Option<(String, String)>) { + let Some((backend, token)) = revocation else { + return; + }; + match AuthClient::new(backend) { + Ok(client) => { + if let Err(error) = client.revoke(&token).await { + warn!( + status = ?error.status(), + auth_rejected = error.is_auth_rejection(), + "previous client credential revocation failed after login" + ); + } + } + Err(_) => warn!("could not construct previous credential revocation client"), } +} + +fn reusable_installation(cfg: &PersistedConfig, same_issuer: bool) -> Option<&str> { + same_issuer + .then_some(cfg.client_installation_id.as_deref()) + .flatten() + .filter(|id| !id.is_empty()) +} - // Require at least the API key + backend URL to exist *after* the - // merge - otherwise `install` will write a supervisor unit that boots - // a daemon doomed to fail at first connect. - if cfg.backend_url.as_deref().unwrap_or("").is_empty() { - return Err(anyhow!( - "missing backend URL. Pass --backend on first login.", - )); +fn configure_legacy_login( + mut cfg: PersistedConfig, + backend: String, + same_issuer: bool, + api_key: String, + args: LoginArgs, +) -> Result { + if api_key.is_empty() { + return Err(anyhow!("legacy API key cannot be empty")); } - if cfg.api_key.as_deref().unwrap_or("").is_empty() { - return Err(anyhow!("missing API key. Pass --api-key on first login.",)); + if !same_issuer + || cfg + .client_access_token + .as_deref() + .is_some_and(|token| !token.is_empty()) + { + cfg.clear_authentication(); } + cfg.backend_url = Some(backend); + cfg.api_key = Some(api_key); + cfg.client_access_token = None; + cfg.client_installation_id = None; + cfg.authenticated_user_id = None; + cfg.authenticated_org_id = None; + cfg.scopes.clear(); + if let Some(value) = args.edison_secret_key { + cfg.edison_secret_key = Some(value); + } + if let Some(value) = args.device_id { + cfg.device_id = Some(value); + } + if let Some(value) = args.device_label { + cfg.device_label = Some(value); + } + Ok(cfg) +} +fn save_login(cfg: &PersistedConfig) -> Result<()> { cfg.save()?; let path = paths::config_file()?; info!(path = %path.display(), "wrote config.toml"); println!("Saved {}", path.display()); - println!("Next: `edison-stdiod install` to register the LaunchAgent."); + println!("Next: `edison-stdiod install` to register the supervisor service."); Ok(()) } -/// Strip a trailing slash so we can build `/api/v1/...` paths by -/// simple concatenation, and reject obvious typos early instead of -/// surfacing a confusing TCP error at first connect. -fn normalize_backend_url(raw: &str) -> Result { - let url = url::Url::parse(raw).map_err(|e| anyhow!("`{}` is not a valid URL ({})", raw, e))?; - if !matches!(url.scheme(), "http" | "https") { - return Err(anyhow!( - "backend URL must use http or https, got `{}`", - url.scheme(), - )); - } - Ok(raw.trim_end_matches('/').to_string()) +/// Validate before invoking the opener. Returning `false` means skipped or +/// failed; both are nonfatal because the printed code is sufficient. +fn try_open_browser(url: &str, no_open: bool, opener: F) -> Result +where + F: FnOnce(&str) -> bool, +{ + crate::auth::validate_http_url(url)?; + if no_open { + return Ok(false); + } + Ok(opener(url)) } #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use super::*; #[test] - fn normalize_strips_trailing_slash() { - assert_eq!( - normalize_backend_url("http://localhost:3001/").unwrap(), - "http://localhost:3001", - ); + fn no_open_does_not_invoke_browser() { + let called = AtomicBool::new(false); + let opened = try_open_browser("https://example.test/activate", true, |_| { + called.store(true, Ordering::Relaxed); + true + }) + .unwrap(); + assert!(!opened); + assert!(!called.load(Ordering::Relaxed)); + } + + #[test] + fn browser_failure_is_nonfatal() { + let result = try_open_browser("https://example.test/activate", false, |_| false); + assert!(matches!(result, Ok(false))); + } + + #[test] + fn browser_url_is_validated_even_when_opening_is_disabled() { + assert!(try_open_browser("javascript:alert(1)", true, |_| true).is_err()); + } + + #[test] + fn existing_installation_is_reused_only_for_same_backend() { + let cfg = PersistedConfig { + client_installation_id: Some("install-1".into()), + ..Default::default() + }; + assert_eq!(reusable_installation(&cfg, true), Some("install-1")); + assert_eq!(reusable_installation(&cfg, false), None); } #[test] - fn normalize_accepts_https() { + fn previous_client_credential_is_captured_before_account_switch() { + let cfg = PersistedConfig { + backend_url: Some("https://old.test".into()), + client_access_token: Some("old-client-token".into()), + api_key: Some("ignored-legacy-key".into()), + ..Default::default() + }; assert_eq!( - normalize_backend_url("https://dashboard.edison.watch").unwrap(), - "https://dashboard.edison.watch", + capture_client_revocation(&cfg), + Some(("https://old.test".into(), "old-client-token".into())) ); } #[test] - fn normalize_rejects_bad_scheme() { - let err = normalize_backend_url("ftp://example.com").unwrap_err(); - assert!(err.to_string().contains("must use http or https")); + fn changed_backend_legacy_login_clears_account_bindings() { + let cfg = PersistedConfig { + backend_url: Some("https://old.test".into()), + client_access_token: Some("client-token".into()), + client_installation_id: Some("install-1".into()), + authenticated_user_id: Some("user-1".into()), + authenticated_org_id: Some("org-1".into()), + edison_secret_key: Some("old-secret".into()), + device_id: Some("old-device".into()), + ..Default::default() + }; + let updated = configure_legacy_login( + cfg, + "https://new.test".into(), + false, + "legacy-key".into(), + LoginArgs { + backend: None, + api_key: None, + no_open: true, + edison_secret_key: None, + device_id: None, + device_label: None, + }, + ) + .unwrap(); + assert!(updated.client_access_token.is_none()); + assert!(updated.client_installation_id.is_none()); + assert!(updated.authenticated_user_id.is_none()); + assert!(updated.authenticated_org_id.is_none()); + assert!(updated.edison_secret_key.is_none()); + assert!(updated.device_id.is_none()); } #[test] - fn normalize_rejects_garbage() { - assert!(normalize_backend_url("not a url").is_err()); + fn changed_backend_legacy_login_keeps_only_explicit_secret_and_device() { + let cfg = PersistedConfig { + backend_url: Some("https://old.test".into()), + edison_secret_key: Some("old-secret".into()), + device_id: Some("old-device".into()), + ..Default::default() + }; + let updated = configure_legacy_login( + cfg, + "https://new.test".into(), + false, + "legacy-key".into(), + LoginArgs { + backend: None, + api_key: None, + no_open: true, + edison_secret_key: Some("new-secret".into()), + device_id: Some("new-device".into()), + device_label: None, + }, + ) + .unwrap(); + assert_eq!(updated.edison_secret_key.as_deref(), Some("new-secret")); + assert_eq!(updated.device_id.as_deref(), Some("new-device")); } } diff --git a/crates/edison-stdiod/src/cli/logout.rs b/crates/edison-stdiod/src/cli/logout.rs new file mode 100644 index 0000000..5ae6b2e --- /dev/null +++ b/crates/edison-stdiod/src/cli/logout.rs @@ -0,0 +1,98 @@ +//! `edison-stdiod logout` - local credential removal followed by best-effort +//! remote revocation. + +use anyhow::Result; +use clap::Args; +use tracing::warn; + +use crate::auth::AuthClient; +use crate::config::{CredentialKind, PersistedConfig}; + +#[derive(Debug, Args)] +pub struct LogoutArgs {} + +pub async fn run(_args: LogoutArgs) -> Result<()> { + let mut cfg = PersistedConfig::load()?; + let revoke = capture_revocation(&cfg); + + // Publish local logout atomically before any network wait. The live daemon + // can then stop authenticated children even if revocation times out. + cfg.clear_authentication(); + cfg.save()?; + + if let Some((backend, token)) = revoke { + match AuthClient::new(backend) { + Ok(client) => { + if let Err(error) = client.revoke(&token).await { + warn!( + status = ?error.status(), + auth_rejected = error.is_auth_rejection(), + "client credential revocation failed; local logout is complete" + ); + } + } + Err(_) => warn!("could not construct revocation client; local logout is complete"), + } + } + + println!("Logged out. Backend URL and local preferences were retained."); + Ok(()) +} + +fn capture_revocation(cfg: &PersistedConfig) -> Option<(String, String)> { + cfg.backend_url.clone().and_then(|backend| { + cfg.usable_credential().and_then(|credential| { + (credential.kind() == CredentialKind::ClientAccessToken) + .then(|| (backend, credential.token().to_string())) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_logout_is_idempotent() { + let mut cfg = PersistedConfig { + backend_url: Some("https://example.test".into()), + client_access_token: Some("token".into()), + client_installation_id: Some("install-1".into()), + device_id: Some("device-1".into()), + device_label: Some("Laptop".into()), + ..Default::default() + }; + cfg.clear_authentication(); + cfg.clear_authentication(); + assert!(cfg.usable_credential().is_none()); + assert_eq!(cfg.backend_url.as_deref(), Some("https://example.test")); + assert_eq!(cfg.device_label.as_deref(), Some("Laptop")); + } + + #[test] + fn revocation_data_survives_local_clear() { + let mut cfg = PersistedConfig { + backend_url: Some("https://example.test".into()), + client_access_token: Some("token".into()), + client_installation_id: Some("install-1".into()), + ..Default::default() + }; + let revocation = capture_revocation(&cfg); + cfg.clear_authentication(); + assert_eq!( + revocation, + Some(("https://example.test".into(), "token".into())) + ); + assert!(cfg.usable_credential().is_none()); + } + + #[test] + fn legacy_keys_are_not_sent_to_client_revocation() { + let cfg = PersistedConfig { + backend_url: Some("https://example.test".into()), + api_key: Some("legacy".into()), + ..Default::default() + }; + assert!(capture_revocation(&cfg).is_none()); + } +} diff --git a/crates/edison-stdiod/src/cli/mod.rs b/crates/edison-stdiod/src/cli/mod.rs index 0d4abb8..b49fcfd 100644 --- a/crates/edison-stdiod/src/cli/mod.rs +++ b/crates/edison-stdiod/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod install; pub mod login; +pub mod logout; pub mod logs; pub mod server; pub mod status; diff --git a/crates/edison-stdiod/src/cli/server.rs b/crates/edison-stdiod/src/cli/server.rs index c7788a3..f4b3a1a 100644 --- a/crates/edison-stdiod/src/cli/server.rs +++ b/crates/edison-stdiod/src/cli/server.rs @@ -1,19 +1,17 @@ -//! `edison-stdiod server add | list | remove` - manage stdio_tunnel -//! servers from the CLI without opening the dashboard. +//! `edison-stdiod server add | list | remove` server actions. //! -//! Each subcommand talks to the same `/api/v1/servers` endpoints the -//! dashboard uses. The CLI does **not** go through the daemon's WS -//! tunnel for these calls: that path is reserved for `mcp_frame` traffic -//! once a server is mounted. CRUD lives on the HTTP API so the same -//! validation (collision check, feature flag, etc.) applies whether the -//! request originates from the dashboard, the CLI, or a script. - -use anyhow::Result; +//! Browser-auth client credentials use the narrow `/api/v1/client/...` +//! surface. Deprecated API keys use the existing user request surface for add +//! and retain broad list/remove compatibility where those operations are valid. + +use std::fmt::Write as _; + +use anyhow::{anyhow, bail, Result}; use clap::{Args, Subcommand}; use serde::{Deserialize, Serialize}; -use crate::config::PersistedConfig; -use crate::http; +use crate::config::{CredentialKind, PersistedConfig}; +use crate::http::{self, BackendClient}; #[derive(Debug, Args)] pub struct ServerArgs { @@ -23,22 +21,19 @@ pub struct ServerArgs { #[derive(Debug, Subcommand)] pub enum ServerCommand { - /// Register a new stdio_tunnel server with the backend. The server - /// will be mounted via this daemon and proxied through the user's - /// gateway. The server's MCP prefix must be alphanumeric. + /// Submit a local stdio MCP server for approval. Add(AddArgs), - /// List all stdio_tunnel servers currently registered for this - /// device (filtered to the device_id from config.toml). + /// List approved stdio_tunnel servers for this client device. List(ListArgs), - /// Delete a server by name. Idempotent: re-running for a missing - /// name reports it as a no-op. + /// Withdraw a pending request by name. With a legacy API key, delete the + /// approved server directly as before. Remove(RemoveArgs), } #[derive(Debug, Args)] pub struct AddArgs { /// MCP prefix name. Must be alphanumeric (plus hyphens). Tool calls - /// will appear in the gateway as ``_``. + /// will appear in the gateway as `_`. pub name: String, /// Executable to spawn on this device. #[arg(long)] @@ -46,12 +41,11 @@ pub struct AddArgs { /// Arguments passed to the executable. #[arg(long = "arg", value_name = "ARG", num_args = 0..)] pub args: Vec, - /// Working directory for the subprocess (defaults to the daemon's - /// cwd, typically `$HOME`). + /// Working directory for the subprocess. MCP requests cannot persist this + /// field, so this option is currently rejected. #[arg(long)] pub working_dir: Option, /// Optional human-readable display name shown in the dashboard. - /// Defaults to the prefix name. #[arg(long)] pub display_name: Option, } @@ -69,67 +63,96 @@ pub struct RemoveArgs { } pub async fn run(args: ServerArgs) -> Result<()> { - match args.command { - ServerCommand::Add(a) => add(a).await, - ServerCommand::List(a) => list(a).await, - ServerCommand::Remove(a) => remove(a).await, - } + let cfg = PersistedConfig::load()?; + let client = http::from_config(&cfg)?; + let output = match args.command { + ServerCommand::Add(args) => add_with(args, &cfg, &client).await?, + ServerCommand::List(args) => list_with(args, &cfg, &client).await?, + ServerCommand::Remove(args) => remove_with(args, &cfg, &client).await?, + }; + println!("{output}"); + Ok(()) } -// --------------------------------------------------------------------------- -// add -// --------------------------------------------------------------------------- -// -// Mirrors the dashboard's CreateServerRequest body (see -// src/api/v1/schemas/servers.py:167 `CreateServerRequest`). Only the -// fields stdio_tunnel callers need are sent; the backend defaults the -// rest. +fn credential_kind(cfg: &PersistedConfig) -> Result { + cfg.usable_credential() + .map(|credential| credential.kind()) + .ok_or_else(|| anyhow!("no credential on disk. Run `edison-stdiod login --backend ...`.")) +} #[derive(Debug, Serialize)] -struct CreateServerBody { +struct ClientCreateRequestBody { name: String, display_name: Option, - transport_type: &'static str, command: String, args: Vec, - device_id: String, - url: &'static str, - enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] - working_dir: Option, + hostname: Option, } -async fn add(args: AddArgs) -> Result<()> { - let cfg = PersistedConfig::load()?; - let device_id = cfg - .device_id - .clone() - .unwrap_or_else(crate::config::hostname); - let client = http::from_persisted()?; - let body = CreateServerBody { - name: args.name.clone(), - display_name: args.display_name, - transport_type: "stdio_tunnel", - command: args.command, - args: args.args, - device_id: device_id.clone(), - url: "", - enabled: true, - working_dir: args.working_dir, - }; - let resp: serde_json::Value = client.post_json("/api/v1/servers", &body).await?; - let name = resp - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(&args.name); - println!("Registered {} on device {}", name, device_id); - println!("Run `edison-stdiod status` to confirm it has spawned."); - Ok(()) +#[derive(Debug, Deserialize)] +struct ClientCreateRequestResponse { + request_id: u64, + #[serde(default)] + auto_approved: bool, } -// --------------------------------------------------------------------------- -// list -// --------------------------------------------------------------------------- +async fn add_with(args: AddArgs, cfg: &PersistedConfig, client: &BackendClient) -> Result { + let AddArgs { + name, + command, + args, + working_dir, + display_name, + } = args; + if working_dir.is_some() { + bail!( + "--working-dir is not supported because MCP requests cannot persist it; omit the option or configure it through the dashboard" + ); + } + + match credential_kind(cfg)? { + CredentialKind::ClientAccessToken => { + let body = ClientCreateRequestBody { + name: name.clone(), + display_name, + command, + args, + hostname: None, + }; + let response: ClientCreateRequestResponse = client + .post_json("/api/v1/client/mcp-requests", &body) + .await?; + Ok(format_create_result(&name, response)) + } + CredentialKind::LegacyApiKey => { + let body = ClientCreateRequestBody { + name: name.clone(), + display_name, + command, + args, + hostname: Some(crate::config::hostname()), + }; + let response: ClientCreateRequestResponse = + client.post_json("/api/v1/mcp-requests", &body).await?; + Ok(format_create_result(&name, response)) + } + } +} + +fn format_create_result(name: &str, response: ClientCreateRequestResponse) -> String { + if response.auto_approved { + format!( + "Request '{name}' was auto-approved (request ID {}). Run `edison-stdiod status` to confirm it has spawned.", + response.request_id + ) + } else { + format!( + "Submitted request '{name}' for approval (request ID {}).", + response.request_id + ) + } +} #[derive(Debug, Deserialize)] struct ListServersResponse { @@ -152,80 +175,355 @@ struct ServerListItem { device_id: Option, } -async fn list(args: ListArgs) -> Result<()> { - let cfg = PersistedConfig::load()?; - let device_id = cfg - .device_id - .clone() - .unwrap_or_else(crate::config::hostname); - let client = http::from_persisted()?; - // The backend returns every server in the org; filter to this - // device's stdio_tunnel rows on the client side. Per-device server - // listing endpoint is a v1.1 nice-to-have. - // The backend caps ``per_page`` at 200. Listing more than that from a - // single device would already be unusual; pagination is a v1.1 item. - let resp: ListServersResponse = client - .get_json("/api/v1/servers?page=1&per_page=200") - .await?; - let mut filtered: Vec<&ServerListItem> = resp - .items - .iter() - .filter(|it| { - it.transport_type.as_deref() == Some("stdio_tunnel") - && it.device_id.as_deref() == Some(device_id.as_str()) - }) - .collect(); - filtered.sort_by(|a, b| a.name.cmp(&b.name)); +async fn list_with( + args: ListArgs, + cfg: &PersistedConfig, + client: &BackendClient, +) -> Result { + let kind = credential_kind(cfg)?; + let mut items = match kind { + CredentialKind::ClientAccessToken => { + client + .get_json::("/api/v1/client/servers") + .await? + .items + } + CredentialKind::LegacyApiKey => { + let device_id = cfg + .device_id + .clone() + .unwrap_or_else(crate::config::hostname); + let mut items = client + .get_json::("/api/v1/servers?page=1&per_page=200") + .await? + .items; + items.retain(|item| { + item.transport_type.as_deref() == Some("stdio_tunnel") + && item.device_id.as_deref() == Some(device_id.as_str()) + }); + items + } + }; + items.sort_by(|left, right| left.name.cmp(&right.name)); if args.json { - let json = serde_json::to_string_pretty( - &filtered + return Ok(serde_json::to_string_pretty( + &items .iter() - .map(|it| { + .map(|item| { serde_json::json!({ - "name": it.name, - "display_name": it.display_name, - "enabled": it.enabled, - "tool_count": it.tool_count, - "device_id": it.device_id, + "name": item.name, + "display_name": item.display_name, + "enabled": item.enabled, + "tool_count": item.tool_count, + "device_id": item.device_id, }) }) .collect::>(), - )?; - println!("{json}"); - return Ok(()); + )?); } - if filtered.is_empty() { - println!("No stdio_tunnel servers registered for device {device_id}."); - return Ok(()); + if items.is_empty() { + return Ok(match kind { + CredentialKind::ClientAccessToken => { + "No approved stdio_tunnel servers registered for this client device.".into() + } + CredentialKind::LegacyApiKey => format!( + "No stdio_tunnel servers registered for device {}.", + cfg.device_id + .clone() + .unwrap_or_else(crate::config::hostname) + ), + }); } - println!("{:<24} {:<8} {:<6} display", "name", "enabled", "tools"); - println!("{:<24} {:<8} {:<6} -------", "----", "-------", "-----"); - for it in filtered { - println!( + let mut output = String::new(); + writeln!( + output, + "{:<24} {:<8} {:<6} display", + "name", "enabled", "tools" + )?; + writeln!( + output, + "{:<24} {:<8} {:<6} -------", + "----", "-------", "-----" + )?; + for item in items { + writeln!( + output, "{:<24} {:<8} {:<6} {}", - it.name, - it.enabled, - it.tool_count, - it.display_name.as_deref().unwrap_or(""), - ); + item.name, + item.enabled, + item.tool_count, + item.display_name.as_deref().unwrap_or("") + )?; + } + Ok(output.trim_end().to_string()) +} + +async fn remove_with( + args: RemoveArgs, + cfg: &PersistedConfig, + client: &BackendClient, +) -> Result { + let encoded_name: String = url::form_urlencoded::byte_serialize(args.name.as_bytes()).collect(); + match credential_kind(cfg)? { + CredentialKind::ClientAccessToken => { + let path = format!("/api/v1/client/mcp-requests/{encoded_name}"); + if client.delete(&path).await? { + Ok(format!("Withdrew pending request '{}'.", args.name)) + } else { + Ok(format!( + "No pending request named '{}'. Approved server removal requires dashboard/admin action.", + args.name + )) + } + } + CredentialKind::LegacyApiKey => { + let path = format!("/api/v1/servers/{encoded_name}"); + if client.delete(&path).await? { + Ok(format!("Removed {}", args.name)) + } else { + Ok(format!("No server named '{}' (already absent).", args.name)) + } + } } - Ok(()) } -// --------------------------------------------------------------------------- -// remove -// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; -async fn remove(args: RemoveArgs) -> Result<()> { - let client = http::from_persisted()?; - let path = format!("/api/v1/servers/{}", args.name); - if client.delete(&path).await? { - println!("Removed {}", args.name); - } else { - println!("No server named '{}' (already absent).", args.name); + use super::*; + + async fn mock_backend( + responses: Vec<(u16, &'static str)>, + ) -> (String, tokio::task::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0, "connection closed before request headers"); + request.extend_from_slice(&chunk[..read]); + if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") + { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::trim) + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(0); + while request.len() < header_end + content_length { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0, "connection closed before request body"); + request.extend_from_slice(&chunk[..read]); + } + requests.push(String::from_utf8(request).unwrap()); + + let reason = if status == 204 { + "No Content" + } else if status == 404 { + "Not Found" + } else { + "OK" + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + requests + }); + (format!("http://{address}"), task) + } + + fn client_config(backend_url: String) -> PersistedConfig { + PersistedConfig { + backend_url: Some(backend_url), + client_access_token: Some("client-token".into()), + device_id: Some("configured-device".into()), + ..Default::default() + } + } + + fn legacy_config(backend_url: String) -> PersistedConfig { + PersistedConfig { + backend_url: Some(backend_url), + api_key: Some("legacy-key".into()), + device_id: Some("legacy-device".into()), + ..Default::default() + } + } + + #[tokio::test] + async fn client_add_uses_request_endpoint_and_reports_request_id() { + let (base, requests) = mock_backend(vec![( + 201, + r#"{"status":"success","message":"queued","request_id":42,"auto_approved":false}"#, + )]) + .await; + let cfg = client_config(base.clone()); + let client = BackendClient::new(base, "client-token").unwrap(); + + let output = add_with( + AddArgs { + name: "filesystem".into(), + command: "npx".into(), + args: vec!["-y".into(), "server-filesystem".into()], + working_dir: None, + display_name: Some("Filesystem".into()), + }, + &cfg, + &client, + ) + .await + .unwrap(); + + assert_eq!( + output, + "Submitted request 'filesystem' for approval (request ID 42)." + ); + let requests = requests.await.unwrap(); + assert!(requests[0].starts_with("POST /api/v1/client/mcp-requests HTTP/1.1")); + let body: serde_json::Value = + serde_json::from_str(requests[0].split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["name"], "filesystem"); + assert_eq!(body["command"], "npx"); + assert!(body.get("device_id").is_none()); + } + + #[tokio::test] + async fn client_add_rejects_working_dir_before_http() { + let cfg = client_config("http://127.0.0.1:1".into()); + let client = BackendClient::new("http://127.0.0.1:1", "client-token").unwrap(); + + let error = add_with( + AddArgs { + name: "demo".into(), + command: "npx".into(), + args: Vec::new(), + working_dir: Some("/tmp".into()), + display_name: None, + }, + &cfg, + &client, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("cannot persist it")); + } + + #[tokio::test] + async fn client_list_uses_scoped_endpoint_without_local_filtering() { + let (base, requests) = mock_backend(vec![( + 200, + r#"{"items":[{"name":"remote-id","display_name":"Remote","enabled":true,"tool_count":3,"device_id":"backend-device"}]}"#, + )]) + .await; + let cfg = client_config(base.clone()); + let client = BackendClient::new(base, "client-token").unwrap(); + + let output = list_with(ListArgs { json: false }, &cfg, &client) + .await + .unwrap(); + + assert!(output.contains("remote-id")); + assert!(output.contains("Remote")); + let requests = requests.await.unwrap(); + assert!(requests[0].starts_with("GET /api/v1/client/servers HTTP/1.1")); + } + + #[tokio::test] + async fn client_remove_404_explains_approved_server_policy() { + let (base, requests) = mock_backend(vec![(404, r#"{"detail":"not found"}"#)]).await; + let cfg = client_config(base.clone()); + let client = BackendClient::new(base, "client-token").unwrap(); + + let output = remove_with( + RemoveArgs { + name: "filesystem".into(), + }, + &cfg, + &client, + ) + .await + .unwrap(); + + assert!(output.contains("Approved server removal requires dashboard/admin action")); + let requests = requests.await.unwrap(); + assert!(requests[0].starts_with("DELETE /api/v1/client/mcp-requests/filesystem HTTP/1.1")); + } + + #[tokio::test] + async fn legacy_add_uses_request_surface_with_hostname() { + let (base, requests) = mock_backend(vec![( + 201, + r#"{"status":"success","message":"created","request_id":17,"auto_approved":true}"#, + )]) + .await; + let cfg = legacy_config(base.clone()); + let client = BackendClient::new(base, "legacy-key").unwrap(); + let expected_hostname = crate::config::hostname(); + + let output = add_with( + AddArgs { + name: "legacy".into(), + command: "npx".into(), + args: Vec::new(), + working_dir: None, + display_name: None, + }, + &cfg, + &client, + ) + .await + .unwrap(); + + assert!(output.contains("was auto-approved (request ID 17)")); + let requests = requests.await.unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /api/v1/mcp-requests HTTP/1.1")); + let body: serde_json::Value = + serde_json::from_str(requests[0].split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["hostname"], expected_hostname); + assert!(body.get("device_ref_id").is_none()); + assert!(body.get("working_dir").is_none()); + } + + #[test] + fn create_result_distinguishes_pending_and_auto_approved() { + let pending = format_create_result( + "demo", + ClientCreateRequestResponse { + request_id: 1, + auto_approved: false, + }, + ); + let approved = format_create_result( + "demo", + ClientCreateRequestResponse { + request_id: 2, + auto_approved: true, + }, + ); + assert!(pending.contains("for approval")); + assert!(approved.contains("auto-approved")); } - Ok(()) } diff --git a/crates/edison-stdiod/src/config.rs b/crates/edison-stdiod/src/config.rs index 7de61c4..470ae4c 100644 --- a/crates/edison-stdiod/src/config.rs +++ b/crates/edison-stdiod/src/config.rs @@ -21,25 +21,62 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use url::Host; -use crate::paths; +use crate::{paths, secure_file}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialKind { + ClientAccessToken, + LegacyApiKey, +} + +/// A selected bearer credential. Its value deliberately has no `Debug` +/// implementation so logging a config decision cannot expose the secret. +#[derive(Clone, Copy)] +pub struct CredentialRef<'a> { + token: &'a str, + kind: CredentialKind, +} + +impl<'a> CredentialRef<'a> { + pub fn token(self) -> &'a str { + self.token + } + + pub fn kind(self) -> CredentialKind { + self.kind + } +} /// On-disk shape of `config.toml`. All fields optional so a partial config /// (e.g. backend URL only, before `login`) still parses. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Default, Clone, Serialize, Deserialize)] pub struct PersistedConfig { /// Backend base URL (`http://localhost:3001` for dev, /// `https://dashboard.edison.watch` for prod). #[serde(default)] pub backend_url: Option, - /// API key used for both the WS Bearer header and HTTP API calls. + /// Deprecated legacy API key. Retained because released desktop clients + /// still invoke `login --api-key`. #[serde(default)] pub api_key: Option, + /// Opaque client credential issued by the browser/device auth flow. + #[serde(default)] + pub client_access_token: Option, + /// Backend identity and account binding for the client credential. + #[serde(default)] + pub client_installation_id: Option, + #[serde(default)] + pub authenticated_user_id: Option, + #[serde(default)] + pub authenticated_org_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scopes: Vec, /// Optional Edison secret key (`X-Edison-Secret-Key`). #[serde(default)] pub edison_secret_key: Option, - /// Stable device id; defaults to the hostname when the user doesn't - /// pick one explicitly. + /// Server-issued device ID for client auth, or a legacy override. #[serde(default)] pub device_id: Option, /// Human-readable label shown in the admin UI. @@ -47,6 +84,30 @@ pub struct PersistedConfig { pub device_label: Option, } +impl std::fmt::Debug for PersistedConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PersistedConfig") + .field("backend_url", &self.backend_url) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field( + "client_access_token", + &self.client_access_token.as_ref().map(|_| ""), + ) + .field("client_installation_id", &self.client_installation_id) + .field("authenticated_user_id", &self.authenticated_user_id) + .field("authenticated_org_id", &self.authenticated_org_id) + .field("scopes", &self.scopes) + .field( + "edison_secret_key", + &self.edison_secret_key.as_ref().map(|_| ""), + ) + .field("device_id", &self.device_id) + .field("device_label", &self.device_label) + .finish() + } +} + impl PersistedConfig { /// Load from `~/.config/edison-stdiod/config.toml`. Missing file is OK /// and returns [`PersistedConfig::default`] so first-run / env-only @@ -58,9 +119,12 @@ impl PersistedConfig { pub fn load_from(path: &Path) -> Result { match std::fs::read_to_string(path) { - Ok(body) => { - toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display())) - } + Ok(body) => toml::from_str(&body).map_err(|_| { + anyhow!( + "failed to parse {}; config contains invalid TOML", + path.display() + ) + }), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } @@ -71,20 +135,66 @@ impl PersistedConfig { /// merge load + mutate first. pub fn save(&self) -> Result<()> { let path = paths::config_file()?; - let tmp = path.with_extension("toml.tmp"); + self.save_to(&path) + } + + pub fn save_to(&self, path: &Path) -> Result<()> { let body = toml::to_string_pretty(self).context("serialising config.toml")?; - std::fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&tmp)?.permissions(); - perms.set_mode(0o600); - std::fs::set_permissions(&tmp, perms)?; + secure_file::write_private(path, body.as_bytes()) + } + + /// Prefer a browser-auth client token over a legacy API key when both are + /// present in an old or manually-edited file. + pub fn usable_credential(&self) -> Option> { + nonempty(self.client_access_token.as_deref()) + .map(|token| CredentialRef { + token, + kind: CredentialKind::ClientAccessToken, + }) + .or_else(|| { + nonempty(self.api_key.as_deref()).map(|token| CredentialRef { + token, + kind: CredentialKind::LegacyApiKey, + }) + }) + } + + /// Validate the persisted values needed by a supervisor installation. + /// Legacy credentials intentionally retain their hostname fallback. + pub fn ensure_installable(&self) -> Result<()> { + let backend = nonempty(self.backend_url.as_deref()) + .ok_or_else(|| missing("backend URL", "--backend / EDISON_BACKEND_URL"))?; + normalize_backend_url(backend)?; + let credential = self + .usable_credential() + .ok_or_else(|| anyhow!("no credentials on disk; run `edison-stdiod login` first"))?; + if credential.kind() == CredentialKind::ClientAccessToken { + if nonempty(self.client_installation_id.as_deref()).is_none() { + return Err(anyhow!( + "client credential is missing client_installation_id; run `edison-stdiod login` again" + )); + } + if nonempty(self.device_id.as_deref()).is_none() { + return Err(anyhow!( + "client credential is missing its server-issued device_id; run `edison-stdiod login` again" + )); + } } - std::fs::rename(&tmp, &path) - .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?; Ok(()) } + + /// Remove credentials and backend-issued account bindings while retaining + /// the backend URL and local presentation preferences. + pub fn clear_authentication(&mut self) { + self.api_key = None; + self.client_access_token = None; + self.client_installation_id = None; + self.authenticated_user_id = None; + self.authenticated_org_id = None; + self.scopes.clear(); + self.edison_secret_key = None; + self.device_id = None; + } } /// Merged view of CLI/env values overlaid on top of the persisted config. @@ -93,6 +203,8 @@ impl PersistedConfig { pub struct Resolved { pub backend_url: Option, pub api_key: Option, + pub client_access_token: Option, + pub client_installation_id: Option, pub edison_secret_key: Option, pub device_id: Option, pub device_label: Option, @@ -101,14 +213,99 @@ pub struct Resolved { impl Resolved { /// Build a [`Resolved`] from the on-disk config, letting any /// caller-provided overrides win when they're `Some`. - pub fn merge(persisted: PersistedConfig, overrides: Resolved) -> Self { - Self { - backend_url: overrides.backend_url.or(persisted.backend_url), - api_key: overrides.api_key.or(persisted.api_key), - edison_secret_key: overrides.edison_secret_key.or(persisted.edison_secret_key), - device_id: overrides.device_id.or(persisted.device_id), - device_label: overrides.device_label.or(persisted.device_label), + pub fn merge(persisted: PersistedConfig, overrides: Resolved) -> Result { + let has_legacy_override = overrides.api_key.is_some(); + let override_backend = overrides + .backend_url + .as_deref() + .map(normalize_backend_url) + .transpose()?; + let saved_backend_result = persisted + .backend_url + .as_deref() + .map(normalize_backend_url) + .transpose(); + let normalized_saved_backend = if has_legacy_override && override_backend.is_some() { + saved_backend_result.ok().flatten() + } else { + saved_backend_result? + }; + let preserve_legacy_values = has_legacy_override + && nonempty(persisted.client_access_token.as_deref()).is_none() + && nonempty(persisted.api_key.as_deref()).is_some() + && override_backend + .as_ref() + .is_none_or(|requested| normalized_saved_backend.as_ref() == Some(requested)); + let persisted_backend = if has_legacy_override && override_backend.is_some() { + None + } else { + normalized_saved_backend + }; + + if !has_legacy_override { + if let (Some(requested), Some(saved)) = (&override_backend, &persisted_backend) { + if requested != saved { + return Err(anyhow!( + "--backend does not match the backend bound to the saved credential; pass an explicit legacy --api-key or run `edison-stdiod login`" + )); + } + } else if override_backend.is_some() + && persisted_backend.is_none() + && persisted.has_issuer_bound_values() + { + return Err(anyhow!( + "saved credentials have no backend binding; pass an explicit legacy --api-key or run `edison-stdiod login`" + )); + } + + if nonempty(persisted.client_access_token.as_deref()).is_some() { + if let Some(device_id) = overrides.device_id.as_deref() { + if nonempty(persisted.device_id.as_deref()) != Some(device_id) { + return Err(anyhow!( + "the server-issued device ID bound to a client credential cannot be overridden" + )); + } + } + } } + + Ok(Self { + backend_url: override_backend.or(persisted_backend), + api_key: if has_legacy_override { + overrides.api_key + } else { + persisted.api_key + }, + client_access_token: if has_legacy_override { + None + } else { + persisted.client_access_token + }, + client_installation_id: if has_legacy_override { + None + } else { + persisted.client_installation_id + }, + edison_secret_key: if has_legacy_override { + overrides.edison_secret_key.or_else(|| { + preserve_legacy_values + .then_some(persisted.edison_secret_key) + .flatten() + }) + } else { + overrides.edison_secret_key.or(persisted.edison_secret_key) + }, + device_id: if has_legacy_override { + overrides.device_id.or_else(|| { + preserve_legacy_values + .then_some(persisted.device_id) + .flatten() + }) + } else { + overrides.device_id.or(persisted.device_id) + }, + device_label: overrides.device_label.or(persisted.device_label), + }) } pub fn backend_url(&self) -> Result<&str> { @@ -117,10 +314,19 @@ impl Resolved { .ok_or_else(|| missing("backend URL", "--backend / EDISON_BACKEND_URL")) } - pub fn api_key(&self) -> Result<&str> { - self.api_key - .as_deref() - .ok_or_else(|| missing("API key", "--api-key / EDISON_API_KEY")) + pub fn usable_credential(&self) -> Result> { + nonempty(self.client_access_token.as_deref()) + .map(|token| CredentialRef { + token, + kind: CredentialKind::ClientAccessToken, + }) + .or_else(|| { + nonempty(self.api_key.as_deref()).map(|token| CredentialRef { + token, + kind: CredentialKind::LegacyApiKey, + }) + }) + .ok_or_else(|| missing("credentials", "--api-key / EDISON_API_KEY")) } pub fn device_id(&self) -> Result { @@ -135,10 +341,71 @@ impl Resolved { } } +impl PersistedConfig { + fn has_issuer_bound_values(&self) -> bool { + self.usable_credential().is_some() + || nonempty(self.client_installation_id.as_deref()).is_some() + || nonempty(self.authenticated_user_id.as_deref()).is_some() + || nonempty(self.authenticated_org_id.as_deref()).is_some() + || nonempty(self.edison_secret_key.as_deref()).is_some() + || nonempty(self.device_id.as_deref()).is_some() + } +} + +/// Validate and canonicalize a backend origin. Production backends must use +/// HTTPS; cleartext HTTP is limited to loopback development endpoints. +pub fn normalize_backend_url(raw: &str) -> Result { + let parsed = url::Url::parse(raw).map_err(|_| anyhow!("backend URL is invalid"))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { + return Err(anyhow!("backend URL must be an absolute HTTP(S) URL")); + } + let authority = raw + .split_once("://") + .map(|(_, remainder)| remainder.split(['/', '?', '#']).next().unwrap_or("")) + .unwrap_or(""); + if !parsed.username().is_empty() || parsed.password().is_some() || authority.contains('@') { + return Err(anyhow!("backend URL must not contain user information")); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(anyhow!("backend URL must not contain a query or fragment")); + } + if !matches!(parsed.path(), "" | "/") { + return Err(anyhow!("backend URL must not contain a path")); + } + if parsed.scheme() == "http" && !is_loopback_host(parsed.host().expect("host checked above")) { + return Err(anyhow!( + "cleartext HTTP backend URLs are allowed only for localhost or loopback IPs" + )); + } + + Ok(parsed.as_str().trim_end_matches('/').to_string()) +} + +pub fn same_backend(left: Option<&str>, right: &str) -> bool { + let right = match normalize_backend_url(right) { + Ok(right) => right, + Err(_) => return false, + }; + left.and_then(|value| normalize_backend_url(value).ok()) + .is_some_and(|left| left == right) +} + +fn is_loopback_host(host: Host<&str>) -> bool { + match host { + Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), + Host::Ipv4(address) => address.is_loopback(), + Host::Ipv6(address) => address.is_loopback(), + } +} + fn missing(name: &str, flag: &str) -> anyhow::Error { anyhow!("missing {name}: run `edison-stdiod login` first, or pass {flag}",) } +fn nonempty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) +} + /// Detected OS, mapped to the wire-protocol `Os` enum. pub fn current_os() -> edison_tunnel_protocol::Os { if cfg!(target_os = "macos") { @@ -193,6 +460,7 @@ mod tests { edison_secret_key: None, device_id: Some("laptop".into()), device_label: Some("My Laptop".into()), + ..Default::default() }; let body = toml::to_string_pretty(&cfg).unwrap(); std::fs::write(&path, body).unwrap(); @@ -212,25 +480,304 @@ mod tests { #[test] fn resolved_overrides_win_over_persisted() { let persisted = PersistedConfig { - backend_url: Some("http://from-disk".into()), + backend_url: Some("https://EXAMPLE.test/".into()), api_key: Some("from-disk".into()), ..Default::default() }; let overrides = Resolved { - backend_url: Some("http://from-env".into()), + backend_url: Some("https://example.test".into()), api_key: None, + client_access_token: None, + client_installation_id: None, edison_secret_key: None, device_id: None, device_label: None, }; - let merged = Resolved::merge(persisted, overrides); - assert_eq!(merged.backend_url.as_deref(), Some("http://from-env")); + let merged = Resolved::merge(persisted, overrides).unwrap(); + assert_eq!(merged.backend_url.as_deref(), Some("https://example.test")); assert_eq!(merged.api_key.as_deref(), Some("from-disk")); } + #[test] + fn new_client_credential_is_preferred_over_legacy_key() { + let cfg = PersistedConfig { + api_key: Some("legacy".into()), + client_access_token: Some("client".into()), + client_installation_id: Some("install-1".into()), + device_id: Some("device-1".into()), + ..Default::default() + }; + let selected = cfg.usable_credential().unwrap(); + assert_eq!(selected.kind(), CredentialKind::ClientAccessToken); + assert_eq!(selected.token(), "client"); + } + + #[test] + fn explicit_legacy_override_wins_over_persisted_client_token() { + let persisted = PersistedConfig { + api_key: Some("old-legacy".into()), + client_access_token: Some("client".into()), + client_installation_id: Some("install-1".into()), + ..Default::default() + }; + let merged = Resolved::merge( + persisted, + Resolved { + backend_url: Some("https://other.test".into()), + api_key: Some("flag-key".into()), + client_access_token: None, + client_installation_id: None, + edison_secret_key: Some("explicit-secret".into()), + device_id: Some("explicit-device".into()), + device_label: None, + }, + ) + .unwrap(); + let selected = merged.usable_credential().unwrap(); + assert_eq!(selected.kind(), CredentialKind::LegacyApiKey); + assert_eq!(selected.token(), "flag-key"); + assert_eq!(merged.backend_url.as_deref(), Some("https://other.test")); + assert_eq!(merged.edison_secret_key.as_deref(), Some("explicit-secret")); + assert_eq!(merged.device_id.as_deref(), Some("explicit-device")); + } + + #[test] + fn backend_override_cannot_rebind_saved_client_identity() { + let persisted = PersistedConfig { + backend_url: Some("https://issuer.test".into()), + client_access_token: Some("client-token".into()), + client_installation_id: Some("install-1".into()), + edison_secret_key: Some("account-secret".into()), + device_id: Some("device-1".into()), + ..Default::default() + }; + let result = Resolved::merge( + persisted, + Resolved { + backend_url: Some("https://other.test".into()), + api_key: None, + client_access_token: None, + client_installation_id: None, + edison_secret_key: None, + device_id: None, + device_label: None, + }, + ); + assert!(result.is_err()); + } + + #[test] + fn explicit_legacy_override_does_not_inherit_client_bindings() { + let persisted = PersistedConfig { + backend_url: Some("https://issuer.test".into()), + client_access_token: Some("client-token".into()), + client_installation_id: Some("install-1".into()), + edison_secret_key: Some("account-secret".into()), + device_id: Some("device-1".into()), + ..Default::default() + }; + let merged = Resolved::merge( + persisted, + Resolved { + backend_url: Some("https://other.test".into()), + api_key: Some("legacy-key".into()), + client_access_token: None, + client_installation_id: None, + edison_secret_key: None, + device_id: None, + device_label: None, + }, + ) + .unwrap(); + assert!(merged.client_access_token.is_none()); + assert!(merged.client_installation_id.is_none()); + assert!(merged.edison_secret_key.is_none()); + assert!(merged.device_id.is_none()); + } + + #[test] + fn client_device_binding_cannot_be_overridden() { + let persisted = PersistedConfig { + backend_url: Some("https://issuer.test".into()), + client_access_token: Some("client-token".into()), + client_installation_id: Some("install-1".into()), + device_id: Some("device-1".into()), + ..Default::default() + }; + let result = Resolved::merge( + persisted, + Resolved { + backend_url: None, + api_key: None, + client_access_token: None, + client_installation_id: None, + edison_secret_key: None, + device_id: Some("device-2".into()), + device_label: None, + }, + ); + assert!(result.is_err()); + } + + #[test] + fn legacy_toml_without_new_fields_still_loads() { + let parsed: PersistedConfig = toml::from_str( + r#"backend_url = "https://example.test" +api_key = "legacy" +device_label = "Laptop" +"#, + ) + .unwrap(); + assert_eq!(parsed.api_key.as_deref(), Some("legacy")); + assert!(parsed.client_access_token.is_none()); + assert!(parsed.scopes.is_empty()); + } + + #[test] + fn clearing_authentication_preserves_preferences() { + let mut cfg = PersistedConfig { + backend_url: Some("https://example.test".into()), + api_key: Some("legacy".into()), + client_access_token: Some("client".into()), + client_installation_id: Some("install-1".into()), + authenticated_user_id: Some("user-1".into()), + authenticated_org_id: Some("org-1".into()), + scopes: vec!["tunnel:connect".into()], + edison_secret_key: Some("secret".into()), + device_id: Some("device-1".into()), + device_label: Some("My Laptop".into()), + }; + cfg.clear_authentication(); + assert_eq!(cfg.backend_url.as_deref(), Some("https://example.test")); + assert_eq!(cfg.device_label.as_deref(), Some("My Laptop")); + assert!(cfg.usable_credential().is_none()); + assert!(cfg.device_id.is_none()); + assert!(cfg.edison_secret_key.is_none()); + } + + #[test] + fn debug_output_redacts_credentials() { + let cfg = PersistedConfig { + api_key: Some("legacy-secret-value".into()), + client_access_token: Some("client-secret-value".into()), + edison_secret_key: Some("edison-secret-value".into()), + ..Default::default() + }; + let debug = format!("{cfg:?}"); + assert!(!debug.contains("legacy-secret-value")); + assert!(!debug.contains("client-secret-value")); + assert!(!debug.contains("edison-secret-value")); + } + + #[test] + fn toml_parse_errors_do_not_reflect_secret_lines() { + let dir = tempdir_or_skip(); + let path = dir.join("malformed-config.toml"); + std::fs::write(&path, "client_access_token = \"do-not-reflect-this-token\n").unwrap(); + let message = PersistedConfig::load_from(&path).unwrap_err().to_string(); + assert!(message.contains("invalid TOML")); + assert!(!message.contains("do-not-reflect-this-token")); + assert!(!message.contains("client_access_token")); + } + + #[test] + fn backend_urls_are_secure_and_canonical() { + assert_eq!( + normalize_backend_url("HTTP://LOCALHOST:3001/").unwrap(), + "http://localhost:3001" + ); + assert_eq!( + normalize_backend_url("http://127.42.0.1:3001/").unwrap(), + "http://127.42.0.1:3001" + ); + assert_eq!( + normalize_backend_url("http://[::1]:3001/").unwrap(), + "http://[::1]:3001" + ); + assert_eq!( + normalize_backend_url("HTTPS://EXAMPLE.COM/").unwrap(), + "https://example.com" + ); + } + + #[test] + fn backend_urls_reject_unsafe_or_non_origin_inputs() { + for invalid in [ + "http://example.com", + "ws://localhost:3001", + "wss://example.com", + "https://user:password@example.com", + "https://example.com?token=secret", + "https://example.com#fragment", + "https://example.com/api", + ] { + assert!( + normalize_backend_url(invalid).is_err(), + "accepted {invalid}" + ); + } + } + + #[test] + fn repeated_config_saves_replace_and_leave_no_temp_files() { + let dir = tempdir_or_skip(); + let path = dir.join("repeat-config.toml"); + let mut cfg = PersistedConfig { + backend_url: Some("https://example.test".into()), + ..Default::default() + }; + cfg.save_to(&path).unwrap(); + cfg.device_label = Some("updated".into()); + cfg.save_to(&path).unwrap(); + assert_eq!( + PersistedConfig::load_from(&path) + .unwrap() + .device_label + .as_deref(), + Some("updated") + ); + let prefix = format!(".{}.", path.file_name().unwrap().to_string_lossy()); + assert!(std::fs::read_dir(&dir).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(&prefix) + })); + } + + #[test] + fn client_installation_requires_server_binding_but_legacy_does_not() { + let incomplete_client = PersistedConfig { + backend_url: Some("https://example.test".into()), + client_access_token: Some("client".into()), + ..Default::default() + }; + assert!(incomplete_client.ensure_installable().is_err()); + + let legacy = PersistedConfig { + backend_url: Some("https://example.test".into()), + api_key: Some("legacy".into()), + ..Default::default() + }; + assert!(legacy.ensure_installable().is_ok()); + } + fn tempdir_or_skip() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("edison-stdiod-test-{}", std::process::id())); + use std::sync::atomic::{AtomicUsize, Ordering}; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "edison-stdiod-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } } + +#[cfg(test)] +#[path = "config_extra_tests.rs"] +mod extra_tests; diff --git a/crates/edison-stdiod/src/config_extra_tests.rs b/crates/edison-stdiod/src/config_extra_tests.rs new file mode 100644 index 0000000..35302e8 --- /dev/null +++ b/crates/edison-stdiod/src/config_extra_tests.rs @@ -0,0 +1,53 @@ +use super::*; + +#[test] +fn explicit_legacy_rotation_keeps_same_issuer_legacy_settings() { + let persisted = PersistedConfig { + backend_url: Some("https://issuer.test".into()), + api_key: Some("old-key".into()), + edison_secret_key: Some("legacy-secret".into()), + device_id: Some("legacy-device".into()), + ..Default::default() + }; + let merged = Resolved::merge( + persisted, + Resolved { + backend_url: Some("https://ISSUER.test/".into()), + api_key: Some("rotated-key".into()), + client_access_token: None, + client_installation_id: None, + edison_secret_key: None, + device_id: None, + device_label: None, + }, + ) + .unwrap(); + assert_eq!(merged.edison_secret_key.as_deref(), Some("legacy-secret")); + assert_eq!(merged.device_id.as_deref(), Some("legacy-device")); +} + +#[test] +fn explicit_legacy_backend_replacement_ignores_invalid_saved_backend() { + let persisted = PersistedConfig { + backend_url: Some("not a URL".into()), + client_access_token: Some("stale-client-token".into()), + ..Default::default() + }; + let merged = Resolved::merge( + persisted, + Resolved { + backend_url: Some("https://replacement.test".into()), + api_key: Some("legacy-key".into()), + client_access_token: None, + client_installation_id: None, + edison_secret_key: None, + device_id: None, + device_label: None, + }, + ) + .unwrap(); + assert_eq!( + merged.backend_url.as_deref(), + Some("https://replacement.test") + ); +} diff --git a/crates/edison-stdiod/src/daemon.rs b/crates/edison-stdiod/src/daemon.rs index 59a59c4..ab170e4 100644 --- a/crates/edison-stdiod/src/daemon.rs +++ b/crates/edison-stdiod/src/daemon.rs @@ -12,15 +12,14 @@ //! until the WS closes. //! 3. On disconnect we clear the handle (so children drop frames silently //! until reconnect) and back off with exponential delay + jitter. -//! 4. Auth failures (401/403) are surfaced as a hard error - v1.1 will -//! instead enter a ``needs_reauth`` state per ARCHITECTURE.md. +//! 4. Auth failures (401/403) enter ``needs_reauth`` and wait for the +//! on-disk credential to change before reconnecting. use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use anyhow::{bail, Result}; -use clap::Args; use edison_tunnel_protocol::{ ClientHello, DesiredServer, DesiredStateUpdate, McpFrame, ServerHello, ServerSpawnResult, ServerSpecUpdate, TunnelError, TunnelFrame, PROTOCOL_VERSION, @@ -30,67 +29,17 @@ use tokio::time::sleep; use tracing::{debug, info, warn}; use crate::config; +pub use crate::daemon_auth::RunArgs; +use crate::daemon_auth::{ + connection_error_message, is_auth_rejection, is_protocol_rejection, requires_child_reset, + ResolveRunError, ResolvedRun, +}; use crate::env_store::{resolve_env_for_spawn, substitute_templated_args, EnvStore}; use crate::proc::ChildServer; use crate::state::{ConnectionState, ServerEntry, ServerStatus, State, StateWriter}; use crate::tunnel::{self, OutgoingHandle}; -#[derive(Debug, Args)] -pub struct RunArgs { - /// Backend base URL (http://localhost:8000, https://dashboard.edison.watch, …). - /// Falls back to `backend_url` in `~/.config/edison-stdiod/config.toml`. - #[arg(long, env = "EDISON_BACKEND_URL")] - pub backend: Option, - /// API key (Bearer token). Falls back to `api_key` in config.toml. - #[arg(long, env = "EDISON_API_KEY")] - pub api_key: Option, - /// Optional edison secret key (X-Edison-Secret-Key). - #[arg(long, env = "EDISON_SECRET_KEY")] - pub edison_secret_key: Option, - /// Device identifier (must match the row in the backend's `devices` table). - /// Defaults to the persisted `device_id`, then the machine hostname. - #[arg(long, env = "EDISON_DEVICE_ID")] - pub device_id: Option, - /// Human-readable device label (shown in the admin UI). - #[arg(long, env = "EDISON_DEVICE_LABEL")] - pub label: Option, -} - -/// Snapshot of the resolved values the rest of `daemon` cares about. Built -/// once per `run` from CLI flags overlaid on `~/.config/edison-stdiod/config.toml`. -struct ResolvedRun { - backend: String, - api_key: String, - edison_secret_key: Option, - device_id: String, - label: String, -} - -impl ResolvedRun { - fn from_args(args: RunArgs) -> Result { - let persisted = config::PersistedConfig::load()?; - let merged = config::Resolved::merge( - persisted, - config::Resolved { - backend_url: args.backend, - api_key: args.api_key, - edison_secret_key: args.edison_secret_key, - device_id: args.device_id, - device_label: args.label, - }, - ); - Ok(Self { - backend: merged.backend_url()?.to_string(), - api_key: merged.api_key()?.to_string(), - edison_secret_key: merged.edison_secret_key.clone(), - device_id: merged.device_id()?, - label: merged.device_label(), - }) - } -} - -// Heartbeat tuning (per ARCHITECTURE.md "Disconnect handling"). The -// daemon pings every 15s and considers the connection dead if no pong +// The daemon pings every 15s and considers the connection dead if no pong // arrives within HEARTBEAT_STALE_AFTER. On stale, the WS is closed and // the outer reconnect loop kicks in. const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); @@ -102,13 +51,49 @@ const HEARTBEAT_STALE_AFTER: Duration = Duration::from_secs(25); // Tear down immediately on resume instead of waiting it out. const HEARTBEAT_RESUME_GAP: Duration = Duration::from_secs(45); -// Exponential backoff with ±25% jitter, capped at 30s so a reconnect after the -// network returns (e.g. post-resume) isn't stranded behind a long backoff. +// Exponential backoff with jitter, capped so reconnects stay responsive. const BACKOFF_MIN: Duration = Duration::from_secs(1); const BACKOFF_MAX: Duration = Duration::from_secs(30); +const CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(1); pub async fn run(args: RunArgs) -> Result<()> { - let resolved = ResolvedRun::from_args(args)?; + let initial = ResolvedRun::from_args(&args); + let writer = StateWriter::new(State { + backend_url: initial.as_ref().ok().map(|value| value.backend.clone()), + device_id: initial.as_ref().ok().map(|value| value.device_id.clone()), + device_label: initial.as_ref().ok().map(|value| value.label.clone()), + ..State::default() + }); + writer.update(|_| {}).await; + let mut resolved = match initial { + Ok(resolved) => resolved, + Err(ResolveRunError::AwaitingLogin) => { + writer + .update(|state| { + state.connection_state = ConnectionState::NeedsReauth; + state.last_error = Some( + "credentials are missing or incomplete; run `edison-stdiod login`".into(), + ); + }) + .await; + loop { + sleep(CONFIG_POLL_INTERVAL).await; + if let Ok(resolved) = ResolvedRun::from_args(&args) { + break resolved; + } + } + } + Err(error) => return Err(error.into()), + }; + writer + .update(|state| { + state.connection_state = ConnectionState::Starting; + state.backend_url = Some(resolved.backend.clone()); + state.device_id = Some(resolved.device_id.clone()); + state.device_label = Some(resolved.label.clone()); + state.last_error = None; + }) + .await; // Resolve the interactive login-shell PATH once, up front, so child MCP // servers can find version-manager node/npx (nvm/fnm/...) - the daemon's own @@ -120,21 +105,8 @@ pub async fn run(args: RunArgs) -> Result<()> { // diff and reconcile. let outgoing = OutgoingHandle::new(); - // state.json is best-effort: ``StateWriter::update`` swallows write - // failures so a full disk can never stall the WS loop. Seed it with - // identity fields known at startup; per-transition fields (connection - // state, last_error, servers[]) get rewritten by the lifecycle hooks - // below. - let writer = StateWriter::new(State { - connection_state: ConnectionState::Starting, - backend_url: Some(resolved.backend.clone()), - device_id: Some(resolved.device_id.clone()), - device_label: Some(resolved.label.clone()), - ..State::default() - }); - writer.update(|_| {}).await; // initial atomic write so `status` sees a file immediately - - let env_store = EnvStore::open()?; + let env_namespace = resolved.env_namespace(); + let env_store = EnvStore::open_for(env_namespace.as_deref())?; let supervisor = Arc::new(Mutex::new(Supervisor::new( outgoing.clone(), writer.clone(), @@ -142,35 +114,175 @@ pub async fn run(args: RunArgs) -> Result<()> { ))); let mut backoff = BACKOFF_MIN; + let shutdown_signal = crate::process_shutdown::wait(); + tokio::pin!(shutdown_signal); loop { - let result = run_one_session(&resolved, supervisor.clone(), &outgoing, &writer).await; - match &result { - Ok(()) => { - info!("WS session ended cleanly; reconnecting"); - backoff = BACKOFF_MIN; - writer - .update(|s| { - s.connection_state = ConnectionState::Reconnecting; - s.last_error = None; - }) - .await; + let result = tokio::select! { + result = run_one_session(&resolved, &args, supervisor.clone(), &outgoing, &writer) => result, + _ = &mut shutdown_signal => { + outgoing.clear(); + supervisor.lock().await.shutdown_children().await; + return Ok(()); } - Err(e) => { - warn!(error = %e, "WS session ended with error; will retry"); - let msg = e.to_string(); - writer - .update(|s| { - s.connection_state = ConnectionState::Reconnecting; - s.last_error = Some(msg); - }) - .await; + }; + outgoing.clear(); + let needs_reauth = result.as_ref().err().is_some_and(is_auth_rejection); + let needs_upgrade = result.as_ref().err().is_some_and(is_protocol_rejection); + let wait_limit = if needs_reauth || needs_upgrade { + supervisor.lock().await.shutdown_children().await; + let message = result + .as_ref() + .err() + .map(|error| connection_error_message(error, &resolved)) + .unwrap_or_else(|| "backend rejected the credential".into()); + if needs_upgrade { + warn!("backend rejected the tunnel protocol; waiting for an upgrade"); + } else { + warn!("backend rejected the credential; waiting for login"); + } + writer + .update(|state| { + state.connection_state = if needs_upgrade { + ConnectionState::NeedsUpgrade + } else { + ConnectionState::NeedsReauth + }; + state.last_error = Some(message); + }) + .await; + backoff = BACKOFF_MIN; + None + } else { + match &result { + Ok(()) => { + info!("WS session ended cleanly; reconnecting"); + backoff = BACKOFF_MIN; + writer + .update(|state| { + state.connection_state = ConnectionState::Reconnecting; + state.last_error = None; + }) + .await; + } + Err(error) => { + let message = connection_error_message(error, &resolved); + warn!(error = %message, "WS session ended with error; will retry"); + writer + .update(|state| { + state.connection_state = ConnectionState::Reconnecting; + state.last_error = Some(message); + }) + .await; + } } + Some(jittered(backoff)) + }; + if let Some(delay) = wait_limit { + info!(?delay, "waiting before reconnect"); + } + let next = tokio::select! { + next = wait_for_config(&resolved, &args, wait_limit, supervisor.clone(), &writer) => next, + _ = &mut shutdown_signal => { + outgoing.clear(); + supervisor.lock().await.shutdown_children().await; + return Ok(()); + } + }; + let changed = !resolved.same_connection(&next); + let reset_children = requires_child_reset(&resolved, &next); + let current_env_namespace = resolved.env_namespace(); + let next_env_namespace = next.env_namespace(); + if current_env_namespace != next_env_namespace { + let env_store = EnvStore::open_for(next_env_namespace.as_deref())?; + supervisor.lock().await.switch_env_store(env_store).await; + } else if reset_children { + supervisor.lock().await.shutdown_children().await; + } + writer + .update(|state| { + state.connection_state = ConnectionState::Reconnecting; + state.backend_url = Some(next.backend.clone()); + state.device_id = Some(next.device_id.clone()); + state.device_label = Some(next.label.clone()); + state.last_error = None; + }) + .await; + resolved = next; + backoff = if changed { + BACKOFF_MIN + } else { + (backoff * 2).min(BACKOFF_MAX) + }; + } +} + +/// Wait until the normal retry deadline or until a changed usable config is +/// observed. If credentials disappear (logout), pause indefinitely until a +/// subsequent login produces a usable selection. +async fn wait_for_config( + current: &ResolvedRun, + args: &RunArgs, + wait_limit: Option, + supervisor: Arc>, + writer: &StateWriter, +) -> ResolvedRun { + let deadline = wait_limit.and_then(|delay| Instant::now().checked_add(delay)); + let mut saw_unusable_config = false; + loop { + match ResolvedRun::from_args(args) { + Ok(next) if !current.same_connection(&next) || saw_unusable_config => return next, + Ok(_) => {} + Err(_) => { + if !saw_unusable_config { + supervisor.lock().await.shutdown_children().await; + writer + .update(|state| { + state.connection_state = ConnectionState::NeedsReauth; + state.last_error = Some( + "credentials were removed or are incomplete; run `edison-stdiod login`" + .into(), + ); + }) + .await; + } + saw_unusable_config = true; + } + } + + if !saw_unusable_config && deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return current.clone(); + } + let sleep_for = deadline + .map(|deadline| { + deadline + .saturating_duration_since(Instant::now()) + .min(CONFIG_POLL_INTERVAL) + }) + .unwrap_or(CONFIG_POLL_INTERVAL); + sleep(sleep_for).await; + } +} + +async fn wait_for_connection_change( + current: &ResolvedRun, + args: &RunArgs, + supervisor: Arc>, +) { + loop { + sleep(CONFIG_POLL_INTERVAL).await; + match ResolvedRun::from_args(args) { + Ok(next) if !current.same_connection(&next) => { + if requires_child_reset(current, &next) { + supervisor.lock().await.shutdown_children().await; + } + return; + } + Err(_) => { + supervisor.lock().await.shutdown_children().await; + return; + } + Ok(_) => {} } - outgoing.clear(); - let delay = jittered(backoff); - info!(?delay, "sleeping before reconnect"); - sleep(delay).await; - backoff = (backoff * 2).min(BACKOFF_MAX); } } @@ -178,13 +290,14 @@ pub async fn run(args: RunArgs) -> Result<()> { /// reconnect), Err on connect failure. async fn run_one_session( args: &ResolvedRun, + run_args: &RunArgs, supervisor: Arc>, outgoing: &OutgoingHandle, writer: &StateWriter, ) -> Result<()> { let ws = tunnel::connect( &args.backend, - &args.api_key, + &args.credential, args.edison_secret_key.as_deref(), &args.device_id, ) @@ -204,7 +317,7 @@ async fn run_one_session( // Wire the broker before any task can publish a frame. outgoing.set(outgoing_tx.clone()); - let ws_task = tokio::spawn(tunnel::run_frame_loop(ws, outgoing_rx, incoming_tx)); + let mut ws_task = tokio::spawn(tunnel::run_frame_loop(ws, outgoing_rx, incoming_tx)); // client_hello: announce which servers we already have running so the // backend can reconcile. @@ -237,10 +350,22 @@ async fn run_one_session( }; let result = tokio::select! { - r = drain_incoming(supervisor, &mut incoming_rx, last_pong) => r, + biased; + r = drain_incoming(supervisor.clone(), &mut incoming_rx, last_pong) => match r { + Ok(()) => websocket_task_result(&mut ws_task).await, + Err(error) => Err(error), + }, + result = &mut ws_task => match result { + Ok(result) => result, + Err(error) => Err(anyhow::Error::from(error).context("WebSocket task failed")), + }, _ = &mut hb_task => { warn!("heartbeat: stale connection, tearing down session to reconnect"); Ok(()) + }, + _ = wait_for_connection_change(args, run_args, supervisor) => { + info!("configuration changed; restarting authenticated session"); + Ok(()) } }; hb_task.abort(); @@ -249,6 +374,13 @@ async fn run_one_session( result } +async fn websocket_task_result(task: &mut tokio::task::JoinHandle>) -> Result<()> { + match task.await { + Ok(result) => result, + Err(error) => Err(anyhow::Error::from(error).context("WebSocket task failed")), + } +} + async fn drain_incoming( supervisor: Arc>, incoming_rx: &mut mpsc::Receiver, @@ -289,7 +421,11 @@ async fn drain_incoming( } } TunnelFrame::TunnelError(err) => { - warn!(?err, "tunnel_error from backend"); + warn!( + code = %err.code, + server_id = err.server_id.as_deref(), + "tunnel_error from backend" + ); // Device-wide (server_id=None) errors are soft rejections like // the ``stdio_tunnel_disabled`` org feature flag. Bail so the // outer reconnect loop persists the friendly message into @@ -421,6 +557,19 @@ impl Supervisor { } } + async fn switch_env_store(&mut self, env_store: EnvStore) { + self.shutdown_children().await; + self.env_store = env_store; + } + + async fn shutdown_children(&mut self) { + let children = std::mem::take(&mut self.children); + for (_, child) in children { + child.shutdown().await; + } + self.publish_state().await; + } + /// Apply the device's stored values to the incoming `DesiredServer`: /// env is overlaid via [`resolve_env_for_spawn`], and any /// `templated_args` substitutions are applied to each arg as a substring diff --git a/crates/edison-stdiod/src/daemon_auth.rs b/crates/edison-stdiod/src/daemon_auth.rs new file mode 100644 index 0000000..3dd86e4 --- /dev/null +++ b/crates/edison-stdiod/src/daemon_auth.rs @@ -0,0 +1,266 @@ +//! Resolved daemon authentication state and account-bound lifecycle decisions. + +use anyhow::Result; +use clap::Args; +use thiserror::Error; + +use crate::{config, tunnel}; + +#[derive(Debug, Args, Clone)] +pub struct RunArgs { + /// Backend base URL (http://localhost:8000, https://dashboard.edison.watch, ...). + /// Falls back to `backend_url` in `~/.config/edison-stdiod/config.toml`. + #[arg(long, env = "EDISON_BACKEND_URL")] + pub backend: Option, + /// Deprecated legacy API key override. Without it, the browser-auth client + /// access token in config.toml is preferred. + #[arg(long, env = "EDISON_API_KEY")] + pub api_key: Option, + /// Optional edison secret key (X-Edison-Secret-Key). + #[arg(long, env = "EDISON_SECRET_KEY")] + pub edison_secret_key: Option, + /// Device identifier (must match the row in the backend's `devices` table). + /// Defaults to the persisted `device_id`, then the machine hostname. + #[arg(long, env = "EDISON_DEVICE_ID")] + pub device_id: Option, + /// Human-readable device label (shown in the admin UI). + #[arg(long, env = "EDISON_DEVICE_LABEL")] + pub label: Option, +} + +#[derive(Debug, Error)] +pub(crate) enum ResolveRunError { + #[error("credentials are missing or incomplete; run `edison-stdiod login`")] + AwaitingLogin, + #[error(transparent)] + Invalid(#[from] anyhow::Error), +} + +/// Snapshot of resolved connection values. It is rebuilt while reconnecting so +/// login, logout, credential rotation, and account changes are observed. +#[derive(Clone)] +pub(crate) struct ResolvedRun { + pub(crate) backend: String, + pub(crate) credential: String, + pub(crate) credential_kind: config::CredentialKind, + pub(crate) client_installation_id: Option, + pub(crate) edison_secret_key: Option, + pub(crate) device_id: String, + pub(crate) label: String, +} + +impl ResolvedRun { + pub(crate) fn from_args(args: &RunArgs) -> Result { + let persisted = config::PersistedConfig::load()?; + let merged = config::Resolved::merge( + persisted, + config::Resolved { + backend_url: args.backend.clone(), + api_key: args.api_key.clone(), + client_access_token: None, + client_installation_id: None, + edison_secret_key: args.edison_secret_key.clone(), + device_id: args.device_id.clone(), + device_label: args.label.clone(), + }, + )?; + let credential = merged + .usable_credential() + .map_err(|_| ResolveRunError::AwaitingLogin)?; + let credential_kind = credential.kind(); + let credential = credential.token().to_string(); + let client_installation_id = if credential_kind == config::CredentialKind::ClientAccessToken + { + Some( + merged + .client_installation_id + .clone() + .filter(|id| !id.is_empty()) + .ok_or(ResolveRunError::AwaitingLogin)?, + ) + } else { + None + }; + let device_id = if credential_kind == config::CredentialKind::ClientAccessToken { + merged + .device_id + .clone() + .filter(|id| !id.is_empty()) + .ok_or(ResolveRunError::AwaitingLogin)? + } else { + merged.device_id()? + }; + Ok(Self { + backend: merged + .backend_url() + .map_err(|_| ResolveRunError::AwaitingLogin)? + .to_string(), + credential, + credential_kind, + client_installation_id, + edison_secret_key: merged.edison_secret_key.clone(), + device_id, + label: merged.device_label(), + }) + } + + pub(crate) fn same_connection(&self, other: &Self) -> bool { + self.backend == other.backend + && self.credential == other.credential + && self.credential_kind == other.credential_kind + && self.client_installation_id == other.client_installation_id + && self.edison_secret_key == other.edison_secret_key + && self.device_id == other.device_id + && self.label == other.label + } + + pub(crate) fn env_namespace(&self) -> Option { + self.client_installation_id + .as_ref() + .map(|installation_id| format!("{}\n{installation_id}", self.backend)) + } +} + +pub(crate) fn is_auth_rejection(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(tunnel::ConnectError::needs_reauth) + || error + .downcast_ref::() + .is_some_and(tunnel::SessionCloseError::needs_reauth) +} + +pub(crate) fn is_protocol_rejection(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(tunnel::SessionCloseError::needs_upgrade) +} + +pub(crate) fn requires_child_reset(current: &ResolvedRun, next: &ResolvedRun) -> bool { + current.backend != next.backend + || current.credential_kind != next.credential_kind + || current.client_installation_id != next.client_installation_id + || current.device_id != next.device_id + || (current.credential_kind == config::CredentialKind::LegacyApiKey + && current.credential != next.credential) +} + +pub(crate) fn connection_error_message(error: &anyhow::Error, resolved: &ResolvedRun) -> String { + let mut message = error + .to_string() + .replace(&resolved.credential, ""); + if let Some(secret) = resolved + .edison_secret_key + .as_deref() + .filter(|secret| !secret.is_empty()) + { + message = message.replace(secret, ""); + } + message +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolved() -> ResolvedRun { + ResolvedRun { + backend: "https://example.test".into(), + credential: "opaque-client-token".into(), + credential_kind: config::CredentialKind::ClientAccessToken, + client_installation_id: Some("install-1".into()), + edison_secret_key: Some("edison-secret".into()), + device_id: "device-1".into(), + label: "Laptop".into(), + } + } + + #[test] + fn backend_close_errors_cannot_reflect_credentials() { + let resolved = resolved(); + let error = + anyhow::anyhow!("backend close reason echoed opaque-client-token and edison-secret"); + let message = connection_error_message(&error, &resolved); + assert_eq!( + message, + "backend close reason echoed and " + ); + } + + #[test] + fn reconnect_snapshot_detects_credential_and_account_changes() { + let current = resolved(); + let mut rotated = current.clone(); + rotated.credential = "rotated-client-token".into(); + assert!(!current.same_connection(&rotated)); + + let mut switched = current.clone(); + switched.client_installation_id = Some("install-2".into()); + assert!(!current.same_connection(&switched)); + assert_ne!(current.env_namespace(), switched.env_namespace()); + + let mut issuer_switched = current.clone(); + issuer_switched.backend = "https://other.test".into(); + assert_ne!(current.env_namespace(), issuer_switched.env_namespace()); + } + + #[test] + fn reset_decisions_preserve_only_same_installation_client_rotation() { + let current = resolved(); + + let mut rotated_client = current.clone(); + rotated_client.credential = "rotated-client-token".into(); + assert!(!requires_child_reset(¤t, &rotated_client)); + + let mut backend_changed = current.clone(); + backend_changed.backend = "https://other.test".into(); + assert!(requires_child_reset(¤t, &backend_changed)); + + let mut account_changed = current.clone(); + account_changed.client_installation_id = Some("install-2".into()); + assert!(requires_child_reset(¤t, &account_changed)); + + let mut device_changed = current.clone(); + device_changed.device_id = "device-2".into(); + assert!(requires_child_reset(¤t, &device_changed)); + + let mut kind_changed = current.clone(); + kind_changed.credential_kind = config::CredentialKind::LegacyApiKey; + kind_changed.client_installation_id = None; + assert!(requires_child_reset(¤t, &kind_changed)); + } + + #[test] + fn legacy_credential_change_resets_without_an_env_namespace() { + let mut current = resolved(); + current.credential_kind = config::CredentialKind::LegacyApiKey; + current.client_installation_id = None; + let mut changed = current.clone(); + changed.credential = "new-legacy-key".into(); + assert_eq!(current.env_namespace(), None); + assert_eq!(changed.env_namespace(), None); + assert!(requires_child_reset(¤t, &changed)); + } + + #[test] + fn daemon_classifies_upgrade_auth_rejections() { + let unauthorized = anyhow::Error::new(tunnel::ConnectError::AuthRejected { status: 401 }); + let server_error = + anyhow::Error::new(tunnel::ConnectError::UpgradeRejected { status: 500 }); + assert!(is_auth_rejection(&unauthorized)); + assert!(!is_auth_rejection(&server_error)); + + let revoked = anyhow::Error::new(tunnel::SessionCloseError::ClientCredentialRevoked); + let other_policy = anyhow::Error::new(tunnel::SessionCloseError::Closed { + code: 1008, + reason: "some other policy".into(), + }); + assert!(is_auth_rejection(&revoked)); + assert!(!is_auth_rejection(&other_policy)); + + let mismatch = anyhow::Error::new(tunnel::SessionCloseError::ProtocolVersionMismatch { + reason: "protocol_version mismatch".into(), + }); + assert!(is_protocol_rejection(&mismatch)); + } +} diff --git a/crates/edison-stdiod/src/env_store.rs b/crates/edison-stdiod/src/env_store.rs index 8eae5ea..467850e 100644 --- a/crates/edison-stdiod/src/env_store.rs +++ b/crates/edison-stdiod/src/env_store.rs @@ -18,7 +18,8 @@ //! ```text //! ~/.config/edison-stdiod/ //! config.toml backend URL + credentials (mode 0600) -//! server_envs.json this file (mode 0600) +//! server_envs.json legacy-auth values (mode 0600) +//! server_envs/ client-installation-scoped values //! ``` //! //! ### On-disk shape @@ -44,8 +45,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; -use crate::paths; +use crate::{paths, secure_file}; /// Map of `ENV_KEY -> ENV_VALUE`. `BTreeMap` rather than `HashMap` so on-disk /// JSON is deterministic; makes diffing the file in the wild far easier. @@ -87,6 +89,23 @@ impl EnvStore { Self::open_at(paths::config_dir()?.join("server_envs.json")) } + /// New browser-auth installations use an isolated file derived from the + /// canonical backend origin and backend-issued installation ID. Legacy + /// auth continues to use the original `server_envs.json` path. + pub fn open_for(client_installation_id: Option<&str>) -> Result { + Self::open_for_at(&paths::config_dir()?, client_installation_id) + } + + fn open_for_at(config_dir: &Path, client_installation_id: Option<&str>) -> Result { + let path = match client_installation_id { + Some(id) => config_dir + .join("server_envs") + .join(format!("{}.json", namespace_hash(id))), + None => config_dir.join("server_envs.json"), + }; + Self::open_at(path) + } + /// Testable variant that lets the caller pick the path. pub fn open_at(path: PathBuf) -> Result { let data = match std::fs::read_to_string(&path) { @@ -167,18 +186,7 @@ impl EnvStore { .with_context(|| format!("creating {}", parent.display()))?; } - let tmp = tmp_path_for(&self.path); - std::fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&tmp)?.permissions(); - perms.set_mode(0o600); - std::fs::set_permissions(&tmp, perms)?; - } - std::fs::rename(&tmp, &self.path) - .with_context(|| format!("renaming {} -> {}", tmp.display(), self.path.display()))?; - Ok(()) + secure_file::write_private(&self.path, body.as_bytes()) } } @@ -215,10 +223,12 @@ pub fn substitute_templated_args( .collect() } -fn tmp_path_for(path: &Path) -> PathBuf { - let mut s = path.as_os_str().to_owned(); - s.push(".tmp"); - PathBuf::from(s) +fn namespace_hash(client_installation_id: &str) -> String { + Sha256::digest(client_installation_id.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join("") } #[cfg(test)] @@ -478,11 +488,36 @@ mod tests { let path = dir.join("server_envs.json"); let mut store = EnvStore::open_at(path.clone()).unwrap(); store.set("srv-1", env_spec(&[("A", "1")])).unwrap(); - let tmp = tmp_path_for(&path); - assert!(!tmp.exists(), "tmp file should have been renamed away"); + let prefix = format!(".{}.", path.file_name().unwrap().to_string_lossy()); + assert!(std::fs::read_dir(&dir).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(&prefix) + })); assert!(path.exists()); } + #[test] + fn repeated_writes_replace_existing_store() { + let dir = tempdir(); + let path = dir.join("server_envs.json"); + let mut store = EnvStore::open_at(path.clone()).unwrap(); + store.set("srv-1", env_spec(&[("A", "1")])).unwrap(); + store.set("srv-1", env_spec(&[("A", "2")])).unwrap(); + let reopened = EnvStore::open_at(path).unwrap(); + assert_eq!( + reopened + .get("srv-1") + .unwrap() + .env + .get("A") + .map(String::as_str), + Some("2") + ); + } + #[cfg(unix)] #[test] fn file_mode_is_0600_on_unix() { @@ -543,6 +578,34 @@ mod tests { assert_eq!(got, fallback); } + #[test] + fn client_installations_and_issuers_use_isolated_env_stores() { + let dir = tempdir(); + let mut first = EnvStore::open_for_at(&dir, Some("https://one.test\ninstall-1")).unwrap(); + first + .set("server-1", env_spec(&[("ACCOUNT_SECRET", "first")])) + .unwrap(); + + let second = EnvStore::open_for_at(&dir, Some("https://one.test\ninstall-2")).unwrap(); + let other_issuer = + EnvStore::open_for_at(&dir, Some("https://two.test\ninstall-1")).unwrap(); + let legacy = EnvStore::open_for_at(&dir, None).unwrap(); + assert!(second.get("server-1").is_none()); + assert!(other_issuer.get("server-1").is_none()); + assert!(legacy.get("server-1").is_none()); + + let first = EnvStore::open_for_at(&dir, Some("https://one.test\ninstall-1")).unwrap(); + assert_eq!( + first + .get("server-1") + .unwrap() + .env + .get("ACCOUNT_SECRET") + .map(String::as_str), + Some("first") + ); + } + #[test] fn parse_error_surfaces_path() { let dir = tempdir(); diff --git a/crates/edison-stdiod/src/http.rs b/crates/edison-stdiod/src/http.rs index 2d0b712..d2faa0b 100644 --- a/crates/edison-stdiod/src/http.rs +++ b/crates/edison-stdiod/src/http.rs @@ -10,23 +10,96 @@ use anyhow::{anyhow, Context, Result}; use reqwest::{Client, Response, StatusCode}; use serde::Serialize; +use thiserror::Error; -use crate::config::PersistedConfig; +use crate::config::{self, PersistedConfig}; -/// Load `config.toml`, validate it has both backend + api_key, and return -/// a configured [`BackendClient`]. Surfaces a clear "run login" hint when -/// either is missing. -pub fn from_persisted() -> Result { - let cfg = PersistedConfig::load()?; - let backend = cfg.backend_url.ok_or_else(|| { +/// Build a client from an already-loaded config so callers can also branch on +/// the selected credential kind without loading the file twice. +pub fn from_config(cfg: &PersistedConfig) -> Result { + let token = cfg + .usable_credential() + .map(|credential| credential.token().to_string()) + .ok_or_else(|| { + anyhow!("no credential on disk. Run `edison-stdiod login --backend ...`.") + })?; + let backend = cfg.backend_url.clone().ok_or_else(|| { anyhow!("no backend URL on disk. Run `edison-stdiod login --backend ...`.") })?; - let token = cfg - .api_key - .ok_or_else(|| anyhow!("no API key on disk. Run `edison-stdiod login --api-key ...`."))?; BackendClient::new(backend, token) } +#[derive(Error)] +pub enum HttpError { + #[error("{operation} request failed: {source}")] + Request { + operation: String, + #[source] + source: reqwest::Error, + }, + // Do not include the response body in Display: a broken or malicious + // backend must not be able to reflect a bearer credential into logs. + #[error("{operation} returned HTTP {status}")] + Status { + operation: String, + status: StatusCode, + body: String, + }, + #[error("{operation} returned invalid JSON (HTTP {status}): {source}")] + Decode { + operation: String, + status: StatusCode, + #[source] + source: serde_json::Error, + }, +} + +impl std::fmt::Debug for HttpError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Request { operation, source } => formatter + .debug_struct("Request") + .field("operation", operation) + .field("source", source) + .finish(), + Self::Status { + operation, status, .. + } => formatter + .debug_struct("Status") + .field("operation", operation) + .field("status", status) + .field("body", &"") + .finish(), + Self::Decode { + operation, + status, + source, + } => formatter + .debug_struct("Decode") + .field("operation", operation) + .field("status", status) + .field("source", source) + .finish(), + } + } +} + +impl HttpError { + pub fn status(&self) -> Option { + match self { + Self::Status { status, .. } | Self::Decode { status, .. } => Some(*status), + Self::Request { .. } => None, + } + } + + pub fn is_auth_rejection(&self) -> bool { + matches!( + self.status(), + Some(StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) + ) + } +} + pub struct BackendClient { base: String, token: String, @@ -35,12 +108,14 @@ pub struct BackendClient { impl BackendClient { pub fn new(base: impl Into, token: impl Into) -> Result { + let base = base.into(); + let base = config::normalize_backend_url(&base)?; let http = Client::builder() .timeout(std::time::Duration::from_secs(20)) .build() .context("building HTTP client")?; Ok(Self { - base: base.into(), + base, token: token.into(), http, }) @@ -50,59 +125,130 @@ impl BackendClient { format!("{}{}", self.base, path) } - pub async fn get_json(&self, path: &str) -> Result { + fn authorize(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + request.bearer_auth(&self.token) + } + + pub async fn get_json( + &self, + path: &str, + ) -> std::result::Result { let resp = self - .http - .get(self.url(path)) - .bearer_auth(&self.token) + .authorize(self.http.get(self.url(path))) .send() .await - .with_context(|| format!("GET {path}"))?; - decode_json(resp).await + .map_err(|source| HttpError::Request { + operation: format!("GET {path}"), + source, + })?; + decode_json(resp, format!("GET {path}")).await } pub async fn post_json( &self, path: &str, body: &B, - ) -> Result { + ) -> std::result::Result { let resp = self - .http - .post(self.url(path)) - .bearer_auth(&self.token) + .authorize(self.http.post(self.url(path))) .json(body) .send() .await - .with_context(|| format!("POST {path}"))?; - decode_json(resp).await + .map_err(|source| HttpError::Request { + operation: format!("POST {path}"), + source, + })?; + decode_json(resp, format!("POST {path}")).await } /// DELETE expecting 204 (No Content). Returns `Ok(false)` on 404 so /// callers can render "nothing to remove" instead of an error. - pub async fn delete(&self, path: &str) -> Result { + pub async fn delete(&self, path: &str) -> std::result::Result { let resp = self - .http - .delete(self.url(path)) - .bearer_auth(&self.token) + .authorize(self.http.delete(self.url(path))) .send() .await - .with_context(|| format!("DELETE {path}"))?; + .map_err(|source| HttpError::Request { + operation: format!("DELETE {path}"), + source, + })?; match resp.status() { StatusCode::NO_CONTENT => Ok(true), StatusCode::NOT_FOUND => Ok(false), - s => { - let body = resp.text().await.unwrap_or_default(); - Err(anyhow!("DELETE {path} -> HTTP {s}: {body}")) - } + _ => Err(status_error(resp, format!("DELETE {path}")).await), } } } -async fn decode_json(resp: Response) -> Result { +pub(crate) async fn decode_json( + resp: Response, + operation: String, +) -> std::result::Result { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); if !status.is_success() { - return Err(anyhow!("HTTP {status}: {body}")); + return Err(HttpError::Status { + operation, + status, + body, + }); + } + serde_json::from_str(&body).map_err(|source| HttpError::Decode { + operation, + status, + source, + }) +} + +pub(crate) async fn expect_success( + resp: Response, + operation: String, +) -> std::result::Result<(), HttpError> { + if resp.status().is_success() { + Ok(()) + } else { + Err(status_error(resp, operation).await) + } +} + +async fn status_error(resp: Response, operation: String) -> HttpError { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + HttpError::Status { + operation, + status, + body, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_client_applies_bearer_credential() { + let client = BackendClient::new("https://example.test", "client-token").unwrap(); + let request = client + .authorize(client.http.get(client.url("/api/v1/servers"))) + .build() + .unwrap(); + assert_eq!( + request + .headers() + .get(reqwest::header::AUTHORIZATION) + .unwrap(), + "Bearer client-token" + ); + } + + #[test] + fn typed_status_classifies_auth_rejection_without_displaying_body() { + let error = HttpError::Status { + operation: "GET /private".into(), + status: StatusCode::UNAUTHORIZED, + body: "client-token".into(), + }; + assert!(error.is_auth_rejection()); + assert!(!error.to_string().contains("client-token")); } - serde_json::from_str(&body).with_context(|| format!("decoding response body: {body}")) } diff --git a/crates/edison-stdiod/src/main.rs b/crates/edison-stdiod/src/main.rs index 6ee6365..7805569 100644 --- a/crates/edison-stdiod/src/main.rs +++ b/crates/edison-stdiod/src/main.rs @@ -10,14 +10,18 @@ use clap::{Parser, Subcommand}; +mod auth; mod cli; mod config; mod daemon; +mod daemon_auth; mod env_store; mod http; mod paths; mod platform; mod proc; +mod process_shutdown; +mod secure_file; mod state; mod tunnel; @@ -33,8 +37,10 @@ struct Cli { enum Command { /// Run the daemon in the foreground. v1 MVP entry point. Run(daemon::RunArgs), - /// Persist credentials + backend URL to `~/.config/edison-stdiod/config.toml`. + /// Authorize this device in a browser and persist its client credential. Login(cli::login::LoginArgs), + /// Revoke the current client credential and remove local account bindings. + Logout(cli::logout::LogoutArgs), /// Register the OS supervisor unit (macOS LaunchAgent) so the daemon /// starts at login and is restarted on crash. Requires `login` first. Install(cli::install::InstallArgs), @@ -103,7 +109,8 @@ async fn main() -> anyhow::Result<()> { match cli.command { Command::Run(args) => daemon::run(args).await, - Command::Login(args) => cli::login::run(args), + Command::Login(args) => cli::login::run(args).await, + Command::Logout(args) => cli::logout::run(args).await, Command::Install(args) => cli::install::install(args), Command::Uninstall(args) => cli::install::uninstall(args), Command::Status(args) => cli::status::run(args), diff --git a/crates/edison-stdiod/src/platform/linux.rs b/crates/edison-stdiod/src/platform/linux.rs index e384bdb..991eccf 100644 --- a/crates/edison-stdiod/src/platform/linux.rs +++ b/crates/edison-stdiod/src/platform/linux.rs @@ -123,13 +123,7 @@ pub fn install() -> Result<()> { // a reconnect loop. Surface it now, not later in the logs. (Mirrors the // macOS path.) let cfg = crate::config::PersistedConfig::load()?; - if cfg.api_key.as_deref().unwrap_or("").is_empty() - || cfg.backend_url.as_deref().unwrap_or("").is_empty() - { - return Err(anyhow!( - "no credentials on disk. Run `edison-stdiod login --backend ... --api-key ...` first.", - )); - } + cfg.ensure_installable()?; if !systemd_user_available() { return Err(anyhow!( diff --git a/crates/edison-stdiod/src/platform/macos.rs b/crates/edison-stdiod/src/platform/macos.rs index 3959245..8990eec 100644 --- a/crates/edison-stdiod/src/platform/macos.rs +++ b/crates/edison-stdiod/src/platform/macos.rs @@ -151,13 +151,7 @@ pub fn install() -> Result<()> { // would just spin in a reconnect loop. The user sees this error // immediately rather than discovering it in the logs later. let cfg = crate::config::PersistedConfig::load()?; - if cfg.api_key.as_deref().unwrap_or("").is_empty() - || cfg.backend_url.as_deref().unwrap_or("").is_empty() - { - return Err(anyhow!( - "no credentials on disk. Run `edison-stdiod login --backend ... --api-key ...` first.", - )); - } + cfg.ensure_installable()?; let binary = std::env::current_exe().context("could not resolve current exe path")?; let log = paths::daemon_log_file()?; diff --git a/crates/edison-stdiod/src/platform/windows.rs b/crates/edison-stdiod/src/platform/windows.rs index e5ea151..2130c45 100644 --- a/crates/edison-stdiod/src/platform/windows.rs +++ b/crates/edison-stdiod/src/platform/windows.rs @@ -160,13 +160,7 @@ pub fn install() -> Result<()> { // Refuse to install without credentials - the daemon would just spin in a // reconnect loop. Surface the error now, not later in the logs. let cfg = crate::config::PersistedConfig::load()?; - if cfg.api_key.as_deref().unwrap_or("").is_empty() - || cfg.backend_url.as_deref().unwrap_or("").is_empty() - { - return Err(anyhow!( - "no credentials on disk. Run `edison-stdiod login --backend ... --api-key ...` first.", - )); - } + cfg.ensure_installable()?; let binary = std::env::current_exe().context("could not resolve current exe path")?; let user = current_user(); diff --git a/crates/edison-stdiod/src/proc.rs b/crates/edison-stdiod/src/proc.rs index b3881c5..f51236f 100644 --- a/crates/edison-stdiod/src/proc.rs +++ b/crates/edison-stdiod/src/proc.rs @@ -13,6 +13,8 @@ //! fails in-flight requests cleanly - this is the load-bearing pattern //! surfaced by the v0 spike (see `stdiod/ARCHITECTURE.md`). +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::process::Stdio; use anyhow::{Context, Result}; @@ -368,6 +370,8 @@ impl ChildServer { ); let mut cmd = build_child_command(&enriched.command, &enriched.args); + #[cfg(unix)] + cmd.as_std_mut().process_group(0); cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -418,6 +422,22 @@ impl ChildServer { /// Kill the child and abort the pumps. pub async fn shutdown(mut self) { + if let Some(pid) = self.child.id() { + #[cfg(unix)] + { + let _ = Command::new("kill") + .args(["-KILL", "--", &format!("-{pid}")]) + .status() + .await; + } + #[cfg(windows)] + { + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + } let _ = self.child.start_kill(); let _ = self.child.wait().await; self.stdin_pump.abort(); diff --git a/crates/edison-stdiod/src/process_shutdown.rs b/crates/edison-stdiod/src/process_shutdown.rs new file mode 100644 index 0000000..bda61ac --- /dev/null +++ b/crates/edison-stdiod/src/process_shutdown.rs @@ -0,0 +1,18 @@ +//! Portable foreground/service shutdown notification. + +pub async fn wait() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + let mut terminate = signal(SignalKind::terminate()).expect("installing SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/crates/edison-stdiod/src/secure_file.rs b/crates/edison-stdiod/src/secure_file.rs new file mode 100644 index 0000000..c6465b4 --- /dev/null +++ b/crates/edison-stdiod/src/secure_file.rs @@ -0,0 +1,147 @@ +//! Private same-directory file replacement for credential and environment data. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context, Result}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +pub fn write_private(path: &Path, body: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("private file path has no parent"))?; + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + + let (tmp, mut file) = create_temp(path)?; + let result = (|| -> Result<()> { + file.write_all(body) + .with_context(|| format!("writing {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("syncing {}", tmp.display()))?; + drop(file); + replace(&tmp, path) + .with_context(|| format!("replacing {} from {}", path.display(), tmp.display()))?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +fn create_temp(path: &Path) -> Result<(PathBuf, File)> { + for _ in 0..32 { + let tmp = unique_temp_path(path); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&tmp) { + Ok(file) => return Ok((tmp, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error).with_context(|| format!("creating {}", tmp.display())), + } + } + Err(anyhow::anyhow!( + "could not allocate a unique temporary file next to {}", + path.display() + )) +} + +fn unique_temp_path(path: &Path) -> PathBuf { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("private")); + let mut tmp_name = std::ffi::OsString::from("."); + tmp_name.push(file_name); + tmp_name.push(format!(".{}.{}.tmp", std::process::id(), id)); + path.with_file_name(tmp_name) +} + +#[cfg(unix)] +fn replace(tmp: &Path, path: &Path) -> io::Result<()> { + std::fs::rename(tmp, path) +} + +#[cfg(windows)] +fn replace(tmp: &Path, path: &Path) -> io::Result<()> { + match std::fs::rename(tmp, path) { + Ok(()) => Ok(()), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AlreadyExists | io::ErrorKind::PermissionDenied + ) => + { + // std has no Windows equivalent of POSIX rename-over-existing. + // This remove/rename fallback makes repeated saves work, but there + // is a brief non-atomic window where the destination is absent. + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::rename(tmp, path) + } + Err(error) => Err(error), + } +} + +#[cfg(not(any(unix, windows)))] +fn replace(tmp: &Path, path: &Path) -> io::Result<()> { + std::fs::rename(tmp, path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tempdir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "edison-stdiod-secure-file-{}-{}", + std::process::id(), + NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn temp_names_are_unique_and_in_destination_directory() { + let path = tempdir().join("config.toml"); + let first = unique_temp_path(&path); + let second = unique_temp_path(&path); + assert_ne!(first, second); + assert_eq!(first.parent(), path.parent()); + assert_eq!(second.parent(), path.parent()); + } + + #[test] + fn repeated_replacement_leaves_no_temp_files() { + let dir = tempdir(); + let path = dir.join("config.toml"); + write_private(&path, b"first").unwrap(); + write_private(&path, b"second").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn replacement_mode_is_private() { + use std::os::unix::fs::PermissionsExt; + + let path = tempdir().join("config.toml"); + write_private(&path, b"secret").unwrap(); + let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/crates/edison-stdiod/src/tunnel.rs b/crates/edison-stdiod/src/tunnel.rs index 891b9dd..4aee87a 100644 --- a/crates/edison-stdiod/src/tunnel.rs +++ b/crates/edison-stdiod/src/tunnel.rs @@ -14,16 +14,56 @@ use std::sync::{Arc, Mutex}; use anyhow::{anyhow, bail, Context, Result}; use edison_tunnel_protocol::TunnelFrame; use futures_util::{SinkExt, StreamExt}; +use thiserror::Error; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tracing::{debug, info, warn}; +use crate::config; + pub type WsStream = WebSocketStream>; +#[derive(Debug, Error)] +pub enum ConnectError { + #[error("WebSocket authentication rejected by backend (HTTP {status})")] + AuthRejected { status: u16 }, + #[error("WebSocket upgrade rejected by backend (HTTP {status})")] + UpgradeRejected { status: u16 }, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl ConnectError { + pub fn needs_reauth(&self) -> bool { + matches!(self, Self::AuthRejected { .. }) + } +} + +#[derive(Debug, Error)] +pub enum SessionCloseError { + #[error("backend reports that the client credential was revoked")] + ClientCredentialRevoked, + #[error("backend requires a different tunnel protocol version: {reason}")] + ProtocolVersionMismatch { reason: String }, + #[error("backend closed WebSocket (code {code}): {reason}")] + Closed { code: u16, reason: String }, +} + +impl SessionCloseError { + pub fn needs_reauth(&self) -> bool { + matches!(self, Self::ClientCredentialRevoked) + } + + pub fn needs_upgrade(&self) -> bool { + matches!(self, Self::ProtocolVersionMismatch { .. }) + } +} + /// Stable handle to the daemon's outbound channel. /// /// Children (and the heartbeat task) clone this once and use it for their @@ -72,67 +112,70 @@ impl OutgoingHandle { /// Connect to the backend's `/api/v1/stdio-tunnel/ws` endpoint. pub async fn connect( backend_url: &str, - api_key: &str, + credential: &str, edison_secret_key: Option<&str>, device_id: &str, -) -> Result { - let ws_url = build_ws_url(backend_url)?; +) -> std::result::Result { + let request = build_request(backend_url, credential, edison_secret_key, device_id)?; + let ws_url = request.uri().to_string(); info!(url = %ws_url, device_id = %device_id, "connecting to backend"); + let (ws, response) = match tokio_tungstenite::connect_async(request).await { + Ok(ok) => ok, + Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => { + let status = resp.status().as_u16(); + return Err(if matches!(status, 401 | 403) { + ConnectError::AuthRejected { status } + } else { + ConnectError::UpgradeRejected { status } + }); + } + Err(error) => { + return Err(ConnectError::Other( + anyhow::Error::from(error).context("WebSocket upgrade failed"), + )); + } + }; + debug!(status = %response.status(), "WS upgrade complete"); + Ok(ws) +} + +fn build_request( + backend_url: &str, + credential: &str, + edison_secret_key: Option<&str>, + device_id: &str, +) -> Result> { + let ws_url = build_ws_url(backend_url)?; let mut request = ws_url .into_client_request() .context("invalid backend URL")?; let headers = request.headers_mut(); - headers.insert( - "Authorization", - HeaderValue::from_str(&format!("Bearer {api_key}")) - .context("invalid api_key for Authorization header")?, - ); + let mut authorization = HeaderValue::from_str(&format!("Bearer {credential}")) + .context("invalid bearer credential for Authorization header")?; + authorization.set_sensitive(true); + headers.insert("Authorization", authorization); headers.insert( "X-Edison-Device-Id", HeaderValue::from_str(device_id).context("invalid device_id header")?, ); if let Some(secret) = edison_secret_key { - headers.insert( - "X-Edison-Secret-Key", - HeaderValue::from_str(secret).context("invalid secret-key header")?, - ); + let mut secret = + HeaderValue::from_str(secret).context("invalid Edison secret-key header")?; + secret.set_sensitive(true); + headers.insert("X-Edison-Secret-Key", secret); } - - let (ws, response) = match tokio_tungstenite::connect_async(request).await { - Ok(ok) => ok, - Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => { - let status = resp.status(); - let body = resp - .into_body() - .map(|b| String::from_utf8_lossy(&b).to_string()) - .unwrap_or_default(); - anyhow::bail!( - "WebSocket upgrade failed: HTTP {} - {}", - status, - if body.is_empty() { - "(no body)".into() - } else { - body - } - ); - } - Err(e) => return Err(anyhow::Error::from(e).context("WebSocket upgrade failed")), - }; - debug!(status = %response.status(), "WS upgrade complete"); - Ok(ws) + Ok(request) } fn build_ws_url(backend_url: &str) -> Result { - let trimmed = backend_url.trim_end_matches('/'); - let ws_base = if let Some(rest) = trimmed.strip_prefix("https://") { + let backend_url = config::normalize_backend_url(backend_url)?; + let ws_base = if let Some(rest) = backend_url.strip_prefix("https://") { format!("wss://{rest}") - } else if let Some(rest) = trimmed.strip_prefix("http://") { + } else if let Some(rest) = backend_url.strip_prefix("http://") { format!("ws://{rest}") - } else if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { - trimmed.to_string() } else { - bail!("backend URL must start with http(s):// or ws(s)://, got `{backend_url}`"); + bail!("backend URL must use HTTP or HTTPS"); }; Ok(format!("{ws_base}/api/v1/stdio-tunnel/ws")) } @@ -187,8 +230,16 @@ pub async fn run_frame_loop( } } } - Message::Close(_) => { - info!("backend closed WS"); + Message::Close(frame) => { + if let Some(frame) = frame { + let reason = frame.reason.to_string(); + if !reason.is_empty() { + return Err(session_close_error(frame.code, reason).into()); + } + info!(code = %frame.code, "backend closed WS"); + } else { + info!("backend closed WS"); + } break; } Message::Ping(p) => debug!(len = p.len(), "got ping"), @@ -203,6 +254,32 @@ pub async fn run_frame_loop( Ok(()) } +fn session_close_error(code: CloseCode, reason: String) -> SessionCloseError { + let normalized = reason.trim(); + if code == CloseCode::Policy { + if normalized.eq_ignore_ascii_case("client credential revoked") + || normalized.eq_ignore_ascii_case("client installation revoked") + { + return SessionCloseError::ClientCredentialRevoked; + } + if normalized + .to_ascii_lowercase() + .starts_with("protocol_version mismatch") + { + return SessionCloseError::ProtocolVersionMismatch { reason }; + } + SessionCloseError::Closed { + code: code.into(), + reason, + } + } else { + SessionCloseError::Closed { + code: code.into(), + reason, + } + } +} + // `anyhow` re-export so the public API can return its Result without callers // needing the same dependency in scope. #[allow(dead_code)] @@ -221,13 +298,62 @@ mod tests { "ws://localhost:8000/api/v1/stdio-tunnel/ws" ); assert_eq!( - build_ws_url("https://demo-dashboard.edison.watch/").unwrap(), + build_ws_url("HTTPS://DEMO-DASHBOARD.EDISON.WATCH/").unwrap(), "wss://demo-dashboard.edison.watch/api/v1/stdio-tunnel/ws" ); - assert_eq!( - build_ws_url("ws://127.0.0.1:9999").unwrap(), - "ws://127.0.0.1:9999/api/v1/stdio-tunnel/ws" - ); + assert!(build_ws_url("ws://127.0.0.1:9999").is_err()); assert!(build_ws_url("plain.example.com").is_err()); } + + #[test] + fn request_uses_client_credential_and_marks_secrets_sensitive() { + let request = build_request( + "https://example.test", + "opaque-client-token", + Some("edison-secret"), + "device-1", + ) + .unwrap(); + let authorization = request.headers().get("Authorization").unwrap(); + assert_eq!(authorization, "Bearer opaque-client-token"); + assert!(authorization.is_sensitive()); + assert!(request + .headers() + .get("X-Edison-Secret-Key") + .unwrap() + .is_sensitive()); + } + + #[test] + fn upgrade_auth_statuses_are_classified_for_reauthentication() { + assert!(ConnectError::AuthRejected { status: 401 }.needs_reauth()); + assert!(ConnectError::AuthRejected { status: 403 }.needs_reauth()); + assert!(!ConnectError::UpgradeRejected { status: 500 }.needs_reauth()); + } + + #[test] + fn only_specific_policy_close_is_classified_as_reauthentication() { + let revoked = + session_close_error(CloseCode::Policy, "client credential revoked".to_string()); + let installation_revoked = + session_close_error(CloseCode::Policy, "client installation revoked".to_string()); + let other_policy = + session_close_error(CloseCode::Policy, "organization policy changed".to_string()); + let same_reason_wrong_code = + session_close_error(CloseCode::Normal, "client credential revoked".to_string()); + assert!(revoked.needs_reauth()); + assert!(installation_revoked.needs_reauth()); + assert!(!other_policy.needs_reauth()); + assert!(!same_reason_wrong_code.needs_reauth()); + } + + #[test] + fn protocol_mismatch_requires_an_upgrade() { + let mismatch = session_close_error( + CloseCode::Policy, + "protocol_version mismatch (server=1, client=0)".to_string(), + ); + assert!(mismatch.needs_upgrade()); + assert!(!mismatch.needs_reauth()); + } }