1use crate::backend::storage::identities::{IdentityAccount, SecretRef};
16
17pub fn email_from_oauth_dir(provider: &str, dir: &str) -> Option<String> {
24 let account = oauth_account(provider, dir)?;
25 let email = account.get("emailAddress")?.as_str()?.trim();
26 (!email.is_empty()).then(|| email.to_string())
27}
28
29pub fn identity_key_from_oauth_dir(provider: &str, dir: &str) -> Option<String> {
38 use sha2::{Digest, Sha256};
39 let account = oauth_account(provider, dir)?;
40 let id = |k: &str| account.get(k)?.as_str().map(str::trim).filter(|v| !v.is_empty());
41 let (account_uuid, org_uuid) = (id("accountUuid")?, id("organizationUuid")?);
42 let digest = Sha256::digest(format!("claude\0{account_uuid}\0{org_uuid}").as_bytes());
43 Some(hex::encode(digest)[..16].to_string())
44}
45
46fn oauth_account(provider: &str, dir: &str) -> Option<serde_json::Value> {
48 if !matches!(provider, "claude" | "claude-code") || dir.is_empty() {
49 return None;
50 }
51 let claude = crate::backend::providers::get_provider("claude")?;
52 if crate::backend::providers::is_provider_ambient_home_dir(claude, dir) {
53 return None;
54 }
55 let raw = std::fs::read_to_string(std::path::Path::new(dir).join(".claude.json")).ok()?;
56 let mut json: serde_json::Value = serde_json::from_str(&raw).ok()?;
57 Some(json.get_mut("oauthAccount")?.take())
58}
59
60pub fn refresh_account_email(account: &mut IdentityAccount) -> Option<String> {
67 if account.kind != "oauth" {
68 return None;
69 }
70 let SecretRef::OAuthConfigDir { dir } = &account.secret_ref else {
71 return None;
72 };
73 let email = email_from_oauth_dir(&account.provider, dir)?;
74 if account.context.get("email").and_then(|v| v.as_str()) == Some(email.as_str()) {
75 return None;
76 }
77 if !account.context.is_object() {
78 account.context = serde_json::json!({});
79 }
80 account.context["email"] = serde_json::Value::String(email.clone());
81 Some(email)
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 fn account(dir: &str, context: serde_json::Value) -> IdentityAccount {
89 IdentityAccount {
90 id: "acc-1".into(),
91 name: "claude-oauth".into(),
92 provider: "claude".into(),
93 kind: "oauth".into(),
94 display_name: String::new(),
95 secret_ref: SecretRef::OAuthConfigDir { dir: dir.into() },
96 context,
97 status: "valid".into(),
98 created_at: 0,
99 updated_at: 0,
100 }
101 }
102
103 fn dir_with(claude_json: &str) -> tempfile::TempDir {
104 let dir = tempfile::tempdir().unwrap();
105 std::fs::write(dir.path().join(".claude.json"), claude_json).unwrap();
106 dir
107 }
108
109 #[test]
110 fn reads_claudes_recorded_login_email() {
111 let dir = dir_with(r#"{"oauthAccount":{"emailAddress":" me@example.com "}}"#);
112 let d = dir.path().to_str().unwrap();
113 assert_eq!(email_from_oauth_dir("claude", d).as_deref(), Some("me@example.com"));
114 assert_eq!(email_from_oauth_dir("claude-code", d).as_deref(), Some("me@example.com"));
115 assert_eq!(email_from_oauth_dir("codex", d), None, "only Claude records one here");
116 let empty = dir_with(r#"{"oauthAccount":{"emailAddress":""}}"#);
117 assert_eq!(email_from_oauth_dir("claude", empty.path().to_str().unwrap()), None);
118 assert_eq!(email_from_oauth_dir("claude", "/no/such/dir"), None);
119 }
120
121 #[test]
122 fn the_identity_key_is_the_account_and_organization_not_the_email() {
123 let personal = dir_with(r#"{"oauthAccount":{"emailAddress":"me@example.com","accountUuid":"acc-1","organizationUuid":"org-personal"}}"#);
124 let team = dir_with(r#"{"oauthAccount":{"emailAddress":"me@example.com","accountUuid":"acc-1","organizationUuid":"org-team"}}"#);
125 let relogin = dir_with(r#"{"oauthAccount":{"emailAddress":"renamed@example.com","accountUuid":"acc-1","organizationUuid":"org-personal"}}"#);
126 let key = |d: &tempfile::TempDir| identity_key_from_oauth_dir("claude", d.path().to_str().unwrap());
127
128 let k = key(&personal).expect("a key");
129 assert_eq!(k.len(), 16);
130 assert!(!k.contains("acc-1") && !k.contains("org-personal"), "hashed, not the raw ids");
131 assert_eq!(key(&relogin).as_deref(), Some(k.as_str()), "same account + org: same identity, whatever the email");
132 assert_ne!(key(&team).as_deref(), Some(k.as_str()), "same email, other org: another identity");
133 assert_eq!(
134 identity_key_from_oauth_dir("claude-code", personal.path().to_str().unwrap()).as_deref(),
135 Some(k.as_str())
136 );
137 }
138
139 #[test]
140 fn no_identity_key_without_both_ids() {
141 for json in [
142 r#"{"oauthAccount":{"emailAddress":"me@example.com","accountUuid":"acc-1"}}"#,
143 r#"{"oauthAccount":{"organizationUuid":"org-1"}}"#,
144 r#"{"oauthAccount":{"accountUuid":" ","organizationUuid":"org-1"}}"#,
145 r#"{}"#,
146 "not json",
147 ] {
148 let d = dir_with(json);
149 assert_eq!(identity_key_from_oauth_dir("claude", d.path().to_str().unwrap()), None, "{json}");
150 }
151 let d = dir_with(r#"{"oauthAccount":{"accountUuid":"acc-1","organizationUuid":"org-1"}}"#);
152 assert_eq!(identity_key_from_oauth_dir("codex", d.path().to_str().unwrap()), None);
153 assert_eq!(identity_key_from_oauth_dir("claude", "/no/such/dir"), None);
154 }
155
156 #[test]
157 fn refresh_backfills_updates_and_leaves_alone() {
158 let dir = dir_with(r#"{"oauthAccount":{"emailAddress":"new@example.com"}}"#);
159 let d = dir.path().to_str().unwrap();
160
161 let mut blank = account(d, serde_json::json!({}));
162 assert_eq!(refresh_account_email(&mut blank).as_deref(), Some("new@example.com"), "backfilled");
163 assert_eq!(blank.context["email"], "new@example.com");
164 assert_eq!(refresh_account_email(&mut blank), None, "unchanged the second time");
165
166 let mut stale = account(d, serde_json::json!({"email": "old@example.com", "k": 1}));
167 assert!(refresh_account_email(&mut stale).is_some(), "a re-login as someone else");
168 assert_eq!(stale.context["email"], "new@example.com");
169 assert_eq!(stale.context["k"], 1, "other context kept");
170
171 let mut unknown = account("/no/such/dir", serde_json::json!({"email": "kept@example.com"}));
172 assert_eq!(refresh_account_email(&mut unknown), None, "no email on disk: left alone");
173 assert_eq!(unknown.context["email"], "kept@example.com");
174 }
175
176 #[test]
180 fn the_store_write_sets_the_email_and_nothing_else() {
181 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
182 let mut acct = account("/dir", serde_json::json!({"k": 1}));
183 acct.status = "needs_reauth".into();
184 acct.updated_at = 42;
185 store.identity_upsert(&acct).unwrap();
186
187 assert!(store.identity_set_context_email("acc-1", "me@example.com").unwrap());
188 let got = store.identity_get("acc-1").unwrap().unwrap();
189 assert_eq!(got.context["email"], "me@example.com");
190 assert_eq!(got.context["k"], 1);
191 assert_eq!(got.status, "needs_reauth");
192 assert_eq!(got.updated_at, 42);
193
194 assert!(!store.identity_set_context_email("acc-gone", "x@example.com").unwrap());
195 assert!(store.identity_get("acc-gone").unwrap().is_none(), "never re-created");
196 }
197}