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/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/config.rs b/crates/workspace-server/src/config.rs index 1c4cfa55..19e9701c 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -15,8 +15,8 @@ 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://127.0.0.1:8787"; -const DEFAULT_AUTH_RP_ID: &str = "127.0.0.1"; +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; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 95b6e783..a24dd637 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -26,13 +26,20 @@ 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_challenge, new_id, new_user_code, normalize_handle, resolve_request_actor, rfc3339_after, - session_set_cookie, token_hash, + 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, @@ -138,9 +145,9 @@ impl ServerConfig { embedded_runtime_store_root, static_assets_dir: None, auth: AuthConfig::Passkey { - rp_id: "127.0.0.1".to_string(), - origin: "http://127.0.0.1:8787".to_string(), - public_base_url: "http://127.0.0.1:8787".to_string(), + 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, @@ -2503,36 +2510,15 @@ struct PasskeyRegistrationOptionsRequest { #[derive(Debug, Serialize, Deserialize)] struct PasskeyRegistrationOptionsResponse { - challenge: String, - rp: PublicKeyCredentialRpEntity, - user: PublicKeyCredentialUserEntity, - timeout_ms: u64, - attestation: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PublicKeyCredentialRpEntity { - id: String, - name: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PublicKeyCredentialUserEntity { - id: String, - name: String, - display_name: String, + challenge_id: String, + public_key: CreationChallengeResponse, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct PasskeyRegistrationCompleteRequest { - challenge: String, - credential_id: String, - public_key_cose: String, - #[serde(default)] - transports: Vec, - #[serde(default)] - sign_count: u64, + challenge_id: String, + credential: RegisterPublicKeyCredential, } #[derive(Debug, Deserialize)] @@ -2544,23 +2530,15 @@ struct PasskeyLoginOptionsRequest { #[derive(Debug, Serialize, Deserialize)] struct PasskeyLoginOptionsResponse { - challenge: String, - rp_id: String, - timeout_ms: u64, - allow_credentials: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PasskeyAllowedCredential { - credential_id: String, - transports: Vec, + challenge_id: String, + public_key: RequestChallengeResponse, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct PasskeyLoginCompleteRequest { - challenge: String, - credential_id: String, + challenge_id: String, + credential: PublicKeyCredential, } #[derive(Debug, Serialize, Deserialize)] @@ -2634,32 +2612,41 @@ async fn post_passkey_registration_options( 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); - let challenge = new_challenge(); api.store.put_auth_challenge(&AuthChallengeRecord { - challenge_id: new_id("auth-challenge"), + challenge_id: challenge_id.clone(), ceremony: "passkey_registration".to_string(), - challenge: challenge.clone(), - user_id: Some(user.user_id.clone()), - rp_id: auth.rp_id.clone(), - origin: auth.origin.clone(), + 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, - rp: PublicKeyCredentialRpEntity { - id: auth.rp_id, - name: "Yoi Workspace".to_string(), - }, - user: PublicKeyCredentialUserEntity { - id: user.user_id, - name: user.handle, - display_name: user.display_name, - }, - timeout_ms: 300_000, - attestation: "none".to_string(), + challenge_id, + public_key, })) } @@ -2669,8 +2656,8 @@ async fn post_passkey_registration_complete( ) -> ApiResult> { let challenge = api .store - .consume_auth_challenge( - &request.challenge, + .consume_auth_challenge_by_id( + &request.challenge_id, "passkey_registration", &crate::auth::now_rfc3339(), )? @@ -2699,21 +2686,33 @@ async fn post_passkey_registration_complete( "passkey registration user does not exist", ) })?; - let transports_json = if request.transports.is_empty() { - None - } else { - Some( - serde_json::to_string(&request.transports) - .map_err(|error| auth_error("invalid_passkey_transports", &error.to_string()))?, + 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: request.credential_id, + credential_id, user_id: user.user_id.clone(), - public_key_cose: request.public_key_cose, - transports_json, - sign_count: request.sign_count, + 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, })?; @@ -2731,41 +2730,39 @@ async fn post_passkey_login_options( None => api.store.any_user()?, } .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; - let credentials = api.store.list_passkey_credentials_for_user(&user.user_id)?; - if credentials.is_empty() { + 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); - let challenge = new_challenge(); api.store.put_auth_challenge(&AuthChallengeRecord { - challenge_id: new_id("auth-challenge"), + challenge_id: challenge_id.clone(), ceremony: "passkey_login".to_string(), - challenge: challenge.clone(), + challenge: challenge_id.clone(), user_id: Some(user.user_id), - rp_id: auth.rp_id.clone(), + 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, - rp_id: auth.rp_id, - timeout_ms: 300_000, - allow_credentials: credentials - .into_iter() - .map(|credential| PasskeyAllowedCredential { - credential_id: credential.credential_id, - transports: credential - .transports_json - .and_then(|raw| serde_json::from_str(&raw).ok()) - .unwrap_or_default(), - }) - .collect(), + challenge_id, + public_key, })) } @@ -2775,8 +2772,8 @@ async fn post_passkey_login_complete( ) -> ApiResult { let challenge = api .store - .consume_auth_challenge( - &request.challenge, + .consume_auth_challenge_by_id( + &request.challenge_id, "passkey_login", &crate::auth::now_rfc3339(), )? @@ -2793,26 +2790,63 @@ async fn post_passkey_login_complete( ) .into()); } - let credential = api + 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_passkey_credential(&request.credential_id)? + .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", - "passkey credential is not registered", + "verified passkey credential is not registered", ) })?; - if Some(credential.user_id.clone()) != challenge.user_id { + if stored.user_id != user.user_id { return Err(auth_error( "passkey_user_mismatch", - "passkey credential does not belong to the challenged user", + "verified passkey credential does not belong to the challenged user", ) .into()); } - let user = api - .store - .get_user(&credential.user_id)? - .ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?; + 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"), @@ -2989,6 +3023,42 @@ async fn get_auth_whoami( })) } +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 { @@ -8551,7 +8621,7 @@ mod tests { } #[tokio::test] - async fn auth_passkey_session_and_device_login_flow_round_trip() { + async fn passkey_registration_rejects_unverified_credential_response() { let dir = tempfile::tempdir().unwrap(); let app = test_app(dir.path()).await; @@ -8564,42 +8634,28 @@ mod tests { }), ) .await; - let registration_challenge = registration_options["challenge"].as_str().unwrap(); - let registered = post_json( - app.clone(), - "/api/auth/passkeys/registration/complete", - json!({ - "challenge": registration_challenge, - "credential_id": "credential-1", - "public_key_cose": "public-key-cose", - "transports": ["internal"] - }), - ) - .await; - assert_eq!(registered["user"]["handle"], "alice"); + assert!(registration_options["public_key"].is_object()); + let challenge_id = registration_options["challenge_id"].as_str().unwrap(); - let login_options = post_json( - app.clone(), - "/api/auth/passkeys/login/options", - json!({ "handle": "alice" }), - ) - .await; - assert_eq!( - login_options["allow_credentials"][0]["credential_id"], - "credential-1" - ); - let login_challenge = login_options["challenge"].as_str().unwrap(); - let login_response = app + let response = app .clone() .oneshot( Request::builder() .method("POST") - .uri("/api/auth/passkeys/login/complete") + .uri("/api/auth/passkeys/registration/complete") .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&json!({ - "challenge": login_challenge, - "credential_id": "credential-1" + "challenge_id": challenge_id, + "credential": { + "id": "credential-1", + "rawId": "Y3JlZGVudGlhbC0x", + "type": "public-key", + "response": { + "clientDataJSON": "e30", + "attestationObject": "e30" + } + } })) .unwrap(), )) @@ -8607,71 +8663,22 @@ mod tests { ) .await .unwrap(); - assert_eq!(login_response.status(), StatusCode::OK); - let cookie = login_response - .headers() - .get(SET_COOKIE) - .unwrap() - .to_str() - .unwrap() - .to_string(); - let login_bytes = to_bytes(login_response.into_body(), usize::MAX) - .await - .unwrap(); - let login_json: Value = serde_json::from_slice(&login_bytes).unwrap(); - assert_eq!(login_json["user"]["handle"], "alice"); + assert_ne!(response.status(), StatusCode::OK); - let device = post_json( - app.clone(), - "/api/auth/device-login/start", - json!({ "client_name": "test cli" }), - ) - .await; - let approved = app - .clone() + let login_without_registered_passkey = app .oneshot( Request::builder() .method("POST") - .uri("/api/auth/device-login/approve") + .uri("/api/auth/passkeys/login/options") .header(CONTENT_TYPE, "application/json") - .header("Cookie", cookie) .body(Body::from( - serde_json::to_vec(&json!({ - "user_code": device["user_code"].as_str().unwrap() - })) - .unwrap(), + serde_json::to_vec(&json!({ "handle": "alice" })).unwrap(), )) .unwrap(), ) .await .unwrap(); - assert_eq!(approved.status(), StatusCode::OK); - - let polled = post_json( - app.clone(), - "/api/auth/device-login/poll", - json!({ "device_code": device["device_code"].as_str().unwrap() }), - ) - .await; - assert_eq!(polled["status"], "approved"); - let access_token = polled["access_token"].as_str().unwrap(); - - let whoami = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/auth/whoami") - .header("Authorization", format!("Bearer {access_token}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(whoami.status(), StatusCode::OK); - let whoami_bytes = to_bytes(whoami.into_body(), usize::MAX).await.unwrap(); - let whoami_json: Value = serde_json::from_slice(&whoami_bytes).unwrap(); - assert_eq!(whoami_json["actor"]["handle"], "alice"); - assert_eq!(whoami_json["actor"]["auth_method"], "api_token"); + assert_ne!(login_without_registered_passkey.status(), StatusCode::OK); } async fn get_json(app: Router, uri: &str) -> Value { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 9f7abd4d..1d4067e1 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -57,6 +57,11 @@ const MIGRATIONS: &[Migration] = &[ 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 { @@ -116,6 +121,7 @@ pub struct AuthChallengeRecord { 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, @@ -229,6 +235,12 @@ pub trait ControlPlaneStore: Send + Sync { 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<()>; @@ -524,9 +536,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { 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, expires_at, created_at, consumed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, - params![record.challenge_id, record.ceremony, record.challenge, record.user_id, record.rp_id, record.origin, record.expires_at, record.created_at, record.consumed_at], + 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(()) }) @@ -559,6 +571,33 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + 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( @@ -1025,7 +1064,7 @@ fn read_passkey_credential_record( fn auth_challenge_select_sql(where_clause: &str) -> String { format!( - "SELECT challenge_id, ceremony, challenge, user_id, rp_id, origin, expires_at, created_at, consumed_at FROM auth_challenges {where_clause}" + "SELECT challenge_id, ceremony, challenge, user_id, rp_id, origin, state_json, expires_at, created_at, consumed_at FROM auth_challenges {where_clause}" ) } @@ -1037,9 +1076,10 @@ fn read_auth_challenge_record(row: &rusqlite::Row<'_>) -> rusqlite::Result 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#" @@ -1265,6 +1312,7 @@ CREATE TABLE IF NOT EXISTS auth_challenges ( 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 @@ -1897,7 +1945,7 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 8); + assert_eq!(store.schema_version().await.unwrap(), 9); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -1910,7 +1958,7 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 8); + assert_eq!(reopened.schema_version().await.unwrap(), 9); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -2127,7 +2175,7 @@ mod tests { .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 8); + assert_eq!(store.schema_version().await.unwrap(), 9); store .with_conn(|conn| { @@ -2317,7 +2365,7 @@ 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(), 8); + 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(), @@ -2388,6 +2436,7 @@ mod tests { 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, diff --git a/docs/backend-auth-account-model.md b/docs/backend-auth-account-model.md index 9dc5850f..e4d6bf44 100644 --- a/docs/backend-auth-account-model.md +++ b/docs/backend-auth-account-model.md @@ -23,9 +23,9 @@ Human browser auth is modeled as Passkey/WebAuthn ceremony endpoints: - `POST /api/auth/passkeys/login/complete` - `GET /api/auth/whoami` -The store persists registration/login challenges, passkey credential IDs, COSE public key payloads, transports, browser sessions, and challenge consumption. Successful login mints an HttpOnly `SameSite=Lax` cookie session. +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. -Current boundary: the API and persistence shape are WebAuthn-oriented, but cryptographic attestation/assertion verification is not implemented in this step. The completion endpoints consume the server-issued challenge and validate credential/user binding. A follow-up should wire a WebAuthn verifier crate and replace the provisional completion payloads with verified browser `PublicKeyCredential` responses before this is exposed outside trusted development use. +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 @@ -52,4 +52,3 @@ The resolved actor includes `user_id`, `account_id`, `handle`, `display_name`, a - Organization membership and RBAC. - Password login, OAuth login, or other human auth methods. - Token scoping beyond Backend API bearer authentication. -- Production WebAuthn cryptographic verification; the schema/API are prepared for it, but the verifier integration remains follow-up work. diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml index badae16a..b15c25d8 100644 --- a/resources/workspace-backend.default.toml +++ b/resources/workspace-backend.default.toml @@ -44,9 +44,9 @@ 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 = "127.0.0.1" -origin = "http://127.0.0.1:8787" -public_base_url = "http://127.0.0.1:8787" +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