From 20d465185d5bae31e618a1aec7fea2d07da83cc9 Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 24 Oct 2026 15:19:14 +0700 Subject: [PATCH] =?UTF-8?q?Sign=20in=20with=20Google,=20Apple=E2=80=A6:=20?= =?UTF-8?q?item=20field,=20bridge=20save=20and=20identities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items record sign_in_with {provider, account}; the browser bridge reports it with the site's identities and saves it after the usual app prompt. Items now keep fields they don't know, so editing in an older app never drops what a newer one added. Co-Authored-By: Claude Opus 5.5 --- crates/kv-ffi/src/bridge/broker.rs | 192 +++++++++++++++++++++++++++-- crates/kv-ffi/src/bridge/mod.rs | 5 +- crates/kv-ffi/src/lib.rs | 49 ++++++++ crates/kv-ffi/src/tests.rs | 26 ++++ crates/kv-ffi/tests/live_server.rs | 1 + crates/kv-model/src/item.rs | 72 +++++++++++ crates/kv-model/src/lib.rs | 2 +- crates/kv-wasm/src/core.rs | 7 ++ crates/kv-wasm/src/dto.rs | 4 + crates/kv-wasm/src/wasm/mod.rs | 16 +++ crates/kv-wasm/tests/fixture.rs | 2 + crates/kv-wasm/tests/sync.rs | 3 + 12 files changed, 364 insertions(+), 15 deletions(-) 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": "