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
+72
View File
@@ -1,4 +1,7 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use zeroize::Zeroize;
@@ -24,6 +27,37 @@ pub struct Passkey {
pub created_at: i64,
}
/// "This site's account uses Sign in with Google (or Apple, Microsoft…)": a login without a password of its
/// own (docs/05 "Sign in with…"). `provider` is one of [`PROVIDERS`]' ids, or any other short id a newer client
/// knows; `account` is the provider account used (usually an email), when known.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignInWith {
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
}
/// Identity providers Kestrove recognises: (id, display name, sign-in hosts). The hosts are where the provider's
/// own sign-in and consent pages live, for the browser extension to notice a "Sign in with…" flow.
pub const PROVIDERS: &[(&str, &str, &[&str])] = &[
("google", "Google", &["accounts.google.com"]),
("apple", "Apple", &["appleid.apple.com"]),
("microsoft", "Microsoft", &["login.microsoftonline.com", "login.live.com"]),
("facebook", "Facebook", &["www.facebook.com", "m.facebook.com"]),
("github", "GitHub", &["github.com"]),
("gitlab", "GitLab", &["gitlab.com"]),
("x", "X", &["x.com", "twitter.com", "api.x.com", "api.twitter.com"]),
("linkedin", "LinkedIn", &["www.linkedin.com"]),
("amazon", "Amazon", &["www.amazon.com"]),
("slack", "Slack", &["slack.com"]),
("discord", "Discord", &["discord.com"]),
];
/// The display name for a provider id: "google" → "Google". Unknown ids are shown as they are.
pub fn provider_name(id: &str) -> &str {
PROVIDERS.iter().find(|(p, _, _)| *p == id).map_or(id, |(_, name, _)| name)
}
/// One item. Field-level structure is what makes three-way merges possible (docs/03).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Item {
@@ -50,12 +84,19 @@ pub struct Item {
/// Android package names this login may fill (verified via Digital Asset Links when possible).
#[serde(default)]
pub android_apps: Vec<String>,
/// The account signs in through an identity provider instead of (or as well as) a password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sign_in_with: Option<SignInWith>,
pub created_at: i64,
pub modified_at: i64,
/// Per-item edit counter, bound inside the ciphertext: every new version has a larger `seq` than the
/// versions it derives from (docs/02 "Item plaintext", docs/03 "Per-item replay protection").
#[serde(default)]
pub seq: u64,
/// Fields from a newer client that this one doesn't know: kept as they are, so editing an item here never
/// drops what another app added (docs/02 "Item plaintext").
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
impl Item {
@@ -73,9 +114,11 @@ impl Item {
tags: vec![],
favorite: false,
android_apps: vec![],
sign_in_with: None,
created_at: now,
modified_at: now,
seq: 1,
extra: BTreeMap::new(),
}
}
}
@@ -129,3 +172,32 @@ mod tag_tests {
assert_eq!(normalize_tags(&many).len(), MAX_TAGS);
}
}
#[cfg(test)]
mod format_tests {
use super::*;
#[test]
fn unknown_fields_survive_a_rewrite() {
let json = r#"{"id":"6f1c2b9a-4e0d-4c7f-9b3a-8e2d1c0b9a87","kind":"login","title":"X","created_at":1,
"modified_at":2,"seq":3,"future_field":{"a":[1,2]},"another":"kept"}"#;
let mut item: Item = serde_json::from_str(json).unwrap();
item.title = "Edited".into();
let back: Value = serde_json::to_value(&item).unwrap();
assert_eq!(back["future_field"], serde_json::json!({"a":[1,2]}));
assert_eq!(back["another"], "kept");
assert_eq!(back["title"], "Edited");
assert!(back.get("sign_in_with").is_none(), "absent stays absent");
}
#[test]
fn sign_in_with_round_trips() {
let mut item = Item::new_login("Notion", 1);
item.sign_in_with = Some(SignInWith { provider: "google".into(), account: Some("[email protected]".into()) });
let v = serde_json::to_value(&item).unwrap();
assert_eq!(v["sign_in_with"], serde_json::json!({"provider":"google","account":"[email protected]"}));
assert_eq!(serde_json::from_value::<Item>(v).unwrap(), item);
assert_eq!(provider_name("google"), "Google");
assert_eq!(provider_name("newco"), "newco");
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod matching;
pub mod passkey;
pub mod totp;
pub use item::{normalize_tags, Item, ItemKind, Passkey, MAX_TAGS, MAX_TAG_LEN};
pub use item::{normalize_tags, provider_name, Item, ItemKind, Passkey, SignInWith, MAX_TAGS, MAX_TAG_LEN, PROVIDERS};
#[cfg(feature = "psl")]
pub use matching::registrable_domain;
pub use matching::{match_url, MatchLevel};