Sign in with Google, Apple…: item field, bridge save and identities
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:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ fn item(title: &str, password: &str) -> ItemDto {
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
sign_in_with: None,
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -297,6 +297,11 @@ impl WebCore {
|
||||
model.notes = item.notes;
|
||||
model.favorite = item.favorite;
|
||||
model.tags = kv_model::normalize_tags(&item.tags);
|
||||
model.sign_in_with = item.sign_in_with.and_then(|s| {
|
||||
let provider: String = s.provider.trim().to_lowercase().chars().take(32).collect();
|
||||
let account = s.account.map(|a| a.trim().chars().take(256).collect::<String>()).filter(|a| !a.is_empty());
|
||||
(!provider.is_empty()).then_some(kv_model::SignInWith { provider, account })
|
||||
});
|
||||
model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1));
|
||||
self.write_item(&acct, &u, &mut model, existing.filter(|_| previous.is_some()), false)?;
|
||||
Ok(id.to_string())
|
||||
@@ -1208,6 +1213,7 @@ fn summary(i: &Item) -> ItemSummary {
|
||||
favorite: i.favorite,
|
||||
has_totp: i.totp.is_some(),
|
||||
tags: i.tags.clone(),
|
||||
sign_in_with: i.sign_in_with.clone(),
|
||||
modified_at: i.modified_at,
|
||||
}
|
||||
}
|
||||
@@ -1235,6 +1241,7 @@ pub fn to_dto(i: &Item) -> ItemDto {
|
||||
notes: i.notes.clone(),
|
||||
favorite: i.favorite,
|
||||
tags: i.tags.clone(),
|
||||
sign_in_with: i.sign_in_with.clone(),
|
||||
modified_at: i.modified_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ pub struct ItemSummary {
|
||||
pub has_totp: bool,
|
||||
/// Web only (not in kv-ffi's record): for tag filters and "recently modified" without `getItem`.
|
||||
pub tags: Vec<String>,
|
||||
/// "Sign in with Google…" for the site (docs/05).
|
||||
pub sign_in_with: Option<kv_model::SignInWith>,
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
@@ -70,6 +72,8 @@ pub struct ItemDto {
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub sign_in_with: Option<kv_model::SignInWith>,
|
||||
#[serde(default)]
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,17 @@ export interface ItemSummary {
|
||||
favorite: boolean;
|
||||
hasTotp: boolean;
|
||||
tags: string[];
|
||||
/** "Sign in with Google…" for the site (docs/05 "Sign in with…"). */
|
||||
signInWith: SignInWith | null;
|
||||
/** Unix seconds. */
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
/** `provider` is an id from `signInProviders()` (`google`, `apple`…); `account` the provider account used. */
|
||||
export interface SignInWith { provider: string; account?: string | null; }
|
||||
|
||||
export interface SignInProvider { id: string; name: string; }
|
||||
|
||||
/** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */
|
||||
export interface ItemDto {
|
||||
id: string;
|
||||
@@ -56,6 +63,7 @@ export interface ItemDto {
|
||||
notes: string | null;
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
signInWith: SignInWith | null;
|
||||
/** Unix seconds; set by the core on save. */
|
||||
modifiedAt: number;
|
||||
}
|
||||
@@ -865,6 +873,14 @@ impl VaultCore {
|
||||
|
||||
// ---------------------------------------------------------------- free functions
|
||||
|
||||
/// The identity providers offered for "Sign in with…", in display order.
|
||||
#[wasm_bindgen(js_name = signInProviders, unchecked_return_type = "SignInProvider[]")]
|
||||
pub fn sign_in_providers() -> std::result::Result<JsValue, JsValue> {
|
||||
let list: Vec<_> =
|
||||
kv_model::PROVIDERS.iter().map(|(id, name, _)| serde_json::json!({ "id": id, "name": name })).collect();
|
||||
to_js(&list)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = generatePassword)]
|
||||
pub fn generate_password(length: u32, symbols: bool) -> String {
|
||||
core::generate_password(length, symbols)
|
||||
|
||||
@@ -110,6 +110,7 @@ fn read_with_kv_ffi(f: &Fixture) -> Vec<ItemDto> {
|
||||
urls: d.urls,
|
||||
totp: d.totp,
|
||||
notes: d.notes,
|
||||
sign_in_with: d.sign_in_with.map(|w| kv_model::SignInWith { provider: w.provider, account: w.account }),
|
||||
favorite: d.favorite,
|
||||
tags: d.tags,
|
||||
modified_at: d.modified_at,
|
||||
@@ -140,6 +141,7 @@ fn ffi_item(title: &str, kind: kv_ffi::ItemKindDto) -> kv_ffi::ItemDto {
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
sign_in_with: None,
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ fn web_item(title: &str, password: &str) -> ItemDto {
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
sign_in_with: None,
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
@@ -82,6 +83,7 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
sign_in_with: None,
|
||||
modified_at: 0,
|
||||
};
|
||||
let bank = app.save_item(on_app.clone()).unwrap();
|
||||
@@ -189,6 +191,7 @@ async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
sign_in_with: None,
|
||||
modified_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user