diff --git a/Cargo.lock b/Cargo.lock index e30142e4..596e68b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,45 @@ dependencies = [ "rustversion", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -203,7 +242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -251,12 +290,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -810,6 +866,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -909,6 +971,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1460,6 +1536,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1646,7 +1733,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2096,7 +2183,7 @@ name = "llm-engine" version = "0.2.1" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chrono", "clap", "dotenv", @@ -2479,6 +2566,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2496,6 +2593,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2542,6 +2648,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2630,6 +2745,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pdf-extract" version = "0.10.0" @@ -3258,7 +3379,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3346,6 +3467,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3562,6 +3692,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4061,7 +4201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.11.0", "fancy-regex", "filedescriptor", @@ -4171,12 +4311,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -4185,6 +4327,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4524,7 +4676,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" name = "tui" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "client", "crossterm 0.28.1", "fs4", @@ -4686,6 +4838,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -5211,6 +5364,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom 7.1.3", + "openssl", + "openssl-sys", + "rand 0.9.4", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-root-certs" version = "1.0.5" @@ -5577,7 +5798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -5777,6 +5998,23 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -5868,7 +6106,9 @@ dependencies = [ "toml", "tower", "tracing", + "url", "uuid", + "webauthn-rs", "worker", "worker-runtime", ] diff --git a/Cargo.toml b/Cargo.toml index 22906815..8dd9078d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,4 +99,6 @@ tokio-tungstenite = "0.29" tower = "0.5" toml = "1.1" tracing = "0.1" +url = "2.5" uuid = "1.23" +webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs new file mode 100644 index 00000000..78cd1097 --- /dev/null +++ b/crates/client/src/backend_auth.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendAuthTarget { + pub base_url: String, +} + +impl BackendAuthTarget { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + } + } + + fn api_url(&self, path: &str) -> String { + format!( + "{}{}", + self.base_url.trim_end_matches('/'), + path.strip_prefix('/') + .map(|path| format!("/{path}")) + .unwrap_or_else(|| path.to_string()) + ) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DeviceLoginStartResponse { + 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(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DeviceLoginPollResponse { + pub status: String, + pub access_token: Option, + pub token_type: Option, +} + +#[derive(Debug)] +pub enum BackendAuthClientError { + Http(reqwest::Error), + BackendStatus { status: u16, body: String }, + MissingAccessToken, +} + +impl fmt::Display for BackendAuthClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http(error) => write!(f, "Backend auth request failed: {error}"), + Self::BackendStatus { status, body } => { + write!(f, "Backend auth returned HTTP {status}: {body}") + } + Self::MissingAccessToken => { + f.write_str("Backend approved device login without an access token") + } + } + } +} + +impl std::error::Error for BackendAuthClientError {} + +impl From for BackendAuthClientError { + fn from(value: reqwest::Error) -> Self { + Self::Http(value) + } +} + +#[derive(Debug, Serialize)] +struct DeviceLoginStartRequest<'a> { + client_name: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +struct DeviceLoginPollRequest<'a> { + device_code: &'a str, +} + +pub async fn start_device_login( + target: &BackendAuthTarget, + client_name: Option<&str>, +) -> Result { + let client = reqwest::Client::new(); + let response = client + .post(target.api_url("/api/auth/device-login/start")) + .json(&DeviceLoginStartRequest { client_name }) + .send() + .await?; + parse_json_response(response).await +} + +pub async fn poll_device_login( + target: &BackendAuthTarget, + device_code: &str, +) -> Result { + let client = reqwest::Client::new(); + let response = client + .post(target.api_url("/api/auth/device-login/poll")) + .json(&DeviceLoginPollRequest { device_code }) + .send() + .await?; + parse_json_response(response).await +} + +pub async fn wait_for_device_login( + target: &BackendAuthTarget, + device_code: &str, + interval: Duration, + expires_in: Duration, +) -> Result { + let started = std::time::Instant::now(); + loop { + let response = poll_device_login(target, device_code).await?; + match response.status.as_str() { + "approved" => { + return response + .access_token + .ok_or(BackendAuthClientError::MissingAccessToken); + } + "expired" => { + return Err(BackendAuthClientError::BackendStatus { + status: 410, + body: "device login expired".to_string(), + }); + } + "consumed" => { + return Err(BackendAuthClientError::BackendStatus { + status: 409, + body: "device login was already consumed".to_string(), + }); + } + _ => {} + } + if started.elapsed() >= expires_in { + return Err(BackendAuthClientError::BackendStatus { + status: 408, + body: "timed out waiting for device login approval".to_string(), + }); + } + tokio::time::sleep(interval).await; + } +} + +async fn parse_json_response Deserialize<'de>>( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(BackendAuthClientError::BackendStatus { + status: status.as_u16(), + body, + }); + } + Ok(response.json::().await?) +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 5d58181a..9eeb164b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -8,12 +8,17 @@ //! //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 +pub mod backend_auth; pub mod backend_runtime; pub mod runtime_command; pub mod spawn; pub mod ticket_role; mod worker_client; +pub use backend_auth::{ + BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse, + poll_device_login, start_device_login, wait_for_device_login, +}; pub use backend_runtime::{ BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 42bee351..003bf1b0 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -28,7 +28,9 @@ worker.workspace = true worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] } toml.workspace = true tracing.workspace = true +url.workspace = true uuid = { workspace = true, features = ["v7"] } +webauthn-rs = { workspace = true } [dev-dependencies] tempfile.workspace = true diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs new file mode 100644 index 00000000..6a561892 --- /dev/null +++ b/crates/workspace-server/src/auth.rs @@ -0,0 +1,189 @@ +use axum::http::{HeaderMap, header}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{Error, Result, store::ControlPlaneStore}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthPublicConfig { + pub rp_id: String, + pub origin: String, + pub public_base_url: String, + pub cookie_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RequestActor { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, + pub auth_method: ActorAuthMethod, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActorAuthMethod { + BrowserSession, + ApiToken, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthenticatedUser { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, +} + +impl RequestActor { + pub fn user(&self) -> AuthenticatedUser { + AuthenticatedUser { + user_id: self.user_id.clone(), + account_id: self.account_id.clone(), + handle: self.handle.clone(), + display_name: self.display_name.clone(), + } + } +} + +pub fn normalize_handle(handle: &str) -> Result { + let normalized = handle.trim().to_ascii_lowercase(); + let valid = !normalized.is_empty() + && normalized.len() <= 64 + && normalized + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); + if !valid { + return Err(auth_error( + "invalid_handle", + "handle must be 1-64 chars of ascii lowercase letters, digits, '-' or '_'", + )); + } + Ok(normalized) +} + +pub fn now_rfc3339() -> String { + Utc::now().to_rfc3339() +} + +pub fn rfc3339_after(duration: Duration) -> String { + (Utc::now() + duration).to_rfc3339() +} + +pub fn is_expired(expires_at: &str) -> bool { + match DateTime::parse_from_rfc3339(expires_at) { + Ok(expires_at) => expires_at.with_timezone(&Utc) <= Utc::now(), + Err(_) => true, + } +} + +pub fn new_id(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::now_v7()) +} + +pub fn new_challenge() -> String { + format!("chal-{}-{}", Uuid::now_v7(), Uuid::now_v7()) +} + +pub fn mint_secret(prefix: &str) -> String { + format!("{prefix}_{}_{}", Uuid::now_v7(), Uuid::now_v7()) +} + +pub fn token_hash(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2 + "sha256:".len()); + encoded.push_str("sha256:"); + for byte in digest { + encoded.push_str(&format!("{byte:02x}")); + } + encoded +} + +pub fn new_user_code() -> String { + let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase(); + format!("{}-{}", &hex[0..4], &hex[4..8]) +} + +pub fn parse_bearer(headers: &HeaderMap) -> Option { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { + let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; + cookie_header.split(';').find_map(|pair| { + let (name, value) = pair.trim().split_once('=')?; + (name == cookie_name && !value.is_empty()).then(|| value.to_string()) + }) +} + +pub async fn resolve_request_actor( + store: &S, + headers: &HeaderMap, + cookie_name: &str, +) -> Result> { + if let Some(token) = parse_bearer(headers) { + let token_hash = token_hash(&token); + if let Some(api_token) = store.resolve_api_token(&token_hash)? { + if api_token + .expires_at + .as_deref() + .is_some_and(|expires_at| is_expired(expires_at)) + { + return Ok(None); + } + store.mark_api_token_used(&token_hash, &now_rfc3339())?; + return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); + } + } + + if let Some(session_token) = parse_cookie(headers, cookie_name) { + if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? { + if is_expired(&session.expires_at) { + return Ok(None); + } + return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); + } + } + + Ok(None) +} + +fn actor_for_user( + store: &S, + user_id: &str, + auth_method: ActorAuthMethod, +) -> Result> { + let Some(user) = store.get_user(user_id)? else { + return Ok(None); + }; + Ok(Some(RequestActor { + user_id: user.user_id, + account_id: user.account_id, + handle: user.handle, + display_name: user.display_name, + auth_method, + })) +} + +pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String { + format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax") +} + +pub fn auth_error(code: &str, message: &str) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-auth".to_string(), + code: code.to_string(), + message: message.to_string(), + } +} diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index 456d192f..19e9701c 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -15,8 +15,27 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = include_str!("../../../resources/workspace-backend.default.toml"); const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; +const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787"; +const DEFAULT_AUTH_RP_ID: &str = "localhost"; +const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_MAX_RECORDS: usize = 200; +fn default_auth_rp_id() -> String { + DEFAULT_AUTH_RP_ID.to_string() +} + +fn default_auth_origin() -> String { + DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() +} + +fn default_auth_public_base_url() -> String { + DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() +} + +fn default_auth_cookie_name() -> String { + DEFAULT_AUTH_COOKIE_NAME.to_string() +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendConfigFile { @@ -27,6 +46,8 @@ pub struct WorkspaceBackendConfigFile { #[serde(default)] pub limits: WorkspaceBackendLimitsConfig, #[serde(default)] + pub auth: WorkspaceBackendAuthConfig, + #[serde(default)] pub repositories: Vec, #[serde(default)] pub runtimes: WorkspaceBackendRuntimesConfig, @@ -61,6 +82,30 @@ pub struct WorkspaceBackendLimitsConfig { pub max_records: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendAuthConfig { + #[serde(default = "default_auth_rp_id")] + pub rp_id: String, + #[serde(default = "default_auth_origin")] + pub origin: String, + #[serde(default = "default_auth_public_base_url")] + pub public_base_url: String, + #[serde(default = "default_auth_cookie_name")] + pub cookie_name: String, +} + +impl Default for WorkspaceBackendAuthConfig { + fn default() -> Self { + Self { + rp_id: default_auth_rp_id(), + origin: default_auth_origin(), + public_base_url: default_auth_public_base_url(), + cookie_name: default_auth_cookie_name(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceRepositoryConfigFile { @@ -290,8 +335,11 @@ impl WorkspaceBackendConfigFile { .iter() .map(resolve_remote_runtime) .collect::>>()?; - server.auth = AuthConfig::LocalDevToken { - token_configured: false, + server.auth = AuthConfig::Passkey { + rp_id: self.auth.rp_id.trim().to_string(), + origin: self.auth.origin.trim().to_string(), + public_base_url: self.auth.public_base_url.trim().to_string(), + cookie_name: self.auth.cookie_name.trim().to_string(), }; Ok(ResolvedWorkspaceBackendConfig { diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 83cd06bb..02dbb199 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -4,6 +4,7 @@ //! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! remain the canonical project records and are read through bounded bridge APIs. +pub mod auth; pub mod companion; pub mod config; pub mod hosts; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 38cd8d63..a24dd637 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Path as AxumPath, Query, State}; -use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION}; +use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, SET_COOKIE}; use axum::http::{HeaderMap, StatusCode, Uri}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; -use chrono::{SecondsFormat, Utc}; +use chrono::{Duration, SecondsFormat, Utc}; use futures::{SinkExt, StreamExt}; use memory::backend::{ MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation, @@ -26,9 +26,21 @@ use tokio::net::TcpListener; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use url::Url; +use uuid::Uuid; +use webauthn_rs::prelude::{ + CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration, + PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, Webauthn, + WebauthnBuilder, +}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; +use crate::auth::{ + AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id, + new_user_code, normalize_handle, resolve_request_actor, rfc3339_after, session_set_cookie, + token_hash, +}; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, @@ -64,8 +76,9 @@ use crate::repositories::{ use crate::resource_broker::BackendResourceBroker; use crate::skills; use crate::store::{ - ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, - WorkspaceRecord, + AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, + DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord, + WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, }; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -91,9 +104,14 @@ fn embedded_runtime_id() -> String { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AuthConfig { - /// Local/dev-only mode. If a token is configured by a future entrypoint, it - /// is a development guard only and not a production SaaS auth model. - LocalDevToken { token_configured: bool }, + /// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions; + /// CLI/TUI auth uses API tokens obtained through the device login flow. + Passkey { + rp_id: String, + origin: String, + public_base_url: String, + cookie_name: String, + }, } #[derive(Clone)] @@ -126,8 +144,11 @@ impl ServerConfig { frontend_url: "http://127.0.0.1:5173".to_string(), embedded_runtime_store_root, static_assets_dir: None, - auth: AuthConfig::LocalDevToken { - token_configured: false, + auth: AuthConfig::Passkey { + rp_id: "localhost".to_string(), + origin: "http://localhost:8787".to_string(), + public_base_url: "http://localhost:8787".to_string(), + cookie_name: "yoi_workspace_session".to_string(), }, max_records: 200, repositories: Vec::new(), @@ -233,6 +254,7 @@ impl WorkspaceApi { store .upsert_workspace(&WorkspaceRecord { workspace_id: config.workspace_id.clone(), + owner_account_id: None, display_name: config.workspace_display_name.clone(), state: "active".to_string(), created_at: config.workspace_created_at.clone(), @@ -297,6 +319,28 @@ impl WorkspaceApi { pub fn build_router(api: WorkspaceApi) -> Router { Router::new() + .route("/api/auth/config", get(get_auth_config)) + .route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user)) + .route( + "/api/auth/passkeys/registration/options", + post(post_passkey_registration_options), + ) + .route( + "/api/auth/passkeys/registration/complete", + post(post_passkey_registration_complete), + ) + .route( + "/api/auth/passkeys/login/options", + post(post_passkey_login_options), + ) + .route( + "/api/auth/passkeys/login/complete", + post(post_passkey_login_complete), + ) + .route("/api/auth/device-login/start", post(post_device_login_start)) + .route("/api/auth/device-login/approve", post(post_device_login_approve)) + .route("/api/auth/device-login/poll", post(post_device_login_poll)) + .route("/api/auth/whoami", get(get_auth_whoami)) .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route( @@ -2443,6 +2487,654 @@ async fn scoped_list_host_workers( list_host_workers(State(api), AxumPath(path.host_id)).await } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthBootstrapUserRequest { + handle: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AuthUserResponse { + user: AuthenticatedUser, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyRegistrationOptionsRequest { + handle: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyRegistrationOptionsResponse { + challenge_id: String, + public_key: CreationChallengeResponse, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyRegistrationCompleteRequest { + challenge_id: String, + credential: RegisterPublicKeyCredential, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyLoginOptionsRequest { + #[serde(default)] + handle: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyLoginOptionsResponse { + challenge_id: String, + public_key: RequestChallengeResponse, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyLoginCompleteRequest { + challenge_id: String, + credential: PublicKeyCredential, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyLoginCompleteResponse { + user: AuthenticatedUser, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviceLoginStartRequest { + #[serde(default)] + client_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceLoginStartResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviceLoginApproveRequest { + user_code: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceLoginApproveResponse { + status: String, + user: AuthenticatedUser, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviceLoginPollRequest { + device_code: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceLoginPollResponse { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + token_type: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WhoamiResponse { + actor: Option, +} + +async fn get_auth_config(State(api): State) -> ApiResult> { + Ok(Json(auth_public_config(&api.config))) +} + +async fn post_auth_bootstrap_user( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; + Ok(Json(AuthUserResponse { user })) +} + +async fn post_passkey_registration_options( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; + let webauthn = webauthn(&api.config)?; + let exclude_credentials = passkeys_for_user(&api, &user.user_id)? + .into_iter() + .map(|passkey| passkey.cred_id().clone()) + .collect(); + let user_unique_id = Uuid::now_v7(); + let (public_key, state) = webauthn + .start_passkey_registration( + user_unique_id, + &user.handle, + &user.display_name, + Some(exclude_credentials), + ) + .map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?; + let challenge_id = new_id("webauthn-registration"); + let auth = auth_public_config(&api.config); + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: challenge_id.clone(), + ceremony: "passkey_registration".to_string(), + challenge: challenge_id.clone(), + user_id: Some(user.user_id), + rp_id: auth.rp_id, + origin: auth.origin, + state_json: Some( + serde_json::to_string(&state).map_err(|error| { + auth_error("webauthn_state_serialize_failed", &error.to_string()) + })?, + ), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(Json(PasskeyRegistrationOptionsResponse { + challenge_id, + public_key, + })) +} + +async fn post_passkey_registration_complete( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let challenge = api + .store + .consume_auth_challenge_by_id( + &request.challenge_id, + "passkey_registration", + &crate::auth::now_rfc3339(), + )? + .ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey registration challenge is invalid or already consumed", + ) + })?; + if is_expired(&challenge.expires_at) { + return Err(auth_error( + "expired_passkey_challenge", + "passkey registration challenge expired", + ) + .into()); + } + let user_id = challenge.user_id.ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey registration challenge is not bound to a user", + ) + })?; + let user = api.store.get_user(&user_id)?.ok_or_else(|| { + auth_error( + "unknown_auth_user", + "passkey registration user does not exist", + ) + })?; + let state_json = challenge.state_json.ok_or_else(|| { + auth_error( + "missing_webauthn_state", + "passkey registration state was not persisted", + ) + })?; + let state: PasskeyRegistration = serde_json::from_str(&state_json) + .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; + let webauthn = webauthn(&api.config)?; + let passkey = webauthn + .finish_passkey_registration(&request.credential, &state) + .map_err(|error| { + auth_error( + "webauthn_registration_verification_failed", + &error.to_string(), + ) + })?; + let credential_id = passkey_credential_id(&passkey)?; + api.store + .upsert_passkey_credential(&PasskeyCredentialRecord { + credential_id, + user_id: user.user_id.clone(), + public_key_cose: serde_json::to_string(&passkey).map_err(|error| { + auth_error("webauthn_passkey_serialize_failed", &error.to_string()) + })?, + transports_json: None, + sign_count: 0, + created_at: crate::auth::now_rfc3339(), + last_used_at: None, + })?; + Ok(Json(AuthUserResponse { + user: user_response(user), + })) +} + +async fn post_passkey_login_options( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let user = match request.handle.as_deref() { + Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?, + None => api.store.any_user()?, + } + .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; + let passkeys = passkeys_for_user(&api, &user.user_id)?; + if passkeys.is_empty() { + return Err(auth_error( + "passkey_not_registered", + "user has no registered passkey credentials", + ) + .into()); + } + let webauthn = webauthn(&api.config)?; + let (public_key, state) = webauthn + .start_passkey_authentication(&passkeys) + .map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?; + let challenge_id = new_id("webauthn-login"); + let auth = auth_public_config(&api.config); + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: challenge_id.clone(), + ceremony: "passkey_login".to_string(), + challenge: challenge_id.clone(), + user_id: Some(user.user_id), + rp_id: auth.rp_id, + origin: auth.origin, + state_json: Some( + serde_json::to_string(&state).map_err(|error| { + auth_error("webauthn_state_serialize_failed", &error.to_string()) + })?, + ), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(Json(PasskeyLoginOptionsResponse { + challenge_id, + public_key, + })) +} + +async fn post_passkey_login_complete( + State(api): State, + Json(request): Json, +) -> ApiResult { + let challenge = api + .store + .consume_auth_challenge_by_id( + &request.challenge_id, + "passkey_login", + &crate::auth::now_rfc3339(), + )? + .ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey login challenge is invalid or already consumed", + ) + })?; + if is_expired(&challenge.expires_at) { + return Err(auth_error( + "expired_passkey_challenge", + "passkey login challenge expired", + ) + .into()); + } + let user_id = challenge.user_id.ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey login challenge is not bound to a user", + ) + })?; + let user = api + .store + .get_user(&user_id)? + .ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?; + let state_json = challenge.state_json.ok_or_else(|| { + auth_error( + "missing_webauthn_state", + "passkey login state was not persisted", + ) + })?; + let state: PasskeyAuthentication = serde_json::from_str(&state_json) + .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; + let webauthn = webauthn(&api.config)?; + let auth_result = webauthn + .finish_passkey_authentication(&request.credential, &state) + .map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?; + let credential_id = serde_json::to_value(auth_result.cred_id()) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .ok_or_else(|| { + auth_error( + "invalid_webauthn_credential_id", + "verified credential id was not serializable", + ) + })?; + let stored = api + .store + .get_passkey_credential(&credential_id)? + .ok_or_else(|| { + auth_error( + "unknown_passkey_credential", + "verified passkey credential is not registered", + ) + })?; + if stored.user_id != user.user_id { + return Err(auth_error( + "passkey_user_mismatch", + "verified passkey credential does not belong to the challenged user", + ) + .into()); + } + api.store + .upsert_passkey_credential(&PasskeyCredentialRecord { + credential_id, + user_id: user.user_id.clone(), + public_key_cose: stored.public_key_cose, + transports_json: stored.transports_json, + sign_count: u64::from(auth_result.counter()), + created_at: stored.created_at, + last_used_at: Some(crate::auth::now_rfc3339()), + })?; + let session_token = mint_secret("yoi_sess"); + api.store.create_browser_session(&BrowserSessionRecord { + session_id: new_id("session"), + token_hash: token_hash(&session_token), + user_id: user.user_id.clone(), + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::days(14)), + revoked_at: None, + })?; + let cookie_name = auth_public_config(&api.config).cookie_name; + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + session_set_cookie(&cookie_name, &session_token, 14 * 24 * 60 * 60) + .parse() + .map_err(|error| { + auth_error( + "invalid_session_cookie", + &format!("failed to build session cookie: {error}"), + ) + })?, + ); + Ok(( + headers, + Json(PasskeyLoginCompleteResponse { + user: user_response(user), + }), + ) + .into_response()) +} + +async fn post_device_login_start( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let auth = auth_public_config(&api.config); + let device_code = mint_secret("yoi_device"); + let user_code = new_user_code(); + let verification_uri = format!( + "{}/login/device", + auth.public_base_url.trim_end_matches('/') + ); + let verification_uri_complete = format!("{verification_uri}?user_code={user_code}"); + api.store.create_device_login_flow(&DeviceLoginFlowRecord { + device_code: device_code.clone(), + user_code: user_code.clone(), + verification_uri: verification_uri.clone(), + client_name: request.client_name, + user_id: None, + api_token_id: None, + issued_access_token: None, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(10)), + approved_at: None, + consumed_at: None, + })?; + Ok(Json(DeviceLoginStartResponse { + device_code, + user_code, + verification_uri, + verification_uri_complete, + expires_in: 600, + interval: 5, + })) +} + +async fn post_device_login_approve( + State(api): State, + headers: HeaderMap, + Json(request): Json, +) -> ApiResult> { + let actor = require_actor(&api, &headers).await?; + let flow = api + .store + .get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())? + .ok_or_else(|| { + auth_error( + "unknown_device_login_code", + "device login code does not exist", + ) + })?; + if is_expired(&flow.expires_at) { + return Err(auth_error("expired_device_login", "device login code expired").into()); + } + if flow.approved_at.is_some() || flow.consumed_at.is_some() { + return Err(auth_error( + "device_login_already_used", + "device login code is already used", + ) + .into()); + } + let access_token = mint_secret("yoi_api"); + let token_id = new_id("api-token"); + api.store.create_api_token(&ApiTokenRecord { + token_id: token_id.clone(), + token_hash: token_hash(&access_token), + user_id: actor.user_id.clone(), + label: flow + .client_name + .clone() + .unwrap_or_else(|| "yoi device login".to_string()), + created_at: crate::auth::now_rfc3339(), + expires_at: None, + revoked_at: None, + last_used_at: None, + })?; + let approved = api.store.approve_device_login_flow( + &flow.device_code, + &actor.user_id, + &token_id, + &access_token, + &crate::auth::now_rfc3339(), + )?; + if !approved { + return Err(auth_error( + "device_login_already_used", + "device login code is already used", + ) + .into()); + } + Ok(Json(DeviceLoginApproveResponse { + status: "approved".to_string(), + user: actor.user(), + })) +} + +async fn post_device_login_poll( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let Some(flow) = api + .store + .get_device_login_flow_by_device_code(&request.device_code)? + else { + return Err(auth_error("unknown_device_login", "device login flow does not exist").into()); + }; + if is_expired(&flow.expires_at) { + return Ok(Json(DeviceLoginPollResponse { + status: "expired".to_string(), + access_token: None, + token_type: None, + })); + } + if flow.approved_at.is_none() { + return Ok(Json(DeviceLoginPollResponse { + status: "pending".to_string(), + access_token: None, + token_type: None, + })); + } + let Some(consumed) = api + .store + .consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())? + else { + return Ok(Json(DeviceLoginPollResponse { + status: "consumed".to_string(), + access_token: None, + token_type: None, + })); + }; + Ok(Json(DeviceLoginPollResponse { + status: "approved".to_string(), + access_token: consumed.issued_access_token, + token_type: Some("Bearer".to_string()), + })) +} + +async fn get_auth_whoami( + State(api): State, + headers: HeaderMap, +) -> ApiResult> { + Ok(Json(WhoamiResponse { + actor: resolve_actor(&api, &headers).await?, + })) +} + +fn webauthn(config: &ServerConfig) -> ApiResult { + let auth = auth_public_config(config); + let origin = Url::parse(&auth.origin) + .map_err(|error| auth_error("invalid_webauthn_origin", &error.to_string()))?; + WebauthnBuilder::new(&auth.rp_id, &origin) + .map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()))? + .rp_name("Yoi Workspace") + .build() + .map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()).into()) +} + +fn passkeys_for_user(api: &WorkspaceApi, user_id: &str) -> ApiResult> { + api.store + .list_passkey_credentials_for_user(user_id)? + .into_iter() + .map(|record| { + serde_json::from_str::(&record.public_key_cose).map_err(|error| { + auth_error("webauthn_passkey_deserialize_failed", &error.to_string()).into() + }) + }) + .collect() +} + +fn passkey_credential_id(passkey: &Passkey) -> ApiResult { + serde_json::to_value(passkey.cred_id()) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .ok_or_else(|| { + auth_error( + "invalid_webauthn_credential_id", + "verified passkey credential id was not serializable", + ) + .into() + }) +} + +fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig { + match &config.auth { + AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + cookie_name, + } => AuthPublicConfig { + rp_id: rp_id.clone(), + origin: origin.clone(), + public_base_url: public_base_url.clone(), + cookie_name: cookie_name.clone(), + }, + } +} + +fn ensure_user_account( + api: &WorkspaceApi, + handle: &str, + display_name: Option<&str>, +) -> ApiResult { + let handle = normalize_handle(handle)?; + if let Some(user) = api.store.get_user_by_handle(&handle)? { + return Ok(user_response(user)); + } + let now = crate::auth::now_rfc3339(); + let display_name = display_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(handle.as_str()) + .to_string(); + let account = AccountRecord { + account_id: new_id("acct-user"), + kind: "user".to_string(), + handle: handle.clone(), + display_name: display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + }; + api.store.upsert_account(&account)?; + let user = UserRecord { + user_id: new_id("user"), + account_id: account.account_id, + handle, + display_name, + created_at: now.clone(), + updated_at: now, + }; + api.store.upsert_user(&user)?; + Ok(user_response(user)) +} + +fn user_response(user: UserRecord) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user.user_id, + account_id: user.account_id, + handle: user.handle, + display_name: user.display_name, + } +} + +async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult> { + let cookie_name = auth_public_config(&api.config).cookie_name; + Ok(resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await?) +} + +async fn require_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult { + resolve_actor(api, headers).await?.ok_or_else(|| { + auth_error( + "auth_required", + "request requires a browser session or Bearer API token", + ) + .into() + }) +} + async fn get_workspace(State(api): State) -> ApiResult> { let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; @@ -7927,6 +8619,68 @@ mod tests { dir, ) } + + #[tokio::test] + async fn passkey_registration_rejects_unverified_credential_response() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + + let registration_options = post_json( + app.clone(), + "/api/auth/passkeys/registration/options", + json!({ + "handle": "alice", + "display_name": "Alice" + }), + ) + .await; + assert!(registration_options["public_key"].is_object()); + let challenge_id = registration_options["challenge_id"].as_str().unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/passkeys/registration/complete") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "challenge_id": challenge_id, + "credential": { + "id": "credential-1", + "rawId": "Y3JlZGVudGlhbC0x", + "type": "public-key", + "response": { + "clientDataJSON": "e30", + "attestationObject": "e30" + } + } + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(response.status(), StatusCode::OK); + + let login_without_registered_passkey = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/passkeys/login/options") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ "handle": "alice" })).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(login_without_registered_passkey.status(), StatusCode::OK); + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 64b198e7..1d4067e1 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -52,6 +52,16 @@ const MIGRATIONS: &[Migration] = &[ name: "remove workdir registry management kind", apply: remove_workdir_registry_management_kind_column, }, + Migration { + version: 8, + name: "account identity and login flow schema", + apply: create_account_identity_tables, + }, + Migration { + version: 9, + name: "webauthn challenge state", + apply: add_webauthn_challenge_state, + }, ]; struct Migration { @@ -63,12 +73,97 @@ struct Migration { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceRecord { pub workspace_id: String, + /// Account/namespace owner abstraction. `None` is allowed for legacy/local + /// workspaces until a user account is bootstrapped. + pub owner_account_id: Option, pub display_name: String, pub state: String, pub created_at: String, pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AccountRecord { + pub account_id: String, + pub kind: String, + pub handle: String, + pub display_name: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UserRecord { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PasskeyCredentialRecord { + pub credential_id: String, + pub user_id: String, + pub public_key_cose: String, + pub transports_json: Option, + pub sign_count: u64, + pub created_at: String, + pub last_used_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthChallengeRecord { + pub challenge_id: String, + pub ceremony: String, + pub challenge: String, + pub user_id: Option, + pub rp_id: String, + pub origin: String, + pub state_json: Option, + pub expires_at: String, + pub created_at: String, + pub consumed_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BrowserSessionRecord { + pub session_id: String, + pub token_hash: String, + pub user_id: String, + pub created_at: String, + pub expires_at: String, + pub revoked_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApiTokenRecord { + pub token_id: String, + pub token_hash: String, + pub user_id: String, + pub label: String, + pub created_at: String, + pub expires_at: Option, + pub revoked_at: Option, + pub last_used_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeviceLoginFlowRecord { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub client_name: Option, + pub user_id: Option, + pub api_token_id: Option, + pub issued_access_token: Option, + pub created_at: String, + pub expires_at: String, + pub approved_at: Option, + pub consumed_at: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerRegistryRecord { pub workspace_id: String, @@ -117,6 +212,63 @@ pub trait ControlPlaneStore: Send + Sync { async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; + fn upsert_account(&self, record: &AccountRecord) -> Result<()>; + fn get_account(&self, account_id: &str) -> Result>; + fn get_account_by_handle(&self, kind: &str, handle: &str) -> Result>; + fn upsert_user(&self, record: &UserRecord) -> Result<()>; + fn get_user(&self, user_id: &str) -> Result>; + fn get_user_by_handle(&self, handle: &str) -> Result>; + fn any_user(&self) -> Result>; + fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; + fn get_passkey_credential( + &self, + credential_id: &str, + ) -> Result>; + fn list_passkey_credentials_for_user( + &self, + user_id: &str, + ) -> Result>; + fn put_auth_challenge(&self, record: &AuthChallengeRecord) -> Result<()>; + fn consume_auth_challenge( + &self, + challenge: &str, + ceremony: &str, + consumed_at: &str, + ) -> Result>; + fn consume_auth_challenge_by_id( + &self, + challenge_id: &str, + ceremony: &str, + consumed_at: &str, + ) -> Result>; + fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()>; + fn resolve_browser_session(&self, token_hash: &str) -> Result>; + fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>; + fn resolve_api_token(&self, token_hash: &str) -> Result>; + fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>; + fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()>; + fn get_device_login_flow_by_user_code( + &self, + user_code: &str, + ) -> Result>; + fn get_device_login_flow_by_device_code( + &self, + device_code: &str, + ) -> Result>; + fn approve_device_login_flow( + &self, + device_code: &str, + user_id: &str, + api_token_id: &str, + issued_access_token: &str, + approved_at: &str, + ) -> Result; + fn consume_device_login_token( + &self, + device_code: &str, + consumed_at: &str, + ) -> Result>; + fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>; fn get_worker_registry( &self, @@ -213,14 +365,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5) + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(workspace_id) DO UPDATE SET + owner_account_id = COALESCE(excluded.owner_account_id, workspaces.owner_account_id), display_name = excluded.display_name, state = excluded.state, updated_at = excluded.updated_at"#, params![ record.workspace_id, + record.owner_account_id, record.display_name, record.state, record.created_at, @@ -234,24 +388,352 @@ impl ControlPlaneStore for SqliteWorkspaceStore { async fn get_workspace(&self, workspace_id: &str) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, display_name, state, created_at, updated_at + r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at FROM workspaces WHERE workspace_id = ?1"#, params![workspace_id], - |row| { - Ok(WorkspaceRecord { - workspace_id: row.get(0)?, - display_name: row.get(1)?, - state: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, - }) - }, + read_workspace_record, ) .optional() .map_err(Error::from) }) } + fn upsert_account(&self, record: &AccountRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO accounts (account_id, kind, handle, display_name, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(account_id) DO UPDATE SET + handle = excluded.handle, + display_name = excluded.display_name, + updated_at = excluded.updated_at"#, + params![record.account_id, record.kind, record.handle, record.display_name, record.created_at, record.updated_at], + )?; + Ok(()) + }) + } + + fn get_account(&self, account_id: &str) -> Result> { + self.with_conn(|conn| { + conn.query_row( + account_select_sql("WHERE account_id = ?1").as_str(), + params![account_id], + read_account_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn get_account_by_handle(&self, kind: &str, handle: &str) -> Result> { + self.with_conn(|conn| { + conn.query_row( + account_select_sql("WHERE kind = ?1 AND handle = ?2").as_str(), + params![kind, handle], + read_account_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn upsert_user(&self, record: &UserRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO users (user_id, account_id, handle, display_name, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(user_id) DO UPDATE SET + handle = excluded.handle, + display_name = excluded.display_name, + updated_at = excluded.updated_at"#, + params![record.user_id, record.account_id, record.handle, record.display_name, record.created_at, record.updated_at], + )?; + Ok(()) + }) + } + + fn get_user(&self, user_id: &str) -> Result> { + self.with_conn(|conn| { + conn.query_row( + user_select_sql("WHERE user_id = ?1").as_str(), + params![user_id], + read_user_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn get_user_by_handle(&self, handle: &str) -> Result> { + self.with_conn(|conn| { + conn.query_row( + user_select_sql("WHERE handle = ?1").as_str(), + params![handle], + read_user_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn any_user(&self) -> Result> { + self.with_conn(|conn| { + conn.query_row( + user_select_sql("ORDER BY created_at ASC LIMIT 1").as_str(), + [], + read_user_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(credential_id) DO UPDATE SET + public_key_cose = excluded.public_key_cose, + transports_json = excluded.transports_json, + sign_count = excluded.sign_count, + last_used_at = excluded.last_used_at"#, + params![record.credential_id, record.user_id, record.public_key_cose, record.transports_json, record.sign_count, record.created_at, record.last_used_at], + )?; + Ok(()) + }) + } + + fn get_passkey_credential( + &self, + credential_id: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + passkey_select_sql("WHERE credential_id = ?1").as_str(), + params![credential_id], + read_passkey_credential_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn list_passkey_credentials_for_user( + &self, + user_id: &str, + ) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + passkey_select_sql("WHERE user_id = ?1 ORDER BY created_at ASC").as_str(), + )?; + let rows = stmt.query_map(params![user_id], read_passkey_credential_record)?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + + fn put_auth_challenge(&self, record: &AuthChallengeRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO auth_challenges (challenge_id, ceremony, challenge, user_id, rp_id, origin, state_json, expires_at, created_at, consumed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#, + params![record.challenge_id, record.ceremony, record.challenge, record.user_id, record.rp_id, record.origin, record.state_json, record.expires_at, record.created_at, record.consumed_at], + )?; + Ok(()) + }) + } + + fn consume_auth_challenge( + &self, + challenge: &str, + ceremony: &str, + consumed_at: &str, + ) -> Result> { + self.with_conn(|conn| { + let record = conn + .query_row( + auth_challenge_select_sql( + "WHERE challenge = ?1 AND ceremony = ?2 AND consumed_at IS NULL", + ) + .as_str(), + params![challenge, ceremony], + read_auth_challenge_record, + ) + .optional()?; + if let Some(record) = record.as_ref() { + conn.execute( + "UPDATE auth_challenges SET consumed_at = ?2 WHERE challenge_id = ?1", + params![record.challenge_id, consumed_at], + )?; + } + Ok(record) + }) + } + + fn consume_auth_challenge_by_id( + &self, + challenge_id: &str, + ceremony: &str, + consumed_at: &str, + ) -> Result> { + self.with_conn(|conn| { + let record = conn + .query_row( + auth_challenge_select_sql( + "WHERE challenge_id = ?1 AND ceremony = ?2 AND consumed_at IS NULL", + ) + .as_str(), + params![challenge_id, ceremony], + read_auth_challenge_record, + ) + .optional()?; + if let Some(record) = record.as_ref() { + conn.execute( + "UPDATE auth_challenges SET consumed_at = ?2 WHERE challenge_id = ?1", + params![record.challenge_id, consumed_at], + )?; + } + Ok(record) + }) + } + + fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![record.session_id, record.token_hash, record.user_id, record.created_at, record.expires_at, record.revoked_at], + )?; + Ok(()) + }) + } + + fn resolve_browser_session(&self, token_hash: &str) -> Result> { + self.with_conn(|conn| { + conn.query_row( + browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(), + params![token_hash], + read_browser_session_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO api_tokens (token_id, token_hash, user_id, label, created_at, expires_at, revoked_at, last_used_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#, + params![record.token_id, record.token_hash, record.user_id, record.label, record.created_at, record.expires_at, record.revoked_at, record.last_used_at], + )?; + Ok(()) + }) + } + + fn resolve_api_token(&self, token_hash: &str) -> Result> { + self.with_conn(|conn| { + conn.query_row( + api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(), + params![token_hash], + read_api_token_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + "UPDATE api_tokens SET last_used_at = ?2 WHERE token_hash = ?1", + params![token_hash, used_at], + )?; + Ok(()) + }) + } + + fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, + params![record.device_code, record.user_code, record.verification_uri, record.client_name, record.user_id, record.api_token_id, record.issued_access_token, record.created_at, record.expires_at, record.approved_at, record.consumed_at], + )?; + Ok(()) + }) + } + + fn get_device_login_flow_by_user_code( + &self, + user_code: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + device_login_select_sql("WHERE user_code = ?1").as_str(), + params![user_code], + read_device_login_flow_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn get_device_login_flow_by_device_code( + &self, + device_code: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + device_login_select_sql("WHERE device_code = ?1").as_str(), + params![device_code], + read_device_login_flow_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn approve_device_login_flow( + &self, + device_code: &str, + user_id: &str, + api_token_id: &str, + issued_access_token: &str, + approved_at: &str, + ) -> Result { + self.with_conn(|conn| { + let changed = conn.execute( + r#"UPDATE device_login_flows + SET user_id = ?2, api_token_id = ?3, issued_access_token = ?4, approved_at = ?5 + WHERE device_code = ?1 AND approved_at IS NULL AND consumed_at IS NULL"#, + params![ + device_code, + user_id, + api_token_id, + issued_access_token, + approved_at + ], + )?; + Ok(changed > 0) + }) + } + + fn consume_device_login_token( + &self, + device_code: &str, + consumed_at: &str, + ) -> Result> { + self.with_conn(|conn| { + let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL").as_str(), params![device_code], read_device_login_flow_record).optional()?; + if record.is_some() { + conn.execute("UPDATE device_login_flows SET consumed_at = ?2, issued_access_token = NULL WHERE device_code = ?1", params![device_code, consumed_at])?; + } + Ok(record) + }) + } + fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -515,6 +997,152 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } } +fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WorkspaceRecord { + workspace_id: row.get(0)?, + owner_account_id: row.get(1)?, + display_name: row.get(2)?, + state: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +fn account_select_sql(where_clause: &str) -> String { + format!( + "SELECT account_id, kind, handle, display_name, created_at, updated_at FROM accounts {where_clause}" + ) +} + +fn read_account_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AccountRecord { + account_id: row.get(0)?, + kind: row.get(1)?, + handle: row.get(2)?, + display_name: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +fn user_select_sql(where_clause: &str) -> String { + format!( + "SELECT user_id, account_id, handle, display_name, created_at, updated_at FROM users {where_clause}" + ) +} + +fn read_user_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UserRecord { + user_id: row.get(0)?, + account_id: row.get(1)?, + handle: row.get(2)?, + display_name: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +fn passkey_select_sql(where_clause: &str) -> String { + format!( + "SELECT credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at FROM passkey_credentials {where_clause}" + ) +} + +fn read_passkey_credential_record( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + Ok(PasskeyCredentialRecord { + credential_id: row.get(0)?, + user_id: row.get(1)?, + public_key_cose: row.get(2)?, + transports_json: row.get(3)?, + sign_count: row.get(4)?, + created_at: row.get(5)?, + last_used_at: row.get(6)?, + }) +} + +fn auth_challenge_select_sql(where_clause: &str) -> String { + format!( + "SELECT challenge_id, ceremony, challenge, user_id, rp_id, origin, state_json, expires_at, created_at, consumed_at FROM auth_challenges {where_clause}" + ) +} + +fn read_auth_challenge_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AuthChallengeRecord { + challenge_id: row.get(0)?, + ceremony: row.get(1)?, + challenge: row.get(2)?, + user_id: row.get(3)?, + rp_id: row.get(4)?, + origin: row.get(5)?, + state_json: row.get(6)?, + expires_at: row.get(7)?, + created_at: row.get(8)?, + consumed_at: row.get(9)?, + }) +} + +fn browser_session_select_sql(where_clause: &str) -> String { + format!( + "SELECT session_id, token_hash, user_id, created_at, expires_at, revoked_at FROM browser_sessions {where_clause}" + ) +} + +fn read_browser_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(BrowserSessionRecord { + session_id: row.get(0)?, + token_hash: row.get(1)?, + user_id: row.get(2)?, + created_at: row.get(3)?, + expires_at: row.get(4)?, + revoked_at: row.get(5)?, + }) +} + +fn api_token_select_sql(where_clause: &str) -> String { + format!( + "SELECT token_id, token_hash, user_id, label, created_at, expires_at, revoked_at, last_used_at FROM api_tokens {where_clause}" + ) +} + +fn read_api_token_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ApiTokenRecord { + token_id: row.get(0)?, + token_hash: row.get(1)?, + user_id: row.get(2)?, + label: row.get(3)?, + created_at: row.get(4)?, + expires_at: row.get(5)?, + revoked_at: row.get(6)?, + last_used_at: row.get(7)?, + }) +} + +fn device_login_select_sql(where_clause: &str) -> String { + format!( + "SELECT device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at FROM device_login_flows {where_clause}" + ) +} + +fn read_device_login_flow_record( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + Ok(DeviceLoginFlowRecord { + device_code: row.get(0)?, + user_code: row.get(1)?, + verification_uri: row.get(2)?, + client_name: row.get(3)?, + user_id: row.get(4)?, + api_token_id: row.get(5)?, + issued_access_token: row.get(6)?, + created_at: row.get(7)?, + expires_at: row.get(8)?, + approved_at: row.get(9)?, + consumed_at: row.get(10)?, + }) +} + fn read_worker_workdir_link_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -638,6 +1266,105 @@ CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker Ok(()) } +fn add_webauthn_challenge_state(conn: &Connection) -> Result<()> { + if !column_exists(conn, "auth_challenges", "state_json")? { + conn.execute_batch("ALTER TABLE auth_challenges ADD COLUMN state_json TEXT;")?; + } + Ok(()) +} + +fn create_account_identity_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS accounts ( + account_id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), + handle TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (kind, handle) +); + +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL UNIQUE REFERENCES accounts(account_id) ON DELETE CASCADE, + handle TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS passkey_credentials ( + credential_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + public_key_cose TEXT NOT NULL, + transports_json TEXT, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_used_at TEXT +); + +CREATE TABLE IF NOT EXISTS auth_challenges ( + challenge_id TEXT PRIMARY KEY, + ceremony TEXT NOT NULL CHECK (ceremony IN ('passkey_registration', 'passkey_login')), + challenge TEXT NOT NULL UNIQUE, + user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, + rp_id TEXT NOT NULL, + origin TEXT NOT NULL, + state_json TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE TABLE IF NOT EXISTS browser_sessions ( + session_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE TABLE IF NOT EXISTS api_tokens ( + token_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT +); + +CREATE TABLE IF NOT EXISTS device_login_flows ( + device_code TEXT PRIMARY KEY, + user_code TEXT NOT NULL UNIQUE, + verification_uri TEXT NOT NULL, + client_name TEXT, + user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, + api_token_id TEXT REFERENCES api_tokens(token_id) ON DELETE SET NULL, + issued_access_token TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + consumed_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_browser_sessions_token_hash ON browser_sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_device_login_user_code ON device_login_flows(user_code); +"#, + )?; + if !column_exists(conn, "workspaces", "owner_account_id")? { + conn.execute_batch( + "ALTER TABLE workspaces ADD COLUMN owner_account_id TEXT REFERENCES accounts(account_id) ON DELETE SET NULL;", + )?; + } + Ok(()) +} + fn configure_sqlite(conn: &Connection) -> Result<()> { conn.busy_timeout(Duration::from_millis(5_000))?; conn.execute_batch( @@ -1218,10 +1945,11 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 7); + assert_eq!(store.schema_version().await.unwrap(), 9); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), + owner_account_id: None, display_name: "Yoi Dev".to_string(), state: "active".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), @@ -1230,7 +1958,7 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 7); + assert_eq!(reopened.schema_version().await.unwrap(), 9); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -1260,6 +1988,13 @@ mod tests { "worker_registry", "workdir_registry", "worker_workdir_links", + "accounts", + "users", + "passkey_credentials", + "auth_challenges", + "browser_sessions", + "api_tokens", + "device_login_flows", ] { assert!( tables.contains(expected), @@ -1293,6 +2028,7 @@ mod tests { "state", "created_at", "updated_at", + "owner_account_id", ], ); assert_columns( @@ -1439,7 +2175,7 @@ mod tests { .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 7); + assert_eq!(store.schema_version().await.unwrap(), 9); store .with_conn(|conn| { @@ -1479,6 +2215,7 @@ mod tests { "state", "created_at", "updated_at", + "owner_account_id", ], ); let legacy_workspace_columns = table_columns(conn, "legacy_workspaces")?; @@ -1503,6 +2240,7 @@ mod tests { store.get_workspace("legacy-workspace").await.unwrap(), Some(WorkspaceRecord { workspace_id: "legacy-workspace".to_string(), + owner_account_id: None, display_name: "Legacy Workspace".to_string(), state: "active".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), @@ -1512,6 +2250,7 @@ mod tests { let new_record = WorkspaceRecord { workspace_id: "new-workspace".to_string(), + owner_account_id: None, display_name: "New Workspace".to_string(), state: "active".to_string(), created_at: "2026-02-01T00:00:00Z".to_string(), @@ -1531,6 +2270,7 @@ mod tests { let store = SqliteWorkspaceStore::open(&db).unwrap(); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), + owner_account_id: None, display_name: "Local Dev".to_string(), state: "active".to_string(), created_at: "1".to_string(), @@ -1622,6 +2362,162 @@ mod tests { ); } + #[tokio::test] + async fn account_and_login_records_round_trip() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + assert_eq!(store.schema_version().await.unwrap(), 9); + let now = "2026-07-22T00:00:00Z".to_string(); + let account = AccountRecord { + account_id: "acct-user-alice".to_string(), + kind: "user".to_string(), + handle: "alice".to_string(), + display_name: "Alice".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }; + store.upsert_account(&account).unwrap(); + let user = UserRecord { + user_id: "user-alice".to_string(), + account_id: account.account_id.clone(), + handle: account.handle.clone(), + display_name: account.display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + }; + store.upsert_user(&user).unwrap(); + assert_eq!( + store.get_account_by_handle("user", "alice").unwrap(), + Some(account.clone()) + ); + assert_eq!( + store.get_user_by_handle("alice").unwrap(), + Some(user.clone()) + ); + + let workspace = WorkspaceRecord { + workspace_id: "workspace".to_string(), + owner_account_id: Some(account.account_id.clone()), + display_name: "Workspace".to_string(), + state: "active".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }; + store.upsert_workspace(&workspace).await.unwrap(); + assert_eq!( + store.get_workspace("workspace").await.unwrap(), + Some(workspace) + ); + + let passkey = PasskeyCredentialRecord { + credential_id: "cred-1".to_string(), + user_id: user.user_id.clone(), + public_key_cose: "public-key".to_string(), + transports_json: Some("[\\\"internal\\\"]".to_string()), + sign_count: 0, + created_at: now.clone(), + last_used_at: None, + }; + store.upsert_passkey_credential(&passkey).unwrap(); + assert_eq!( + store.get_passkey_credential("cred-1").unwrap(), + Some(passkey.clone()) + ); + assert_eq!( + store + .list_passkey_credentials_for_user(&user.user_id) + .unwrap(), + vec![passkey] + ); + + let challenge = AuthChallengeRecord { + challenge_id: "challenge-id".to_string(), + ceremony: "passkey_login".to_string(), + challenge: "challenge".to_string(), + user_id: Some(user.user_id.clone()), + rp_id: "127.0.0.1".to_string(), + origin: "http://127.0.0.1:8787".to_string(), + state_json: None, + expires_at: "2026-07-22T00:05:00Z".to_string(), + created_at: now.clone(), + consumed_at: None, + }; + store.put_auth_challenge(&challenge).unwrap(); + assert_eq!( + store + .consume_auth_challenge("challenge", "passkey_login", "2026-07-22T00:01:00Z") + .unwrap(), + Some(challenge) + ); + assert_eq!( + store + .consume_auth_challenge("challenge", "passkey_login", "2026-07-22T00:01:00Z") + .unwrap(), + None + ); + + let api_token = ApiTokenRecord { + token_id: "token-id".to_string(), + token_hash: "hash".to_string(), + user_id: user.user_id.clone(), + label: "cli".to_string(), + created_at: now.clone(), + expires_at: None, + revoked_at: None, + last_used_at: None, + }; + store.create_api_token(&api_token).unwrap(); + assert_eq!( + store.resolve_api_token("hash").unwrap(), + Some(api_token.clone()) + ); + store + .mark_api_token_used("hash", "2026-07-22T00:02:00Z") + .unwrap(); + assert_eq!( + store + .resolve_api_token("hash") + .unwrap() + .unwrap() + .last_used_at, + Some("2026-07-22T00:02:00Z".to_string()) + ); + + let flow = DeviceLoginFlowRecord { + device_code: "device".to_string(), + user_code: "USER-CODE".to_string(), + verification_uri: "http://127.0.0.1:8787/login/device".to_string(), + client_name: Some("yoi".to_string()), + user_id: None, + api_token_id: None, + issued_access_token: None, + created_at: now.clone(), + expires_at: "2026-07-22T00:10:00Z".to_string(), + approved_at: None, + consumed_at: None, + }; + store.create_device_login_flow(&flow).unwrap(); + store + .approve_device_login_flow( + "device", + &user.user_id, + "token-id", + "access", + "2026-07-22T00:03:00Z", + ) + .unwrap(); + let approved = store + .consume_device_login_token("device", "2026-07-22T00:04:00Z") + .unwrap() + .unwrap(); + assert_eq!(approved.issued_access_token, Some("access".to_string())); + assert_eq!( + store + .consume_device_login_token("device", "2026-07-22T00:05:00Z") + .unwrap(), + None + ); + } + fn table_names(conn: &Connection) -> BTreeSet { let mut stmt = conn .prepare( diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 91263bc3..a9786394 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -12,10 +12,14 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; +use std::time::Duration; -use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; +use client::{ + BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand, + start_device_login, wait_for_device_login, +}; use memory_lint::{LintCliOptions, LintStatus}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use session_store::SegmentId; use tui::{LaunchMode, LaunchOptions}; @@ -36,6 +40,10 @@ enum Mode { subcommand: String, args: Vec, }, + Login { + backend_url: String, + no_wait: bool, + }, WorkerRuntime(Vec), Keys, SetupModel, @@ -85,6 +93,16 @@ async fn main() -> ExitCode { ExitCode::SUCCESS } Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args), + Mode::Login { + backend_url, + no_wait, + } => match run_login(&backend_url, no_wait).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("yoi login: {e}"); + ExitCode::FAILURE + } + }, Mode::MemoryLint(options) => match memory_lint::run(&options) { Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Failed) => ExitCode::FAILURE, @@ -241,6 +259,9 @@ fn parse_args_slice(args: &[String]) -> Result { "workspace" => { return parse_workspace_args(&args[1..]); } + "login" => { + return parse_login_args(&args[1..]); + } "mcp" => { let mcp_cli = parse_mcp_args(&args[1..])?; return Ok(Mode::Mcp(mcp_cli)); @@ -859,15 +880,7 @@ fn read_client_config() -> Result, ParseError> { } fn client_config_path() -> Option { - if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { - return Some(PathBuf::from(home).join("yoi").join("client.toml")); - } - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".config") - .join("yoi") - .join("client.toml") - }) + yoi_config_dir().map(|dir| dir.join("client.toml")) } fn client_config_missing_message(workspace_id: Option<&str>) -> String { @@ -879,6 +892,137 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String { } } +fn parse_login_args(args: &[String]) -> Result { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(ParseError( + "yoi login usage: yoi login [--backend ] [--no-wait]".to_string(), + )); + } + let mut backend_url = None; + let mut no_wait = false; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--backend" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--backend requires a URL".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + backend_url = Some(value.clone()); + i += 2; + } + arg if arg.starts_with("--backend=") => { + let value = arg.trim_start_matches("--backend="); + if value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + backend_url = Some(value.to_string()); + i += 1; + } + "--no-wait" => { + no_wait = true; + i += 1; + } + other => { + return Err(ParseError(format!( + "unknown yoi login argument '{other}' (try 'yoi login --help')" + ))); + } + } + } + let backend_url = resolve_backend_url(backend_url, None)?; + Ok(Mode::Login { + backend_url, + no_wait, + }) +} + +async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> { + let target = BackendAuthTarget::new(backend_url.to_string()); + let start = start_device_login(&target, Some("yoi cli")) + .await + .map_err(|error| ParseError(error.to_string()))?; + println!("Open this URL in your browser to approve Yoi CLI login:"); + println!(" {}", start.verification_uri_complete); + println!(); + println!("User code: {}", start.user_code); + println!("Expires in: {} seconds", start.expires_in); + if no_wait { + println!("Device code: {}", start.device_code); + println!( + "Run without --no-wait after approving, or poll the Backend device endpoint manually." + ); + return Ok(()); + } + let token = wait_for_device_login( + &target, + &start.device_code, + Duration::from_secs(start.interval.max(1)), + Duration::from_secs(start.expires_in.max(1)), + ) + .await + .map_err(|error| ParseError(error.to_string()))?; + save_backend_token(backend_url, &token)?; + println!("Saved Backend API token for {backend_url}"); + Ok(()) +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct BackendTokenFile { + #[serde(default)] + tokens: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackendTokenEntry { + token_type: String, + access_token: String, +} + +fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> { + let path = backend_token_path().ok_or_else(|| { + ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string()) + })?; + let mut file = if path.is_file() { + let contents = fs::read_to_string(&path) + .map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?; + serde_json::from_str::(&contents) + .map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))? + } else { + BackendTokenFile::default() + }; + file.tokens.insert( + backend_url.trim_end_matches('/').to_string(), + BackendTokenEntry { + token_type: "Bearer".to_string(), + access_token: access_token.to_string(), + }, + ); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + ParseError(format!("failed to create {}: {error}", parent.display())) + })?; + } + let serialized = serde_json::to_string_pretty(&file) + .map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?; + fs::write(&path, format!("{serialized}\n")) + .map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?; + Ok(()) +} + +fn backend_token_path() -> Option { + yoi_config_dir().map(|dir| dir.join("backend-tokens.json")) +} + +fn yoi_config_dir() -> Option { + if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(home).join("yoi")); + } + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi")) +} + fn parse_workspace_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( diff --git a/docs/backend-auth-account-model.md b/docs/backend-auth-account-model.md new file mode 100644 index 00000000..e4d6bf44 --- /dev/null +++ b/docs/backend-auth-account-model.md @@ -0,0 +1,54 @@ +# Backend account model and login flows + +This document records the first Backend identity/auth layer introduced for the multi-workspace Backend direction. + +## Account and owner model + +The Backend store now separates a human user from the owner namespace used by workspace ownership: + +- `accounts`: canonical namespace records. `kind = "user"` is implemented now; `kind = "organization"` is reserved in the schema for a later organization model. +- `users`: human user records. Each user owns exactly one `accounts` row. +- `workspaces.owner_account_id`: optional account owner reference. Existing/local workspaces may keep it unset until account bootstrap/ownership migration code assigns it. + +This avoids baking `owner_user_id` into workspace authority while keeping the current implementation small. + +## Browser login + +Human browser auth is modeled as Passkey/WebAuthn ceremony endpoints: + +- `POST /api/auth/bootstrap-user` +- `POST /api/auth/passkeys/registration/options` +- `POST /api/auth/passkeys/registration/complete` +- `POST /api/auth/passkeys/login/options` +- `POST /api/auth/passkeys/login/complete` +- `GET /api/auth/whoami` + +The store persists registration/login challenges, serialized WebAuthn ceremony state, verified passkey credentials, browser sessions, and challenge consumption. Successful login mints an HttpOnly `SameSite=Lax` cookie session. + +Completion endpoints require browser `PublicKeyCredential` responses and verify them with `webauthn-rs` before a passkey is stored or a browser session is issued. Fake or stale credential responses are rejected by the verifier, and consumed challenges cannot be reused. + +## CLI/TUI device login + +CLI/TUI clients use a device-login flow instead of browser cookies: + +- `POST /api/auth/device-login/start` creates a `device_code` and human `user_code`. +- The CLI prints `verification_uri_complete` and polls `POST /api/auth/device-login/poll`. +- A browser-authenticated user approves with `POST /api/auth/device-login/approve`. +- Approval mints a Backend API token and returns it exactly once to the polling client. + +`yoi login [--backend ] [--no-wait]` starts this flow and stores the received token in `$XDG_CONFIG_HOME/yoi/backend-tokens.json` or `$HOME/.config/yoi/backend-tokens.json`. + +## Request actor extraction + +`workspace-server::auth::resolve_request_actor` resolves a typed request actor from either: + +- `Authorization: Bearer ` for CLI/TUI API tokens, or +- the configured browser session cookie. + +The resolved actor includes `user_id`, `account_id`, `handle`, `display_name`, and `auth_method`. This helper is intended to be used by mutating Backend APIs as authorization gets added route-by-route. + +## Non-goals for this step + +- Organization membership and RBAC. +- Password login, OAuth login, or other human auth methods. +- Token scoping beyond Backend API bearer authentication. diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml index 921d420d..b15c25d8 100644 --- a/resources/workspace-backend.default.toml +++ b/resources/workspace-backend.default.toml @@ -41,6 +41,14 @@ frontend_url = "http://127.0.0.1:5173" [limits] max_records = 200 +[auth] +# WebAuthn / Passkey relying-party settings. For local development keep rp_id +# aligned with the browser host in public_base_url/origin. +rp_id = "localhost" +origin = "http://localhost:8787" +public_base_url = "http://localhost:8787" +cookie_name = "yoi_workspace_session" + # Repository registry. Browser/API repository projection reads only configured # entries and never falls back to the backend process cwd. Relative URI values # are resolved from this workspace config root. Git is the v0 supported provider.