diff --git a/crates/kv-ffi/src/bridge/broker.rs b/crates/kv-ffi/src/bridge/broker.rs index a0f2641..0d7afee 100644 --- a/crates/kv-ffi/src/bridge/broker.rs +++ b/crates/kv-ffi/src/bridge/broker.rs @@ -11,6 +11,10 @@ //! | `{t:"open", pairing_id, nonce}` | `{t:"opened", nonce}` / `{t:"error", code:"not_paired"}` | //! | `{t:"msg", n, c}` | `{t:"msg", n, c}` / `{t:"error", code:"bad_session"}` | //! +//! Sign in with Google, Apple… (docs/05 "Sign in with…"): `query` identities carry `signInWith {provider, +//! providerName, account}`, and `{type:"save_sign_in", origin, provider, account}` asks to remember that the site's +//! account signs in through that provider (the same app prompt as `save`). +//! //! `msg` plaintexts are `{rid, body}` where `body` is a `NativeRequest` / `NativeResponse` from //! kestrove-browser/src/protocol.ts. Origins come from the extension's `sender`, and are //! re-checked here against the vault before any secret leaves the app. @@ -20,7 +24,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crate::{CoreError, ItemDto, ItemKindDto, VaultCore}; +use crate::{CoreError, ItemDto, ItemKindDto, SignInWithDto, VaultCore}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use serde::{Deserialize, Serialize}; @@ -64,6 +68,8 @@ pub struct SaveView { pub username: String, /// Some item already has this username for this site: saving updates its password. pub update: bool, + /// Set for "this site signs in with Google…" (no password): the provider's display name. + pub provider: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -148,7 +154,9 @@ struct PendingSave { id: String, origin: String, username: String, + /// Empty for a sign-in-with save. password: Zeroizing, + sign_in: Option, at: Instant, } @@ -169,6 +177,14 @@ fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } +/// "https://www.notion.so" → "notion.so", the title of a login saved from the browser. +fn host_title(origin: &str) -> String { + url::Url::parse(origin) + .ok() + .and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string())) + .unwrap_or_else(|| origin.to_string()) +} + pub(super) fn err(code: &str) -> Value { json!({ "type": "error", "code": code }) } @@ -389,9 +405,18 @@ impl Broker { "query" => match self.core.items_for_url(origin) { Ok(items) => json!({ "type": "identities", - "items": items.iter().map(|i| json!({ - "id": i.id, "title": i.title, "username": i.username.clone().unwrap_or_default(), "totp": i.has_totp, - })).collect::>(), + "items": items.iter().map(|i| { + let mut v = json!({ + "id": i.id, "title": i.title, "username": i.username.clone().unwrap_or_default(), "totp": i.has_totp, + }); + if let Some(s) = &i.sign_in_with { + v["signInWith"] = json!({ + "provider": s.provider, "providerName": kv_model::provider_name(&s.provider), + "account": s.account, + }); + } + v + }).collect::>(), }), Err(e) => core_err(&e), }, @@ -421,6 +446,26 @@ impl Broker { } } k if k.starts_with("passkey_") => self.handle_passkey(k, origin, body), + "save_sign_in" => { + let provider: String = body + .get("provider") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_lowercase() + .chars() + .take(32) + .collect(); + if provider.is_empty() || !provider.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return err("bad_request"); + } + let account: String = + body.get("account").and_then(Value::as_str).unwrap_or("").trim().chars().take(256).collect(); + self.queue_sign_in( + origin, + SignInWithDto { provider, account: (!account.is_empty()).then_some(account) }, + ) + } "save" => { let username = body.get("username").and_then(Value::as_str).unwrap_or(""); let Some(password) = body.get("password").and_then(Value::as_str).filter(|p| !p.is_empty()) else { @@ -457,7 +502,44 @@ impl Broker { return json!({ "type": "saved" }); // already up to date: no prompt } } - let save = PendingSave { id: uuid::Uuid::new_v4().to_string(), origin, username, password, at: Instant::now() }; + let save = PendingSave { + id: uuid::Uuid::new_v4().to_string(), + origin, + username, + password, + sign_in: None, + at: Instant::now(), + }; + self.push_save(save) + } + + /// The login on this origin that already records this way of signing in, if any. + fn existing_sign_in(&self, origin: &str, sign_in: &SignInWithDto) -> Option { + let items = self.core.items_for_url(origin.to_string()).ok()?; + let s = items.into_iter().find(|i| { + i.sign_in_with.as_ref().is_some_and(|w| { + w.provider == sign_in.provider && (sign_in.account.is_none() || w.account == sign_in.account) + }) + })?; + self.core.get_item(s.id).ok() + } + + fn queue_sign_in(&self, origin: String, sign_in: SignInWithDto) -> Value { + if self.existing_sign_in(&origin, &sign_in).is_some() { + return json!({ "type": "saved" }); // already remembered: no prompt + } + let save = PendingSave { + id: uuid::Uuid::new_v4().to_string(), + origin, + username: sign_in.account.clone().unwrap_or_default(), + password: Zeroizing::new(String::new()), + sign_in: Some(sign_in), + at: Instant::now(), + }; + self.push_save(save) + } + + fn push_save(&self, save: PendingSave) -> Value { let view = self.save_view(&save); { let mut q = lock(&self.saves); @@ -476,19 +558,26 @@ impl Broker { id: s.id.clone(), origin: s.origin.clone(), username: s.username.clone(), - update: self.existing(&s.origin, &s.username).is_some(), + update: s.sign_in.is_none() && self.existing(&s.origin, &s.username).is_some(), + provider: s.sign_in.as_ref().map(|w| kv_model::provider_name(&w.provider).to_string()), } } pub fn pending_saves(&self) -> Vec { let mut q = lock(&self.saves); q.retain(|s| s.at.elapsed() < SAVE_TTL); - let pending: Vec<_> = q.iter().map(|s| (s.id.clone(), s.origin.clone(), s.username.clone())).collect(); + let pending: Vec<_> = q + .iter() + .map(|s| { + (s.id.clone(), s.origin.clone(), s.username.clone(), s.sign_in.as_ref().map(|w| w.provider.clone())) + }) + .collect(); drop(q); pending .into_iter() - .map(|(id, origin, username)| SaveView { - update: self.existing(&origin, &username).is_some(), + .map(|(id, origin, username, provider)| SaveView { + update: provider.is_none() && self.existing(&origin, &username).is_some(), + provider: provider.map(|p| kv_model::provider_name(&p).to_string()), id, origin, username, @@ -509,6 +598,9 @@ impl Broker { if !accept { return Ok(None); } + if let Some(sign_in) = s.sign_in { + return self.save_sign_in(&s.origin, sign_in); + } let item = match self.existing(&s.origin, &s.username) { Some(mut e) => { e.password = Some(s.password.to_string()); @@ -517,10 +609,7 @@ impl Broker { None => ItemDto { id: String::new(), kind: ItemKindDto::Login, - title: url::Url::parse(&s.origin) - .ok() - .and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string())) - .unwrap_or_else(|| s.origin.clone()), + title: host_title(&s.origin), username: (!s.username.is_empty()).then(|| s.username.clone()), password: Some(s.password.to_string()), urls: vec![s.origin.clone()], @@ -528,6 +617,38 @@ impl Broker { notes: None, favorite: false, tags: vec![], + sign_in_with: None, + modified_at: 0, + }, + }; + self.core.save_item(item).map(Some).map_err(|e| e.to_string()) + } + + /// Records the sign-in-with on the site's login for that account (or its only login), else a new login. + fn save_sign_in(&self, origin: &str, sign_in: SignInWithDto) -> Result, String> { + let account = sign_in.account.clone().unwrap_or_default(); + let candidates = self.core.items_for_url(origin.to_string()).map_err(|e| e.to_string())?; + let same_user = + candidates.iter().find(|i| !account.is_empty() && i.username.as_deref() == Some(account.as_str())); + let target = same_user + .or_else(|| (candidates.len() == 1 && candidates[0].sign_in_with.is_none()).then(|| &candidates[0])); + let item = match target.and_then(|t| self.core.get_item(t.id.clone()).ok()) { + Some(mut e) => { + e.sign_in_with = Some(sign_in); + e + } + None => ItemDto { + id: String::new(), + kind: ItemKindDto::Login, + title: host_title(origin), + username: sign_in.account.clone(), + password: None, + urls: vec![origin.to_string()], + totp: None, + notes: None, + favorite: false, + tags: vec![], + sign_in_with: Some(sign_in), modified_at: 0, }, }; @@ -562,6 +683,7 @@ mod tests { notes: None, favorite: false, tags: vec![], + sign_in_with: None, modified_at: 0, }) .unwrap(); @@ -670,6 +792,50 @@ mod tests { assert!(b.pairings().is_empty()); } + #[test] + fn sign_in_with_is_remembered_after_approval() { + let (b, _) = broker_with_item(); + let origin = "https://www.notion.so"; + let r = b.handle_request( + &json!({ "type": "save_sign_in", "origin": origin, "provider": "google", "account": "sami@example.com" }), + ); + assert_eq!(r["type"], "save_prompted"); + let pending = b.pending_saves(); + assert_eq!( + (pending[0].provider.as_deref(), pending[0].username.as_str()), + (Some("Google"), "sami@example.com") + ); + assert!(!pending[0].update); + b.resolve_save(&pending[0].id, true).unwrap(); + + let q = b.handle_request(&json!({ "type": "query", "origin": origin })); + assert_eq!(q["items"][0]["title"], "notion.so"); + assert_eq!( + q["items"][0]["signInWith"], + json!({ "provider": "google", "providerName": "Google", "account": "sami@example.com" }) + ); + // Already remembered: no second prompt. + let again = b.handle_request( + &json!({ "type": "save_sign_in", "origin": origin, "provider": "google", "account": "sami@example.com" }), + ); + assert_eq!(again["type"], "saved"); + // Garbage providers are refused. + let bad = b.handle_request(&json!({ "type": "save_sign_in", "origin": origin, "provider": "