feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
+195 -6
View File
@@ -40,7 +40,7 @@ impl std::fmt::Debug for ProviderOAuthCookieAuthorizationInput {
}
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Clone, PartialEq)]
pub struct ProviderOAuthTransportContext {
pub provider_id: String,
pub provider_type: String,
@@ -55,13 +55,57 @@ pub struct ProviderOAuthTransportContext {
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
impl std::fmt::Debug for ProviderOAuthTransportContext {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderOAuthTransportContext")
.field("provider_id", &self.provider_id)
.field("provider_type", &self.provider_type)
.field("endpoint_id", &self.endpoint_id)
.field("key_id", &self.key_id)
.field("auth_type", &self.auth_type)
.field(
"decrypted_api_key",
&self.decrypted_api_key.as_ref().map(|_| "<redacted>"),
)
.field(
"decrypted_auth_config",
&self.decrypted_auth_config.as_ref().map(|_| "<redacted>"),
)
.field(
"provider_config",
&self.provider_config.as_ref().map(|_| "<redacted>"),
)
.field(
"endpoint_config",
&self.endpoint_config.as_ref().map(|_| "<redacted>"),
)
.field(
"key_config",
&self.key_config.as_ref().map(|_| "<redacted>"),
)
.field("network", &self.network)
.finish()
}
}
#[derive(Clone, PartialEq)]
pub struct ProviderOAuthTokenSet {
pub token_set: OAuthTokenSet,
pub auth_config: Value,
}
#[derive(Debug, Clone, PartialEq)]
impl std::fmt::Debug for ProviderOAuthTokenSet {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderOAuthTokenSet")
.field("token_set", &self.token_set)
.field("auth_config", &"<redacted>")
.finish()
}
}
#[derive(Clone, PartialEq)]
pub struct ProviderOAuthAccount {
pub provider_type: String,
pub access_token: String,
@@ -70,6 +114,19 @@ pub struct ProviderOAuthAccount {
pub identity: BTreeMap<String, Value>,
}
impl std::fmt::Debug for ProviderOAuthAccount {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderOAuthAccount")
.field("provider_type", &self.provider_type)
.field("access_token", &"<redacted>")
.field("auth_config", &"<redacted>")
.field("expires_at_unix_secs", &self.expires_at_unix_secs)
.field("identity_keys", &self.identity.keys().collect::<Vec<_>>())
.finish()
}
}
impl ProviderOAuthAccount {
pub fn request_bearer_auth(&self) -> ProviderOAuthRequestAuth {
ProviderOAuthRequestAuth::Header {
@@ -79,7 +136,7 @@ impl ProviderOAuthAccount {
}
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Clone, PartialEq)]
pub enum ProviderOAuthRequestAuth {
Header {
name: String,
@@ -93,7 +150,28 @@ pub enum ProviderOAuthRequestAuth {
},
}
#[derive(Debug, Clone, PartialEq)]
impl std::fmt::Debug for ProviderOAuthRequestAuth {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Header { name, .. } => formatter
.debug_struct("Header")
.field("name", name)
.field("value", &"<redacted>")
.finish(),
Self::Kiro {
name, machine_id, ..
} => formatter
.debug_struct("Kiro")
.field("name", name)
.field("value", &"<redacted>")
.field("auth_config", &"<redacted>")
.field("machine_id", machine_id)
.finish(),
}
}
}
#[derive(Clone, PartialEq)]
pub struct ProviderOAuthImportInput {
pub provider_type: String,
pub name: Option<String>,
@@ -102,7 +180,26 @@ pub struct ProviderOAuthImportInput {
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
impl std::fmt::Debug for ProviderOAuthImportInput {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderOAuthImportInput")
.field("provider_type", &self.provider_type)
.field("name", &self.name)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field(
"raw_credentials",
&self.raw_credentials.as_ref().map(|_| "<redacted>"),
)
.field("network", &self.network)
.finish()
}
}
#[derive(Clone, PartialEq)]
pub struct ProviderOAuthAccountState {
pub is_valid: bool,
pub email: Option<String>,
@@ -110,3 +207,95 @@ pub struct ProviderOAuthAccountState {
pub invalid_reason: Option<String>,
pub raw: Option<Value>,
}
impl std::fmt::Debug for ProviderOAuthAccountState {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderOAuthAccountState")
.field("is_valid", &self.is_valid)
.field("email", &self.email)
.field("quota", &self.quota)
.field("invalid_reason", &self.invalid_reason)
.field("raw", &self.raw.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}
#[cfg(test)]
mod tests {
use super::{
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthImportInput,
ProviderOAuthTokenSet, ProviderOAuthTransportContext,
};
use crate::core::OAuthTokenSet;
use crate::network::OAuthNetworkContext;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn debug_output_redacts_provider_oauth_credentials() {
let context = ProviderOAuthTransportContext {
provider_id: "provider-1".to_string(),
provider_type: "generic".to_string(),
endpoint_id: None,
key_id: None,
auth_type: None,
decrypted_api_key: Some("api-key-secret-sentinel".to_string()),
decrypted_auth_config: Some("auth-config-secret-sentinel".to_string()),
provider_config: Some(json!({"client_secret": "provider-secret-sentinel"})),
endpoint_config: None,
key_config: None,
network: OAuthNetworkContext::direct_identity(),
};
let token_set = ProviderOAuthTokenSet {
token_set: OAuthTokenSet {
access_token: "access-secret-sentinel".to_string(),
refresh_token: Some("refresh-secret-sentinel".to_string()),
token_type: None,
scope: None,
expires_at_unix_secs: None,
raw_payload: None,
},
auth_config: json!({"password": "password-secret-sentinel"}),
};
let account = ProviderOAuthAccount {
provider_type: "generic".to_string(),
access_token: "account-secret-sentinel".to_string(),
auth_config: json!({"client_secret": "account-config-secret-sentinel"}),
expires_at_unix_secs: None,
identity: BTreeMap::new(),
};
let import = ProviderOAuthImportInput {
provider_type: "generic".to_string(),
name: None,
refresh_token: Some("import-refresh-secret-sentinel".to_string()),
raw_credentials: Some(json!({"api_key": "import-raw-secret-sentinel"})),
network: OAuthNetworkContext::direct_identity(),
};
let state = ProviderOAuthAccountState {
is_valid: false,
email: None,
quota: None,
invalid_reason: None,
raw: Some(json!({"access_token": "probe-raw-secret-sentinel"})),
};
let debug = format!("{context:?} {token_set:?} {account:?} {import:?} {state:?}");
for secret in [
"api-key-secret-sentinel",
"auth-config-secret-sentinel",
"provider-secret-sentinel",
"access-secret-sentinel",
"refresh-secret-sentinel",
"password-secret-sentinel",
"account-secret-sentinel",
"account-config-secret-sentinel",
"import-refresh-secret-sentinel",
"import-raw-secret-sentinel",
"probe-raw-secret-sentinel",
] {
assert!(!debug.contains(secret), "debug leaked {secret}");
}
assert!(debug.contains("<redacted>"));
}
}
@@ -1,4 +1,7 @@
use crate::core::{current_unix_secs, OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
use crate::core::{
current_unix_secs, redacted_oauth_error_body_excerpt, OAuthAuthorizeResponse, OAuthError,
OAuthTokenSet,
};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
use crate::provider::ProviderOAuthAdapter;
use crate::provider::{
@@ -17,6 +20,10 @@ use super::claude_code::{
CLAUDE_CODE_PROVIDER_TYPE, CLAUDE_CODE_REDIRECT_URI, CLAUDE_CODE_TOKEN_URL,
};
pub const GEMINI_CLI_OAUTH_CLIENT_ID_ENV: &str = "AETHER_GEMINI_CLI_OAUTH_CLIENT_ID";
pub const GEMINI_CLI_OAUTH_CLIENT_SECRET_ENV: &str = "AETHER_GEMINI_CLI_OAUTH_CLIENT_SECRET";
pub const ANTIGRAVITY_OAUTH_CLIENT_ID_ENV: &str = "AETHER_ANTIGRAVITY_OAUTH_CLIENT_ID";
pub const ANTIGRAVITY_OAUTH_CLIENT_SECRET_ENV: &str = "AETHER_ANTIGRAVITY_OAUTH_CLIENT_SECRET";
const CODEX_IDENTITY_FINGERPRINT_FIELD: &str = "codex_identity_fingerprint";
const CODEX_IDENTITY_FINGERPRINT_VERSION: &str = "codex-persisted-fingerprint:v1";
@@ -53,7 +60,8 @@ pub struct GenericProviderOAuthTemplate {
pub authorize_url: &'static str,
pub token_url: &'static str,
pub client_id: &'static str,
pub client_secret: &'static str,
pub client_id_env: Option<&'static str>,
pub client_secret_env: Option<&'static str>,
pub scopes: &'static [&'static str],
pub redirect_uri: &'static str,
pub use_pkce: bool,
@@ -68,7 +76,8 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
authorize_url: CLAUDE_CODE_AUTHORIZE_URL,
token_url: CLAUDE_CODE_TOKEN_URL,
client_id: CLAUDE_CODE_CLIENT_ID,
client_secret: "",
client_id_env: None,
client_secret_env: None,
scopes: CLAUDE_CODE_OAUTH_SCOPES,
redirect_uri: CLAUDE_CODE_REDIRECT_URI,
use_pkce: true,
@@ -81,7 +90,8 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
authorize_url: "https://auth.openai.com/oauth/authorize",
token_url: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
client_secret: "",
client_id_env: None,
client_secret_env: None,
scopes: &["openid", "email", "profile", "offline_access"],
redirect_uri: "http://localhost:1455/auth/callback",
use_pkce: true,
@@ -94,7 +104,8 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
authorize_url: "https://auth.openai.com/oauth/authorize",
token_url: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
client_secret: "",
client_id_env: None,
client_secret_env: None,
scopes: &["openid", "email", "profile", "offline_access"],
redirect_uri: "http://localhost:1455/auth/callback",
use_pkce: true,
@@ -107,7 +118,8 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
client_secret: "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",
client_id_env: Some(GEMINI_CLI_OAUTH_CLIENT_ID_ENV),
client_secret_env: Some(GEMINI_CLI_OAUTH_CLIENT_SECRET_ENV),
scopes: &[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
@@ -124,7 +136,8 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
client_id: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret: "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",
client_id_env: Some(ANTIGRAVITY_OAUTH_CLIENT_ID_ENV),
client_secret_env: Some(ANTIGRAVITY_OAUTH_CLIENT_SECRET_ENV),
scopes: &[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
@@ -139,10 +152,24 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
},
];
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct GenericProviderOAuthAdapter {
template: GenericProviderOAuthTemplate,
token_url_override: Option<String>,
client_id_override: Option<String>,
client_secret_override: Option<String>,
}
impl std::fmt::Debug for GenericProviderOAuthAdapter {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("GenericProviderOAuthAdapter")
.field("provider_type", &self.template.provider_type)
.field("has_token_url_override", &self.token_url_override.is_some())
.field("client_id_env", &self.template.client_id_env)
.field("client_secret_env", &self.template.client_secret_env)
.finish_non_exhaustive()
}
}
impl GenericProviderOAuthAdapter {
@@ -150,6 +177,8 @@ impl GenericProviderOAuthAdapter {
Self {
template,
token_url_override: None,
client_id_override: None,
client_secret_override: None,
}
}
@@ -166,12 +195,52 @@ impl GenericProviderOAuthAdapter {
self.with_token_url_override(token_url)
}
#[doc(hidden)]
pub fn with_oauth_credentials_for_tests(
mut self,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
self.client_id_override = Some(client_id.into());
self.client_secret_override = Some(client_secret.into());
self
}
#[cfg(test)]
fn without_oauth_client_secret_for_tests(mut self) -> Self {
self.client_secret_override = Some(String::new());
self
}
fn token_url(&self) -> String {
self.token_url_override
.clone()
.unwrap_or_else(|| self.template.token_url.to_string())
}
fn client_id(&self) -> String {
if let Some(value) = self.client_id_override.clone().and_then(non_empty_owned) {
return value;
}
self.template
.client_id_env
.and_then(non_empty_environment_value)
.unwrap_or_else(|| self.template.client_id.to_string())
}
fn client_secret(&self) -> Result<Option<String>, OAuthError> {
let Some(env_name) = self.template.client_secret_env else {
return Ok(None);
};
if let Some(value) = self.client_secret_override.clone() {
return required_client_secret(env_name, non_empty_owned(value)).map(Some);
}
required_client_secret(env_name, non_empty_environment_value(env_name)).map(Some)
}
async fn exchange_grant(
&self,
executor: &dyn OAuthHttpExecutor,
@@ -181,6 +250,8 @@ impl GenericProviderOAuthAdapter {
state: Option<&str>,
pkce_verifier: Option<&str>,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let client_id = self.client_id();
let client_secret = self.client_secret()?;
let scope = (!self.template.scopes.is_empty()).then(|| self.template.scopes.join(" "));
let request_id = match grant_type {
"authorization_code" => "provider-oauth:exchange-code".to_string(),
@@ -196,11 +267,14 @@ impl GenericProviderOAuthAdapter {
"grant_type".to_string(),
Value::String(grant_type.to_string()),
),
(
"client_id".to_string(),
Value::String(self.template.client_id.to_string()),
),
("client_id".to_string(), Value::String(client_id.clone())),
]);
if let Some(client_secret) = client_secret.as_ref() {
body.insert(
"client_secret".to_string(),
Value::String(client_secret.clone()),
);
}
if grant_type == "authorization_code" {
body.insert(
"code".to_string(),
@@ -247,7 +321,7 @@ impl GenericProviderOAuthAdapter {
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", grant_type);
form.append_pair("client_id", self.template.client_id);
form.append_pair("client_id", &client_id);
if grant_type == "authorization_code" {
form.append_pair("redirect_uri", self.template.redirect_uri);
form.append_pair("code", code_or_refresh_token);
@@ -262,8 +336,8 @@ impl GenericProviderOAuthAdapter {
form.append_pair("scope", scope);
}
}
if !self.template.client_secret.trim().is_empty() {
form.append_pair("client_secret", self.template.client_secret);
if let Some(client_secret) = client_secret.as_deref() {
form.append_pair("client_secret", client_secret);
}
form.finish().into_bytes()
};
@@ -340,12 +414,14 @@ impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
state: &str,
code_challenge: Option<&str>,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
self.client_secret()?;
let client_id = self.client_id();
let mut url = url::Url::parse(self.template.authorize_url)
.map_err(|_| OAuthError::invalid_request("authorize_url must be absolute"))?;
{
let mut query = url.query_pairs_mut();
query.append_pair("response_type", "code");
query.append_pair("client_id", self.template.client_id);
query.append_pair("client_id", &client_id);
query.append_pair("redirect_uri", self.template.redirect_uri);
query.append_pair("state", state);
if !self.template.scopes.is_empty() {
@@ -472,6 +548,26 @@ impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
}
}
fn non_empty_environment_value(name: &str) -> Option<String> {
std::env::var(name).ok().and_then(non_empty_owned)
}
fn non_empty_owned(value: String) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn required_client_secret(
env_name: &'static str,
configured: Option<String>,
) -> Result<String, OAuthError> {
configured.ok_or_else(|| {
OAuthError::invalid_request(format!(
"{env_name} must be configured for this OAuth provider"
))
})
}
pub fn template_for_provider_type(provider_type: &str) -> Option<GenericProviderOAuthTemplate> {
let normalized = provider_type.trim();
GENERIC_PROVIDER_OAUTH_TEMPLATES
@@ -506,12 +602,7 @@ fn json_headers(provider_type: &str) -> BTreeMap<String, String> {
}
fn truncate_body(body: &str) -> String {
let body = body.trim();
if body.is_empty() {
"-".to_string()
} else {
body.chars().take(500).collect()
}
redacted_oauth_error_body_excerpt(body)
}
fn secret_fingerprint(value: &str) -> String {
@@ -790,8 +881,21 @@ fn value_to_string(value: &Value) -> Option<String> {
fn decode_jwt_claims(token: &str) -> Option<serde_json::Map<String, Value>> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
const MAX_UNVERIFIED_JWT_CLAIMS_BYTES: usize = 64 * 1024;
let payload = token.split('.').nth(1)?;
let max_encoded_len = MAX_UNVERIFIED_JWT_CLAIMS_BYTES
.saturating_add(2)
.checked_div(3)
.unwrap_or(usize::MAX)
.saturating_mul(4);
if payload.len() > max_encoded_len {
return None;
}
let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()?;
if bytes.len() > MAX_UNVERIFIED_JWT_CLAIMS_BYTES {
return None;
}
serde_json::from_slice::<Value>(&bytes)
.ok()?
.as_object()
@@ -801,9 +905,12 @@ fn decode_jwt_claims(token: &str) -> Option<serde_json::Map<String, Value>> {
#[cfg(test)]
mod tests {
use super::{
derive_codex_identity_fingerprint, enrich_generic_identity, template_for_provider_type,
GenericProviderOAuthAdapter, CODEX_IDENTITY_FINGERPRINT_FIELD,
decode_jwt_claims, derive_codex_identity_fingerprint, enrich_generic_identity,
template_for_provider_type, GenericProviderOAuthAdapter,
ANTIGRAVITY_OAUTH_CLIENT_SECRET_ENV, CODEX_IDENTITY_FINGERPRINT_FIELD,
GEMINI_CLI_OAUTH_CLIENT_SECRET_ENV,
};
use crate::core::OAuthError;
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::ProviderOAuthAdapter;
use crate::provider::{ProviderOAuthAccount, ProviderOAuthTransportContext};
@@ -835,6 +942,36 @@ mod tests {
)
}
#[test]
fn google_oauth_templates_reference_external_client_secrets() {
let gemini = template_for_provider_type("gemini_cli").expect("gemini template");
let antigravity = template_for_provider_type("antigravity").expect("antigravity template");
assert_eq!(
gemini.client_secret_env,
Some(GEMINI_CLI_OAUTH_CLIENT_SECRET_ENV)
);
assert_eq!(
antigravity.client_secret_env,
Some(ANTIGRAVITY_OAUTH_CLIENT_SECRET_ENV)
);
}
#[test]
fn generic_adapter_debug_redacts_oauth_credentials() {
let adapter = GenericProviderOAuthAdapter::for_provider_type("gemini_cli")
.expect("gemini adapter")
.with_token_url_override("https://token.example.test/private-path")
.with_oauth_credentials_for_tests("private-client-id", "private-client-secret");
let debug = format!("{adapter:?}");
assert!(!debug.contains("private-client-id"));
assert!(!debug.contains("private-client-secret"));
assert!(!debug.contains("private-path"));
assert!(debug.contains(GEMINI_CLI_OAUTH_CLIENT_SECRET_ENV));
}
#[test]
fn codex_identity_extracts_fedramp_workspace_claim() {
let claims = json!({
@@ -852,6 +989,19 @@ mod tests {
assert_eq!(auth_config.get("is_fedramp"), Some(&json!(true)));
}
#[test]
fn generic_identity_rejects_oversized_jwt_claims_before_decode() {
const MAX_UNVERIFIED_JWT_CLAIMS_BYTES: usize = 64 * 1024;
let max_encoded_len = MAX_UNVERIFIED_JWT_CLAIMS_BYTES
.saturating_add(2)
.checked_div(3)
.unwrap()
.saturating_mul(4);
let token = format!("header.{}.signature", "A".repeat(max_encoded_len + 1));
assert_eq!(decode_jwt_claims(&token), None);
}
#[test]
fn codex_persisted_fingerprint_is_member_scoped_and_token_independent() {
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
@@ -937,6 +1087,108 @@ mod tests {
}
}
fn transport_context(provider_type: &str) -> ProviderOAuthTransportContext {
ProviderOAuthTransportContext {
provider_id: "provider-1".to_string(),
provider_type: provider_type.to_string(),
endpoint_id: None,
key_id: Some("key-1".to_string()),
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: None,
endpoint_config: None,
key_config: None,
network: crate::network::OAuthNetworkContext::provider_operation(None),
}
}
fn oauth_account(provider_type: &str) -> ProviderOAuthAccount {
ProviderOAuthAccount {
provider_type: provider_type.to_string(),
access_token: "old-access-token".to_string(),
auth_config: json!({
"provider_type": provider_type,
"refresh_token": "old-refresh-token",
"updated_at": 1
}),
expires_at_unix_secs: Some(1),
identity: BTreeMap::new(),
}
}
#[tokio::test]
async fn google_oauth_fails_closed_before_network_without_client_secret() {
let seen_request = Arc::new(Mutex::new(None));
let executor = StaticExecutor {
seen_request: Arc::clone(&seen_request),
response_payload: json!({}),
};
let adapter = GenericProviderOAuthAdapter::for_provider_type("gemini_cli")
.expect("gemini adapter")
.without_oauth_client_secret_for_tests();
let ctx = transport_context("gemini_cli");
let authorize_error = adapter
.build_authorize_url(&ctx, "state", None)
.expect_err("authorization must fail without the configured secret");
assert!(matches!(authorize_error, OAuthError::InvalidRequest(_)));
let refresh_error = adapter
.refresh(&executor, &ctx, &oauth_account("gemini_cli"))
.await
.expect_err("refresh must fail without the configured secret");
assert!(matches!(refresh_error, OAuthError::InvalidRequest(_)));
assert!(
seen_request.lock().expect("mutex should lock").is_none(),
"credential validation must happen before the HTTP executor runs"
);
}
#[tokio::test]
async fn google_oauth_injected_credentials_are_sent_in_token_form() {
let seen_request = Arc::new(Mutex::new(None));
let executor = StaticExecutor {
seen_request: Arc::clone(&seen_request),
response_payload: json!({
"access_token": "new-access-token",
"expires_in": 3600,
}),
};
let adapter = GenericProviderOAuthAdapter::for_provider_type("gemini_cli")
.expect("gemini adapter")
.with_oauth_credentials_for_tests("test-client-id", "test-client-secret");
let ctx = transport_context("gemini_cli");
adapter
.refresh(&executor, &ctx, &oauth_account("gemini_cli"))
.await
.expect("refresh should succeed");
let seen = seen_request
.lock()
.expect("mutex should lock")
.clone()
.expect("request should be captured");
let form = String::from_utf8(seen.body_bytes.expect("form body should exist"))
.expect("form body should be utf8");
let fields = url::form_urlencoded::parse(form.as_bytes())
.into_owned()
.collect::<BTreeMap<_, _>>();
assert_eq!(
fields.get("client_id").map(String::as_str),
Some("test-client-id")
);
assert_eq!(
fields.get("client_secret").map(String::as_str),
Some("test-client-secret")
);
assert_eq!(
fields.get("refresh_token").map(String::as_str),
Some("old-refresh-token")
);
}
#[tokio::test]
async fn refresh_preserves_existing_metadata_when_refresh_token_is_not_rotated() {
let seen_request = Arc::new(Mutex::new(None));
@@ -1,4 +1,4 @@
use crate::core::{current_unix_secs, OAuthError};
use crate::core::{current_unix_secs, redacted_oauth_error_body_excerpt, OAuthError};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
use crate::provider::ProviderOAuthAdapter;
use crate::provider::{
@@ -18,7 +18,7 @@ pub const DEFAULT_SYSTEM_VERSION: &str = "other#unknown";
const IDC_AMZ_USER_AGENT: &str =
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct KiroAuthConfig {
pub auth_method: Option<String>,
pub refresh_token: Option<String>,
@@ -36,6 +36,75 @@ pub struct KiroAuthConfig {
pub access_token: Option<String>,
}
impl std::fmt::Debug for KiroAuthConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("KiroAuthConfig")
.field("auth_method", &self.auth_method)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("expires_at", &self.expires_at)
.field(
"profile_arn",
&self.profile_arn.as_ref().map(|_| "[REDACTED]"),
)
.field("region", &self.region)
.field("auth_region", &self.auth_region)
.field("api_region", &self.api_region)
.field("client_id", &self.client_id.as_ref().map(|_| "[REDACTED]"))
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
)
.field(
"machine_id",
&self.machine_id.as_ref().map(|_| "[REDACTED]"),
)
.field("kiro_version", &self.kiro_version)
.field("system_version", &self.system_version)
.field("node_version", &self.node_version)
.field(
"access_token",
&self.access_token.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
/// Returns whether a value is safe to interpolate as one DNS label in a Kiro
/// service hostname.
///
/// Region values are persisted in the encrypted auth configuration and can
/// also come from an upstream OAuth response. They must never be treated as
/// URL syntax: accepting `/`, `.`, `?`, `#`, `@`, or control characters here
/// would let a crafted region redirect a token-bearing request to another
/// origin. AWS region names are DNS labels, so an ASCII alphanumeric/hyphen
/// allow-list is both stricter and forward-compatible with future regions.
pub fn is_valid_kiro_region(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value.len() <= 63
&& !value.starts_with('-')
&& !value.ends_with('-')
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
/// Trims a region and falls back to the known-safe default when it is not a
/// single DNS label. The returned value is suitable for URL and `Host`
/// header interpolation.
pub fn normalize_kiro_region(value: &str) -> &str {
let value = value.trim();
if is_valid_kiro_region(value) {
value
} else {
DEFAULT_REGION
}
}
impl KiroAuthConfig {
pub fn from_json_value(value: &Value) -> Option<Self> {
let object = value.as_object()?;
@@ -93,19 +162,22 @@ impl KiroAuthConfig {
}
pub fn effective_auth_region(&self) -> &str {
self.auth_region
.as_deref()
.or(self.region.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_REGION)
for candidate in [self.auth_region.as_deref(), self.region.as_deref()] {
let Some(candidate) = candidate else {
continue;
};
let candidate = candidate.trim();
if is_valid_kiro_region(candidate) {
return candidate;
}
}
DEFAULT_REGION
}
pub fn effective_api_region(&self) -> &str {
self.api_region
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(normalize_kiro_region)
.unwrap_or(DEFAULT_REGION)
}
@@ -304,7 +376,7 @@ impl KiroProviderOAuthAdapter {
if !(200..300).contains(&response.status_code) {
return Err(OAuthError::HttpStatus {
status_code: response.status_code,
body_excerpt: response.body_text.chars().take(500).collect(),
body_excerpt: redacted_oauth_error_body_excerpt(&response.body_text),
});
}
let payload = response
@@ -383,7 +455,7 @@ impl KiroProviderOAuthAdapter {
if !(200..300).contains(&response.status_code) {
return Err(OAuthError::HttpStatus {
status_code: response.status_code,
body_excerpt: response.body_text.chars().take(500).collect(),
body_excerpt: redacted_oauth_error_body_excerpt(&response.body_text),
});
}
let payload = response
@@ -648,7 +720,8 @@ fn secret_fingerprint(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::{
generate_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter, IDC_AMZ_USER_AGENT,
generate_kiro_machine_id, is_valid_kiro_region, normalize_kiro_region, KiroAuthConfig,
KiroProviderOAuthAdapter, IDC_AMZ_USER_AGENT,
};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::ProviderOAuthTransportContext;
@@ -662,6 +735,39 @@ mod tests {
response: serde_json::Value,
}
#[test]
fn kiro_auth_config_debug_output_redacts_all_token_material() {
let config = KiroAuthConfig {
auth_method: Some("idc".to_string()),
refresh_token: Some("kiro-refresh-canary".to_string()),
expires_at: Some(123),
profile_arn: Some("kiro-profile-arn-canary".to_string()),
region: Some("us-east-1".to_string()),
auth_region: None,
api_region: None,
client_id: Some("kiro-client-id-canary".to_string()),
client_secret: Some("kiro-client-secret-canary".to_string()),
machine_id: Some("kiro-machine-id-canary".to_string()),
kiro_version: None,
system_version: None,
node_version: None,
access_token: Some("kiro-access-canary".to_string()),
};
let debug = format!("{config:?}");
for secret in [
"kiro-refresh-canary",
"kiro-client-id-canary",
"kiro-client-secret-canary",
"kiro-access-canary",
"kiro-profile-arn-canary",
"kiro-machine-id-canary",
] {
assert!(!debug.contains(secret), "debug leaked {secret}");
}
assert!(debug.contains("[REDACTED]"));
}
#[async_trait]
impl OAuthHttpExecutor for StaticExecutor {
async fn execute(
@@ -717,6 +823,106 @@ mod tests {
);
}
#[test]
fn rejects_region_values_that_can_escape_a_hostname() {
for value in [
"evil.example/",
"evil.example\\",
"evil.example?next=1",
"evil.example#fragment",
"evil@example",
"us-east-1\r\nX-Injected: yes",
"us.east.1",
] {
assert!(
!is_valid_kiro_region(value),
"region should be rejected: {value:?}"
);
assert_eq!(normalize_kiro_region(value), super::DEFAULT_REGION);
}
for value in [
"us-east-1",
"us-gov-west-1",
"us-iso-east-1",
"eu-central-1",
] {
assert!(
is_valid_kiro_region(value),
"region should be accepted: {value}"
);
assert_eq!(normalize_kiro_region(value), value);
}
}
#[test]
fn effective_regions_fall_back_when_auth_config_is_malicious() {
let auth_config = KiroAuthConfig {
auth_method: None,
refresh_token: None,
expires_at: None,
profile_arn: None,
region: Some("eu-west-1".to_string()),
auth_region: Some("evil.example/".to_string()),
api_region: Some("evil.example/".to_string()),
client_id: None,
client_secret: None,
machine_id: None,
kiro_version: None,
system_version: None,
node_version: None,
access_token: None,
};
assert_eq!(auth_config.effective_auth_region(), "eu-west-1");
assert_eq!(auth_config.effective_api_region(), super::DEFAULT_REGION);
}
#[tokio::test]
async fn refresh_urls_use_safe_default_for_malicious_auth_region() {
let seen_request = Arc::new(Mutex::new(None));
let executor = StaticExecutor {
seen_request: Arc::clone(&seen_request),
response: json!({
"accessToken": "new-access-token",
"refreshToken": "r".repeat(120),
"expiresIn": 3600
}),
};
let auth_config = KiroAuthConfig {
auth_method: Some("social".to_string()),
refresh_token: Some("r".repeat(120)),
expires_at: Some(1),
profile_arn: None,
region: None,
auth_region: Some("attacker.example/".to_string()),
api_region: None,
client_id: None,
client_secret: None,
machine_id: Some("machine".to_string()),
kiro_version: None,
system_version: None,
node_version: None,
access_token: None,
};
KiroProviderOAuthAdapter::default()
.refresh_auth_config(&executor, &test_ctx(), &auth_config)
.await
.expect("refresh should succeed");
let seen = seen_request
.lock()
.expect("mutex should lock")
.clone()
.expect("request should be captured");
assert_eq!(
seen.url,
"https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"
);
assert!(!seen.url.contains("attacker.example"));
}
#[tokio::test]
async fn refreshes_social_auth_config_with_provider_adapter() {
let seen_request = Arc::new(Mutex::new(None));
@@ -14,12 +14,14 @@ pub use claude_code::{
pub use codex::CodexProviderOAuthAdapter;
pub use generic::{
derive_codex_identity_fingerprint, GenericProviderOAuthAdapter, GenericProviderOAuthTemplate,
ANTIGRAVITY_OAUTH_CLIENT_ID_ENV, ANTIGRAVITY_OAUTH_CLIENT_SECRET_ENV,
GEMINI_CLI_OAUTH_CLIENT_ID_ENV, GEMINI_CLI_OAUTH_CLIENT_SECRET_ENV,
GENERIC_PROVIDER_OAUTH_TEMPLATES,
};
pub use kiro::{
generate_kiro_machine_id, normalize_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter,
DEFAULT_KIRO_VERSION, DEFAULT_NODE_VERSION, DEFAULT_REGION, DEFAULT_SYSTEM_VERSION,
KIRO_PROVIDER_TYPE,
generate_kiro_machine_id, is_valid_kiro_region, normalize_kiro_machine_id,
normalize_kiro_region, KiroAuthConfig, KiroProviderOAuthAdapter, DEFAULT_KIRO_VERSION,
DEFAULT_NODE_VERSION, DEFAULT_REGION, DEFAULT_SYSTEM_VERSION, KIRO_PROVIDER_TYPE,
};
pub use windsurf::{
WindsurfProviderOAuthAdapter, WINDSURF_CLIENT_ID, WINDSURF_PROVIDER_TYPE,