Skip to main content

agent_core/core/auth/
identity.rs

1//! Provider-generic seat identity resolution.
2//!
3//! One entry point, [`resolve_seat`], answers "which provider account does
4//! this access token belong to?" so the login guard, the `auth identify`
5//! backfill and the listing all key duplicate detection on the same value:
6//!
7//! - **OpenAI Codex** — read off the JWT (`chatgpt_account_id` claim, email
8//!   from the profile claim). No network.
9//! - **Anthropic** — tokens are opaque; one read-only `GET` to the pinned
10//!   profile endpoint ([`super::anthropic_profile`]). Seat = `account.uuid`.
11//! - **Every other provider** — no trustworthy identity is exposed;
12//!   [`SeatResolution::Unsupported`].
13//!
14//! Nothing here reads or writes `auth.json`, and no variant ever carries
15//! token material. A failure is reported, never turned into a guess.
16
17use super::provider::OAuthProviderId;
18
19/// Identity of the seat an access token belongs to. `account_id` is the
20/// provider's FULL account id (the value stored as `accountId` and hashed by
21/// [`super::seat_fingerprint`]); `identity` is a display-only label such as
22/// an email. Neither is a secret.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct ResolvedSeat {
25    pub account_id: String,
26    pub identity: Option<String>,
27}
28
29/// Outcome of [`resolve_seat`].
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum SeatResolution {
32    Resolved(ResolvedSeat),
33    /// The provider (or this particular token) exposes no identity. A
34    /// duplicate of another slot cannot be detected.
35    Unsupported,
36    /// The provider does expose an identity but it could not be obtained
37    /// (network, upstream status, parse). The message is secret-free.
38    Failed(String),
39}
40
41/// True when [`resolve_seat`] can, in principle, return
42/// [`SeatResolution::Resolved`] for `provider`. Used by listings to point at
43/// slots whose identity is still unverified.
44pub fn supports_seat_identity(provider: OAuthProviderId) -> bool {
45    matches!(
46        provider,
47        OAuthProviderId::Anthropic | OAuthProviderId::OpenAiCodex
48    )
49}
50
51/// HTTP client with the policy identity lookups (and the token refresh that
52/// may precede them) require: no redirects, bounded connect/total timeouts.
53pub fn identity_http_client() -> Result<reqwest::Client, String> {
54    reqwest::Client::builder()
55        .redirect(reqwest::redirect::Policy::none())
56        .connect_timeout(std::time::Duration::from_secs(10))
57        .timeout(std::time::Duration::from_secs(30))
58        .user_agent(concat!("SynapsCLI/", env!("CARGO_PKG_VERSION")))
59        .build()
60        .map_err(|_| "could not build HTTP client".to_string())
61}
62
63/// Resolve the seat behind `access_token` for `provider`. `endpoint_override`
64/// is a loopback-only test seam for the network-backed providers.
65pub async fn resolve_seat(
66    provider: OAuthProviderId,
67    access_token: &str,
68    client: &reqwest::Client,
69    endpoint_override: Option<&str>,
70) -> SeatResolution {
71    match provider {
72        OAuthProviderId::OpenAiCodex => {
73            match super::openai_codex::extract_account_id(access_token) {
74                Some(account_id) => SeatResolution::Resolved(ResolvedSeat {
75                    account_id,
76                    identity: super::openai_codex::extract_email(access_token),
77                }),
78                None => SeatResolution::Unsupported,
79            }
80        }
81        OAuthProviderId::Anthropic => {
82            match super::anthropic_profile::fetch_anthropic_profile(
83                client,
84                access_token,
85                endpoint_override,
86            )
87            .await
88            {
89                Ok(profile) => SeatResolution::Resolved(ResolvedSeat {
90                    account_id: profile.account_uuid,
91                    identity: profile.email,
92                }),
93                Err(msg) => SeatResolution::Failed(msg),
94            }
95        }
96        OAuthProviderId::Xai
97        | OAuthProviderId::GitHubCopilot
98        | OAuthProviderId::GoogleGemini
99        | OAuthProviderId::KimiCode
100        | OAuthProviderId::Muse => SeatResolution::Unsupported,
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
108
109    fn jwt(claims: serde_json::Value) -> String {
110        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
111        let payload = URL_SAFE_NO_PAD.encode(claims.to_string());
112        format!("{header}.{payload}.sig")
113    }
114
115    fn client() -> reqwest::Client {
116        identity_http_client().unwrap()
117    }
118
119    #[test]
120    fn identity_support_matrix() {
121        assert!(supports_seat_identity(OAuthProviderId::Anthropic));
122        assert!(supports_seat_identity(OAuthProviderId::OpenAiCodex));
123        for p in [
124            OAuthProviderId::Xai,
125            OAuthProviderId::GitHubCopilot,
126            OAuthProviderId::GoogleGemini,
127            OAuthProviderId::KimiCode,
128            OAuthProviderId::Muse,
129        ] {
130            assert!(!supports_seat_identity(p), "{p}");
131        }
132    }
133
134    #[tokio::test]
135    async fn codex_resolves_from_jwt_without_network() {
136        let token = jwt(serde_json::json!({
137            "https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"},
138            "https://api.openai.com/profile": {"email": "codex@example.com"}
139        }));
140        // A dead override proves no request is made for Codex.
141        let out = resolve_seat(
142            OAuthProviderId::OpenAiCodex,
143            &token,
144            &client(),
145            Some("http://127.0.0.1:9/never"),
146        )
147        .await;
148        assert_eq!(
149            out,
150            SeatResolution::Resolved(ResolvedSeat {
151                account_id: "acct_123".into(),
152                identity: Some("codex@example.com".into()),
153            })
154        );
155        // Same values the login flow used to derive directly.
156        assert_eq!(
157            super::super::openai_codex::extract_email(&token).as_deref(),
158            Some("codex@example.com")
159        );
160        // Email absent → identity None, seat still resolved.
161        let token = jwt(serde_json::json!({
162            "https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"}
163        }));
164        assert_eq!(
165            resolve_seat(OAuthProviderId::OpenAiCodex, &token, &client(), None).await,
166            SeatResolution::Resolved(ResolvedSeat {
167                account_id: "acct_123".into(),
168                identity: None,
169            })
170        );
171    }
172
173    #[tokio::test]
174    async fn codex_without_claim_and_other_providers_are_unsupported() {
175        let token = jwt(serde_json::json!({
176            "https://api.openai.com/profile": {"email": "codex@example.com"}
177        }));
178        assert_eq!(
179            resolve_seat(OAuthProviderId::OpenAiCodex, &token, &client(), None).await,
180            SeatResolution::Unsupported
181        );
182        assert_eq!(
183            resolve_seat(OAuthProviderId::OpenAiCodex, "not-a-jwt", &client(), None).await,
184            SeatResolution::Unsupported
185        );
186        for p in [
187            OAuthProviderId::Xai,
188            OAuthProviderId::GitHubCopilot,
189            OAuthProviderId::GoogleGemini,
190            OAuthProviderId::KimiCode,
191        ] {
192            assert_eq!(
193                resolve_seat(p, "opaque", &client(), Some("http://127.0.0.1:9/never")).await,
194                SeatResolution::Unsupported,
195                "{p}"
196            );
197        }
198    }
199
200    async fn spawn(app: axum::Router) -> String {
201        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
202        let addr = listener.local_addr().unwrap();
203        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
204        format!("http://{addr}")
205    }
206
207    #[tokio::test]
208    async fn anthropic_resolves_account_uuid_and_email_from_profile() {
209        use axum::routing::get;
210        let app = axum::Router::new().route(
211            "/profile",
212            get(|| async {
213                (
214                    [("content-type", "application/json")],
215                    r#"{"account":{"uuid":"b8a1448d-0000-4000-8000-00000000000a","email":"jr.a@example.com"},"organization":{"uuid":"3895e3cf"}}"#,
216                )
217            }),
218        );
219        let url = spawn(app).await;
220        let out = resolve_seat(
221            OAuthProviderId::Anthropic,
222            "anthropic-access-token",
223            &client(),
224            Some(&format!("{url}/profile")),
225        )
226        .await;
227        assert_eq!(
228            out,
229            SeatResolution::Resolved(ResolvedSeat {
230                account_id: "b8a1448d-0000-4000-8000-00000000000a".into(),
231                identity: Some("jr.a@example.com".into()),
232            })
233        );
234    }
235
236    #[tokio::test]
237    async fn anthropic_failure_is_reported_without_secrets() {
238        use axum::{http::StatusCode, routing::get};
239        let app = axum::Router::new().route(
240            "/profile",
241            get(|| async { (StatusCode::BAD_GATEWAY, "UPSTREAM-SECRET-BODY") }),
242        );
243        let url = spawn(app).await;
244        let out = resolve_seat(
245            OAuthProviderId::Anthropic,
246            "SECRET-ACCESS-TOKEN",
247            &client(),
248            Some(&format!("{url}/profile")),
249        )
250        .await;
251        let SeatResolution::Failed(msg) = out else {
252            panic!("expected Failed, got {out:?}");
253        };
254        assert!(msg.contains("HTTP 502"), "{msg}");
255        assert!(!msg.contains("SECRET"), "{msg}");
256        // Non-loopback override never sends.
257        let out = resolve_seat(
258            OAuthProviderId::Anthropic,
259            "SECRET-ACCESS-TOKEN",
260            &client(),
261            Some("https://evil.example/profile"),
262        )
263        .await;
264        assert!(matches!(out, SeatResolution::Failed(m) if m.contains("loopback")));
265    }
266}