Files
1jehuang--jcode/crates/jcode-base/src/auth/antigravity.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

640 lines
22 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{self, IsTerminal, Write};
const GOOGLE_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const GOOGLE_USERINFO_URL: &str = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
pub const DEFAULT_PORT: u16 = 51121;
const LOOPBACK_HOST: &str = "127.0.0.1";
const REDIRECT_PATH: &str = "/oauth-callback";
// OAuth credentials from Google's Antigravity desktop app.
// These are for a desktop OAuth client where the client secret is safe to embed.
// Env vars remain available as optional overrides.
// gitleaks:allow - public desktop OAuth credentials, safe to embed
const ANTIGRAVITY_CLIENT_ID: &str =
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; // gitleaks:allow
const ANTIGRAVITY_CLIENT_SECRET: &str = "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"; // gitleaks:allow
const CLIENT_ID_ENV: &str = "JCODE_ANTIGRAVITY_CLIENT_ID";
const CLIENT_SECRET_ENV: &str = "JCODE_ANTIGRAVITY_CLIENT_SECRET";
const VERSION_ENV: &str = "JCODE_ANTIGRAVITY_VERSION";
const ANTIGRAVITY_VERSION: &str = "1.18.3";
const ANTIGRAVITY_SCOPES: &[&str] = &[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
const LOAD_ENDPOINTS: &[&str] = &[
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
];
const GOOGLE_OAUTH_USER_AGENT: &str = "google-api-nodejs-client/9.15.1";
fn antigravity_client_id() -> String {
std::env::var(CLIENT_ID_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ANTIGRAVITY_CLIENT_ID.to_string())
}
fn antigravity_client_secret() -> String {
std::env::var(CLIENT_SECRET_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ANTIGRAVITY_CLIENT_SECRET.to_string())
}
fn antigravity_version() -> String {
std::env::var(VERSION_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ANTIGRAVITY_VERSION.to_string())
}
fn metadata_platform() -> &'static str {
// The Cloud Code backend currently rejects OS-specific string enum values
// such as MACOS, WINDOWS, and LINUX for ClientMetadata.Platform. Use the
// string value that is accepted across platforms instead of varying by OS.
"PLATFORM_UNSPECIFIED"
}
fn user_agent() -> String {
if cfg!(target_os = "windows") {
format!("antigravity/{} windows/amd64", antigravity_version())
} else if cfg!(target_arch = "aarch64") {
format!("antigravity/{} darwin/arm64", antigravity_version())
} else {
format!("antigravity/{} darwin/amd64", antigravity_version())
}
}
fn client_metadata_header() -> String {
format!(
"{{\"ideType\":\"ANTIGRAVITY\",\"platform\":\"{}\",\"pluginType\":\"GEMINI\"}}",
metadata_platform()
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AntigravityTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
}
impl AntigravityTokens {
pub fn is_expired(&self) -> bool {
let now_ms = chrono::Utc::now().timestamp_millis();
self.expires_at <= now_ms + 60_000
}
}
#[derive(Debug, Deserialize)]
struct GoogleTokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
expires_in: i64,
}
#[derive(Debug, Deserialize)]
struct GoogleUserInfo {
email: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LoadCodeAssistResponse {
#[serde(default)]
cloudaicompanion_project: Option<serde_json::Value>,
}
pub fn tokens_path() -> Result<std::path::PathBuf> {
Ok(crate::storage::jcode_dir()?.join("antigravity_oauth.json"))
}
pub fn load_tokens() -> Result<AntigravityTokens> {
let path = tokens_path()?;
if path.exists() {
crate::storage::harden_secret_file_permissions(&path);
return crate::storage::read_json(&path).map_err(|_| {
anyhow::anyhow!(
"No Antigravity tokens found. Run `jcode login --provider antigravity`."
)
});
}
if let Some(tokens) = crate::auth::external::load_antigravity_oauth_tokens() {
return Ok(AntigravityTokens {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_at,
email: None,
project_id: None,
});
}
anyhow::bail!("No Antigravity tokens found. Run `jcode login --provider antigravity`.");
}
pub fn save_tokens(tokens: &AntigravityTokens) -> Result<()> {
let path = tokens_path()?;
crate::storage::write_json_secret(&path, tokens)
}
pub fn has_cached_auth() -> bool {
load_tokens().is_ok()
}
pub async fn load_or_refresh_tokens() -> Result<AntigravityTokens> {
let tokens = load_tokens()?;
if tokens.is_expired() {
refresh_tokens(&tokens).await
} else {
Ok(tokens)
}
}
/// Refresh Antigravity OAuth tokens, serialized via the refresh coordinator
/// so concurrent callers do not race the token endpoint and the stored file.
pub async fn refresh_tokens(tokens: &AntigravityTokens) -> Result<AntigravityTokens> {
crate::auth::refresh_coordinator::single_flight(
"antigravity".to_string(),
|| load_tokens().ok(),
|stored: &AntigravityTokens| !stored.is_expired(),
{
let observed = tokens.clone();
move |stored: Option<AntigravityTokens>| async move {
let source = stored.unwrap_or(observed);
refresh_tokens_uncoordinated(&source).await
}
},
)
.await
}
async fn refresh_tokens_uncoordinated(tokens: &AntigravityTokens) -> Result<AntigravityTokens> {
let result: Result<AntigravityTokens> = async {
let token = crate::auth::google_oauth::refresh_access_token(
"Antigravity",
&antigravity_client_id(),
&antigravity_client_secret(),
&tokens.refresh_token,
Some(GOOGLE_OAUTH_USER_AGENT),
)
.await?;
let mut refreshed = AntigravityTokens {
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at: token.expires_at_ms,
email: tokens.email.clone(),
project_id: tokens.project_id.clone(),
};
if refreshed.email.is_none() {
refreshed.email = fetch_email(&refreshed.access_token).await.ok();
}
if refreshed.project_id.is_none() {
refreshed.project_id = fetch_project_id(&refreshed.access_token).await.ok();
}
save_tokens(&refreshed)?;
Ok(refreshed)
}
.await;
match &result {
Ok(_) => {
let _ = crate::auth::refresh_state::record_success("antigravity");
}
Err(err) => {
let _ = crate::auth::refresh_state::record_failure("antigravity", err.to_string());
}
}
result
}
pub async fn login(no_browser: bool) -> Result<AntigravityTokens> {
let (verifier, challenge) = crate::auth::oauth::generate_pkce_public();
let state = crate::auth::oauth::generate_state_public();
let redirect_uri = redirect_uri(DEFAULT_PORT);
let auth_url = build_auth_url(&redirect_uri, &challenge, &state)?;
if !crate::auth::browser_suppressed(no_browser)
&& let Ok(listener) = crate::auth::oauth::bind_callback_listener(DEFAULT_PORT)
{
eprintln!("\nOpening browser for Antigravity login...\n");
eprintln!("If the browser didn't open, visit:\n{}\n", auth_url);
if let Some(qr) = crate::login_qr::indented_section(
&auth_url,
"Scan this QR on another device if this machine has no browser:",
" ",
) {
eprintln!("{qr}\n");
}
let browser_opened = open::that(&auth_url).is_ok();
if browser_opened {
eprintln!(
"Waiting up to 300s for automatic callback on {}",
redirect_uri
);
eprintln!(
"If the browser lands on a loopback error page instead of returning to jcode, copy the full URL from the address bar and re-run with `--no-browser` to paste it manually."
);
match tokio::time::timeout(
std::time::Duration::from_secs(300),
crate::auth::oauth::wait_for_callback_async_on_listener(listener, &state),
)
.await
{
Ok(Ok(code)) => {
return exchange_callback_code(&code, &verifier, &redirect_uri).await;
}
Ok(Err(err)) => {
eprintln!(
"Automatic callback failed ({err}). Falling back to manual callback paste."
);
}
Err(_) => {
eprintln!(
"Timed out waiting for callback. Falling back to manual callback paste."
);
}
}
} else {
eprintln!(
"Couldn't open a browser on this machine. Falling back to manual callback paste.\n"
);
}
}
manual_login(&verifier, &state, &redirect_uri, &auth_url, no_browser).await
}
async fn manual_login(
verifier: &str,
expected_state: &str,
redirect_uri: &str,
auth_url: &str,
no_browser: bool,
) -> Result<AntigravityTokens> {
if !io::stdin().is_terminal() {
anyhow::bail!(
"Antigravity login needs an interactive terminal for manual callback entry. Re-run in an interactive terminal."
);
}
eprintln!("\nManual Antigravity auth required.\n");
eprintln!("Open this URL in your browser:\n\n{}\n", auth_url);
if let Some(qr) = crate::login_qr::indented_section(
auth_url,
"Scan this QR on another device if needed:",
" ",
) {
eprintln!("{qr}\n");
}
if !crate::auth::browser_suppressed(no_browser) {
let _ = open::that(auth_url);
}
eprintln!(
"After approving access, paste the full callback URL (or query string) here so jcode can verify the login state.\n"
);
eprintln!(
"If the browser shows a local callback error, copy the full URL from the address bar before closing the tab.\n"
);
eprint!("Callback URL: ");
io::stdout().flush()?;
let input = crate::secret_input::read_secret_line()?;
if input.trim().is_empty() {
anyhow::bail!("No callback URL provided.");
}
exchange_callback_input(verifier, &input, Some(expected_state), redirect_uri).await
}
pub async fn exchange_callback_input(
verifier: &str,
input: &str,
expected_state: Option<&str>,
redirect_uri: &str,
) -> Result<AntigravityTokens> {
let code = if let Some(expected_state) = expected_state {
let (code, callback_state) = crate::auth::oauth::parse_callback_input_with_state(input)?;
if callback_state != expected_state {
anyhow::bail!(
"OAuth state mismatch. Start Antigravity login again and use the latest callback URL."
);
}
code
} else {
input.trim().to_string()
};
let tokens = exchange_authorization_code(&code, verifier, redirect_uri).await?;
save_tokens(&tokens)?;
Ok(tokens)
}
pub async fn exchange_callback_code(
code: &str,
verifier: &str,
redirect_uri: &str,
) -> Result<AntigravityTokens> {
let tokens = exchange_authorization_code(code, verifier, redirect_uri).await?;
save_tokens(&tokens)?;
Ok(tokens)
}
async fn exchange_authorization_code(
code: &str,
verifier: &str,
redirect_uri: &str,
) -> Result<AntigravityTokens> {
let client = crate::provider::shared_http_client();
let client_id = antigravity_client_id();
let client_secret = antigravity_client_secret();
let resp = client
.post(GOOGLE_TOKEN_URL)
.header(reqwest::header::USER_AGENT, GOOGLE_OAUTH_USER_AGENT)
.form(&vec![
("grant_type", "authorization_code".to_string()),
("client_id", client_id),
("client_secret", client_secret),
("code", code.trim().to_string()),
("code_verifier", verifier.to_string()),
("redirect_uri", redirect_uri.to_string()),
])
.send()
.await
.context("Failed to exchange Antigravity authorization code")?;
if !resp.status().is_success() {
let body = crate::util::http_error_body(resp, "HTTP error").await;
anyhow::bail!("Antigravity token exchange failed: {}", body.trim());
}
let token_resp: GoogleTokenResponse = resp
.json()
.await
.context("Failed to parse Antigravity token exchange response")?;
let refresh_token = token_resp.refresh_token.ok_or_else(|| {
anyhow::anyhow!(
"No refresh token received. Revoke access at https://myaccount.google.com/permissions and try again."
)
})?;
let email = fetch_email(&token_resp.access_token).await.ok();
let project_id = fetch_project_id(&token_resp.access_token).await.ok();
Ok(AntigravityTokens {
access_token: token_resp.access_token,
refresh_token,
expires_at: chrono::Utc::now().timestamp_millis() + (token_resp.expires_in * 1000),
email,
project_id,
})
}
pub async fn fetch_email(access_token: &str) -> Result<String> {
let client = crate::provider::shared_http_client();
let resp = client
.get(GOOGLE_USERINFO_URL)
.header(reqwest::header::USER_AGENT, GOOGLE_OAUTH_USER_AGENT)
.bearer_auth(access_token)
.send()
.await
.context("Failed to fetch Antigravity Google profile")?;
if !resp.status().is_success() {
let body = crate::util::http_error_body(resp, "HTTP error").await;
anyhow::bail!(
"Failed to fetch Antigravity Google profile: {}",
body.trim()
);
}
let profile: GoogleUserInfo = resp
.json()
.await
.context("Failed to parse Antigravity Google profile")?;
profile
.email
.filter(|email| !email.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("Google profile did not include an email address"))
}
pub async fn fetch_project_id(access_token: &str) -> Result<String> {
let client = crate::provider::shared_http_client();
let headers = antigravity_headers(access_token)?;
let body = serde_json::json!({
"metadata": {
"ideType": "ANTIGRAVITY",
"platform": metadata_platform(),
"pluginType": "GEMINI"
}
});
let mut errors = Vec::new();
for base_url in LOAD_ENDPOINTS {
let resp = match client
.post(format!("{base_url}/v1internal:loadCodeAssist"))
.headers(headers.clone())
.json(&body)
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
errors.push(format!("{base_url}: {err}"));
continue;
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = crate::util::http_error_body(resp, "HTTP error").await;
errors.push(format!("{base_url}: HTTP {status} {}", text.trim()));
continue;
}
let parsed: LoadCodeAssistResponse = resp
.json()
.await
.with_context(|| format!("Failed to parse loadCodeAssist response from {base_url}"))?;
if let Some(project_id) = extract_project_id(parsed.cloudaicompanion_project) {
return Ok(project_id);
}
errors.push(format!("{base_url}: project id missing from response"));
}
anyhow::bail!(
"Failed to resolve Antigravity project via loadCodeAssist: {}",
errors.join("; ")
)
}
pub fn build_auth_url(redirect_uri: &str, challenge: &str, state: &str) -> Result<String> {
let scope = ANTIGRAVITY_SCOPES.join(" ");
let client_id = antigravity_client_id();
Ok(format!(
"{base}?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&code_challenge={challenge}&code_challenge_method=S256&state={state}&access_type=offline&prompt=consent",
base = GOOGLE_AUTHORIZE_URL,
client_id = urlencoding::encode(&client_id),
redirect_uri = urlencoding::encode(redirect_uri),
scope = urlencoding::encode(&scope),
challenge = urlencoding::encode(challenge),
state = urlencoding::encode(state),
))
}
pub fn redirect_uri(port: u16) -> String {
format!("http://{LOOPBACK_HOST}:{port}{REDIRECT_PATH}")
}
fn antigravity_headers(access_token: &str) -> Result<reqwest::header::HeaderMap> {
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access_token}"))
.context("invalid Antigravity authorization header")?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
USER_AGENT,
HeaderValue::from_str(&user_agent()).context("invalid Antigravity user-agent header")?,
);
headers.insert(
reqwest::header::HeaderName::from_static("x-goog-api-client"),
HeaderValue::from_static("google-cloud-sdk vscode_cloudshelleditor/0.1"),
);
headers.insert(
reqwest::header::HeaderName::from_static("client-metadata"),
HeaderValue::from_str(&client_metadata_header())
.context("invalid Antigravity client-metadata header")?,
);
Ok(headers)
}
fn extract_project_id(value: Option<serde_json::Value>) -> Option<String> {
match value {
Some(serde_json::Value::String(project_id)) => {
let trimmed = project_id.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Some(serde_json::Value::Object(map)) => map
.get("id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::lock_test_env;
#[test]
fn build_auth_url_includes_antigravity_scope_and_redirect() {
let _guard = lock_test_env();
crate::env::set_var(
CLIENT_ID_ENV,
"test-antigravity-client-id.apps.googleusercontent.com",
);
let url = build_auth_url(
"http://127.0.0.1:51121/oauth-callback",
"challenge",
"state",
)
.expect("build auth url");
assert!(url.contains("client_id=test-antigravity-client-id.apps.googleusercontent.com"));
assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A51121%2Foauth-callback"));
assert!(url.contains("code_challenge=challenge"));
assert!(url.contains("state=state"));
assert!(url.contains("cloud-platform"));
assert!(url.contains("experimentsandconfigs"));
crate::env::remove_var(CLIENT_ID_ENV);
}
#[test]
fn build_auth_url_uses_default_client_id_when_env_missing() {
let _guard = lock_test_env();
crate::env::remove_var(CLIENT_ID_ENV);
let url = build_auth_url(
"http://127.0.0.1:51121/oauth-callback",
"challenge",
"state",
)
.expect("missing env should use built-in client id");
assert!(url.contains(&format!(
"client_id={}",
urlencoding::encode(ANTIGRAVITY_CLIENT_ID)
)));
}
#[test]
fn blank_env_vars_fall_back_to_built_in_credentials() {
let _guard = lock_test_env();
crate::env::set_var(CLIENT_ID_ENV, " ");
crate::env::set_var(CLIENT_SECRET_ENV, " ");
assert_eq!(antigravity_client_id(), ANTIGRAVITY_CLIENT_ID);
assert_eq!(antigravity_client_secret(), ANTIGRAVITY_CLIENT_SECRET);
crate::env::remove_var(CLIENT_ID_ENV);
crate::env::remove_var(CLIENT_SECRET_ENV);
}
#[test]
fn redirect_uri_uses_ipv4_loopback() {
assert_eq!(
redirect_uri(DEFAULT_PORT),
"http://127.0.0.1:51121/oauth-callback"
);
}
#[test]
fn client_metadata_uses_backend_accepted_platform() {
assert_eq!(metadata_platform(), "PLATFORM_UNSPECIFIED");
assert!(client_metadata_header().contains("\"platform\":\"PLATFORM_UNSPECIFIED\""));
}
#[test]
fn extract_project_id_supports_string_or_object() {
assert_eq!(
extract_project_id(Some(serde_json::Value::String("proj-123".to_string()))),
Some("proj-123".to_string())
);
assert_eq!(
extract_project_id(Some(serde_json::json!({ "id": "proj-456" }))),
Some("proj-456".to_string())
);
assert_eq!(
extract_project_id(Some(serde_json::json!({ "id": " " }))),
None
);
}
}