mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
feat(providers): expand OAuth account management
Add Claude Code manual and cookie authorization, including redacted batch tasks. Harden OAuth imports, duplicate replacement, provider dialogs, and related account-management tests.
This commit is contained in:
@@ -81,6 +81,7 @@ impl IdentityOAuthProvider for CustomOidcIdentityOAuthProvider {
|
||||
json_body: None,
|
||||
body_bytes: Some(body_bytes),
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?;
|
||||
if !(200..300).contains(&response.status_code) {
|
||||
@@ -123,6 +124,7 @@ impl IdentityOAuthProvider for CustomOidcIdentityOAuthProvider {
|
||||
json_body: None,
|
||||
body_bytes: None,
|
||||
network,
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?;
|
||||
if !(200..300).contains(&response.status_code) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use crate::core::OAuthError;
|
||||
use aether_contracts::ResolvedTransportProfile;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::OAuthNetworkContext;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct OAuthHttpRequest {
|
||||
pub request_id: String,
|
||||
pub method: reqwest::Method,
|
||||
@@ -15,6 +16,31 @@ pub struct OAuthHttpRequest {
|
||||
pub json_body: Option<Value>,
|
||||
pub body_bytes: Option<Vec<u8>>,
|
||||
pub network: OAuthNetworkContext,
|
||||
pub transport_profile: Option<ResolvedTransportProfile>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OAuthHttpRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthHttpRequest")
|
||||
.field("request_id", &self.request_id)
|
||||
.field("method", &self.method)
|
||||
.field("url", &self.url)
|
||||
.field("header_names", &self.headers.keys().collect::<Vec<_>>())
|
||||
.field("content_type", &self.content_type)
|
||||
.field("has_json_body", &self.json_body.is_some())
|
||||
.field("body_bytes_len", &self.body_bytes.as_ref().map(Vec::len))
|
||||
.field("network_policy", &self.network.policy)
|
||||
.field("has_proxy", &self.network.proxy.is_some())
|
||||
.field(
|
||||
"transport_profile_id",
|
||||
&self
|
||||
.transport_profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.profile_id.as_str()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeMap;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderOAuthCapabilities {
|
||||
pub supports_authorization_code: bool,
|
||||
pub supports_cookie_authorization: bool,
|
||||
pub supports_refresh_token_import: bool,
|
||||
pub supports_batch_import: bool,
|
||||
pub supports_device_flow: bool,
|
||||
@@ -16,6 +17,7 @@ pub struct ProviderOAuthCapabilities {
|
||||
impl ProviderOAuthCapabilities {
|
||||
pub const GENERIC_AUTH_CODE: Self = Self {
|
||||
supports_authorization_code: true,
|
||||
supports_cookie_authorization: false,
|
||||
supports_refresh_token_import: true,
|
||||
supports_batch_import: true,
|
||||
supports_device_flow: false,
|
||||
@@ -24,6 +26,20 @@ impl ProviderOAuthCapabilities {
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ProviderOAuthCookieAuthorizationInput {
|
||||
pub session_key: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderOAuthCookieAuthorizationInput {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderOAuthCookieAuthorizationInput")
|
||||
.field("session_key", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderOAuthTransportContext {
|
||||
pub provider_id: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
|
||||
ProviderOAuthImportInput, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
|
||||
ProviderOAuthTransportContext,
|
||||
ProviderOAuthCookieAuthorizationInput, ProviderOAuthImportInput, ProviderOAuthRequestAuth,
|
||||
ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||
};
|
||||
use crate::core::{OAuthAuthorizeResponse, OAuthError};
|
||||
use crate::network::OAuthHttpExecutor;
|
||||
@@ -42,6 +42,17 @@ pub trait ProviderOAuthAdapter: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
async fn authorize_with_cookie(
|
||||
&self,
|
||||
_executor: &dyn OAuthHttpExecutor,
|
||||
_ctx: &ProviderOAuthTransportContext,
|
||||
_input: ProviderOAuthCookieAuthorizationInput,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
Err(OAuthError::UnsupportedProvider(
|
||||
self.provider_type().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn import_credentials(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
|
||||
@@ -5,8 +5,8 @@ mod service;
|
||||
|
||||
pub use account::{
|
||||
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthCapabilities,
|
||||
ProviderOAuthImportInput, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
|
||||
ProviderOAuthTransportContext,
|
||||
ProviderOAuthCookieAuthorizationInput, ProviderOAuthImportInput, ProviderOAuthRequestAuth,
|
||||
ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||
};
|
||||
pub use adapter::{ProviderOAuthAdapter, ProviderOAuthProbeResult};
|
||||
pub use service::ProviderOAuthService;
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
use super::generic::{template_for_provider_type, GenericProviderOAuthAdapter};
|
||||
use crate::core::{
|
||||
generate_oauth_nonce, generate_pkce_verifier, pkce_s256, OAuthAuthorizeResponse,
|
||||
};
|
||||
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
|
||||
use crate::provider::{
|
||||
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthCapabilities,
|
||||
ProviderOAuthCookieAuthorizationInput, ProviderOAuthImportInput, ProviderOAuthProbeResult,
|
||||
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||
};
|
||||
use crate::OAuthError;
|
||||
use aether_contracts::{
|
||||
ResolvedTransportProfile, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
|
||||
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use url::Url;
|
||||
|
||||
pub const CLAUDE_CODE_PROVIDER_TYPE: &str = "claude_code";
|
||||
pub const CLAUDE_CODE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
pub const CLAUDE_CODE_WEB_BASE_URL: &str = "https://claude.ai";
|
||||
pub const CLAUDE_CODE_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
|
||||
pub const CLAUDE_CODE_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
|
||||
pub const CLAUDE_CODE_REDIRECT_URI: &str = "https://platform.claude.com/oauth/code/callback";
|
||||
pub const CLAUDE_CODE_OAUTH_SCOPES: &[&str] = &[
|
||||
"org:create_api_key",
|
||||
"user:profile",
|
||||
"user:inference",
|
||||
"user:sessions:claude_code",
|
||||
"user:mcp_servers",
|
||||
"user:file_upload",
|
||||
];
|
||||
pub const CLAUDE_CODE_COOKIE_SCOPE: &str =
|
||||
"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
|
||||
const MAX_CLAUDE_SESSION_KEY_BYTES: usize = 16 * 1024;
|
||||
const CLAUDE_CODE_BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClaudeCodeProviderOAuthAdapter {
|
||||
inner: GenericProviderOAuthAdapter,
|
||||
web_base_url: String,
|
||||
}
|
||||
|
||||
impl Default for ClaudeCodeProviderOAuthAdapter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: GenericProviderOAuthAdapter::new(
|
||||
template_for_provider_type(CLAUDE_CODE_PROVIDER_TYPE)
|
||||
.expect("claude code oauth template should exist"),
|
||||
),
|
||||
web_base_url: CLAUDE_CODE_WEB_BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClaudeCodeProviderOAuthAdapter {
|
||||
pub fn with_endpoint_overrides(
|
||||
mut self,
|
||||
web_base_url: impl Into<String>,
|
||||
token_url: impl Into<String>,
|
||||
) -> Self {
|
||||
self.web_base_url = web_base_url.into();
|
||||
self.inner = self.inner.with_token_url_override(token_url);
|
||||
self
|
||||
}
|
||||
|
||||
fn web_url(&self, path_segments: &[&str]) -> Result<String, OAuthError> {
|
||||
let mut url = Url::parse(self.web_base_url.trim())
|
||||
.map_err(|_| OAuthError::invalid_request("claude web base url must be absolute"))?;
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| OAuthError::invalid_request("claude web base url is invalid"))?;
|
||||
segments.clear();
|
||||
segments.extend(path_segments.iter().copied());
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn session_cookie(session_key: &str) -> Result<String, OAuthError> {
|
||||
let session_key = session_key.trim();
|
||||
if session_key.is_empty()
|
||||
|| session_key.len() > MAX_CLAUDE_SESSION_KEY_BYTES
|
||||
|| session_key.contains(['\r', '\n', ';'])
|
||||
|| http::HeaderValue::from_str(session_key).is_err()
|
||||
{
|
||||
return Err(OAuthError::invalid_request("invalid Claude sessionKey"));
|
||||
}
|
||||
Ok(format!("sessionKey={session_key}"))
|
||||
}
|
||||
|
||||
async fn organization_uuid(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
cookie: &str,
|
||||
) -> Result<String, OAuthError> {
|
||||
let response = executor
|
||||
.execute(OAuthHttpRequest {
|
||||
request_id: "provider-oauth:claude-cookie-organizations".to_string(),
|
||||
method: reqwest::Method::GET,
|
||||
url: self.web_url(&["api", "organizations"])?,
|
||||
headers: cookie_headers(cookie, false),
|
||||
content_type: None,
|
||||
json_body: None,
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: claude_code_oauth_transport_profile_for_context(ctx),
|
||||
})
|
||||
.await?;
|
||||
ensure_success(&response)?;
|
||||
let organizations = response
|
||||
.json_body
|
||||
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("Claude organizations response is invalid")
|
||||
})?;
|
||||
|
||||
let organization = if organizations.len() == 1 {
|
||||
organizations.first()
|
||||
} else {
|
||||
organizations
|
||||
.iter()
|
||||
.find(|organization| {
|
||||
organization
|
||||
.get("raven_type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("team"))
|
||||
})
|
||||
.or_else(|| organizations.first())
|
||||
}
|
||||
.ok_or_else(|| OAuthError::invalid_response("Claude account has no organizations"))?;
|
||||
|
||||
organization
|
||||
.get("uuid")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| OAuthError::invalid_response("Claude organization is missing uuid"))
|
||||
}
|
||||
|
||||
async fn authorization_code(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
cookie: &str,
|
||||
organization_uuid: &str,
|
||||
state: &str,
|
||||
code_challenge: &str,
|
||||
) -> Result<String, OAuthError> {
|
||||
let response = executor
|
||||
.execute(OAuthHttpRequest {
|
||||
request_id: "provider-oauth:claude-cookie-authorize".to_string(),
|
||||
method: reqwest::Method::POST,
|
||||
url: self.web_url(&["v1", "oauth", organization_uuid, "authorize"])?,
|
||||
headers: cookie_headers(cookie, true),
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(json!({
|
||||
"response_type": "code",
|
||||
"client_id": CLAUDE_CODE_CLIENT_ID,
|
||||
"organization_uuid": organization_uuid,
|
||||
"redirect_uri": CLAUDE_CODE_REDIRECT_URI,
|
||||
"scope": CLAUDE_CODE_COOKIE_SCOPE,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
})),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: claude_code_oauth_transport_profile_for_context(ctx),
|
||||
})
|
||||
.await?;
|
||||
ensure_success(&response)?;
|
||||
let payload = response
|
||||
.json_body
|
||||
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||
.ok_or_else(|| OAuthError::invalid_response("Claude authorize response is invalid"))?;
|
||||
let redirect_uri = payload
|
||||
.get("redirect_uri")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("Claude authorize response is missing redirect_uri")
|
||||
})?;
|
||||
|
||||
validate_authorization_redirect(&redirect_uri, state)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderOAuthAdapter for ClaudeCodeProviderOAuthAdapter {
|
||||
fn provider_type(&self) -> &'static str {
|
||||
CLAUDE_CODE_PROVIDER_TYPE
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderOAuthCapabilities {
|
||||
ProviderOAuthCapabilities {
|
||||
supports_cookie_authorization: true,
|
||||
..self.inner.capabilities()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_authorize_url(
|
||||
&self,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
state: &str,
|
||||
code_challenge: Option<&str>,
|
||||
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||
let mut response = self.inner.build_authorize_url(ctx, state, code_challenge)?;
|
||||
let mut url = Url::parse(&response.authorize_url)
|
||||
.map_err(|_| OAuthError::invalid_response("invalid Claude authorize_url"))?;
|
||||
url.query_pairs_mut().append_pair("code", "true");
|
||||
response.authorize_url = url.to_string();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn exchange_code(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
code: &str,
|
||||
state: &str,
|
||||
pkce_verifier: Option<&str>,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
self.inner
|
||||
.exchange_code(executor, ctx, code, state, pkce_verifier)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authorize_with_cookie(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
input: ProviderOAuthCookieAuthorizationInput,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
let cookie = Self::session_cookie(&input.session_key)?;
|
||||
let organization_uuid = self.organization_uuid(executor, ctx, &cookie).await?;
|
||||
let state = generate_oauth_nonce();
|
||||
let verifier = generate_pkce_verifier();
|
||||
let challenge = pkce_s256(&verifier);
|
||||
let code = self
|
||||
.authorization_code(
|
||||
executor,
|
||||
ctx,
|
||||
&cookie,
|
||||
&organization_uuid,
|
||||
&state,
|
||||
&challenge,
|
||||
)
|
||||
.await?;
|
||||
self.inner
|
||||
.exchange_code(executor, ctx, &code, &state, Some(&verifier))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn import_credentials(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
input: ProviderOAuthImportInput,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
self.inner.import_credentials(executor, ctx, input).await
|
||||
}
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
account: &ProviderOAuthAccount,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
self.inner.refresh(executor, ctx, account).await
|
||||
}
|
||||
|
||||
fn resolve_request_auth(
|
||||
&self,
|
||||
account: &ProviderOAuthAccount,
|
||||
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
|
||||
self.inner.resolve_request_auth(account)
|
||||
}
|
||||
|
||||
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
|
||||
self.inner.account_fingerprint(account)
|
||||
}
|
||||
|
||||
async fn probe_account_state(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
account: &ProviderOAuthAccount,
|
||||
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
|
||||
self.inner.probe_account_state(executor, ctx, account).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn claude_code_oauth_transport_profile() -> ResolvedTransportProfile {
|
||||
ResolvedTransportProfile {
|
||||
profile_id: "claude_oauth_chrome136".to_string(),
|
||||
backend: TRANSPORT_BACKEND_BROWSER_WREQ.to_string(),
|
||||
http_mode: TRANSPORT_HTTP_MODE_AUTO.to_string(),
|
||||
pool_scope: TRANSPORT_POOL_SCOPE_KEY.to_string(),
|
||||
header_fingerprint: None,
|
||||
extra: Some(json!({ "browser_profile": "chrome136" })),
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_code_oauth_transport_profile_for_context(
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
) -> Option<ResolvedTransportProfile> {
|
||||
// Node-only proxies must execute through the tunnel runtime, which cannot use browser_wreq.
|
||||
// The explicit browser headers still keep that fallback compatible with Claude's web flow.
|
||||
let tunnel_only_proxy = ctx.network.proxy.as_ref().is_some_and(|proxy| {
|
||||
if proxy.enabled == Some(false) {
|
||||
return false;
|
||||
}
|
||||
let has_proxy_url = proxy
|
||||
.url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_node_id = proxy
|
||||
.node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let tunnel_mode = proxy
|
||||
.mode
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("tunnel"));
|
||||
has_node_id && (tunnel_mode || !has_proxy_url)
|
||||
});
|
||||
(!tunnel_only_proxy).then(claude_code_oauth_transport_profile)
|
||||
}
|
||||
|
||||
fn cookie_headers(cookie: &str, json_request: bool) -> BTreeMap<String, String> {
|
||||
let mut headers = BTreeMap::from([
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
("accept-language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
("cache-control".to_string(), "no-cache".to_string()),
|
||||
("cookie".to_string(), cookie.to_string()),
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
CLAUDE_CODE_BROWSER_USER_AGENT.to_string(),
|
||||
),
|
||||
(
|
||||
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.to_string(),
|
||||
"false".to_string(),
|
||||
),
|
||||
]);
|
||||
if json_request {
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
headers.insert("origin".to_string(), CLAUDE_CODE_WEB_BASE_URL.to_string());
|
||||
headers.insert(
|
||||
"referer".to_string(),
|
||||
format!("{CLAUDE_CODE_WEB_BASE_URL}/new"),
|
||||
);
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn ensure_success(response: &crate::network::OAuthHttpResponse) -> Result<(), OAuthError> {
|
||||
if (200..300).contains(&response.status_code) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(OAuthError::HttpStatus {
|
||||
status_code: response.status_code,
|
||||
body_excerpt: "Claude Cookie authorization request failed".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_authorization_redirect(
|
||||
redirect_uri: &str,
|
||||
expected_state: &str,
|
||||
) -> Result<String, OAuthError> {
|
||||
let redirect = Url::parse(redirect_uri)
|
||||
.map_err(|_| OAuthError::invalid_response("Claude authorize redirect_uri is invalid"))?;
|
||||
let expected = Url::parse(CLAUDE_CODE_REDIRECT_URI).map_err(|_| {
|
||||
OAuthError::invalid_response("Claude redirect URI configuration is invalid")
|
||||
})?;
|
||||
if redirect.scheme() != expected.scheme()
|
||||
|| redirect.host_str() != expected.host_str()
|
||||
|| redirect.port_or_known_default() != expected.port_or_known_default()
|
||||
|| redirect.path() != expected.path()
|
||||
|| !redirect.username().is_empty()
|
||||
|| redirect.password().is_some()
|
||||
|| redirect.fragment().is_some()
|
||||
{
|
||||
return Err(OAuthError::invalid_response(
|
||||
"Claude authorize redirect_uri target is invalid",
|
||||
));
|
||||
}
|
||||
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
for (key, value) in redirect.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" if code.is_none() => code = Some(value.into_owned()),
|
||||
"state" if state.is_none() => state = Some(value.into_owned()),
|
||||
"code" | "state" => {
|
||||
return Err(OAuthError::invalid_response(
|
||||
"Claude authorize redirect_uri has duplicate parameters",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let code = code
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("Claude authorize redirect_uri is missing code")
|
||||
})?;
|
||||
let state = state
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("Claude authorize redirect_uri is missing state")
|
||||
})?;
|
||||
if state != expected_state {
|
||||
return Err(OAuthError::InvalidState);
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::network::{OAuthHttpResponse, OAuthNetworkContext};
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
enum RedirectMode {
|
||||
#[default]
|
||||
Matching,
|
||||
WrongState,
|
||||
HostileHost,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RecordingExecutor {
|
||||
requests: Arc<Mutex<Vec<OAuthHttpRequest>>>,
|
||||
organizations: Value,
|
||||
token_payload: Value,
|
||||
redirect_mode: RedirectMode,
|
||||
}
|
||||
|
||||
impl Default for RecordingExecutor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
organizations: json!([
|
||||
{"uuid": "org-personal", "raven_type": "personal"},
|
||||
{"uuid": "org-team", "raven_type": "team"}
|
||||
]),
|
||||
token_payload: json!({
|
||||
"access_token": "sk-ant-oat01-new",
|
||||
"refresh_token": "sk-ant-ort01-new",
|
||||
"expires_in": 3600,
|
||||
"organization": {"uuid": "org-team"},
|
||||
"account": {
|
||||
"uuid": "account-123",
|
||||
"email_address": "[email protected]"
|
||||
}
|
||||
}),
|
||||
redirect_mode: RedirectMode::Matching,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OAuthHttpExecutor for RecordingExecutor {
|
||||
async fn execute(
|
||||
&self,
|
||||
request: OAuthHttpRequest,
|
||||
) -> Result<OAuthHttpResponse, OAuthError> {
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("requests lock")
|
||||
.push(request.clone());
|
||||
|
||||
let payload = if request.url.ends_with("/api/organizations") {
|
||||
self.organizations.clone()
|
||||
} else if request.url.contains("/authorize") {
|
||||
let requested_state = request
|
||||
.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("state"))
|
||||
.and_then(Value::as_str)
|
||||
.expect("authorize request should contain state");
|
||||
let state = match self.redirect_mode {
|
||||
RedirectMode::Matching | RedirectMode::HostileHost => requested_state,
|
||||
RedirectMode::WrongState => "wrong-state",
|
||||
};
|
||||
let redirect_base = match self.redirect_mode {
|
||||
RedirectMode::HostileHost => {
|
||||
"https://platform.claude.com.evil/oauth/code/callback"
|
||||
}
|
||||
_ => CLAUDE_CODE_REDIRECT_URI,
|
||||
};
|
||||
let mut redirect = Url::parse(redirect_base).expect("redirect URL should parse");
|
||||
redirect
|
||||
.query_pairs_mut()
|
||||
.append_pair("code", "authorization-code")
|
||||
.append_pair("state", state);
|
||||
json!({"redirect_uri": redirect.to_string()})
|
||||
} else {
|
||||
self.token_payload.clone()
|
||||
};
|
||||
|
||||
Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: payload.to_string(),
|
||||
json_body: Some(payload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn context(proxy: Option<ProxySnapshot>) -> ProviderOAuthTransportContext {
|
||||
ProviderOAuthTransportContext {
|
||||
provider_id: "provider-claude".to_string(),
|
||||
provider_type: CLAUDE_CODE_PROVIDER_TYPE.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: OAuthNetworkContext::provider_operation(proxy),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_current_manual_authorize_url() {
|
||||
let adapter = ClaudeCodeProviderOAuthAdapter::default();
|
||||
let response = adapter
|
||||
.build_authorize_url(&context(None), "state-123", Some("challenge-123"))
|
||||
.expect("authorize URL should build");
|
||||
let url = Url::parse(&response.authorize_url).expect("authorize URL should parse");
|
||||
let query = url
|
||||
.query_pairs()
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}://{}{}",
|
||||
url.scheme(),
|
||||
url.host_str().unwrap_or_default(),
|
||||
url.path()
|
||||
),
|
||||
CLAUDE_CODE_AUTHORIZE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
query.get("client_id").map(String::as_str),
|
||||
Some(CLAUDE_CODE_CLIENT_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
query.get("redirect_uri").map(String::as_str),
|
||||
Some(CLAUDE_CODE_REDIRECT_URI)
|
||||
);
|
||||
assert_eq!(
|
||||
query.get("scope").map(String::as_str),
|
||||
Some(CLAUDE_CODE_OAUTH_SCOPES.join(" ").as_str())
|
||||
);
|
||||
assert_eq!(query.get("code").map(String::as_str), Some("true"));
|
||||
assert_eq!(
|
||||
query.get("code_challenge").map(String::as_str),
|
||||
Some("challenge-123")
|
||||
);
|
||||
assert!(adapter.capabilities().supports_cookie_authorization);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cookie_authorization_uses_team_org_safe_headers_and_current_token_contract() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = ClaudeCodeProviderOAuthAdapter::default().with_endpoint_overrides(
|
||||
"https://claude.test",
|
||||
"https://platform.test/v1/oauth/token",
|
||||
);
|
||||
let session_key = "sk-ant-sid01-secret";
|
||||
|
||||
let result = adapter
|
||||
.authorize_with_cookie(
|
||||
&executor,
|
||||
&context(None),
|
||||
ProviderOAuthCookieAuthorizationInput {
|
||||
session_key: session_key.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("cookie authorization should succeed");
|
||||
|
||||
assert_eq!(result.token_set.access_token, "sk-ant-oat01-new");
|
||||
assert_eq!(
|
||||
result.token_set.refresh_token.as_deref(),
|
||||
Some("sk-ant-ort01-new")
|
||||
);
|
||||
assert_eq!(result.auth_config["org_uuid"], "org-team");
|
||||
assert_eq!(result.auth_config["account_uuid"], "account-123");
|
||||
assert_eq!(result.auth_config["email"], "[email protected]");
|
||||
|
||||
let requests = executor.requests.lock().expect("requests lock").clone();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(requests[0].method, reqwest::Method::GET);
|
||||
assert!(requests[0].url.ends_with("/api/organizations"));
|
||||
assert!(requests[1].url.ends_with("/v1/oauth/org-team/authorize"));
|
||||
for request in &requests[..2] {
|
||||
assert_eq!(
|
||||
request.headers.get("cookie").map(String::as_str),
|
||||
Some("sessionKey=sk-ant-sid01-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
request
|
||||
.headers
|
||||
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
||||
.map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some(CLAUDE_CODE_BROWSER_USER_AGENT)
|
||||
);
|
||||
assert_eq!(
|
||||
request
|
||||
.transport_profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.backend.as_str()),
|
||||
Some(TRANSPORT_BACKEND_BROWSER_WREQ)
|
||||
);
|
||||
assert!(!format!("{request:?}").contains(session_key));
|
||||
}
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("scope"))
|
||||
.and_then(Value::as_str),
|
||||
Some(CLAUDE_CODE_COOKIE_SCOPE)
|
||||
);
|
||||
|
||||
let token_request = &requests[2];
|
||||
assert_eq!(token_request.url, "https://platform.test/v1/oauth/token");
|
||||
assert!(!token_request.headers.contains_key("cookie"));
|
||||
assert!(token_request.transport_profile.is_none());
|
||||
assert_eq!(
|
||||
token_request.headers.get("user-agent").map(String::as_str),
|
||||
Some("axios/1.13.6")
|
||||
);
|
||||
let token_body = token_request
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("token request should be JSON");
|
||||
assert!(token_body.get("scope").is_none());
|
||||
assert_eq!(token_body["redirect_uri"], CLAUDE_CODE_REDIRECT_URI);
|
||||
assert_eq!(token_body["code"], "authorization-code");
|
||||
assert!(token_body.get("code_verifier").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_state_and_hostile_authorize_redirects() {
|
||||
for redirect_mode in [RedirectMode::WrongState, RedirectMode::HostileHost] {
|
||||
let executor = RecordingExecutor {
|
||||
redirect_mode,
|
||||
..RecordingExecutor::default()
|
||||
};
|
||||
let adapter = ClaudeCodeProviderOAuthAdapter::default().with_endpoint_overrides(
|
||||
"https://claude.test",
|
||||
"https://platform.test/v1/oauth/token",
|
||||
);
|
||||
let error = adapter
|
||||
.authorize_with_cookie(
|
||||
&executor,
|
||||
&context(None),
|
||||
ProviderOAuthCookieAuthorizationInput {
|
||||
session_key: "sk-ant-sid01-secret".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("unsafe redirect should be rejected");
|
||||
match redirect_mode {
|
||||
RedirectMode::WrongState => assert!(matches!(error, OAuthError::InvalidState)),
|
||||
RedirectMode::HostileHost => {
|
||||
assert!(matches!(error, OAuthError::InvalidResponse(_)))
|
||||
}
|
||||
RedirectMode::Matching => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunnel_proxy_falls_back_from_browser_transport_even_when_url_is_present() {
|
||||
for proxy in [
|
||||
ProxySnapshot {
|
||||
node_id: Some("node-only".to_string()),
|
||||
..ProxySnapshot::default()
|
||||
},
|
||||
ProxySnapshot {
|
||||
mode: Some("tunnel".to_string()),
|
||||
node_id: Some("node-with-url".to_string()),
|
||||
url: Some("http://127.0.0.1:9999".to_string()),
|
||||
..ProxySnapshot::default()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
claude_code_oauth_transport_profile_for_context(&context(Some(proxy))).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
let url_proxy = ProxySnapshot {
|
||||
mode: Some("url".to_string()),
|
||||
node_id: Some("metadata-node".to_string()),
|
||||
url: Some("http://127.0.0.1:9999".to_string()),
|
||||
..ProxySnapshot::default()
|
||||
};
|
||||
assert!(
|
||||
claude_code_oauth_transport_profile_for_context(&context(Some(url_proxy))).is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_headers("sessionKey=test", false)
|
||||
.get("user-agent")
|
||||
.map(String::as_str),
|
||||
Some(CLAUDE_CODE_BROWSER_USER_AGENT)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_claude_refresh_token_without_scope() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = ClaudeCodeProviderOAuthAdapter::default().with_endpoint_overrides(
|
||||
"https://claude.test",
|
||||
"https://platform.test/v1/oauth/token",
|
||||
);
|
||||
let account = ProviderOAuthAccount {
|
||||
provider_type: CLAUDE_CODE_PROVIDER_TYPE.to_string(),
|
||||
access_token: "sk-ant-oat01-old".to_string(),
|
||||
auth_config: json!({
|
||||
"provider_type": CLAUDE_CODE_PROVIDER_TYPE,
|
||||
"refresh_token": "sk-ant-ort01-old",
|
||||
"email": "[email protected]"
|
||||
}),
|
||||
expires_at_unix_secs: Some(1),
|
||||
identity: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let refreshed = adapter
|
||||
.refresh(&executor, &context(None), &account)
|
||||
.await
|
||||
.expect("refresh should succeed");
|
||||
|
||||
assert_eq!(refreshed.token_set.access_token, "sk-ant-oat01-new");
|
||||
assert_eq!(
|
||||
refreshed.token_set.refresh_token.as_deref(),
|
||||
Some("sk-ant-ort01-new")
|
||||
);
|
||||
assert_eq!(refreshed.auth_config["refresh_token"], "sk-ant-ort01-new");
|
||||
let requests = executor.requests.lock().expect("requests lock");
|
||||
assert_eq!(requests.len(), 1);
|
||||
let body = requests[0]
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("refresh request should be JSON");
|
||||
assert_eq!(body["grant_type"], "refresh_token");
|
||||
assert_eq!(body["refresh_token"], "sk-ant-ort01-old");
|
||||
assert!(body.get("scope").is_none());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,11 @@ use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use super::claude_code::{
|
||||
CLAUDE_CODE_AUTHORIZE_URL, CLAUDE_CODE_CLIENT_ID, CLAUDE_CODE_OAUTH_SCOPES,
|
||||
CLAUDE_CODE_PROVIDER_TYPE, CLAUDE_CODE_REDIRECT_URI, CLAUDE_CODE_TOKEN_URL,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct GenericProviderOAuthTemplate {
|
||||
pub provider_type: &'static str,
|
||||
@@ -24,20 +29,22 @@ pub struct GenericProviderOAuthTemplate {
|
||||
pub redirect_uri: &'static str,
|
||||
pub use_pkce: bool,
|
||||
pub uses_json_payload: bool,
|
||||
pub include_scope_in_token_request: bool,
|
||||
}
|
||||
|
||||
pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "claude_code",
|
||||
provider_type: CLAUDE_CODE_PROVIDER_TYPE,
|
||||
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",
|
||||
authorize_url: CLAUDE_CODE_AUTHORIZE_URL,
|
||||
token_url: CLAUDE_CODE_TOKEN_URL,
|
||||
client_id: CLAUDE_CODE_CLIENT_ID,
|
||||
client_secret: "",
|
||||
scopes: &["org:create_api_key", "user:profile", "user:inference"],
|
||||
redirect_uri: "http://localhost:54545/callback",
|
||||
scopes: CLAUDE_CODE_OAUTH_SCOPES,
|
||||
redirect_uri: CLAUDE_CODE_REDIRECT_URI,
|
||||
use_pkce: true,
|
||||
uses_json_payload: true,
|
||||
include_scope_in_token_request: false,
|
||||
},
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "codex",
|
||||
@@ -50,6 +57,7 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||
redirect_uri: "http://localhost:1455/auth/callback",
|
||||
use_pkce: true,
|
||||
uses_json_payload: false,
|
||||
include_scope_in_token_request: true,
|
||||
},
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "chatgpt_web",
|
||||
@@ -62,6 +70,7 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||
redirect_uri: "http://localhost:1455/auth/callback",
|
||||
use_pkce: true,
|
||||
uses_json_payload: false,
|
||||
include_scope_in_token_request: true,
|
||||
},
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "gemini_cli",
|
||||
@@ -78,6 +87,7 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||
redirect_uri: "http://localhost:8085/oauth2callback",
|
||||
use_pkce: false,
|
||||
uses_json_payload: false,
|
||||
include_scope_in_token_request: true,
|
||||
},
|
||||
GenericProviderOAuthTemplate {
|
||||
provider_type: "antigravity",
|
||||
@@ -96,6 +106,7 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
|
||||
redirect_uri: "http://localhost:51121/oauth2callback",
|
||||
use_pkce: true,
|
||||
uses_json_payload: false,
|
||||
include_scope_in_token_request: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -185,19 +196,22 @@ impl GenericProviderOAuthAdapter {
|
||||
Value::String(code_or_refresh_token.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(scope) = scope.as_ref() {
|
||||
body.insert("scope".to_string(), Value::String(scope.clone()));
|
||||
if self.template.include_scope_in_token_request {
|
||||
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(),
|
||||
headers: json_headers(self.template.provider_type),
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(Value::Object(body)),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?
|
||||
} else {
|
||||
@@ -214,8 +228,10 @@ impl GenericProviderOAuthAdapter {
|
||||
} 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.include_scope_in_token_request {
|
||||
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);
|
||||
@@ -232,6 +248,7 @@ impl GenericProviderOAuthAdapter {
|
||||
json_body: None,
|
||||
body_bytes: Some(form_body),
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?
|
||||
};
|
||||
@@ -422,11 +439,19 @@ fn form_headers() -> BTreeMap<String, String> {
|
||||
])
|
||||
}
|
||||
|
||||
fn json_headers() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
fn json_headers(provider_type: &str) -> BTreeMap<String, String> {
|
||||
let mut headers = BTreeMap::from([
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
])
|
||||
]);
|
||||
if provider_type.eq_ignore_ascii_case(CLAUDE_CODE_PROVIDER_TYPE) {
|
||||
headers.insert(
|
||||
"accept".to_string(),
|
||||
"application/json, text/plain, */*".to_string(),
|
||||
);
|
||||
headers.insert("user-agent".to_string(), "axios/1.13.6".to_string());
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn truncate_body(body: &str) -> String {
|
||||
@@ -470,6 +495,32 @@ fn enrich_generic_identity(
|
||||
}
|
||||
}
|
||||
}
|
||||
if provider_type.eq_ignore_ascii_case(CLAUDE_CODE_PROVIDER_TYPE) {
|
||||
if let Some(organization_uuid) = token_payload
|
||||
.get("organization")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("uuid"))
|
||||
.cloned()
|
||||
{
|
||||
auth_config
|
||||
.entry("org_uuid".to_string())
|
||||
.or_insert(organization_uuid);
|
||||
}
|
||||
if let Some(account) = token_payload.get("account").and_then(Value::as_object) {
|
||||
if let Some(account_uuid) = account.get("uuid").cloned() {
|
||||
auth_config
|
||||
.entry("account_uuid".to_string())
|
||||
.or_insert(account_uuid);
|
||||
}
|
||||
if let Some(email) = account.get("email_address").cloned() {
|
||||
auth_config
|
||||
.entry("email_address".to_string())
|
||||
.or_insert_with(|| email.clone());
|
||||
auth_config.entry("email".to_string()).or_insert(email);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"codex" | "chatgpt_web"
|
||||
|
||||
@@ -298,6 +298,7 @@ impl KiroProviderOAuthAdapter {
|
||||
})),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?;
|
||||
if !(200..300).contains(&response.status_code) {
|
||||
@@ -376,6 +377,7 @@ impl KiroProviderOAuthAdapter {
|
||||
})),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?;
|
||||
if !(200..300).contains(&response.status_code) {
|
||||
@@ -420,6 +422,7 @@ impl ProviderOAuthAdapter for KiroProviderOAuthAdapter {
|
||||
fn capabilities(&self) -> ProviderOAuthCapabilities {
|
||||
ProviderOAuthCapabilities {
|
||||
supports_authorization_code: false,
|
||||
supports_cookie_authorization: false,
|
||||
supports_refresh_token_import: true,
|
||||
supports_batch_import: true,
|
||||
supports_device_flow: true,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
mod antigravity;
|
||||
mod claude_code;
|
||||
mod codex;
|
||||
mod generic;
|
||||
mod kiro;
|
||||
mod windsurf;
|
||||
|
||||
pub use antigravity::AntigravityProviderOAuthAdapter;
|
||||
pub use claude_code::{
|
||||
ClaudeCodeProviderOAuthAdapter, CLAUDE_CODE_AUTHORIZE_URL, CLAUDE_CODE_CLIENT_ID,
|
||||
CLAUDE_CODE_COOKIE_SCOPE, CLAUDE_CODE_OAUTH_SCOPES, CLAUDE_CODE_PROVIDER_TYPE,
|
||||
CLAUDE_CODE_REDIRECT_URI, CLAUDE_CODE_TOKEN_URL, CLAUDE_CODE_WEB_BASE_URL,
|
||||
};
|
||||
pub use codex::CodexProviderOAuthAdapter;
|
||||
pub use generic::{
|
||||
GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, GENERIC_PROVIDER_OAUTH_TEMPLATES,
|
||||
|
||||
@@ -104,6 +104,7 @@ impl WindsurfProviderOAuthAdapter {
|
||||
json_body,
|
||||
body_bytes,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await;
|
||||
match response {
|
||||
@@ -234,6 +235,7 @@ impl WindsurfProviderOAuthAdapter {
|
||||
json_body: Some(json!({ "email": email, "password": password })),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await?;
|
||||
if !(200..300).contains(&login_response.status_code) {
|
||||
@@ -266,6 +268,7 @@ impl WindsurfProviderOAuthAdapter {
|
||||
json_body: None,
|
||||
body_bytes: Some(Vec::new()),
|
||||
network: ctx.network.clone(),
|
||||
transport_profile: None,
|
||||
})
|
||||
.await;
|
||||
match response {
|
||||
@@ -388,6 +391,7 @@ impl ProviderOAuthAdapter for WindsurfProviderOAuthAdapter {
|
||||
fn capabilities(&self) -> ProviderOAuthCapabilities {
|
||||
ProviderOAuthCapabilities {
|
||||
supports_authorization_code: false,
|
||||
supports_cookie_authorization: false,
|
||||
supports_refresh_token_import: true,
|
||||
supports_batch_import: true,
|
||||
supports_device_flow: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{
|
||||
ProviderOAuthAdapter, ProviderOAuthImportInput, ProviderOAuthProbeResult,
|
||||
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||
ProviderOAuthAdapter, ProviderOAuthCookieAuthorizationInput, ProviderOAuthImportInput,
|
||||
ProviderOAuthProbeResult, ProviderOAuthRequestAuth, ProviderOAuthTokenSet,
|
||||
ProviderOAuthTransportContext,
|
||||
};
|
||||
use crate::core::{OAuthAdapterRegistry, OAuthAuthorizeResponse, OAuthError};
|
||||
use crate::network::OAuthHttpExecutor;
|
||||
@@ -18,16 +19,18 @@ impl ProviderOAuthService {
|
||||
|
||||
pub fn with_builtin_adapters() -> Self {
|
||||
use super::providers::{
|
||||
AntigravityProviderOAuthAdapter, CodexProviderOAuthAdapter,
|
||||
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter, WindsurfProviderOAuthAdapter,
|
||||
AntigravityProviderOAuthAdapter, ClaudeCodeProviderOAuthAdapter,
|
||||
CodexProviderOAuthAdapter, GenericProviderOAuthAdapter, KiroProviderOAuthAdapter,
|
||||
WindsurfProviderOAuthAdapter,
|
||||
};
|
||||
|
||||
let mut service = Self::new()
|
||||
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(ClaudeCodeProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(WindsurfProviderOAuthAdapter));
|
||||
for provider_type in ["claude_code", "chatgpt_web", "gemini_cli"] {
|
||||
for provider_type in ["chatgpt_web", "gemini_cli"] {
|
||||
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
|
||||
service = service.with_adapter(Arc::new(adapter));
|
||||
}
|
||||
@@ -83,6 +86,17 @@ impl ProviderOAuthService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn authorize_with_cookie(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
input: ProviderOAuthCookieAuthorizationInput,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
self.adapter(&ctx.provider_type)?
|
||||
.authorize_with_cookie(executor, ctx, input)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
|
||||
Reference in New Issue
Block a user