1use super::provider::OAuthProviderId;
18
19#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct ResolvedSeat {
25 pub account_id: String,
26 pub identity: Option<String>,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum SeatResolution {
32 Resolved(ResolvedSeat),
33 Unsupported,
36 Failed(String),
39}
40
41pub fn supports_seat_identity(provider: OAuthProviderId) -> bool {
45 matches!(
46 provider,
47 OAuthProviderId::Anthropic | OAuthProviderId::OpenAiCodex
48 )
49}
50
51pub 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
63pub 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 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 assert_eq!(
157 super::super::openai_codex::extract_email(&token).as_deref(),
158 Some("codex@example.com")
159 );
160 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 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}