Skip to main content

agentmux_srv/identity/
account_email.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The login email of an OAuth provider account, read from the account's own
5//! config dir — `SPEC_ACCOUNT_EMAIL_IN_ARMORY_2026_09_23.md` §4.
6//!
7//! #3541 recorded the email only when the login transcript printed one, and
8//! Claude's login does not, so every Claude account showed its generic name
9//! (`claude-oauth`). The CLI does record who is signed in: Claude writes
10//! `oauthAccount.emailAddress` to `.claude.json` inside the config dir the
11//! account's `SecretRef::OAuthConfigDir` points at. Reading it there covers
12//! accounts that authenticated before #3541 (the backfill) and follows a
13//! re-login as a different user.
14
15use crate::backend::storage::identities::{IdentityAccount, SecretRef};
16
17/// The email the provider CLI recorded in `dir`, if any. Only Claude writes
18/// one today (`claude`, or its `claude-code` alias); other providers return
19/// `None` and keep showing their name. The ambient `~/.claude` is skipped: a
20/// legacy account bound there shares credentials with a terminal `claude`,
21/// whose own config (`~/.claude.json`) is outside it, so the `.claude.json`
22/// inside may name a user no longer signed in.
23pub 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
29/// The Anthropic identity a Claude config dir is signed in as: a short hash
30/// of `oauthAccount.accountUuid` and `oauthAccount.organizationUuid`
31/// (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION_2026_09_25.md §4.1).
32/// Not the email: one email can belong to several organizations, a
33/// personal one and a Team one, and a session from one isn't the other's.
34/// Hashed because it goes into a log every srv on the host shares. `None`
35/// on the same terms as [`email_from_oauth_dir`], or when either id is
36/// missing.
37pub 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
46/// `oauthAccount` from the `.claude.json` Claude writes into `dir`.
47fn 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
60/// Set `account.context.email` from its config dir when that differs from
61/// what is stored, and return the new email (the caller persists only that
62/// field, `Store::identity_set_context_email`). A different email replaces
63/// the stored one — an account re-authenticated as another user must not
64/// keep showing the old address (§4). An account whose dir reports no email
65/// is left as it is.
66pub 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    /// The backfill's write touches `context.email` only: other context keys,
177    /// `status` and `updated_at` stand, and an account that is gone is not
178    /// re-created.
179    #[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}