merge: account passkey login flow

This commit is contained in:
Keisuke Hirata 2026-07-23 02:08:56 +09:00
commit 681034db1e
No known key found for this signature in database
13 changed files with 2548 additions and 44 deletions

254
Cargo.lock generated
View File

@ -132,6 +132,45 @@ dependencies = [
"rustversion", "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]] [[package]]
name = "assert-json-diff" name = "assert-json-diff"
version = "2.0.2" version = "2.0.2"
@ -203,7 +242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"base64", "base64 0.22.1",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -251,12 +290,29 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@ -810,6 +866,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -909,6 +971,20 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" 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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@ -1460,6 +1536,17 @@ dependencies = [
"tracing", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -1646,7 +1733,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@ -2096,7 +2183,7 @@ name = "llm-engine"
version = "0.2.1" version = "0.2.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64 0.22.1",
"chrono", "chrono",
"clap", "clap",
"dotenv", "dotenv",
@ -2479,6 +2566,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@ -2496,6 +2593,15 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -2542,6 +2648,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -2630,6 +2745,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdf-extract" name = "pdf-extract"
version = "0.10.0" version = "0.10.0"
@ -3258,7 +3379,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -3346,6 +3467,15 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.44" version = "0.38.44"
@ -3562,6 +3692,16 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@ -4061,7 +4201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64 0.22.1",
"bitflags 2.11.0", "bitflags 2.11.0",
"fancy-regex", "fancy-regex",
"filedescriptor", "filedescriptor",
@ -4171,12 +4311,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"libc", "libc",
"num-conv", "num-conv",
"num_threads", "num_threads",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
"time-macros",
] ]
[[package]] [[package]]
@ -4185,6 +4327,16 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" 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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@ -4524,7 +4676,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
name = "tui" name = "tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"client", "client",
"crossterm 0.28.1", "crossterm 0.28.1",
"fs4", "fs4",
@ -4686,6 +4838,7 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]] [[package]]
@ -5211,6 +5364,74 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.5" version = "1.0.5"
@ -5577,7 +5798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [ dependencies = [
"assert-json-diff", "assert-json-diff",
"base64", "base64 0.22.1",
"deadpool", "deadpool",
"futures", "futures",
"http", "http",
@ -5777,6 +5998,23 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 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]] [[package]]
name = "xattr" name = "xattr"
version = "1.6.1" version = "1.6.1"
@ -5868,7 +6106,9 @@ dependencies = [
"toml", "toml",
"tower", "tower",
"tracing", "tracing",
"url",
"uuid", "uuid",
"webauthn-rs",
"worker", "worker",
"worker-runtime", "worker-runtime",
] ]

View File

@ -99,4 +99,6 @@ tokio-tungstenite = "0.29"
tower = "0.5" tower = "0.5"
toml = "1.1" toml = "1.1"
tracing = "0.1" tracing = "0.1"
url = "2.5"
uuid = "1.23" uuid = "1.23"
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }

View File

@ -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<String>) -> 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<String>,
pub token_type: Option<String>,
}
#[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<reqwest::Error> 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<DeviceLoginStartResponse, BackendAuthClientError> {
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<DeviceLoginPollResponse, BackendAuthClientError> {
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<String, BackendAuthClientError> {
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<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, BackendAuthClientError> {
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::<T>().await?)
}

View File

@ -8,12 +8,17 @@
//! //!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_auth;
pub mod backend_runtime; pub mod backend_runtime;
pub mod runtime_command; pub mod runtime_command;
pub mod spawn; pub mod spawn;
pub mod ticket_role; pub mod ticket_role;
mod worker_client; 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::{ pub use backend_runtime::{
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,

View File

@ -28,7 +28,9 @@ worker.workspace = true
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] } worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
toml.workspace = true toml.workspace = true
tracing.workspace = true tracing.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
webauthn-rs = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true

View File

@ -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<String> {
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<String> {
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<String> {
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<S: ControlPlaneStore + ?Sized>(
store: &S,
headers: &HeaderMap,
cookie_name: &str,
) -> Result<Option<RequestActor>> {
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<S: ControlPlaneStore + ?Sized>(
store: &S,
user_id: &str,
auth_method: ActorAuthMethod,
) -> Result<Option<RequestActor>> {
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(),
}
}

View File

@ -15,8 +15,27 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml"); include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; 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; 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile { pub struct WorkspaceBackendConfigFile {
@ -27,6 +46,8 @@ pub struct WorkspaceBackendConfigFile {
#[serde(default)] #[serde(default)]
pub limits: WorkspaceBackendLimitsConfig, pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)] #[serde(default)]
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>, pub repositories: Vec<WorkspaceRepositoryConfigFile>,
#[serde(default)] #[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig, pub runtimes: WorkspaceBackendRuntimesConfig,
@ -61,6 +82,30 @@ pub struct WorkspaceBackendLimitsConfig {
pub max_records: Option<usize>, pub max_records: Option<usize>,
} }
#[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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile { pub struct WorkspaceRepositoryConfigFile {
@ -290,8 +335,11 @@ impl WorkspaceBackendConfigFile {
.iter() .iter()
.map(resolve_remote_runtime) .map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::LocalDevToken { server.auth = AuthConfig::Passkey {
token_configured: false, 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 { Ok(ResolvedWorkspaceBackendConfig {

View File

@ -4,6 +4,7 @@
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! 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. //! remain the canonical project records and are read through bounded bridge APIs.
pub mod auth;
pub mod companion; pub mod companion;
pub mod config; pub mod config;
pub mod hosts; pub mod hosts;

View File

@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path as AxumPath, Query, State}; 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::http::{HeaderMap, StatusCode, Uri};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put}; use axum::routing::{delete, get, post, put};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::{SecondsFormat, Utc}; use chrono::{Duration, SecondsFormat, Utc};
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use memory::backend::{ use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation, MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
@ -26,9 +26,21 @@ use tokio::net::TcpListener;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; 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::resource::{BackendResourceError, BackendResourceFetchRequest};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; 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::{ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection, CompanionStatusResponse, CompanionTranscriptProjection,
@ -64,8 +76,9 @@ use crate::repositories::{
use crate::resource_broker::BackendResourceBroker; use crate::resource_broker::BackendResourceBroker;
use crate::skills; use crate::skills;
use crate::store::{ use crate::store::{
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
WorkspaceRecord, DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
}; };
use crate::{Error, Result}; use crate::{Error, Result};
use worker_runtime::catalog::{ use worker_runtime::catalog::{
@ -91,9 +104,14 @@ fn embedded_runtime_id() -> String {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuthConfig { pub enum AuthConfig {
/// Local/dev-only mode. If a token is configured by a future entrypoint, it /// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions;
/// is a development guard only and not a production SaaS auth model. /// CLI/TUI auth uses API tokens obtained through the device login flow.
LocalDevToken { token_configured: bool }, Passkey {
rp_id: String,
origin: String,
public_base_url: String,
cookie_name: String,
},
} }
#[derive(Clone)] #[derive(Clone)]
@ -126,8 +144,11 @@ impl ServerConfig {
frontend_url: "http://127.0.0.1:5173".to_string(), frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root, embedded_runtime_store_root,
static_assets_dir: None, static_assets_dir: None,
auth: AuthConfig::LocalDevToken { auth: AuthConfig::Passkey {
token_configured: false, 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, max_records: 200,
repositories: Vec::new(), repositories: Vec::new(),
@ -233,6 +254,7 @@ impl WorkspaceApi {
store store
.upsert_workspace(&WorkspaceRecord { .upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(), workspace_id: config.workspace_id.clone(),
owner_account_id: None,
display_name: config.workspace_display_name.clone(), display_name: config.workspace_display_name.clone(),
state: "active".to_string(), state: "active".to_string(),
created_at: config.workspace_created_at.clone(), created_at: config.workspace_created_at.clone(),
@ -297,6 +319,28 @@ impl WorkspaceApi {
pub fn build_router(api: WorkspaceApi) -> Router { pub fn build_router(api: WorkspaceApi) -> Router {
Router::new() 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/workspace", get(get_workspace))
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
.route( .route(
@ -2443,6 +2487,654 @@ async fn scoped_list_host_workers(
list_host_workers(State(api), AxumPath(path.host_id)).await 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<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AuthUserResponse {
user: AuthenticatedUser,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PasskeyRegistrationOptionsRequest {
handle: String,
#[serde(default)]
display_name: Option<String>,
}
#[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<String>,
}
#[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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
token_type: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct WhoamiResponse {
actor: Option<RequestActor>,
}
async fn get_auth_config(State(api): State<WorkspaceApi>) -> ApiResult<Json<AuthPublicConfig>> {
Ok(Json(auth_public_config(&api.config)))
}
async fn post_auth_bootstrap_user(
State(api): State<WorkspaceApi>,
Json(request): Json<AuthBootstrapUserRequest>,
) -> ApiResult<Json<AuthUserResponse>> {
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<WorkspaceApi>,
Json(request): Json<PasskeyRegistrationOptionsRequest>,
) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> {
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<WorkspaceApi>,
Json(request): Json<PasskeyRegistrationCompleteRequest>,
) -> ApiResult<Json<AuthUserResponse>> {
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<WorkspaceApi>,
Json(request): Json<PasskeyLoginOptionsRequest>,
) -> ApiResult<Json<PasskeyLoginOptionsResponse>> {
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<WorkspaceApi>,
Json(request): Json<PasskeyLoginCompleteRequest>,
) -> ApiResult<Response> {
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<WorkspaceApi>,
Json(request): Json<DeviceLoginStartRequest>,
) -> ApiResult<Json<DeviceLoginStartResponse>> {
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<WorkspaceApi>,
headers: HeaderMap,
Json(request): Json<DeviceLoginApproveRequest>,
) -> ApiResult<Json<DeviceLoginApproveResponse>> {
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<WorkspaceApi>,
Json(request): Json<DeviceLoginPollRequest>,
) -> ApiResult<Json<DeviceLoginPollResponse>> {
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<WorkspaceApi>,
headers: HeaderMap,
) -> ApiResult<Json<WhoamiResponse>> {
Ok(Json(WhoamiResponse {
actor: resolve_actor(&api, &headers).await?,
}))
}
fn webauthn(config: &ServerConfig) -> ApiResult<Webauthn> {
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<Vec<Passkey>> {
api.store
.list_passkey_credentials_for_user(user_id)?
.into_iter()
.map(|record| {
serde_json::from_str::<Passkey>(&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<String> {
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<AuthenticatedUser> {
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<Option<RequestActor>> {
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<RequestActor> {
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<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> { async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
let schema_version = api.store.schema_version().await?; let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?; let stored = api.store.get_workspace(api.workspace_id()).await?;
@ -7927,6 +8619,68 @@ mod tests {
dir, 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 { async fn get_json(app: Router, uri: &str) -> Value {
let response = app let response = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())

File diff suppressed because it is too large Load Diff

View File

@ -12,10 +12,14 @@ use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode}; 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 memory_lint::{LintCliOptions, LintStatus};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use session_store::SegmentId; use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions}; use tui::{LaunchMode, LaunchOptions};
@ -36,6 +40,10 @@ enum Mode {
subcommand: String, subcommand: String,
args: Vec<String>, args: Vec<String>,
}, },
Login {
backend_url: String,
no_wait: bool,
},
WorkerRuntime(Vec<String>), WorkerRuntime(Vec<String>),
Keys, Keys,
SetupModel, SetupModel,
@ -85,6 +93,16 @@ async fn main() -> ExitCode {
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args), 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) { Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE, Ok(LintStatus::Failed) => ExitCode::FAILURE,
@ -241,6 +259,9 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
"workspace" => { "workspace" => {
return parse_workspace_args(&args[1..]); return parse_workspace_args(&args[1..]);
} }
"login" => {
return parse_login_args(&args[1..]);
}
"mcp" => { "mcp" => {
let mcp_cli = parse_mcp_args(&args[1..])?; let mcp_cli = parse_mcp_args(&args[1..])?;
return Ok(Mode::Mcp(mcp_cli)); return Ok(Mode::Mcp(mcp_cli));
@ -859,15 +880,7 @@ fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
} }
fn client_config_path() -> Option<PathBuf> { fn client_config_path() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { yoi_config_dir().map(|dir| dir.join("client.toml"))
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")
})
} }
fn client_config_missing_message(workspace_id: Option<&str>) -> String { 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<Mode, ParseError> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Err(ParseError(
"yoi login usage: yoi login [--backend <URL>] [--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<String, BackendTokenEntry>,
}
#[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::<BackendTokenFile>(&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<PathBuf> {
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
}
fn yoi_config_dir() -> Option<PathBuf> {
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<Mode, ParseError> { fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(

View File

@ -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 <URL>] [--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 <token>` 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.

View File

@ -41,6 +41,14 @@ frontend_url = "http://127.0.0.1:5173"
[limits] [limits]
max_records = 200 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 # Repository registry. Browser/API repository projection reads only configured
# entries and never falls back to the backend process cwd. Relative URI values # 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. # are resolved from this workspace config root. Git is the v0 supported provider.