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:
elky
2026-07-27 15:53:28 +08:00
parent 531cf11025
commit 550cc36760
55 changed files with 4957 additions and 403 deletions
@@ -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) {
+27 -1
View File
@@ -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,
+13 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+19 -5
View File
@@ -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,