Add shared OAuth flows

This commit is contained in:
fawney19
2026-04-28 15:46:21 +08:00
parent 712b484bc8
commit 70f747d406
56 changed files with 5927 additions and 977 deletions

View File

@@ -0,0 +1,23 @@
[package]
name = "aether-oauth"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Shared OAuth abstractions for Aether identity and provider account flows"
[dependencies]
aether-contracts.workspace = true
async-trait.workspace = true
base64.workspace = true
http.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio.workspace = true

View File

@@ -0,0 +1,38 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OAuthError {
#[error("unsupported oauth provider: {0}")]
UnsupportedProvider(String),
#[error("invalid oauth request: {0}")]
InvalidRequest(String),
#[error("oauth state is invalid or expired")]
InvalidState,
#[error("oauth provider returned HTTP {status_code}: {body_excerpt}")]
HttpStatus {
status_code: u16,
body_excerpt: String,
},
#[error("oauth provider returned invalid response: {0}")]
InvalidResponse(String),
#[error("oauth transport failed: {0}")]
Transport(String),
#[error("oauth storage failed: {0}")]
Storage(String),
#[error("oauth encryption failed")]
EncryptionUnavailable,
}
impl OAuthError {
pub fn invalid_request(detail: impl Into<String>) -> Self {
Self::InvalidRequest(detail.into())
}
pub fn invalid_response(detail: impl Into<String>) -> Self {
Self::InvalidResponse(detail.into())
}
pub fn transport(detail: impl Into<String>) -> Self {
Self::Transport(detail.into())
}
}

View File

@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthProviderMetadata {
pub provider_type: String,
pub display_name: String,
pub authorize_url: String,
pub token_url: String,
pub client_id: String,
pub client_secret: Option<String>,
pub scopes: Vec<String>,
pub redirect_uri: String,
pub use_pkce: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthAuthorizeRequest {
pub state: String,
pub code_challenge: Option<String>,
pub prompt: Option<String>,
pub login_hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OAuthAuthorizeResponse {
pub authorize_url: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthCallback {
pub code: String,
pub state: String,
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct OAuthDeviceAuthorization {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}

View File

@@ -0,0 +1,16 @@
mod error;
mod flow;
mod pkce;
mod registry;
mod token;
pub use error::OAuthError;
pub use flow::{
OAuthAuthorizeRequest, OAuthAuthorizeResponse, OAuthCallback, OAuthDeviceAuthorization,
OAuthProviderMetadata,
};
pub use pkce::{
generate_oauth_nonce, generate_pkce_verifier, parse_oauth_callback_params, pkce_s256,
};
pub use registry::OAuthAdapterRegistry;
pub use token::{current_unix_secs, OAuthTokenSet};

View File

@@ -0,0 +1,86 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use url::{form_urlencoded, Url};
use uuid::Uuid;
pub fn generate_oauth_nonce() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
pub fn generate_pkce_verifier() -> String {
format!(
"{}{}{}",
Uuid::new_v4().simple(),
Uuid::new_v4().simple(),
Uuid::new_v4().simple()
)
}
pub fn pkce_s256(verifier: &str) -> String {
let digest = Sha256::digest(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
pub fn parse_oauth_callback_params(callback_url: &str) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
let Ok(url) = Url::parse(callback_url.trim()) else {
return merged;
};
for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) {
merged.insert(key.into_owned(), value.into_owned());
}
if let Some(fragment) = url.fragment() {
for (key, value) in form_urlencoded::parse(fragment.trim_start_matches('#').as_bytes()) {
merged.insert(key.into_owned(), value.into_owned());
}
}
if let Some(code) = merged.get("code").cloned() {
if let Some((code_part, state_part)) = code.split_once('#') {
merged.insert("code".to_string(), code_part.to_string());
if !merged.contains_key("state") && !state_part.is_empty() {
let normalized_state = state_part
.strip_prefix("state=")
.unwrap_or(state_part)
.trim();
if !normalized_state.is_empty() {
merged.insert("state".to_string(), normalized_state.to_string());
}
}
}
}
merged
}
#[cfg(test)]
mod tests {
use super::{parse_oauth_callback_params, pkce_s256};
#[test]
fn parses_query_and_fragment_callback_params() {
let params = parse_oauth_callback_params(
"http://localhost/callback?code=query&state=old#code=fragment&scope=email",
);
assert_eq!(params.get("code").map(String::as_str), Some("fragment"));
assert_eq!(params.get("state").map(String::as_str), Some("old"));
assert_eq!(params.get("scope").map(String::as_str), Some("email"));
}
#[test]
fn extracts_state_from_code_suffix() {
let params =
parse_oauth_callback_params("http://localhost/callback?code=abc%23state=state-1");
assert_eq!(params.get("code").map(String::as_str), Some("abc"));
assert_eq!(params.get("state").map(String::as_str), Some("state-1"));
}
#[test]
fn pkce_s256_is_url_safe() {
let value = pkce_s256("verifier");
assert!(!value.contains('+'));
assert!(!value.contains('/'));
assert!(!value.contains('='));
}
}

View File

@@ -0,0 +1,54 @@
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
pub struct OAuthAdapterRegistry<T: ?Sized> {
adapters: BTreeMap<String, Arc<T>>,
}
impl<T: ?Sized> fmt::Debug for OAuthAdapterRegistry<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthAdapterRegistry")
.field("provider_types", &self.adapters.keys().collect::<Vec<_>>())
.finish()
}
}
impl<T: ?Sized> Clone for OAuthAdapterRegistry<T> {
fn clone(&self) -> Self {
Self {
adapters: self.adapters.clone(),
}
}
}
impl<T: ?Sized> Default for OAuthAdapterRegistry<T> {
fn default() -> Self {
Self {
adapters: BTreeMap::new(),
}
}
}
impl<T: ?Sized> OAuthAdapterRegistry<T> {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, provider_type: &str, adapter: Arc<T>) {
let key = provider_type.trim().to_ascii_lowercase();
if !key.is_empty() {
self.adapters.insert(key, adapter);
}
}
pub fn get(&self, provider_type: &str) -> Option<Arc<T>> {
self.adapters
.get(provider_type.trim().to_ascii_lowercase().as_str())
.cloned()
}
pub fn provider_types(&self) -> impl Iterator<Item = &str> {
self.adapters.keys().map(String::as_str)
}
}

View File

@@ -0,0 +1,112 @@
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq)]
pub struct OAuthTokenSet {
pub access_token: String,
pub refresh_token: Option<String>,
pub token_type: Option<String>,
pub scope: Option<String>,
pub expires_at_unix_secs: Option<u64>,
pub raw_payload: Option<Value>,
}
impl OAuthTokenSet {
pub fn from_token_payload(payload: Value) -> Option<Self> {
let access_token = non_empty_string(payload.get("access_token"))
.or_else(|| non_empty_string(payload.get("accessToken")))?;
let expires_at_unix_secs = json_u64(
payload
.get("expires_in")
.or_else(|| payload.get("expiresIn")),
)
.map(|expires_in| current_unix_secs().saturating_add(expires_in))
.or_else(|| {
json_u64(
payload
.get("expires_at")
.or_else(|| payload.get("expiresAt")),
)
});
Some(Self {
access_token,
refresh_token: non_empty_string(
payload
.get("refresh_token")
.or_else(|| payload.get("refreshToken")),
),
token_type: non_empty_string(
payload
.get("token_type")
.or_else(|| payload.get("tokenType")),
),
scope: non_empty_string(payload.get("scope")),
expires_at_unix_secs,
raw_payload: Some(payload),
})
}
pub fn bearer_header_value(&self) -> String {
format!("Bearer {}", self.access_token.trim())
}
pub fn requires_refresh(&self, skew_secs: u64) -> bool {
self.expires_at_unix_secs
.map(|expires_at| current_unix_secs() >= expires_at.saturating_sub(skew_secs))
.unwrap_or(false)
}
pub fn rotated_refresh_token<'a>(&'a self, existing: Option<&'a str>) -> Option<&'a str> {
self.refresh_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| existing.map(str::trim).filter(|value| !value.is_empty()))
}
}
pub fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|value| value.as_secs())
.unwrap_or_default()
}
fn non_empty_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn json_u64(value: Option<&Value>) -> Option<u64> {
match value? {
Value::Number(number) => number.as_u64(),
Value::String(value) => value.trim().parse::<u64>().ok(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::OAuthTokenSet;
use serde_json::json;
#[test]
fn parses_token_payload_and_preserves_refresh_token() {
let token = OAuthTokenSet::from_token_payload(json!({
"access_token": "access",
"refresh_token": "refresh",
"expires_in": 3600
}))
.expect("token should parse");
assert_eq!(token.access_token, "access");
assert_eq!(token.refresh_token.as_deref(), Some("refresh"));
assert!(token.expires_at_unix_secs.is_some());
assert_eq!(token.bearer_header_value(), "Bearer access");
}
}

View File

@@ -0,0 +1,126 @@
use crate::core::{OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
use crate::network::{OAuthHttpExecutor, OAuthNetworkContext};
use async_trait::async_trait;
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityOAuthProviderConfig {
pub provider_type: String,
pub display_name: String,
pub authorization_url: String,
pub token_url: String,
pub userinfo_url: Option<String>,
pub client_id: String,
pub client_secret: Option<String>,
pub scopes: Vec<String>,
pub redirect_uri: String,
pub frontend_callback_url: String,
pub attribute_mapping: Option<Value>,
pub extra_config: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityOAuthStartContext {
pub state: String,
pub code_challenge: Option<String>,
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityOAuthExchangeContext {
pub code: String,
pub state: String,
pub pkce_verifier: Option<String>,
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExternalIdentity {
pub provider_type: String,
pub subject: String,
pub email: Option<String>,
pub username: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub raw: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityClaims {
pub provider_type: String,
pub subject: String,
pub email: Option<String>,
pub username: Option<String>,
pub display_name: Option<String>,
pub raw: Value,
}
#[async_trait]
pub trait IdentityOAuthProvider: Send + Sync {
fn provider_type(&self) -> &'static str;
fn build_authorize_url(
&self,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthStartContext,
) -> Result<OAuthAuthorizeResponse, OAuthError>;
async fn exchange_code(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<OAuthTokenSet, OAuthError>;
async fn fetch_identity(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
tokens: &OAuthTokenSet,
network: OAuthNetworkContext,
) -> Result<ExternalIdentity, OAuthError>;
fn map_identity(
&self,
config: &IdentityOAuthProviderConfig,
identity: ExternalIdentity,
) -> Result<IdentityClaims, OAuthError>;
}
pub(crate) fn mapped_string(
raw: &Value,
mapping: Option<&Value>,
logical_key: &str,
) -> Option<String> {
let mapped_key = mapping
.and_then(Value::as_object)
.and_then(|object| object.get(logical_key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(logical_key);
find_string(raw, mapped_key)
}
pub(crate) fn find_string(raw: &Value, key: &str) -> Option<String> {
let mut current = raw;
for segment in key.split('.') {
current = current.get(segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub(crate) fn form_headers() -> BTreeMap<String, String> {
BTreeMap::from([
(
"content-type".to_string(),
"application/x-www-form-urlencoded".to_string(),
),
("accept".to_string(), "application/json".to_string()),
])
}

View File

@@ -0,0 +1,12 @@
mod adapter;
pub mod providers;
mod service;
pub use adapter::{
ExternalIdentity, IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
};
pub use service::{
bind_oauth_identity, login_with_oauth, start_identity_oauth, BoundOAuthIdentity,
IdentityOAuthService, OAuthLoginOutcome,
};

View File

@@ -0,0 +1,167 @@
use super::super::adapter::{find_string, form_headers, mapped_string};
use crate::core::{OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
use crate::identity::{
ExternalIdentity, IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthNetworkContext};
use async_trait::async_trait;
use url::form_urlencoded;
#[derive(Debug, Clone, Default)]
pub struct CustomOidcIdentityOAuthProvider;
#[async_trait]
impl IdentityOAuthProvider for CustomOidcIdentityOAuthProvider {
fn provider_type(&self) -> &'static str {
"custom_oidc"
}
fn build_authorize_url(
&self,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthStartContext,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
let mut url = url::Url::parse(&config.authorization_url)
.map_err(|_| OAuthError::invalid_request("authorization_url must be absolute"))?;
{
let mut query = url.query_pairs_mut();
query.append_pair("response_type", "code");
query.append_pair("client_id", &config.client_id);
query.append_pair("redirect_uri", &config.redirect_uri);
query.append_pair("state", &ctx.state);
if !config.scopes.is_empty() {
query.append_pair("scope", &config.scopes.join(" "));
}
if let Some(challenge) = ctx.code_challenge.as_deref() {
query.append_pair("code_challenge", challenge);
query.append_pair("code_challenge_method", "S256");
}
}
Ok(OAuthAuthorizeResponse {
authorize_url: url.to_string(),
state: ctx.state.clone(),
code_challenge: ctx.code_challenge.clone(),
})
}
async fn exchange_code(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<OAuthTokenSet, OAuthError> {
let body_bytes = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "authorization_code");
form.append_pair("client_id", &config.client_id);
form.append_pair("redirect_uri", &config.redirect_uri);
form.append_pair("code", &ctx.code);
if let Some(secret) = config
.client_secret
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
form.append_pair("client_secret", secret);
}
if let Some(verifier) = ctx.pkce_verifier.as_deref() {
form.append_pair("code_verifier", verifier);
}
form.finish().into_bytes()
};
let response = executor
.execute(OAuthHttpRequest {
request_id: format!("identity-oauth:{}:exchange-code", config.provider_type),
method: reqwest::Method::POST,
url: config.token_url.clone(),
headers: form_headers(),
content_type: Some("application/x-www-form-urlencoded".to_string()),
json_body: None,
body_bytes: Some(body_bytes),
network: ctx.network.clone(),
})
.await?;
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(),
});
}
let payload = response
.json_body
.or_else(|| serde_json::from_str(&response.body_text).ok())
.ok_or_else(|| OAuthError::invalid_response("token response is not json"))?;
OAuthTokenSet::from_token_payload(payload)
.ok_or_else(|| OAuthError::invalid_response("token response missing access_token"))
}
async fn fetch_identity(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
tokens: &OAuthTokenSet,
network: OAuthNetworkContext,
) -> Result<ExternalIdentity, OAuthError> {
let userinfo_url = config
.userinfo_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| OAuthError::invalid_request("userinfo_url is required"))?;
let response = executor
.execute(OAuthHttpRequest {
request_id: format!("identity-oauth:{}:userinfo", config.provider_type),
method: reqwest::Method::GET,
url: userinfo_url.to_string(),
headers: std::collections::BTreeMap::from([
("authorization".to_string(), tokens.bearer_header_value()),
("accept".to_string(), "application/json".to_string()),
]),
content_type: None,
json_body: None,
body_bytes: None,
network,
})
.await?;
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(),
});
}
let raw = response
.json_body
.or_else(|| serde_json::from_str(&response.body_text).ok())
.ok_or_else(|| OAuthError::invalid_response("userinfo response is not json"))?;
let subject = mapped_string(&raw, config.attribute_mapping.as_ref(), "sub")
.or_else(|| find_string(&raw, "id"))
.ok_or_else(|| OAuthError::invalid_response("userinfo response missing subject"))?;
Ok(ExternalIdentity {
provider_type: config.provider_type.clone(),
subject,
email: mapped_string(&raw, config.attribute_mapping.as_ref(), "email"),
username: mapped_string(&raw, config.attribute_mapping.as_ref(), "username"),
display_name: mapped_string(&raw, config.attribute_mapping.as_ref(), "display_name")
.or_else(|| find_string(&raw, "name")),
avatar_url: mapped_string(&raw, config.attribute_mapping.as_ref(), "avatar_url"),
raw,
})
}
fn map_identity(
&self,
config: &IdentityOAuthProviderConfig,
identity: ExternalIdentity,
) -> Result<IdentityClaims, OAuthError> {
Ok(IdentityClaims {
provider_type: config.provider_type.clone(),
subject: identity.subject,
email: identity.email,
username: identity.username,
display_name: identity.display_name,
raw: identity.raw,
})
}
}

View File

@@ -0,0 +1,57 @@
use super::custom_oidc::CustomOidcIdentityOAuthProvider;
use crate::core::{OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
use crate::identity::{
ExternalIdentity, IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
};
use crate::network::{OAuthHttpExecutor, OAuthNetworkContext};
use async_trait::async_trait;
#[derive(Debug, Clone, Default)]
pub struct LinuxDoIdentityOAuthProvider {
inner: CustomOidcIdentityOAuthProvider,
}
#[async_trait]
impl IdentityOAuthProvider for LinuxDoIdentityOAuthProvider {
fn provider_type(&self) -> &'static str {
"linuxdo"
}
fn build_authorize_url(
&self,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthStartContext,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
self.inner.build_authorize_url(config, ctx)
}
async fn exchange_code(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<OAuthTokenSet, OAuthError> {
self.inner.exchange_code(executor, config, ctx).await
}
async fn fetch_identity(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
tokens: &OAuthTokenSet,
network: OAuthNetworkContext,
) -> Result<ExternalIdentity, OAuthError> {
self.inner
.fetch_identity(executor, config, tokens, network)
.await
}
fn map_identity(
&self,
config: &IdentityOAuthProviderConfig,
identity: ExternalIdentity,
) -> Result<IdentityClaims, OAuthError> {
self.inner.map_identity(config, identity)
}
}

View File

@@ -0,0 +1,5 @@
mod custom_oidc;
mod linuxdo;
pub use custom_oidc::CustomOidcIdentityOAuthProvider;
pub use linuxdo::LinuxDoIdentityOAuthProvider;

View File

@@ -0,0 +1,137 @@
use super::{
IdentityClaims, IdentityOAuthExchangeContext, IdentityOAuthProvider,
IdentityOAuthProviderConfig, IdentityOAuthStartContext,
};
use crate::core::{OAuthAdapterRegistry, OAuthAuthorizeResponse, OAuthError};
use crate::network::OAuthHttpExecutor;
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub struct IdentityOAuthService {
registry: OAuthAdapterRegistry<dyn IdentityOAuthProvider>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OAuthLoginOutcome {
pub claims: IdentityClaims,
pub is_new_external_identity: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BoundOAuthIdentity {
pub claims: IdentityClaims,
pub replaced_existing_binding: bool,
}
impl IdentityOAuthService {
pub fn new() -> Self {
Self::default()
}
pub fn with_builtin_providers() -> Self {
use super::providers::{CustomOidcIdentityOAuthProvider, LinuxDoIdentityOAuthProvider};
Self::new()
.with_provider(Arc::new(LinuxDoIdentityOAuthProvider::default()))
.with_provider(Arc::new(CustomOidcIdentityOAuthProvider))
}
pub fn with_provider(mut self, provider: Arc<dyn IdentityOAuthProvider>) -> Self {
self.registry.insert(provider.provider_type(), provider);
self
}
pub fn provider(
&self,
provider_type: &str,
) -> Result<Arc<dyn IdentityOAuthProvider>, OAuthError> {
self.registry
.get(provider_type)
.ok_or_else(|| OAuthError::UnsupportedProvider(provider_type.to_string()))
}
pub fn start(
&self,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthStartContext,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
self.provider(&config.provider_type)?
.build_authorize_url(config, ctx)
}
pub async fn login(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<OAuthLoginOutcome, OAuthError> {
let provider = self.provider(&config.provider_type)?;
login_with_oauth(provider.as_ref(), executor, config, ctx).await
}
pub async fn bind(
&self,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<BoundOAuthIdentity, OAuthError> {
let provider = self.provider(&config.provider_type)?;
bind_oauth_identity(provider.as_ref(), executor, config, ctx).await
}
}
pub fn start_identity_oauth(
provider: &dyn IdentityOAuthProvider,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthStartContext,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
provider.build_authorize_url(config, ctx)
}
pub async fn login_with_oauth(
provider: &dyn IdentityOAuthProvider,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<OAuthLoginOutcome, OAuthError> {
let tokens = provider.exchange_code(executor, config, ctx).await?;
let identity = provider
.fetch_identity(executor, config, &tokens, ctx.network.clone())
.await?;
let claims = provider.map_identity(config, identity)?;
Ok(OAuthLoginOutcome {
claims,
is_new_external_identity: false,
})
}
pub async fn bind_oauth_identity(
provider: &dyn IdentityOAuthProvider,
executor: &dyn OAuthHttpExecutor,
config: &IdentityOAuthProviderConfig,
ctx: &IdentityOAuthExchangeContext,
) -> Result<BoundOAuthIdentity, OAuthError> {
let tokens = provider.exchange_code(executor, config, ctx).await?;
let identity = provider
.fetch_identity(executor, config, &tokens, ctx.network.clone())
.await?;
let claims = provider.map_identity(config, identity)?;
Ok(BoundOAuthIdentity {
claims,
replaced_existing_binding: false,
})
}
#[cfg(test)]
mod tests {
use super::IdentityOAuthService;
#[test]
fn builtin_identity_service_registers_login_and_custom_oidc_providers() {
let service = IdentityOAuthService::with_builtin_providers();
assert!(service.provider("linuxdo").is_ok());
assert!(service.provider("custom_oidc").is_ok());
assert!(service.provider("missing").is_err());
}
}

View File

@@ -0,0 +1,14 @@
pub mod core;
pub mod identity;
pub mod network;
pub mod provider;
pub use core::{
current_unix_secs, generate_oauth_nonce, generate_pkce_verifier, parse_oauth_callback_params,
pkce_s256, OAuthAdapterRegistry, OAuthAuthorizeRequest, OAuthAuthorizeResponse, OAuthCallback,
OAuthError, OAuthProviderMetadata, OAuthTokenSet,
};
pub use network::{
NetworkRequirement, OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse,
OAuthNetworkContext, OAuthNetworkPolicy, OAuthTimeouts,
};

View File

@@ -0,0 +1,72 @@
use aether_contracts::ProxySnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthNetworkPolicy {
DirectOnly,
DirectOrSystemProxy,
ProviderOperationProxy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkRequirement {
Optional,
RequiredProxyNode,
RequiredConfiguredProxy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OAuthTimeouts {
pub connect_ms: u64,
pub read_ms: u64,
pub write_ms: u64,
pub total_ms: u64,
}
impl OAuthTimeouts {
pub const DIRECT_DEFAULT: Self = Self {
connect_ms: 30_000,
read_ms: 30_000,
write_ms: 30_000,
total_ms: 30_000,
};
pub const PROXY_DEFAULT: Self = Self {
connect_ms: 60_000,
read_ms: 60_000,
write_ms: 60_000,
total_ms: 60_000,
};
}
#[derive(Debug, Clone, PartialEq)]
pub struct OAuthNetworkContext {
pub policy: OAuthNetworkPolicy,
pub requirement: NetworkRequirement,
pub proxy: Option<ProxySnapshot>,
pub timeouts: OAuthTimeouts,
}
impl OAuthNetworkContext {
pub fn direct_identity() -> Self {
Self {
policy: OAuthNetworkPolicy::DirectOrSystemProxy,
requirement: NetworkRequirement::Optional,
proxy: None,
timeouts: OAuthTimeouts::DIRECT_DEFAULT,
}
}
pub fn provider_operation(proxy: Option<ProxySnapshot>) -> Self {
let timeouts = if proxy.is_some() {
OAuthTimeouts::PROXY_DEFAULT
} else {
OAuthTimeouts::DIRECT_DEFAULT
};
Self {
policy: OAuthNetworkPolicy::ProviderOperationProxy,
requirement: NetworkRequirement::Optional,
proxy,
timeouts,
}
}
}

View File

@@ -0,0 +1,74 @@
use crate::core::OAuthError;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::BTreeMap;
use super::OAuthNetworkContext;
#[derive(Debug, Clone, PartialEq)]
pub struct OAuthHttpRequest {
pub request_id: String,
pub method: reqwest::Method,
pub url: String,
pub headers: BTreeMap<String, String>,
pub content_type: Option<String>,
pub json_body: Option<Value>,
pub body_bytes: Option<Vec<u8>>,
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OAuthHttpResponse {
pub status_code: u16,
pub body_text: String,
pub json_body: Option<Value>,
}
#[async_trait]
pub trait OAuthHttpExecutor: Send + Sync {
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError>;
}
#[derive(Debug, Clone)]
pub struct ReqwestOAuthHttpExecutor {
client: reqwest::Client,
}
impl ReqwestOAuthHttpExecutor {
pub fn new(client: reqwest::Client) -> Self {
Self { client }
}
}
#[async_trait]
impl OAuthHttpExecutor for ReqwestOAuthHttpExecutor {
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError> {
let mut builder = self
.client
.request(request.method.clone(), request.url.as_str());
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
if let Some(json_body) = request.json_body.as_ref() {
builder = builder.json(json_body);
} else if let Some(body_bytes) = request.body_bytes.as_ref() {
builder = builder.body(body_bytes.clone());
}
let response = builder
.send()
.await
.map_err(|err| OAuthError::transport(err.to_string()))?;
let status_code = response.status().as_u16();
let body_text = response
.text()
.await
.map_err(|err| OAuthError::transport(err.to_string()))?;
let json_body = serde_json::from_str::<Value>(&body_text).ok();
Ok(OAuthHttpResponse {
status_code,
body_text,
json_body,
})
}
}

View File

@@ -0,0 +1,7 @@
mod context;
mod executor;
pub use context::{NetworkRequirement, OAuthNetworkContext, OAuthNetworkPolicy, OAuthTimeouts};
pub use executor::{
OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse, ReqwestOAuthHttpExecutor,
};

View File

@@ -0,0 +1,96 @@
use crate::core::OAuthTokenSet;
use crate::network::OAuthNetworkContext;
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderOAuthCapabilities {
pub supports_authorization_code: bool,
pub supports_refresh_token_import: bool,
pub supports_batch_import: bool,
pub supports_device_flow: bool,
pub supports_account_probe: bool,
pub rotates_refresh_token: bool,
}
impl ProviderOAuthCapabilities {
pub const GENERIC_AUTH_CODE: Self = Self {
supports_authorization_code: true,
supports_refresh_token_import: true,
supports_batch_import: true,
supports_device_flow: false,
supports_account_probe: false,
rotates_refresh_token: true,
};
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderOAuthTransportContext {
pub provider_id: String,
pub provider_type: String,
pub endpoint_id: Option<String>,
pub key_id: Option<String>,
pub auth_type: Option<String>,
pub decrypted_api_key: Option<String>,
pub decrypted_auth_config: Option<String>,
pub provider_config: Option<Value>,
pub endpoint_config: Option<Value>,
pub key_config: Option<Value>,
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderOAuthTokenSet {
pub token_set: OAuthTokenSet,
pub auth_config: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderOAuthAccount {
pub provider_type: String,
pub access_token: String,
pub auth_config: Value,
pub expires_at_unix_secs: Option<u64>,
pub identity: BTreeMap<String, Value>,
}
impl ProviderOAuthAccount {
pub fn request_bearer_auth(&self) -> ProviderOAuthRequestAuth {
ProviderOAuthRequestAuth::Header {
name: "authorization".to_string(),
value: format!("Bearer {}", self.access_token.trim()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProviderOAuthRequestAuth {
Header {
name: String,
value: String,
},
Kiro {
name: String,
value: String,
auth_config: Value,
machine_id: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderOAuthImportInput {
pub provider_type: String,
pub name: Option<String>,
pub refresh_token: Option<String>,
pub raw_credentials: Option<Value>,
pub network: OAuthNetworkContext,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderOAuthAccountState {
pub is_valid: bool,
pub email: Option<String>,
pub quota: Option<Value>,
pub invalid_reason: Option<String>,
pub raw: Option<Value>,
}

View File

@@ -0,0 +1,74 @@
use super::{
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
ProviderOAuthImportInput, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
ProviderOAuthTransportContext,
};
use crate::core::{OAuthAuthorizeResponse, OAuthError};
use crate::network::OAuthHttpExecutor;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderOAuthProbeResult {
pub state: ProviderOAuthAccountState,
}
#[async_trait]
pub trait ProviderOAuthAdapter: Send + Sync {
fn provider_type(&self) -> &'static str;
fn capabilities(&self) -> ProviderOAuthCapabilities;
fn build_authorize_url(
&self,
_ctx: &ProviderOAuthTransportContext,
_state: &str,
_code_challenge: Option<&str>,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
Err(OAuthError::UnsupportedProvider(
self.provider_type().to_string(),
))
}
async fn exchange_code(
&self,
_executor: &dyn OAuthHttpExecutor,
_ctx: &ProviderOAuthTransportContext,
_code: &str,
_state: &str,
_pkce_verifier: Option<&str>,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
Err(OAuthError::UnsupportedProvider(
self.provider_type().to_string(),
))
}
async fn import_credentials(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: ProviderOAuthImportInput,
) -> Result<ProviderOAuthTokenSet, OAuthError>;
async fn refresh(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthTokenSet, OAuthError>;
fn resolve_request_auth(
&self,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthRequestAuth, OAuthError>;
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String>;
async fn probe_account_state(
&self,
_executor: &dyn OAuthHttpExecutor,
_ctx: &ProviderOAuthTransportContext,
_account: &ProviderOAuthAccount,
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
Ok(None)
}
}

View File

@@ -0,0 +1,12 @@
mod account;
mod adapter;
pub mod providers;
mod service;
pub use account::{
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
ProviderOAuthImportInput, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
ProviderOAuthTransportContext,
};
pub use adapter::{ProviderOAuthAdapter, ProviderOAuthProbeResult};
pub use service::ProviderOAuthService;

View File

@@ -0,0 +1,168 @@
use super::generic::{
provider_account_state_from_metadata, template_for_provider_type, GenericProviderOAuthAdapter,
};
use crate::provider::ProviderOAuthAdapter;
#[derive(Debug, Clone)]
pub struct AntigravityProviderOAuthAdapter {
inner: GenericProviderOAuthAdapter,
}
impl Default for AntigravityProviderOAuthAdapter {
fn default() -> Self {
Self {
inner: GenericProviderOAuthAdapter::new(
template_for_provider_type("antigravity")
.expect("antigravity template should exist"),
),
}
}
}
#[async_trait::async_trait]
impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter {
fn provider_type(&self) -> &'static str {
self.inner.provider_type()
}
fn capabilities(&self) -> crate::provider::ProviderOAuthCapabilities {
crate::provider::ProviderOAuthCapabilities {
supports_account_probe: true,
..self.inner.capabilities()
}
}
fn build_authorize_url(
&self,
ctx: &crate::provider::ProviderOAuthTransportContext,
state: &str,
code_challenge: Option<&str>,
) -> Result<crate::core::OAuthAuthorizeResponse, crate::core::OAuthError> {
self.inner.build_authorize_url(ctx, state, code_challenge)
}
async fn exchange_code(
&self,
executor: &dyn crate::network::OAuthHttpExecutor,
ctx: &crate::provider::ProviderOAuthTransportContext,
code: &str,
state: &str,
pkce_verifier: Option<&str>,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner
.exchange_code(executor, ctx, code, state, pkce_verifier)
.await
}
async fn import_credentials(
&self,
executor: &dyn crate::network::OAuthHttpExecutor,
ctx: &crate::provider::ProviderOAuthTransportContext,
input: crate::provider::ProviderOAuthImportInput,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner.import_credentials(executor, ctx, input).await
}
async fn refresh(
&self,
executor: &dyn crate::network::OAuthHttpExecutor,
ctx: &crate::provider::ProviderOAuthTransportContext,
account: &crate::provider::ProviderOAuthAccount,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner.refresh(executor, ctx, account).await
}
fn resolve_request_auth(
&self,
account: &crate::provider::ProviderOAuthAccount,
) -> Result<crate::provider::ProviderOAuthRequestAuth, crate::core::OAuthError> {
self.inner.resolve_request_auth(account)
}
fn account_fingerprint(
&self,
account: &crate::provider::ProviderOAuthAccount,
) -> Option<String> {
self.inner.account_fingerprint(account)
}
async fn probe_account_state(
&self,
_executor: &dyn crate::network::OAuthHttpExecutor,
_ctx: &crate::provider::ProviderOAuthTransportContext,
account: &crate::provider::ProviderOAuthAccount,
) -> Result<Option<crate::provider::ProviderOAuthProbeResult>, crate::core::OAuthError> {
Ok(Some(provider_account_state_from_metadata(
"antigravity",
account,
)))
}
}
#[cfg(test)]
mod tests {
use super::AntigravityProviderOAuthAdapter;
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::{
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::json;
use std::collections::BTreeMap;
struct UnusedExecutor;
#[async_trait]
impl OAuthHttpExecutor for UnusedExecutor {
async fn execute(
&self,
_request: OAuthHttpRequest,
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
unreachable!("metadata probe should not execute network requests")
}
}
#[tokio::test]
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
let adapter = AntigravityProviderOAuthAdapter::default();
let ctx = ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: "antigravity".to_string(),
endpoint_id: None,
key_id: None,
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),
};
let account = ProviderOAuthAccount {
provider_type: "antigravity".to_string(),
access_token: "access-token".to_string(),
auth_config: json!({
"email": "ag@example.com",
"antigravity": {
"is_forbidden": true,
"forbidden_reason": "project blocked"
}
}),
expires_at_unix_secs: Some(2000),
identity: BTreeMap::new(),
};
let probe = adapter
.probe_account_state(&UnusedExecutor, &ctx, &account)
.await
.expect("probe should succeed")
.expect("probe should return state");
assert!(!probe.state.is_valid);
assert_eq!(probe.state.email.as_deref(), Some("ag@example.com"));
assert_eq!(
probe.state.invalid_reason.as_deref(),
Some("project blocked")
);
}
}

View File

@@ -0,0 +1,201 @@
use super::generic::{
provider_account_state_from_metadata, template_for_provider_type, GenericProviderOAuthAdapter,
};
use crate::provider::ProviderOAuthAdapter;
#[derive(Debug, Clone)]
pub struct CodexProviderOAuthAdapter {
inner: GenericProviderOAuthAdapter,
}
impl Default for CodexProviderOAuthAdapter {
fn default() -> Self {
Self {
inner: GenericProviderOAuthAdapter::new(
template_for_provider_type("codex").expect("codex template should exist"),
),
}
}
}
#[async_trait::async_trait]
impl ProviderOAuthAdapter for CodexProviderOAuthAdapter {
fn provider_type(&self) -> &'static str {
self.inner.provider_type()
}
fn capabilities(&self) -> crate::provider::ProviderOAuthCapabilities {
crate::provider::ProviderOAuthCapabilities {
supports_account_probe: true,
..self.inner.capabilities()
}
}
fn build_authorize_url(
&self,
ctx: &crate::provider::ProviderOAuthTransportContext,
state: &str,
code_challenge: Option<&str>,
) -> Result<crate::core::OAuthAuthorizeResponse, crate::core::OAuthError> {
let mut response = self.inner.build_authorize_url(ctx, state, code_challenge)?;
let mut url = url::Url::parse(&response.authorize_url)
.map_err(|_| crate::core::OAuthError::invalid_response("invalid authorize_url"))?;
{
let mut query = url.query_pairs_mut();
query.append_pair("prompt", "login");
query.append_pair("id_token_add_organizations", "true");
query.append_pair("codex_cli_simplified_flow", "true");
}
response.authorize_url = url.to_string();
Ok(response)
}
async fn exchange_code(
&self,
executor: &dyn crate::network::OAuthHttpExecutor,
ctx: &crate::provider::ProviderOAuthTransportContext,
code: &str,
state: &str,
pkce_verifier: Option<&str>,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner
.exchange_code(executor, ctx, code, state, pkce_verifier)
.await
}
async fn import_credentials(
&self,
executor: &dyn crate::network::OAuthHttpExecutor,
ctx: &crate::provider::ProviderOAuthTransportContext,
input: crate::provider::ProviderOAuthImportInput,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner.import_credentials(executor, ctx, input).await
}
async fn refresh(
&self,
executor: &dyn crate::network::OAuthHttpExecutor,
ctx: &crate::provider::ProviderOAuthTransportContext,
account: &crate::provider::ProviderOAuthAccount,
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
self.inner.refresh(executor, ctx, account).await
}
fn resolve_request_auth(
&self,
account: &crate::provider::ProviderOAuthAccount,
) -> Result<crate::provider::ProviderOAuthRequestAuth, crate::core::OAuthError> {
self.inner.resolve_request_auth(account)
}
fn account_fingerprint(
&self,
account: &crate::provider::ProviderOAuthAccount,
) -> Option<String> {
self.inner.account_fingerprint(account)
}
async fn probe_account_state(
&self,
_executor: &dyn crate::network::OAuthHttpExecutor,
_ctx: &crate::provider::ProviderOAuthTransportContext,
account: &crate::provider::ProviderOAuthAccount,
) -> Result<Option<crate::provider::ProviderOAuthProbeResult>, crate::core::OAuthError> {
Ok(Some(provider_account_state_from_metadata("codex", account)))
}
}
#[cfg(test)]
mod tests {
use super::CodexProviderOAuthAdapter;
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::{
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::json;
use std::collections::BTreeMap;
struct UnusedExecutor;
#[async_trait]
impl OAuthHttpExecutor for UnusedExecutor {
async fn execute(
&self,
_request: OAuthHttpRequest,
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
unreachable!("metadata probe should not execute network requests")
}
}
#[test]
fn codex_authorize_url_includes_cli_flow_hints() {
let adapter = CodexProviderOAuthAdapter::default();
let ctx = ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: "codex".to_string(),
endpoint_id: None,
key_id: None,
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),
};
let response = adapter
.build_authorize_url(&ctx, "state-1", Some("challenge-1"))
.expect("authorize url should build");
assert!(response.authorize_url.contains("prompt=login"));
assert!(response
.authorize_url
.contains("id_token_add_organizations=true"));
assert!(response
.authorize_url
.contains("codex_cli_simplified_flow=true"));
}
#[tokio::test]
async fn codex_probe_reports_metadata_quota_and_email() {
let adapter = CodexProviderOAuthAdapter::default();
let ctx = ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: "codex".to_string(),
endpoint_id: None,
key_id: None,
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),
};
let account = ProviderOAuthAccount {
provider_type: "codex".to_string(),
access_token: "access-token".to_string(),
auth_config: json!({
"email": "alice@example.com",
"codex": {
"remaining_percent": 42,
"updated_at": 1000
}
}),
expires_at_unix_secs: Some(2000),
identity: BTreeMap::new(),
};
let probe = adapter
.probe_account_state(&UnusedExecutor, &ctx, &account)
.await
.expect("probe should succeed")
.expect("probe should return state");
assert!(probe.state.is_valid);
assert_eq!(probe.state.email.as_deref(), Some("alice@example.com"));
assert_eq!(probe.state.quota.as_ref().unwrap()["remaining_percent"], 42);
}
}

View File

@@ -0,0 +1,687 @@
use crate::core::{current_unix_secs, OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
use crate::provider::ProviderOAuthAdapter;
use crate::provider::{
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
ProviderOAuthImportInput, ProviderOAuthProbeResult, ProviderOAuthRequestAuth,
ProviderOAuthTokenSet, ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use url::form_urlencoded;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GenericProviderOAuthTemplate {
pub provider_type: &'static str,
pub display_name: &'static str,
pub authorize_url: &'static str,
pub token_url: &'static str,
pub client_id: &'static str,
pub client_secret: &'static str,
pub scopes: &'static [&'static str],
pub redirect_uri: &'static str,
pub use_pkce: bool,
pub uses_json_payload: bool,
}
pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
GenericProviderOAuthTemplate {
provider_type: "claude_code",
display_name: "ClaudeCode",
authorize_url: "https://claude.ai/oauth/authorize",
token_url: "https://console.anthropic.com/v1/oauth/token",
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
client_secret: "",
scopes: &["org:create_api_key", "user:profile", "user:inference"],
redirect_uri: "http://localhost:54545/callback",
use_pkce: true,
uses_json_payload: true,
},
GenericProviderOAuthTemplate {
provider_type: "codex",
display_name: "Codex",
authorize_url: "https://auth.openai.com/oauth/authorize",
token_url: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
client_secret: "",
scopes: &["openid", "email", "profile", "offline_access"],
redirect_uri: "http://localhost:1455/auth/callback",
use_pkce: true,
uses_json_payload: false,
},
GenericProviderOAuthTemplate {
provider_type: "gemini_cli",
display_name: "GeminiCli",
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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",
scopes: &[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
redirect_uri: "http://localhost:8085/oauth2callback",
use_pkce: false,
uses_json_payload: false,
},
GenericProviderOAuthTemplate {
provider_type: "antigravity",
display_name: "Antigravity",
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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",
scopes: &[
"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",
],
redirect_uri: "http://localhost:51121/oauth2callback",
use_pkce: true,
uses_json_payload: false,
},
];
#[derive(Debug, Clone)]
pub struct GenericProviderOAuthAdapter {
template: GenericProviderOAuthTemplate,
token_url_override: Option<String>,
}
impl GenericProviderOAuthAdapter {
pub fn new(template: GenericProviderOAuthTemplate) -> Self {
Self {
template,
token_url_override: None,
}
}
pub fn for_provider_type(provider_type: &str) -> Option<Self> {
template_for_provider_type(provider_type).map(Self::new)
}
pub fn with_token_url_override(mut self, token_url: impl Into<String>) -> Self {
self.token_url_override = Some(token_url.into());
self
}
pub fn with_token_url_for_tests(self, token_url: impl Into<String>) -> Self {
self.with_token_url_override(token_url)
}
fn token_url(&self) -> String {
self.token_url_override
.clone()
.unwrap_or_else(|| self.template.token_url.to_string())
}
async fn exchange_grant(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
grant_type: &str,
code_or_refresh_token: &str,
state: Option<&str>,
pkce_verifier: Option<&str>,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
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(),
"refresh_token" => "provider-oauth:refresh-token".to_string(),
_ => format!(
"provider-oauth:{}:{grant_type}",
self.template.provider_type
),
};
let response = if self.template.uses_json_payload {
let mut body = serde_json::Map::from_iter([
(
"grant_type".to_string(),
Value::String(grant_type.to_string()),
),
(
"client_id".to_string(),
Value::String(self.template.client_id.to_string()),
),
]);
if grant_type == "authorization_code" {
body.insert(
"code".to_string(),
Value::String(code_or_refresh_token.to_string()),
);
body.insert(
"redirect_uri".to_string(),
Value::String(self.template.redirect_uri.to_string()),
);
if let Some(state) = state {
body.insert("state".to_string(), Value::String(state.to_string()));
}
if let Some(verifier) = pkce_verifier {
body.insert(
"code_verifier".to_string(),
Value::String(verifier.to_string()),
);
}
} else {
body.insert(
"refresh_token".to_string(),
Value::String(code_or_refresh_token.to_string()),
);
}
if let Some(scope) = scope.as_ref() {
body.insert("scope".to_string(), Value::String(scope.clone()));
}
executor
.execute(OAuthHttpRequest {
request_id: request_id.clone(),
method: reqwest::Method::POST,
url: self.token_url(),
headers: json_headers(),
content_type: Some("application/json".to_string()),
json_body: Some(Value::Object(body)),
body_bytes: None,
network: ctx.network.clone(),
})
.await?
} else {
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);
if grant_type == "authorization_code" {
form.append_pair("redirect_uri", self.template.redirect_uri);
form.append_pair("code", code_or_refresh_token);
if let Some(verifier) = pkce_verifier {
form.append_pair("code_verifier", verifier);
}
} else {
form.append_pair("refresh_token", code_or_refresh_token);
}
if let Some(scope) = scope.as_ref() {
form.append_pair("scope", scope);
}
if !self.template.client_secret.trim().is_empty() {
form.append_pair("client_secret", self.template.client_secret);
}
form.finish().into_bytes()
};
executor
.execute(OAuthHttpRequest {
request_id,
method: reqwest::Method::POST,
url: self.token_url(),
headers: form_headers(),
content_type: Some("application/x-www-form-urlencoded".to_string()),
json_body: None,
body_bytes: Some(form_body),
network: ctx.network.clone(),
})
.await?
};
if !(200..300).contains(&response.status_code) {
return Err(OAuthError::HttpStatus {
status_code: response.status_code,
body_excerpt: truncate_body(&response.body_text),
});
}
let payload = response
.json_body
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
.ok_or_else(|| OAuthError::invalid_response("token response is not json"))?;
self.token_set_from_payload(payload)
}
fn token_set_from_payload(&self, payload: Value) -> Result<ProviderOAuthTokenSet, OAuthError> {
let token_set = OAuthTokenSet::from_token_payload(payload.clone())
.ok_or_else(|| OAuthError::invalid_response("token response missing access_token"))?;
let mut auth_config = serde_json::Map::new();
auth_config.insert(
"provider_type".to_string(),
json!(self.template.provider_type),
);
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
if let Some(token_type) = token_set.token_type.as_ref() {
auth_config.insert("token_type".to_string(), json!(token_type));
}
if let Some(refresh_token) = token_set.refresh_token.as_ref() {
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
}
if let Some(expires_at) = token_set.expires_at_unix_secs {
auth_config.insert("expires_at".to_string(), json!(expires_at));
}
if let Some(scope) = token_set.scope.as_ref() {
auth_config.insert("scope".to_string(), json!(scope));
}
enrich_generic_identity(self.template.provider_type, &mut auth_config, &payload);
Ok(ProviderOAuthTokenSet {
token_set,
auth_config: Value::Object(auth_config),
})
}
}
#[async_trait]
impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
fn provider_type(&self) -> &'static str {
self.template.provider_type
}
fn capabilities(&self) -> ProviderOAuthCapabilities {
ProviderOAuthCapabilities::GENERIC_AUTH_CODE
}
fn build_authorize_url(
&self,
_ctx: &ProviderOAuthTransportContext,
state: &str,
code_challenge: Option<&str>,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
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("redirect_uri", self.template.redirect_uri);
query.append_pair("state", state);
if !self.template.scopes.is_empty() {
query.append_pair("scope", &self.template.scopes.join(" "));
}
if let Some(challenge) = code_challenge {
query.append_pair("code_challenge", challenge);
query.append_pair("code_challenge_method", "S256");
}
}
Ok(OAuthAuthorizeResponse {
authorize_url: url.to_string(),
state: state.to_string(),
code_challenge: code_challenge.map(ToOwned::to_owned),
})
}
async fn exchange_code(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
code: &str,
state: &str,
pkce_verifier: Option<&str>,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
self.exchange_grant(
executor,
ctx,
"authorization_code",
code,
Some(state),
pkce_verifier,
)
.await
}
async fn import_credentials(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: ProviderOAuthImportInput,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let refresh_token = input
.refresh_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| OAuthError::invalid_request("refresh_token is required"))?;
self.exchange_grant(executor, ctx, "refresh_token", refresh_token, None, None)
.await
}
async fn refresh(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let refresh_token = account
.auth_config
.get("refresh_token")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| OAuthError::invalid_request("auth_config missing refresh_token"))?;
let mut refreshed = self
.exchange_grant(executor, ctx, "refresh_token", refresh_token, None, None)
.await?;
// Refresh responses often omit stable account metadata, and some providers
// do not rotate refresh_token on every refresh. Preserve the stored config
// as the base while letting the fresh token payload win.
if let Some(existing) = account.auth_config.as_object() {
let mut merged = existing.clone();
if let Some(updated) = refreshed.auth_config.as_object() {
for (key, value) in updated {
merged.insert(key.clone(), value.clone());
}
}
if refreshed.token_set.refresh_token.is_none() {
refreshed.token_set.refresh_token = Some(refresh_token.to_string());
merged.insert("refresh_token".to_string(), json!(refresh_token));
}
refreshed.auth_config = Value::Object(merged);
}
Ok(refreshed)
}
fn resolve_request_auth(
&self,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
Ok(account.request_bearer_auth())
}
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
let refresh_token = account
.auth_config
.get("refresh_token")
.and_then(Value::as_str)
.or(Some(account.access_token.as_str()))?;
Some(secret_fingerprint(refresh_token))
}
}
pub fn template_for_provider_type(provider_type: &str) -> Option<GenericProviderOAuthTemplate> {
let normalized = provider_type.trim();
GENERIC_PROVIDER_OAUTH_TEMPLATES
.iter()
.find(|template| normalized.eq_ignore_ascii_case(template.provider_type))
.copied()
}
fn form_headers() -> BTreeMap<String, String> {
BTreeMap::from([
(
"content-type".to_string(),
"application/x-www-form-urlencoded".to_string(),
),
("accept".to_string(), "application/json".to_string()),
])
}
fn json_headers() -> BTreeMap<String, String> {
BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "application/json".to_string()),
])
}
fn truncate_body(body: &str) -> String {
let body = body.trim();
if body.is_empty() {
"-".to_string()
} else {
body.chars().take(500).collect()
}
}
fn secret_fingerprint(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
let mut fingerprint = String::with_capacity(16);
for byte in digest.iter().take(8) {
use std::fmt::Write as _;
let _ = write!(&mut fingerprint, "{byte:02x}");
}
fingerprint
}
fn enrich_generic_identity(
provider_type: &str,
auth_config: &mut serde_json::Map<String, Value>,
token_payload: &Value,
) {
if let Some(object) = token_payload.as_object() {
for field in [
"email",
"account_id",
"account_user_id",
"plan_type",
"user_id",
"account_name",
] {
if !auth_config.contains_key(field) {
if let Some(value) = object.get(field).cloned() {
auth_config.insert(field.to_string(), value);
}
}
}
}
if !provider_type.eq_ignore_ascii_case("codex") {
return;
}
if let Some(access_token) = token_payload
.get("access_token")
.and_then(Value::as_str)
.or_else(|| token_payload.get("id_token").and_then(Value::as_str))
{
if let Some(claims) = decode_jwt_claims(access_token) {
for field in ["email", "sub"] {
if let Some(value) = claims.get(field).cloned() {
let target = if field == "sub" { "user_id" } else { field };
auth_config.entry(target.to_string()).or_insert(value);
}
}
if let Some(auth) = claims
.get("https://api.openai.com/auth")
.and_then(Value::as_object)
{
for (source, target) in [
("chatgpt_account_id", "account_id"),
("chatgpt_account_user_id", "account_user_id"),
("chatgpt_plan_type", "plan_type"),
("chatgpt_user_id", "user_id"),
] {
if let Some(value) = auth.get(source).cloned() {
auth_config.entry(target.to_string()).or_insert(value);
}
}
if let Some(value) = auth.get("organizations").cloned() {
auth_config
.entry("organizations".to_string())
.or_insert(value);
}
}
}
}
}
pub(super) fn provider_account_state_from_metadata(
metadata_key: &str,
account: &ProviderOAuthAccount,
) -> ProviderOAuthProbeResult {
let metadata = account
.identity
.get(metadata_key)
.cloned()
.or_else(|| account.auth_config.get(metadata_key).cloned());
let email = string_field(&account.auth_config, "email")
.or_else(|| account.identity.get("email").and_then(value_to_string))
.or_else(|| {
metadata
.as_ref()
.and_then(|value| string_field(value, "email"))
});
let invalid_reason = string_field(&account.auth_config, "oauth_invalid_reason")
.or_else(|| string_field(&account.auth_config, "invalid_reason"))
.or_else(|| metadata.as_ref().and_then(metadata_invalid_reason));
let raw = json!({
"auth_config": account.auth_config,
"identity": account.identity,
});
ProviderOAuthProbeResult {
state: ProviderOAuthAccountState {
is_valid: !account.access_token.trim().is_empty() && invalid_reason.is_none(),
email,
quota: metadata,
invalid_reason,
raw: Some(raw),
},
}
}
fn metadata_invalid_reason(value: &Value) -> Option<String> {
if value
.get("is_forbidden")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return string_field(value, "forbidden_reason")
.or_else(|| string_field(value, "message"))
.or_else(|| Some("account_forbidden".to_string()));
}
if value
.get("account_disabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return string_field(value, "message")
.or_else(|| string_field(value, "reason"))
.or_else(|| Some("account_disabled".to_string()));
}
string_field(value, "invalid_reason").or_else(|| string_field(value, "reason"))
}
fn string_field(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(value_to_string)
}
fn value_to_string(value: &Value) -> Option<String> {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn decode_jwt_claims(token: &str) -> Option<serde_json::Map<String, Value>> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let payload = token.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()?;
serde_json::from_slice::<Value>(&bytes)
.ok()?
.as_object()
.cloned()
}
#[cfg(test)]
mod tests {
use super::{template_for_provider_type, GenericProviderOAuthAdapter};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::ProviderOAuthAdapter;
use crate::provider::{ProviderOAuthAccount, ProviderOAuthTransportContext};
use async_trait::async_trait;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[test]
fn resolves_generic_provider_templates() {
assert!(template_for_provider_type("codex").is_some());
assert!(template_for_provider_type("claude_code").is_some());
assert!(template_for_provider_type("kiro").is_none());
}
#[test]
fn generic_adapter_exposes_provider_type() {
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
.expect("codex template should exist");
assert_eq!(adapter.provider_type(), "codex");
assert!(adapter.capabilities().supports_refresh_token_import);
}
#[derive(Debug, Clone)]
struct StaticExecutor {
seen_request: Arc<Mutex<Option<OAuthHttpRequest>>>,
}
#[async_trait]
impl OAuthHttpExecutor for StaticExecutor {
async fn execute(
&self,
request: OAuthHttpRequest,
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
*self.seen_request.lock().expect("mutex should lock") = Some(request);
Ok(OAuthHttpResponse {
status_code: 200,
body_text: json!({
"access_token": "new-access-token",
"expires_in": 3600
})
.to_string(),
json_body: None,
})
}
}
#[tokio::test]
async fn refresh_preserves_existing_metadata_when_refresh_token_is_not_rotated() {
let seen_request = Arc::new(Mutex::new(None));
let executor = StaticExecutor {
seen_request: Arc::clone(&seen_request),
};
let adapter = GenericProviderOAuthAdapter::for_provider_type("codex")
.expect("codex adapter should exist")
.with_token_url_override("https://auth.example.test/token");
let ctx = ProviderOAuthTransportContext {
provider_id: "provider-1".to_string(),
provider_type: "codex".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),
};
let account = ProviderOAuthAccount {
provider_type: "codex".to_string(),
access_token: "old-access-token".to_string(),
auth_config: json!({
"provider_type": "codex",
"refresh_token": "old-refresh-token",
"email": "alice@example.com",
"account_id": "acct-123",
"updated_at": 1
}),
expires_at_unix_secs: Some(1),
identity: BTreeMap::new(),
};
let refreshed = adapter
.refresh(&executor, &ctx, &account)
.await
.expect("refresh should succeed");
assert_eq!(refreshed.token_set.access_token, "new-access-token");
assert_eq!(
refreshed.token_set.refresh_token.as_deref(),
Some("old-refresh-token")
);
assert_eq!(refreshed.auth_config["email"], "alice@example.com");
assert_eq!(refreshed.auth_config["account_id"], "acct-123");
assert_eq!(refreshed.auth_config["refresh_token"], "old-refresh-token");
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");
assert!(form.contains("grant_type=refresh_token"));
assert!(form.contains("refresh_token=old-refresh-token"));
}
}

View File

@@ -0,0 +1,576 @@
use crate::core::{current_unix_secs, OAuthError};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
use crate::provider::ProviderOAuthAdapter;
use crate::provider::{
ProviderOAuthAccount, ProviderOAuthCapabilities, ProviderOAuthImportInput,
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
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)]
pub struct KiroAuthConfig {
pub auth_method: Option<String>,
pub refresh_token: Option<String>,
pub expires_at: Option<u64>,
pub profile_arn: Option<String>,
pub region: Option<String>,
pub auth_region: Option<String>,
pub api_region: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub machine_id: Option<String>,
pub kiro_version: Option<String>,
pub system_version: Option<String>,
pub node_version: Option<String>,
pub access_token: Option<String>,
}
impl KiroAuthConfig {
pub fn from_json_value(value: &Value) -> Option<Self> {
let object = value.as_object()?;
Some(Self {
auth_method: string_field(
object,
&["auth_method", "authMethod", "auth_type", "authType"],
),
refresh_token: string_field(object, &["refresh_token", "refreshToken"]),
expires_at: u64_field(object.get("expires_at"))
.or_else(|| u64_field(object.get("expiresAt"))),
profile_arn: string_field(object, &["profile_arn", "profileArn"]),
region: string_field(object, &["region"]),
auth_region: string_field(object, &["auth_region", "authRegion"]),
api_region: string_field(object, &["api_region", "apiRegion"]),
client_id: string_field(object, &["client_id", "clientId"]),
client_secret: string_field(object, &["client_secret", "clientSecret"]),
machine_id: string_field(object, &["machine_id", "machineId"]),
kiro_version: string_field(object, &["kiro_version", "kiroVersion"]),
system_version: string_field(object, &["system_version", "systemVersion"]),
node_version: string_field(object, &["node_version", "nodeVersion"]),
access_token: string_field(object, &["access_token", "accessToken"]),
})
}
pub fn from_raw_json(raw: Option<&str>) -> Option<Self> {
let parsed: Value = serde_json::from_str(raw?.trim()).ok()?;
Self::from_json_value(&parsed)
}
pub fn to_json_value(&self) -> Value {
let mut object = serde_json::Map::new();
insert_string(&mut object, "auth_method", self.auth_method.as_deref());
insert_string(&mut object, "refresh_token", self.refresh_token.as_deref());
if let Some(expires_at) = self.expires_at {
object.insert("expires_at".to_string(), json!(expires_at));
}
insert_string(&mut object, "profile_arn", self.profile_arn.as_deref());
insert_string(&mut object, "region", self.region.as_deref());
insert_string(&mut object, "auth_region", self.auth_region.as_deref());
insert_string(&mut object, "api_region", self.api_region.as_deref());
insert_string(&mut object, "client_id", self.client_id.as_deref());
insert_string(&mut object, "client_secret", self.client_secret.as_deref());
insert_string(&mut object, "machine_id", self.machine_id.as_deref());
insert_string(&mut object, "kiro_version", self.kiro_version.as_deref());
insert_string(
&mut object,
"system_version",
self.system_version.as_deref(),
);
insert_string(&mut object, "node_version", self.node_version.as_deref());
insert_string(&mut object, "access_token", self.access_token.as_deref());
Value::Object(object)
}
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("us-east-1")
}
pub fn effective_api_region(&self) -> &str {
self.api_region
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("us-east-1")
}
pub fn effective_kiro_version(&self) -> &str {
self.kiro_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("0.3.210")
}
pub fn is_idc_auth(&self) -> bool {
self.auth_method
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase)
.is_some_and(|value| matches!(value.as_str(), "idc" | "external_idp"))
|| (self
.client_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& self
.client_secret
.as_deref()
.is_some_and(|value| !value.trim().is_empty()))
}
pub fn can_refresh_access_token(&self) -> bool {
self.refresh_token
.as_deref()
.map(str::trim)
.filter(|value| value.len() >= 100 && !value.contains("..."))
.is_some()
&& (!self.is_idc_auth()
|| (self
.client_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& self
.client_secret
.as_deref()
.is_some_and(|value| !value.trim().is_empty())))
}
}
#[derive(Debug, Clone, Default)]
pub struct KiroProviderOAuthAdapter {
social_refresh_base_url: Option<String>,
idc_refresh_base_url: Option<String>,
}
impl KiroProviderOAuthAdapter {
pub fn with_refresh_base_urls(
mut self,
social_refresh_base_url: Option<String>,
idc_refresh_base_url: Option<String>,
) -> Self {
self.social_refresh_base_url = social_refresh_base_url;
self.idc_refresh_base_url = idc_refresh_base_url;
self
}
async fn refresh_auth_config(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, OAuthError> {
if auth_config.is_idc_auth() {
self.refresh_idc_token(executor, ctx, auth_config).await
} else {
self.refresh_social_token(executor, ctx, auth_config).await
}
}
async fn refresh_social_token(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, OAuthError> {
let url = self
.social_refresh_base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|base| format!("{}/refreshToken", base.trim_end_matches('/')))
.unwrap_or_else(|| {
format!(
"https://prod.{}.auth.desktop.kiro.dev/refreshToken",
auth_config.effective_auth_region()
)
});
let machine_id = generate_kiro_machine_id(auth_config, None)
.ok_or_else(|| OAuthError::invalid_request("missing machine_id seed"))?;
let response = executor
.execute(OAuthHttpRequest {
request_id: "provider-oauth:kiro-social-refresh".to_string(),
method: reqwest::Method::POST,
url,
headers: BTreeMap::from([
(
"user-agent".to_string(),
format!(
"KiroIDE-{}-{machine_id}",
auth_config.effective_kiro_version()
),
),
(
"accept".to_string(),
"application/json, text/plain, */*".to_string(),
),
("content-type".to_string(), "application/json".to_string()),
("connection".to_string(), "close".to_string()),
]),
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"refreshToken": auth_config.refresh_token.as_deref().unwrap_or_default()
})),
body_bytes: None,
network: ctx.network.clone(),
})
.await?;
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(),
});
}
let payload = response
.json_body
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
.ok_or_else(|| OAuthError::invalid_response("kiro refresh response is not json"))?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| OAuthError::invalid_response("kiro refresh missing accessToken"))?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(resolve_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
refreshed.machine_id = Some(machine_id);
}
if let Some(refresh_token) = payload.get("refreshToken").and_then(Value::as_str) {
refreshed.refresh_token = Some(refresh_token.trim().to_string());
}
if let Some(profile_arn) = payload.get("profileArn").and_then(Value::as_str) {
refreshed.profile_arn = Some(profile_arn.trim().to_string());
}
Ok(refreshed)
}
async fn refresh_idc_token(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, OAuthError> {
let url = self
.idc_refresh_base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|base| format!("{}/token", base.trim_end_matches('/')))
.unwrap_or_else(|| {
format!(
"https://oidc.{}.amazonaws.com/token",
auth_config.effective_auth_region()
)
});
let response = executor
.execute(OAuthHttpRequest {
request_id: "provider-oauth:kiro-idc-refresh".to_string(),
method: reqwest::Method::POST,
url,
headers: BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
(
"x-amz-user-agent".to_string(),
IDC_AMZ_USER_AGENT.to_string(),
),
("user-agent".to_string(), "node".to_string()),
("accept".to_string(), "*/*".to_string()),
]),
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"clientId": auth_config.client_id.as_deref().unwrap_or_default(),
"clientSecret": auth_config.client_secret.as_deref().unwrap_or_default(),
"refreshToken": auth_config.refresh_token.as_deref().unwrap_or_default(),
"grantType": "refresh_token",
})),
body_bytes: None,
network: ctx.network.clone(),
})
.await?;
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(),
});
}
let payload = response
.json_body
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
.ok_or_else(|| OAuthError::invalid_response("kiro idc response is not json"))?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| OAuthError::invalid_response("kiro idc missing accessToken"))?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(resolve_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
refreshed.machine_id = generate_kiro_machine_id(auth_config, None);
}
if let Some(refresh_token) = payload.get("refreshToken").and_then(Value::as_str) {
refreshed.refresh_token = Some(refresh_token.trim().to_string());
}
Ok(refreshed)
}
}
#[async_trait]
impl ProviderOAuthAdapter for KiroProviderOAuthAdapter {
fn provider_type(&self) -> &'static str {
KIRO_PROVIDER_TYPE
}
fn capabilities(&self) -> ProviderOAuthCapabilities {
ProviderOAuthCapabilities {
supports_authorization_code: false,
supports_refresh_token_import: true,
supports_batch_import: true,
supports_device_flow: true,
supports_account_probe: true,
rotates_refresh_token: true,
}
}
async fn import_credentials(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: ProviderOAuthImportInput,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let auth_config = input
.raw_credentials
.as_ref()
.and_then(KiroAuthConfig::from_json_value)
.or_else(|| {
input
.refresh_token
.as_ref()
.map(|refresh_token| KiroAuthConfig {
auth_method: None,
refresh_token: Some(refresh_token.clone()),
expires_at: None,
profile_arn: None,
region: None,
auth_region: None,
api_region: None,
client_id: None,
client_secret: None,
machine_id: None,
kiro_version: None,
system_version: None,
node_version: None,
access_token: None,
})
})
.ok_or_else(|| OAuthError::invalid_request("kiro credentials are required"))?;
let refreshed = self
.refresh_auth_config(executor, ctx, &auth_config)
.await?;
token_set_from_kiro_auth_config(refreshed)
}
async fn refresh(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let auth_config = KiroAuthConfig::from_json_value(&account.auth_config)
.ok_or_else(|| OAuthError::invalid_request("invalid kiro auth_config"))?;
let refreshed = self
.refresh_auth_config(executor, ctx, &auth_config)
.await?;
token_set_from_kiro_auth_config(refreshed)
}
fn resolve_request_auth(
&self,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
let auth_config = KiroAuthConfig::from_json_value(&account.auth_config)
.ok_or_else(|| OAuthError::invalid_request("invalid kiro auth_config"))?;
let machine_id = generate_kiro_machine_id(&auth_config, Some(&account.access_token))
.ok_or_else(|| OAuthError::invalid_request("missing kiro machine_id"))?;
Ok(ProviderOAuthRequestAuth::Kiro {
name: "authorization".to_string(),
value: format!("Bearer {}", account.access_token.trim()),
auth_config: account.auth_config.clone(),
machine_id,
})
}
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
account
.auth_config
.get("refresh_token")
.and_then(Value::as_str)
.map(secret_fingerprint)
}
}
pub fn generate_kiro_machine_id(
auth_config: &KiroAuthConfig,
fallback_secret: Option<&str>,
) -> Option<String> {
if let Some(machine_id) = auth_config
.machine_id
.as_deref()
.and_then(normalize_machine_id)
{
return Some(machine_id);
}
let seed = auth_config
.refresh_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
fallback_secret
.map(str::trim)
.filter(|value| !value.is_empty())
})?;
let mut hasher = Sha256::new();
hasher.update(b"KotlinNativeAPI/");
hasher.update(seed.as_bytes());
Some(format!("{:x}", hasher.finalize()))
}
fn token_set_from_kiro_auth_config(
auth_config: KiroAuthConfig,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let access_token = auth_config
.access_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| OAuthError::invalid_response("kiro auth_config missing access_token"))?
.to_string();
let token_set = crate::core::OAuthTokenSet {
access_token,
refresh_token: auth_config.refresh_token.clone(),
token_type: Some("Bearer".to_string()),
scope: None,
expires_at_unix_secs: auth_config.expires_at,
raw_payload: Some(auth_config.to_json_value()),
};
let mut value = auth_config.to_json_value();
if let Some(object) = value.as_object_mut() {
object.insert("provider_type".to_string(), json!(KIRO_PROVIDER_TYPE));
}
Ok(ProviderOAuthTokenSet {
token_set,
auth_config: value,
})
}
fn resolve_expires_at(payload: &Value) -> u64 {
let expires_in = payload
.get("expiresIn")
.or_else(|| payload.get("expires_in"))
.and_then(|value| {
value
.as_u64()
.or_else(|| value.as_str()?.parse::<u64>().ok())
})
.unwrap_or(3600);
current_unix_secs().saturating_add(expires_in)
}
fn normalize_machine_id(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.len() == 64 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Some(raw.to_ascii_lowercase());
}
if raw.len() == 36
&& raw.chars().enumerate().all(|(idx, ch)| match idx {
8 | 13 | 18 | 23 => ch == '-',
_ => ch.is_ascii_hexdigit(),
})
{
let normalized = raw.replace('-', "").to_ascii_lowercase();
return Some(format!("{normalized}{normalized}"));
}
None
}
fn string_field(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| object.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn u64_field(value: Option<&Value>) -> Option<u64> {
match value? {
Value::Number(number) => number.as_u64(),
Value::String(value) => value.trim().parse::<u64>().ok(),
_ => None,
}
}
fn insert_string(object: &mut serde_json::Map<String, Value>, key: &str, value: Option<&str>) {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
object.insert(key.to_string(), Value::String(value.to_string()));
}
}
fn secret_fingerprint(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
digest
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]
mod tests {
use super::{generate_kiro_machine_id, KiroAuthConfig};
#[test]
fn normalizes_kiro_uuid_machine_id() {
let auth_config = KiroAuthConfig {
auth_method: None,
refresh_token: Some("r".repeat(128)),
expires_at: None,
profile_arn: None,
region: None,
auth_region: None,
api_region: None,
client_id: None,
client_secret: None,
machine_id: Some("123e4567-e89b-12d3-a456-426614174000".to_string()),
kiro_version: None,
system_version: None,
node_version: None,
access_token: None,
};
assert_eq!(
generate_kiro_machine_id(&auth_config, None).as_deref(),
Some("123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000")
);
}
}

View File

@@ -0,0 +1,13 @@
mod antigravity;
mod codex;
mod generic;
mod kiro;
pub use antigravity::AntigravityProviderOAuthAdapter;
pub use codex::CodexProviderOAuthAdapter;
pub use generic::{
GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, GENERIC_PROVIDER_OAUTH_TEMPLATES,
};
pub use kiro::{
generate_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter, KIRO_PROVIDER_TYPE,
};

View File

@@ -0,0 +1,132 @@
use super::{
ProviderOAuthAdapter, ProviderOAuthImportInput, ProviderOAuthProbeResult,
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
};
use crate::core::{OAuthAdapterRegistry, OAuthAuthorizeResponse, OAuthError};
use crate::network::OAuthHttpExecutor;
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub struct ProviderOAuthService {
registry: OAuthAdapterRegistry<dyn ProviderOAuthAdapter>,
}
impl ProviderOAuthService {
pub fn new() -> Self {
Self::default()
}
pub fn with_builtin_adapters() -> Self {
use super::providers::{
AntigravityProviderOAuthAdapter, CodexProviderOAuthAdapter,
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter,
};
let mut service = Self::new()
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
for provider_type in ["claude_code", "gemini_cli"] {
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
service = service.with_adapter(Arc::new(adapter));
}
}
service
}
pub fn with_adapter(mut self, adapter: Arc<dyn ProviderOAuthAdapter>) -> Self {
self.registry.insert(adapter.provider_type(), adapter);
self
}
pub fn adapter(
&self,
provider_type: &str,
) -> Result<Arc<dyn ProviderOAuthAdapter>, OAuthError> {
self.registry
.get(provider_type)
.ok_or_else(|| OAuthError::UnsupportedProvider(provider_type.to_string()))
}
pub fn build_authorize_url(
&self,
ctx: &ProviderOAuthTransportContext,
state: &str,
code_challenge: Option<&str>,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
self.adapter(&ctx.provider_type)?
.build_authorize_url(ctx, state, code_challenge)
}
pub async fn exchange_code(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
code: &str,
state: &str,
pkce_verifier: Option<&str>,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
self.adapter(&ctx.provider_type)?
.exchange_code(executor, ctx, code, state, pkce_verifier)
.await
}
pub async fn import_credentials(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: ProviderOAuthImportInput,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
self.adapter(&ctx.provider_type)?
.import_credentials(executor, ctx, input)
.await
}
pub async fn refresh(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
account: &super::ProviderOAuthAccount,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
self.adapter(&ctx.provider_type)?
.refresh(executor, ctx, account)
.await
}
pub fn resolve_request_auth(
&self,
account: &super::ProviderOAuthAccount,
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
self.adapter(&account.provider_type)?
.resolve_request_auth(account)
}
pub async fn probe_account_state(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
account: &super::ProviderOAuthAccount,
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
self.adapter(&account.provider_type)?
.probe_account_state(executor, ctx, account)
.await
}
}
#[cfg(test)]
mod tests {
use super::ProviderOAuthService;
#[test]
fn builtin_provider_service_registers_supported_provider_types() {
let service = ProviderOAuthService::with_builtin_adapters();
for provider_type in ["claude_code", "codex", "gemini_cli", "antigravity", "kiro"] {
assert!(
service.adapter(provider_type).is_ok(),
"{provider_type} adapter should be registered"
);
}
assert!(service.adapter("unknown").is_err());
}
}

View File

@@ -12,6 +12,7 @@ aether-contracts.workspace = true
aether-crypto.workspace = true
aether-data.workspace = true
aether-data-contracts.workspace = true
aether-oauth.workspace = true
aether-video-tasks-core.workspace = true
async-trait.workspace = true
http.workspace = true

View File

@@ -1,14 +1,16 @@
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use aether_oauth::provider::providers::{
GenericProviderOAuthAdapter, GENERIC_PROVIDER_OAUTH_TEMPLATES,
};
use aether_oauth::provider::{ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTokenSet};
use async_trait::async_trait;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use url::form_urlencoded;
use serde_json::Value;
use super::oauth_refresh::{
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
oauth_error_to_local_refresh_error, provider_oauth_transport_context_from_snapshot,
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
LocalResolvedOAuthRequestAuth, ProviderOAuthLocalHttpExecutor,
};
use super::snapshot::GatewayProviderTransportSnapshot;
@@ -16,66 +18,11 @@ const AUTH_HEADER_NAME: &str = "authorization";
const OAUTH_REFRESH_SKEW_SECS: u64 = 120;
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GenericOAuthTemplate {
provider_type: &'static str,
token_url: &'static str,
client_id: &'static str,
client_secret: &'static str,
scopes: &'static [&'static str],
uses_json_payload: bool,
}
const GENERIC_OAUTH_TEMPLATES: &[GenericOAuthTemplate] = &[
GenericOAuthTemplate {
provider_type: "claude_code",
token_url: "https://console.anthropic.com/v1/oauth/token",
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
client_secret: "",
scopes: &["org:create_api_key", "user:profile", "user:inference"],
uses_json_payload: true,
},
GenericOAuthTemplate {
provider_type: "codex",
token_url: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
client_secret: "",
scopes: &["openid", "email", "profile", "offline_access"],
uses_json_payload: false,
},
GenericOAuthTemplate {
provider_type: "gemini_cli",
token_url: "https://oauth2.googleapis.com/token",
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
client_secret: "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",
scopes: &[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
uses_json_payload: false,
},
GenericOAuthTemplate {
provider_type: "antigravity",
token_url: "https://oauth2.googleapis.com/token",
client_id: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret: "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",
scopes: &[
"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",
],
uses_json_payload: false,
},
];
pub fn supports_local_generic_oauth_request_auth_resolution(
transport: &GatewayProviderTransportSnapshot,
) -> bool {
transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
&& template_for_provider_type(transport.provider.provider_type.as_str()).is_some()
&& generic_provider_type(transport.provider.provider_type.as_str()).is_some()
}
#[derive(Debug, Clone, Default)]
@@ -94,11 +41,15 @@ impl GenericOAuthRefreshAdapter {
self
}
fn token_url_for_template(&self, template: GenericOAuthTemplate) -> String {
self.token_url_overrides
.get(template.provider_type)
.cloned()
.unwrap_or_else(|| template.token_url.to_string())
fn adapter_for_provider_type(
&self,
provider_type: &'static str,
) -> Option<GenericProviderOAuthAdapter> {
let adapter = GenericProviderOAuthAdapter::for_provider_type(provider_type)?;
if let Some(token_url) = self.token_url_overrides.get(provider_type) {
return Some(adapter.with_token_url_override(token_url.clone()));
}
Some(adapter)
}
fn auth_config_from_transport(transport: &GatewayProviderTransportSnapshot) -> Option<Value> {
@@ -186,18 +137,15 @@ impl GenericOAuthRefreshAdapter {
}
fn build_cached_entry(
&self,
template: GenericOAuthTemplate,
access_token: &str,
metadata: Value,
expires_at_unix_secs: Option<u64>,
provider_type: &'static str,
refreshed: ProviderOAuthTokenSet,
) -> CachedOAuthEntry {
CachedOAuthEntry {
provider_type: template.provider_type.to_string(),
provider_type: provider_type.to_string(),
auth_header_name: AUTH_HEADER_NAME.to_string(),
auth_header_value: format!("Bearer {access_token}"),
expires_at_unix_secs,
metadata: Some(metadata),
auth_header_value: refreshed.token_set.bearer_header_value(),
expires_at_unix_secs: refreshed.token_set.expires_at_unix_secs,
metadata: Some(refreshed.auth_config),
}
}
}
@@ -274,252 +222,70 @@ impl LocalOAuthRefreshAdapter for GenericOAuthRefreshAdapter {
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
let Some(template) = template_for_provider_type(transport.provider.provider_type.as_str())
let Some(provider_type) = generic_provider_type(transport.provider.provider_type.as_str())
else {
return Ok(None);
};
let cached_auth_config =
entry.and_then(|cached| Self::auth_config_from_entry(transport, cached));
let transport_auth_config = Self::auth_config_from_transport(transport);
let base_auth_config = self.base_auth_config(transport, entry);
let base_auth_config_source = match (
base_auth_config.as_ref(),
cached_auth_config.as_ref(),
transport_auth_config.as_ref(),
) {
(Some(selected), Some(cached), Some(transport_auth))
if selected == transport_auth && selected != cached =>
{
"transport_auth_config"
}
(Some(selected), Some(cached), Some(transport_auth))
if selected == cached && selected != transport_auth =>
{
"cached_entry"
}
(Some(_), Some(_), Some(_)) => "cached_entry",
(Some(_), Some(_), None) => "cached_entry",
(Some(_), None, Some(_)) => "transport_auth_config",
_ => "none",
let Some(auth_config) = self.base_auth_config(transport, entry) else {
return Ok(None);
};
let mut metadata = base_auth_config
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
let Some(refresh_token) = metadata.get("refresh_token").and_then(non_empty_string) else {
let Some(refresh_token) = refresh_token_from_auth_config(&auth_config) else {
tracing::warn!(
key_id = %transport.key.id,
provider_id = %transport.provider.id,
provider_type = template.provider_type,
auth_config_source = base_auth_config_source,
provider_type,
"gateway generic oauth refresh skipped because auth_config has no refresh_token"
);
return Ok(None);
};
let Some(adapter) = self.adapter_for_provider_type(provider_type) else {
return Ok(None);
};
let token_url = self.token_url_for_template(template);
let request_refresh_token_fingerprint = secret_fingerprint(refresh_token.as_str());
tracing::info!(
key_id = %transport.key.id,
provider_id = %transport.provider.id,
endpoint_id = %transport.endpoint.id,
provider_type = template.provider_type,
auth_config_source = base_auth_config_source,
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
provider_type,
request_refresh_token_len = refresh_token.len(),
token_url = %token_url,
uses_json_payload = template.uses_json_payload,
"gateway generic oauth refresh request prepared"
"gateway generic oauth refresh delegated to provider oauth adapter"
);
let scope = (!template.scopes.is_empty()).then(|| template.scopes.join(" "));
let response = if template.uses_json_payload {
let mut body = serde_json::Map::from_iter([
(
"grant_type".to_string(),
Value::String("refresh_token".to_string()),
),
(
"client_id".to_string(),
Value::String(template.client_id.to_string()),
),
(
"refresh_token".to_string(),
Value::String(refresh_token.clone()),
),
]);
if let Some(scope) = scope.as_ref() {
body.insert("scope".to_string(), Value::String(scope.clone()));
}
executor
.execute(
template.provider_type,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:local-refresh-token",
method: reqwest::Method::POST,
url: token_url,
headers: BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "application/json".to_string()),
]),
json_body: Some(Value::Object(body)),
body_bytes: None,
},
)
.await?
} else {
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "refresh_token");
form.append_pair("client_id", template.client_id);
form.append_pair("refresh_token", refresh_token.as_str());
if let Some(scope) = scope.as_ref() {
form.append_pair("scope", scope);
}
if !template.client_secret.trim().is_empty() {
form.append_pair("client_secret", template.client_secret);
}
form.finish().into_bytes()
};
executor
.execute(
template.provider_type,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:local-refresh-token",
method: reqwest::Method::POST,
url: token_url,
headers: BTreeMap::from([
(
"content-type".to_string(),
"application/x-www-form-urlencoded".to_string(),
),
("accept".to_string(), "application/json".to_string()),
]),
json_body: None,
body_bytes: Some(form_body),
},
)
.await?
};
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
let body = response.body_text;
if !status.is_success() {
let body_excerpt = truncate_body(&body);
tracing::warn!(
key_id = %transport.key.id,
provider_id = %transport.provider.id,
endpoint_id = %transport.endpoint.id,
provider_type = template.provider_type,
status_code = status.as_u16(),
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
body_excerpt = %body_excerpt,
"gateway generic oauth refresh returned error status"
);
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: template.provider_type,
status_code: status.as_u16(),
body_excerpt,
});
}
let payload: Value =
serde_json::from_str(&body).map_err(|_| LocalOAuthRefreshError::InvalidResponse {
provider_type: template.provider_type,
message: "generic oauth refresh returned non-json body".to_string(),
})?;
let Some(access_token) = payload.get("access_token").and_then(non_empty_string) else {
return Err(LocalOAuthRefreshError::InvalidResponse {
provider_type: template.provider_type,
message: "generic oauth refresh returned empty access_token".to_string(),
});
let oauth_executor =
ProviderOAuthLocalHttpExecutor::new(provider_type, transport, executor);
let ctx = provider_oauth_transport_context_from_snapshot(transport);
let account = ProviderOAuthAccount {
provider_type: provider_type.to_string(),
access_token: current_access_token(transport, entry).unwrap_or_default(),
expires_at_unix_secs: auth_config_expires_at(&auth_config),
auth_config,
identity: BTreeMap::new(),
};
let refreshed = adapter
.refresh(&oauth_executor, &ctx, &account)
.await
.map_err(|error| oauth_error_to_local_refresh_error(provider_type, error))?;
let expires_at_unix_secs = resolve_expires_at(payload.get("expires_in"));
metadata.insert(
"provider_type".to_string(),
Value::String(template.provider_type.to_string()),
);
metadata.insert("updated_at".to_string(), json!(current_unix_secs()));
let response_refresh_token = payload.get("refresh_token").and_then(non_empty_string);
let response_refresh_token_fingerprint = response_refresh_token
.as_deref()
.map(secret_fingerprint)
.unwrap_or_else(|| "-".to_string());
let response_refresh_token_rotated = response_refresh_token
.as_deref()
.map(|value| value != refresh_token.as_str());
if let Some(refresh_token) = response_refresh_token.as_ref() {
metadata.insert(
"refresh_token".to_string(),
Value::String(refresh_token.clone()),
);
}
if let Some(token_type) = payload.get("token_type").and_then(non_empty_string) {
metadata.insert("token_type".to_string(), Value::String(token_type));
}
if let Some(scope) = payload.get("scope").and_then(non_empty_string) {
metadata.insert("scope".to_string(), Value::String(scope));
}
match expires_at_unix_secs {
Some(expires_at_unix_secs) => {
metadata.insert("expires_at".to_string(), json!(expires_at_unix_secs));
}
None => {
metadata.remove("expires_at");
}
}
let stored_refresh_token_fingerprint = metadata
.get("refresh_token")
.and_then(non_empty_string)
.map(|value| secret_fingerprint(value.as_str()))
.unwrap_or_else(|| "-".to_string());
let stored_refresh_token_source = if response_refresh_token.is_some() {
"response"
} else {
"existing"
};
tracing::info!(
key_id = %transport.key.id,
provider_id = %transport.provider.id,
endpoint_id = %transport.endpoint.id,
provider_type = template.provider_type,
status_code = status.as_u16(),
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
response_has_refresh_token = response_refresh_token.is_some(),
response_refresh_token_fingerprint = %response_refresh_token_fingerprint,
response_refresh_token_rotated = ?response_refresh_token_rotated,
stored_refresh_token_source = stored_refresh_token_source,
stored_refresh_token_fingerprint = %stored_refresh_token_fingerprint,
expires_at_unix_secs = ?expires_at_unix_secs,
provider_type,
expires_at_unix_secs = ?refreshed.token_set.expires_at_unix_secs,
response_has_refresh_token = refreshed.token_set.refresh_token.is_some(),
"gateway generic oauth refresh succeeded"
);
if response_refresh_token.is_none() && template.provider_type == "codex" {
tracing::warn!(
key_id = %transport.key.id,
provider_id = %transport.provider.id,
endpoint_id = %transport.endpoint.id,
provider_type = template.provider_type,
request_refresh_token_fingerprint = %request_refresh_token_fingerprint,
stored_refresh_token_fingerprint = %stored_refresh_token_fingerprint,
"gateway codex oauth refresh succeeded without replacement refresh_token"
);
}
Ok(Some(self.build_cached_entry(
template,
access_token.as_str(),
Value::Object(metadata),
expires_at_unix_secs,
)))
Ok(Some(Self::build_cached_entry(provider_type, refreshed)))
}
}
fn template_for_provider_type(provider_type: &str) -> Option<GenericOAuthTemplate> {
fn generic_provider_type(provider_type: &str) -> Option<&'static str> {
let normalized = provider_type.trim();
GENERIC_OAUTH_TEMPLATES
GENERIC_PROVIDER_OAUTH_TEMPLATES
.iter()
.find(|template| normalized.eq_ignore_ascii_case(template.provider_type))
.copied()
.map(|template| template.provider_type)
}
fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
@@ -529,27 +295,26 @@ fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
.and_then(non_empty_string)
}
fn auth_config_expires_at(auth_config: &Value) -> Option<u64> {
auth_config
.as_object()
.and_then(|object| object.get("expires_at"))
.and_then(|value| parse_u64_value(Some(value)))
}
fn auth_config_expires_soon(auth_config: Option<&Value>) -> bool {
expires_at_requires_refresh(
auth_config
.and_then(|value| value.as_object())
.and_then(|object| object.get("expires_at"))
.and_then(|value| parse_u64_value(Some(value))),
)
expires_at_requires_refresh(auth_config.and_then(auth_config_expires_at))
}
fn expires_at_requires_refresh(expires_at_unix_secs: Option<u64>) -> bool {
expires_at_unix_secs
.map(|expires_at_unix_secs| {
current_unix_secs() >= expires_at_unix_secs.saturating_sub(OAUTH_REFRESH_SKEW_SECS)
aether_oauth::core::current_unix_secs()
>= expires_at_unix_secs.saturating_sub(OAUTH_REFRESH_SKEW_SECS)
})
.unwrap_or(false)
}
fn resolve_expires_at(expires_in: Option<&Value>) -> Option<u64> {
parse_u64_value(expires_in).map(|expires_in| current_unix_secs().saturating_add(expires_in))
}
fn parse_u64_value(value: Option<&Value>) -> Option<u64> {
match value? {
Value::Number(number) => number.as_u64(),
@@ -566,28 +331,22 @@ fn non_empty_string(value: &Value) -> Option<String> {
.map(ToOwned::to_owned)
}
fn secret_fingerprint(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
let mut fingerprint = String::with_capacity(16);
for byte in digest.iter().take(8) {
use std::fmt::Write as _;
let _ = write!(&mut fingerprint, "{byte:02x}");
}
fingerprint
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|value| value.as_secs())
.unwrap_or_default()
}
fn truncate_body(body: &str) -> String {
let body = body.trim();
if body.is_empty() {
return String::from("-");
}
body.chars().take(500).collect()
fn current_access_token(
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Option<String> {
entry
.and_then(|entry| {
entry
.auth_header_value
.trim()
.strip_prefix("Bearer ")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| {
let secret = transport.key.decrypted_api_key.trim();
(!secret.is_empty() && secret != PLACEHOLDER_API_KEY).then(|| secret.to_string())
})
}

View File

@@ -1,18 +1,21 @@
use std::time::{SystemTime, UNIX_EPOCH};
use std::collections::BTreeMap;
use aether_oauth::provider::providers::KiroProviderOAuthAdapter as CoreKiroProviderOAuthAdapter;
use aether_oauth::provider::{ProviderOAuthAccount, ProviderOAuthAdapter};
use async_trait::async_trait;
use serde_json::{json, Value};
use super::super::oauth_refresh::{
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
oauth_error_to_local_refresh_error, provider_oauth_transport_context_from_snapshot,
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
LocalResolvedOAuthRequestAuth, ProviderOAuthLocalHttpExecutor,
};
use super::super::snapshot::GatewayProviderTransportSnapshot;
use super::auth::{
build_kiro_request_auth_from_config, resolve_local_kiro_request_auth, PROVIDER_TYPE,
};
use super::credentials::{generate_machine_id, KiroAuthConfig};
use super::credentials::KiroAuthConfig;
#[cfg(test)]
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, Default)]
@@ -38,39 +41,33 @@ impl KiroOAuthRefreshAdapter {
transport: &GatewayProviderTransportSnapshot,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
if auth_config.is_idc_auth() {
self.refresh_idc_token(executor, transport, auth_config)
.await
} else {
self.refresh_social_token(executor, transport, auth_config)
.await
}
}
fn social_refresh_url(&self, auth_config: &KiroAuthConfig) -> String {
if let Some(base_url) = self
.social_refresh_base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{}/refreshToken", base_url.trim_end_matches('/'));
}
let region = auth_config.effective_auth_region();
format!("https://prod.{region}.auth.desktop.kiro.dev/refreshToken")
}
fn idc_refresh_url(&self, auth_config: &KiroAuthConfig) -> String {
if let Some(base_url) = self
.idc_refresh_base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{}/token", base_url.trim_end_matches('/'));
}
let region = auth_config.effective_auth_region();
format!("https://oidc.{region}.amazonaws.com/token")
let adapter = CoreKiroProviderOAuthAdapter::default().with_refresh_base_urls(
self.social_refresh_base_url.clone(),
self.idc_refresh_base_url.clone(),
);
let oauth_executor =
ProviderOAuthLocalHttpExecutor::new(PROVIDER_TYPE, transport, executor);
let ctx = provider_oauth_transport_context_from_snapshot(transport);
let account = ProviderOAuthAccount {
provider_type: PROVIDER_TYPE.to_string(),
access_token: auth_config
.cached_access_token()
.map(ToOwned::to_owned)
.unwrap_or_default(),
auth_config: auth_config.to_json_value(),
expires_at_unix_secs: auth_config.expires_at,
identity: BTreeMap::new(),
};
let refreshed = adapter
.refresh(&oauth_executor, &ctx, &account)
.await
.map_err(|error| oauth_error_to_local_refresh_error(PROVIDER_TYPE, error))?;
KiroAuthConfig::from_json_value(&refreshed.auth_config).ok_or_else(|| {
LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "kiro refresh returned invalid auth_config".to_string(),
}
})
}
fn auth_config_from_entry(entry: &CachedOAuthEntry) -> Option<KiroAuthConfig> {
@@ -102,222 +99,6 @@ impl KiroOAuthRefreshAdapter {
})
}
async fn refresh_social_token(
&self,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
let url = self.social_refresh_url(auth_config);
let host = reqwest::Url::parse(&url)
.ok()
.and_then(|value| value.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| {
format!(
"prod.{}.auth.desktop.kiro.dev",
auth_config.effective_auth_region()
)
});
let machine_id = generate_machine_id(auth_config, None).ok_or_else(|| {
LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "missing machine_id seed for social refresh".to_string(),
}
})?;
let kiro_version = auth_config.effective_kiro_version();
let user_agent = build_kiro_ide_tag(kiro_version, &machine_id);
let response = executor
.execute(
PROVIDER_TYPE,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:kiro-social-refresh",
method: reqwest::Method::POST,
url,
headers: std::collections::BTreeMap::from([
("user-agent".to_string(), user_agent),
("host".to_string(), host),
(
"accept".to_string(),
"application/json, text/plain, */*".to_string(),
),
("content-type".to_string(), "application/json".to_string()),
("connection".to_string(), "close".to_string()),
(
"accept-encoding".to_string(),
"gzip, compress, deflate, br".to_string(),
),
]),
json_body: Some(json!({
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default()
})),
body_bytes: None,
},
)
.await?;
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
let body = response.body_text;
if !status.is_success() {
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: PROVIDER_TYPE,
status_code: status.as_u16(),
body_excerpt: truncate_body(&body),
});
}
let payload: Value =
serde_json::from_str(&body).map_err(|_| LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "social refresh returned non-json body".to_string(),
})?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "social refresh returned empty accessToken".to_string(),
})?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(resolve_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.map(str::trim)
.is_none_or(|value| value.is_empty())
{
refreshed.machine_id = Some(machine_id);
}
if let Some(refresh_token) = payload
.get("refreshToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.refresh_token = Some(refresh_token.to_string());
}
if let Some(profile_arn) = payload
.get("profileArn")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.profile_arn = Some(profile_arn.to_string());
}
Ok(refreshed)
}
async fn refresh_idc_token(
&self,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
let url = self.idc_refresh_url(auth_config);
let host = reqwest::Url::parse(&url)
.ok()
.and_then(|value| value.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| {
format!("oidc.{}.amazonaws.com", auth_config.effective_auth_region())
});
let response = executor
.execute(
PROVIDER_TYPE,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:kiro-idc-refresh",
method: reqwest::Method::POST,
url,
headers: std::collections::BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("host".to_string(), host),
(
"x-amz-user-agent".to_string(),
IDC_AMZ_USER_AGENT.to_string(),
),
("user-agent".to_string(), "node".to_string()),
("accept".to_string(), "*/*".to_string()),
]),
json_body: Some(json!({
"clientId": auth_config
.client_id
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"clientSecret": auth_config
.client_secret
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"grantType": "refresh_token"
})),
body_bytes: None,
},
)
.await?;
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
let body = response.body_text;
if !status.is_success() {
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: PROVIDER_TYPE,
status_code: status.as_u16(),
body_excerpt: truncate_body(&body),
});
}
let payload: Value =
serde_json::from_str(&body).map_err(|_| LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "idc refresh returned non-json body".to_string(),
})?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "idc refresh returned empty accessToken".to_string(),
})?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(resolve_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.map(str::trim)
.is_none_or(|value| value.is_empty())
{
refreshed.machine_id = generate_machine_id(auth_config, None);
}
if let Some(refresh_token) = payload
.get("refreshToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.refresh_token = Some(refresh_token.to_string());
}
Ok(refreshed)
}
fn refreshable_auth_config(
&self,
transport: &GatewayProviderTransportSnapshot,
@@ -374,53 +155,13 @@ impl LocalOAuthRefreshAdapter for KiroOAuthRefreshAdapter {
let Some(auth_config) = self.refreshable_auth_config(transport, entry) else {
return Ok(None);
};
let refreshed = if auth_config.is_idc_auth() {
self.refresh_idc_token(executor, transport, &auth_config)
.await?
} else {
self.refresh_social_token(executor, transport, &auth_config)
.await?
};
let refreshed = self
.refresh_auth_config(executor, transport, &auth_config)
.await?;
Ok(Self::build_cached_entry(&refreshed))
}
}
fn build_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> String {
if machine_id.trim().is_empty() {
format!("KiroIDE-{kiro_version}")
} else {
format!("KiroIDE-{kiro_version}-{machine_id}")
}
}
fn resolve_expires_at(payload: &Value) -> u64 {
let expires_in = payload
.get("expiresIn")
.and_then(|value| {
value
.as_u64()
.or_else(|| value.as_str()?.parse::<u64>().ok())
})
.unwrap_or(3600);
current_unix_secs().saturating_add(expires_in)
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|value| value.as_secs())
.unwrap_or_default()
}
fn truncate_body(body: &str) -> String {
let body = body.trim();
if body.is_empty() {
return String::from("-");
}
body.chars().take(500).collect()
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};

View File

@@ -17,6 +17,7 @@ pub mod url;
pub mod vertex;
mod video;
pub use aether_oauth as oauth;
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
pub use cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
pub use generic_oauth::{

View File

@@ -3,6 +3,11 @@ use std::fmt;
use std::sync::Arc;
use aether_data::redis::{RedisLockKey, RedisLockRunner};
use aether_oauth::core::OAuthError;
use aether_oauth::network::{
OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse, OAuthNetworkContext,
};
use aether_oauth::provider::ProviderOAuthTransportContext;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
@@ -72,6 +77,11 @@ pub enum LocalOAuthRefreshError {
status_code: u16,
body_excerpt: String,
},
#[error("{provider_type} oauth refresh transport failed: {message}")]
TransportMessage {
provider_type: &'static str,
message: String,
},
#[error("{provider_type} oauth refresh returned invalid response: {message}")]
InvalidResponse {
provider_type: &'static str,
@@ -144,6 +154,127 @@ impl LocalOAuthHttpExecutor for ReqwestLocalOAuthHttpExecutor {
}
}
pub(crate) struct ProviderOAuthLocalHttpExecutor<'a> {
provider_type: &'static str,
transport: &'a GatewayProviderTransportSnapshot,
inner: &'a dyn LocalOAuthHttpExecutor,
}
impl<'a> ProviderOAuthLocalHttpExecutor<'a> {
pub(crate) fn new(
provider_type: &'static str,
transport: &'a GatewayProviderTransportSnapshot,
inner: &'a dyn LocalOAuthHttpExecutor,
) -> Self {
Self {
provider_type,
transport,
inner,
}
}
}
#[async_trait]
impl OAuthHttpExecutor for ProviderOAuthLocalHttpExecutor<'_> {
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError> {
let response = self
.inner
.execute(
self.provider_type,
self.transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:local-refresh-token",
method: request.method,
url: request.url,
headers: request.headers,
json_body: request.json_body,
body_bytes: request.body_bytes,
},
)
.await
.map_err(local_refresh_error_to_oauth_error)?;
let json_body = serde_json::from_str::<Value>(&response.body_text).ok();
Ok(OAuthHttpResponse {
status_code: response.status_code,
body_text: response.body_text,
json_body,
})
}
}
pub(crate) fn provider_oauth_transport_context_from_snapshot(
transport: &GatewayProviderTransportSnapshot,
) -> ProviderOAuthTransportContext {
ProviderOAuthTransportContext {
provider_id: transport.provider.id.clone(),
provider_type: transport.provider.provider_type.clone(),
endpoint_id: Some(transport.endpoint.id.clone()),
key_id: Some(transport.key.id.clone()),
auth_type: Some(transport.key.auth_type.clone()),
decrypted_api_key: Some(transport.key.decrypted_api_key.clone()),
decrypted_auth_config: transport.key.decrypted_auth_config.clone(),
provider_config: transport.provider.config.clone(),
endpoint_config: transport.endpoint.config.clone(),
key_config: None,
network: OAuthNetworkContext::provider_operation(None),
}
}
pub(crate) fn oauth_error_to_local_refresh_error(
provider_type: &'static str,
error: OAuthError,
) -> LocalOAuthRefreshError {
match error {
OAuthError::HttpStatus {
status_code,
body_excerpt,
} => LocalOAuthRefreshError::HttpStatus {
provider_type,
status_code,
body_excerpt,
},
OAuthError::Transport(message) => LocalOAuthRefreshError::TransportMessage {
provider_type,
message,
},
OAuthError::InvalidRequest(message)
| OAuthError::InvalidResponse(message)
| OAuthError::Storage(message)
| OAuthError::UnsupportedProvider(message) => LocalOAuthRefreshError::InvalidResponse {
provider_type,
message,
},
OAuthError::InvalidState => LocalOAuthRefreshError::InvalidResponse {
provider_type,
message: "oauth state is invalid or expired".to_string(),
},
OAuthError::EncryptionUnavailable => LocalOAuthRefreshError::InvalidResponse {
provider_type,
message: "oauth encryption unavailable".to_string(),
},
}
}
fn local_refresh_error_to_oauth_error(error: LocalOAuthRefreshError) -> OAuthError {
match error {
LocalOAuthRefreshError::Transport { source, .. } => {
OAuthError::Transport(source.to_string())
}
LocalOAuthRefreshError::TransportMessage { message, .. } => OAuthError::Transport(message),
LocalOAuthRefreshError::HttpStatus {
status_code,
body_excerpt,
..
} => OAuthError::HttpStatus {
status_code,
body_excerpt,
},
LocalOAuthRefreshError::InvalidResponse { message, .. } => {
OAuthError::InvalidResponse(message)
}
}
}
#[async_trait]
pub trait LocalOAuthRefreshAdapter: Send + Sync {
fn provider_type(&self) -> &'static str;