Sign in with Google, Apple…: item field, bridge save and identities
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

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 <[email protected]>
This commit is contained in:
2026-10-24 15:19:14 +07:00
co-authored by Claude Opus 5.5
parent 8622123b00
commit 20d465185d
12 changed files with 364 additions and 15 deletions
+179 -13
View File
@@ -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<String>,
}
#[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<String>,
sign_in: Option<SignInWithDto>,
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::<Vec<_>>(),
"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::<Vec<_>>(),
}),
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<ItemDto> {
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<SaveView> {
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<Option<String>, 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": "[email protected]" }),
);
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"), "[email protected]")
);
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": "[email protected]" })
);
// Already remembered: no second prompt.
let again = b.handle_request(
&json!({ "type": "save_sign_in", "origin": origin, "provider": "google", "account": "[email protected]" }),
);
assert_eq!(again["type"], "saved");
// Garbage providers are refused.
let bad = b.handle_request(&json!({ "type": "save_sign_in", "origin": origin, "provider": "<script>" }));
assert_eq!(bad["code"], "bad_request");
// A site with one password login gets the sign-in-with added to that login.
let r =
b.handle_request(&json!({ "type": "save_sign_in", "origin": "https://example.com", "provider": "apple" }));
assert_eq!(r["type"], "save_prompted");
let id = b.pending_saves()[0].id.clone();
b.resolve_save(&id, true).unwrap();
let q = b.handle_request(&json!({ "type": "query", "origin": "https://example.com" }));
assert_eq!(q["items"].as_array().unwrap().len(), 1);
assert_eq!(
(q["items"][0]["username"].as_str(), q["items"][0]["signInWith"]["provider"].as_str()),
(Some("alice"), Some("apple"))
);
}
#[test]
fn save_prompts_then_creates_or_updates() {
let (b, id) = broker_with_item();
+4 -1
View File
@@ -40,6 +40,9 @@ pub struct BrowserSaveRequest {
pub username: String,
/// A login with this username already exists for the site: accepting updates its password.
pub update: bool,
/// "This site signs in with Google" (no password to save): the provider's name, and `username` is the
/// provider account when the extension saw it.
pub provider: Option<String>,
}
/// One passkey the user can pick for a sign-in.
@@ -134,7 +137,7 @@ fn pairing_request(v: PairingView) -> BrowserPairingRequest {
}
fn save_request(v: SaveView) -> BrowserSaveRequest {
BrowserSaveRequest { id: v.id, origin: v.origin, username: v.username, update: v.update }
BrowserSaveRequest { id: v.id, origin: v.origin, username: v.username, update: v.update, provider: v.provider }
}
fn pairing(p: StoredPairing) -> BrowserPairing {
+49
View File
@@ -165,6 +165,22 @@ pub enum PlatformDto {
Linux,
}
/// "Sign in with Google" (or Apple, Microsoft…) for one site.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SignInWithDto {
/// `google`, `apple`, `microsoft`… (see `sign_in_providers`).
pub provider: String,
/// The provider account used, usually an email.
pub account: Option<String>,
}
/// An identity provider the apps offer for "Sign in with…".
#[derive(Debug, Clone, uniffi::Record)]
pub struct SignInProvider {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ItemSummary {
pub id: String,
@@ -175,6 +191,8 @@ pub struct ItemSummary {
pub favorite: bool,
pub has_totp: bool,
pub tags: Vec<String>,
/// The account signs in with Google, Apple… (docs/05 "Sign in with…").
pub sign_in_with: Option<SignInWithDto>,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -190,6 +208,8 @@ pub struct ItemDto {
pub notes: Option<String>,
pub favorite: bool,
pub tags: Vec<String>,
/// The account signs in with Google, Apple… (docs/05 "Sign in with…").
pub sign_in_with: Option<SignInWithDto>,
pub modified_at: i64,
}
@@ -637,6 +657,7 @@ impl VaultCore {
model.notes = item.notes;
model.favorite = item.favorite;
model.tags = kv_model::normalize_tags(&item.tags);
model.sign_in_with = sign_in_from_dto(item.sign_in_with);
model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1));
model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?;
@@ -1268,6 +1289,32 @@ impl VaultCore {
}
}
/// The identity providers the apps offer in their "Sign in with" picker, in display order.
#[uniffi::export]
pub fn sign_in_providers() -> Vec<SignInProvider> {
kv_model::PROVIDERS.iter().map(|(id, name, _)| SignInProvider { id: (*id).into(), name: (*name).into() }).collect()
}
/// "google" → "Google"; unknown ids as they are.
#[uniffi::export]
pub fn sign_in_provider_name(id: String) -> String {
kv_model::provider_name(&id).to_string()
}
fn sign_in_to_dto(i: &Item) -> Option<SignInWithDto> {
i.sign_in_with.as_ref().map(|s| SignInWithDto { provider: s.provider.clone(), account: s.account.clone() })
}
/// Trimmed and lowercased provider id; no provider means none at all.
fn sign_in_from_dto(d: Option<SignInWithDto>) -> Option<kv_model::SignInWith> {
let d = d?;
let provider: String = d.provider.trim().to_lowercase().chars().take(32).collect();
(!provider.is_empty()).then(|| kv_model::SignInWith {
provider,
account: d.account.map(|a| a.trim().chars().take(256).collect::<String>()).filter(|a| !a.is_empty()),
})
}
#[uniffi::export]
pub fn generate_password(length: u32, symbols: bool) -> String {
generator::generate_password(&generator::PasswordOptions { length: length as usize, symbols, ..Default::default() })
@@ -1321,6 +1368,7 @@ fn summary(i: &Item) -> ItemSummary {
favorite: i.favorite,
has_totp: i.totp.is_some(),
tags: i.tags.clone(),
sign_in_with: sign_in_to_dto(i),
}
}
@@ -1336,6 +1384,7 @@ fn to_dto(i: &Item) -> ItemDto {
notes: i.notes.clone(),
favorite: i.favorite,
tags: i.tags.clone(),
sign_in_with: sign_in_to_dto(i),
modified_at: i.modified_at,
}
}
+26
View File
@@ -19,6 +19,7 @@ fn login_item(title: &str, password: &str) -> ItemDto {
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
}
}
@@ -716,3 +717,28 @@ fn master_password_confirmation() {
core.lock();
assert!(matches!(core.confirm_master_password("correct horse battery".into()), Err(CoreError::Locked)));
}
#[test]
fn sign_in_with_is_saved_listed_and_normalised() {
let core = VaultCore::new_in_memory();
core.create_account("correct horse battery".into()).unwrap();
let mut item = login_item("Notion", "");
item.password = None;
item.urls = vec!["https://www.notion.so".into()];
item.sign_in_with = Some(SignInWithDto { provider: " Google ".into(), account: Some(" [email protected] ".into()) });
let id = core.save_item(item).unwrap();
let want = Some(SignInWithDto { provider: "google".into(), account: Some("[email protected]".into()) });
assert_eq!(core.get_item(id.clone()).unwrap().sign_in_with, want);
assert_eq!(core.items_for_url("https://www.notion.so/login".into()).unwrap()[0].sign_in_with, want);
// Editing another field keeps it; clearing the provider removes it.
let mut d = core.get_item(id.clone()).unwrap();
d.title = "Notion (work)".into();
core.save_item(d.clone()).unwrap();
assert_eq!(core.get_item(id.clone()).unwrap().sign_in_with, want);
d.sign_in_with = Some(SignInWithDto { provider: " ".into(), account: None });
core.save_item(d).unwrap();
assert_eq!(core.get_item(id).unwrap().sign_in_with, None);
assert_eq!(sign_in_providers()[0].name, "Google");
assert_eq!(sign_in_provider_name("apple".into()), "Apple");
}
+1
View File
@@ -54,6 +54,7 @@ fn item(title: &str, password: &str) -> ItemDto {
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
}
}