use super::provider::OAuthProviderId;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedSeat {
pub account_id: String,
pub identity: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SeatResolution {
Resolved(ResolvedSeat),
Unsupported,
Failed(String),
}
pub fn supports_seat_identity(provider: OAuthProviderId) -> bool {
matches!(
provider,
OAuthProviderId::Anthropic | OAuthProviderId::OpenAiCodex
)
}
pub fn identity_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.user_agent(concat!("SynapsCLI/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| "could not build HTTP client".to_string())
}
pub async fn resolve_seat(
provider: OAuthProviderId,
access_token: &str,
client: &reqwest::Client,
endpoint_override: Option<&str>,
) -> SeatResolution {
match provider {
OAuthProviderId::OpenAiCodex => {
match super::openai_codex::extract_account_id(access_token) {
Some(account_id) => SeatResolution::Resolved(ResolvedSeat {
account_id,
identity: super::openai_codex::extract_email(access_token),
}),
None => SeatResolution::Unsupported,
}
}
OAuthProviderId::Anthropic => {
match super::anthropic_profile::fetch_anthropic_profile(
client,
access_token,
endpoint_override,
)
.await
{
Ok(profile) => SeatResolution::Resolved(ResolvedSeat {
account_id: profile.account_uuid,
identity: profile.email,
}),
Err(msg) => SeatResolution::Failed(msg),
}
}
OAuthProviderId::Xai
| OAuthProviderId::GitHubCopilot
| OAuthProviderId::GoogleGemini
| OAuthProviderId::KimiCode
| OAuthProviderId::Muse => SeatResolution::Unsupported,
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
fn jwt(claims: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let payload = URL_SAFE_NO_PAD.encode(claims.to_string());
format!("{header}.{payload}.sig")
}
fn client() -> reqwest::Client {
identity_http_client().unwrap()
}
#[test]
fn identity_support_matrix() {
assert!(supports_seat_identity(OAuthProviderId::Anthropic));
assert!(supports_seat_identity(OAuthProviderId::OpenAiCodex));
for p in [
OAuthProviderId::Xai,
OAuthProviderId::GitHubCopilot,
OAuthProviderId::GoogleGemini,
OAuthProviderId::KimiCode,
OAuthProviderId::Muse,
] {
assert!(!supports_seat_identity(p), "{p}");
}
}
#[tokio::test]
async fn codex_resolves_from_jwt_without_network() {
let token = jwt(serde_json::json!({
"https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"},
"https://api.openai.com/profile": {"email": "codex@example.com"}
}));
let out = resolve_seat(
OAuthProviderId::OpenAiCodex,
&token,
&client(),
Some("http://127.0.0.1:9/never"),
)
.await;
assert_eq!(
out,
SeatResolution::Resolved(ResolvedSeat {
account_id: "acct_123".into(),
identity: Some("codex@example.com".into()),
})
);
assert_eq!(
super::super::openai_codex::extract_email(&token).as_deref(),
Some("codex@example.com")
);
let token = jwt(serde_json::json!({
"https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"}
}));
assert_eq!(
resolve_seat(OAuthProviderId::OpenAiCodex, &token, &client(), None).await,
SeatResolution::Resolved(ResolvedSeat {
account_id: "acct_123".into(),
identity: None,
})
);
}
#[tokio::test]
async fn codex_without_claim_and_other_providers_are_unsupported() {
let token = jwt(serde_json::json!({
"https://api.openai.com/profile": {"email": "codex@example.com"}
}));
assert_eq!(
resolve_seat(OAuthProviderId::OpenAiCodex, &token, &client(), None).await,
SeatResolution::Unsupported
);
assert_eq!(
resolve_seat(OAuthProviderId::OpenAiCodex, "not-a-jwt", &client(), None).await,
SeatResolution::Unsupported
);
for p in [
OAuthProviderId::Xai,
OAuthProviderId::GitHubCopilot,
OAuthProviderId::GoogleGemini,
OAuthProviderId::KimiCode,
] {
assert_eq!(
resolve_seat(p, "opaque", &client(), Some("http://127.0.0.1:9/never")).await,
SeatResolution::Unsupported,
"{p}"
);
}
}
async fn spawn(app: axum::Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
#[tokio::test]
async fn anthropic_resolves_account_uuid_and_email_from_profile() {
use axum::routing::get;
let app = axum::Router::new().route(
"/profile",
get(|| async {
(
[("content-type", "application/json")],
r#"{"account":{"uuid":"b8a1448d-0000-4000-8000-00000000000a","email":"jr.a@example.com"},"organization":{"uuid":"3895e3cf"}}"#,
)
}),
);
let url = spawn(app).await;
let out = resolve_seat(
OAuthProviderId::Anthropic,
"anthropic-access-token",
&client(),
Some(&format!("{url}/profile")),
)
.await;
assert_eq!(
out,
SeatResolution::Resolved(ResolvedSeat {
account_id: "b8a1448d-0000-4000-8000-00000000000a".into(),
identity: Some("jr.a@example.com".into()),
})
);
}
#[tokio::test]
async fn anthropic_failure_is_reported_without_secrets() {
use axum::{http::StatusCode, routing::get};
let app = axum::Router::new().route(
"/profile",
get(|| async { (StatusCode::BAD_GATEWAY, "UPSTREAM-SECRET-BODY") }),
);
let url = spawn(app).await;
let out = resolve_seat(
OAuthProviderId::Anthropic,
"SECRET-ACCESS-TOKEN",
&client(),
Some(&format!("{url}/profile")),
)
.await;
let SeatResolution::Failed(msg) = out else {
panic!("expected Failed, got {out:?}");
};
assert!(msg.contains("HTTP 502"), "{msg}");
assert!(!msg.contains("SECRET"), "{msg}");
let out = resolve_seat(
OAuthProviderId::Anthropic,
"SECRET-ACCESS-TOKEN",
&client(),
Some("https://evil.example/profile"),
)
.await;
assert!(matches!(out, SeatResolution::Failed(m) if m.contains("loopback")));
}
}