mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 07:01:52 +08:00
Enable saved OAuth startup auth without breaking local version output
Startup auth was split between the CLI and API crates, which made saved OAuth refresh behavior eager and easy to drift. This change adds a startup-specific resolver in the API layer, keeps env-only auth semantics intact, preserves saved refresh tokens when refresh responses omit them, and lets the CLI reuse the shared resolver while keeping --version on a purely local path. Constraint: Saved OAuth credentials live in ~/.claude/credentials.json and must remain compatible with existing runtime helpers Constraint: --version must not require config loading or any API/auth client initialization Rejected: Keep refresh orchestration only in rusty-claude-cli | would preserve split auth policy and lazy-load bugs Rejected: Change AnthropicClient::from_env to load config | would broaden configless API semantics for non-CLI callers Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep startup-only OAuth refresh separate from AuthSource::from_env() / AnthropicClient::from_env() unless all non-CLI callers are re-evaluated Tested: cargo fmt --all; cargo build; cargo clippy --workspace --all-targets -- -D warnings; cargo test; cargo run -p rusty-claude-cli -- --version Not-tested: Live OAuth refresh against a real auth server
This commit is contained in:
@@ -392,8 +392,52 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
|
|||||||
let Some(token_set) = load_saved_oauth_token()? else {
|
let Some(token_set) = load_saved_oauth_token()? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
resolve_saved_oauth_token_set(config, token_set).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
||||||
|
{
|
||||||
|
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||||
|
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
|
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
||||||
|
api_key,
|
||||||
|
bearer_token,
|
||||||
|
}),
|
||||||
|
None => Ok(AuthSource::ApiKey(api_key)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
|
return Ok(AuthSource::BearerToken(bearer_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(token_set) = load_saved_oauth_token()? else {
|
||||||
|
return Err(ApiError::MissingApiKey);
|
||||||
|
};
|
||||||
if !oauth_token_is_expired(&token_set) {
|
if !oauth_token_is_expired(&token_set) {
|
||||||
return Ok(Some(token_set));
|
return Ok(AuthSource::BearerToken(token_set.access_token));
|
||||||
|
}
|
||||||
|
if token_set.refresh_token.is_none() {
|
||||||
|
return Err(ApiError::ExpiredOAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(config) = load_oauth_config()? else {
|
||||||
|
return Err(ApiError::Auth(
|
||||||
|
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(AuthSource::from(resolve_saved_oauth_token_set(
|
||||||
|
&config, token_set,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_saved_oauth_token_set(
|
||||||
|
config: &OAuthConfig,
|
||||||
|
token_set: OAuthTokenSet,
|
||||||
|
) -> Result<OAuthTokenSet, ApiError> {
|
||||||
|
if !oauth_token_is_expired(&token_set) {
|
||||||
|
return Ok(token_set);
|
||||||
}
|
}
|
||||||
let Some(refresh_token) = token_set.refresh_token.clone() else {
|
let Some(refresh_token) = token_set.refresh_token.clone() else {
|
||||||
return Err(ApiError::ExpiredOAuthToken);
|
return Err(ApiError::ExpiredOAuthToken);
|
||||||
@@ -403,18 +447,28 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
|
|||||||
client
|
client
|
||||||
.refresh_oauth_token(
|
.refresh_oauth_token(
|
||||||
config,
|
config,
|
||||||
&OAuthRefreshRequest::from_config(config, refresh_token, Some(token_set.scopes)),
|
&OAuthRefreshRequest::from_config(
|
||||||
|
config,
|
||||||
|
refresh_token,
|
||||||
|
Some(token_set.scopes.clone()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})?;
|
})?;
|
||||||
save_oauth_credentials(&runtime::OAuthTokenSet {
|
let resolved = OAuthTokenSet {
|
||||||
access_token: refreshed.access_token.clone(),
|
access_token: refreshed.access_token,
|
||||||
refresh_token: refreshed.refresh_token.clone(),
|
refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
|
||||||
expires_at: refreshed.expires_at,
|
expires_at: refreshed.expires_at,
|
||||||
scopes: refreshed.scopes.clone(),
|
scopes: refreshed.scopes,
|
||||||
|
};
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: resolved.access_token.clone(),
|
||||||
|
refresh_token: resolved.refresh_token.clone(),
|
||||||
|
expires_at: resolved.expires_at,
|
||||||
|
scopes: resolved.scopes.clone(),
|
||||||
})
|
})
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
Ok(Some(refreshed))
|
Ok(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
|
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
|
||||||
@@ -571,8 +625,8 @@ mod tests {
|
|||||||
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
|
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
|
||||||
|
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient,
|
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
|
||||||
AuthSource, OAuthTokenSet,
|
resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
|
||||||
};
|
};
|
||||||
use crate::types::{ContentBlockDelta, MessageRequest};
|
use crate::types::{ContentBlockDelta, MessageRequest};
|
||||||
|
|
||||||
@@ -760,6 +814,95 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "saved-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh".to_string()),
|
||||||
|
expires_at: Some(now_unix_timestamp() + 300),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
|
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
|
||||||
|
.expect("startup auth");
|
||||||
|
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||||
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "expired-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh-token".to_string()),
|
||||||
|
expires_at: Some(1),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save expired oauth credentials");
|
||||||
|
|
||||||
|
let error =
|
||||||
|
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
|
||||||
|
assert!(
|
||||||
|
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let stored = runtime::load_oauth_credentials()
|
||||||
|
.expect("load stored credentials")
|
||||||
|
.expect("stored token set");
|
||||||
|
assert_eq!(stored.access_token, "expired-access-token");
|
||||||
|
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||||
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let config_home = temp_config_home();
|
||||||
|
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "expired-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh-token".to_string()),
|
||||||
|
expires_at: Some(1),
|
||||||
|
scopes: vec!["scope:a".to_string()],
|
||||||
|
})
|
||||||
|
.expect("save expired oauth credentials");
|
||||||
|
|
||||||
|
let token_url = spawn_token_server(
|
||||||
|
"{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
|
||||||
|
);
|
||||||
|
let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
|
||||||
|
.expect("resolve refreshed token")
|
||||||
|
.expect("token set present");
|
||||||
|
assert_eq!(resolved.access_token, "refreshed-token");
|
||||||
|
assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
let stored = runtime::load_oauth_credentials()
|
||||||
|
.expect("load stored credentials")
|
||||||
|
.expect("stored token set");
|
||||||
|
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
|
||||||
|
|
||||||
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
|
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||||
|
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn message_request_stream_helper_sets_stream_true() {
|
fn message_request_stream_helper_sets_stream_true() {
|
||||||
let request = MessageRequest {
|
let request = MessageRequest {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ mod sse;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use client::{
|
pub use client::{
|
||||||
oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient, AuthSource, MessageStream,
|
oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source,
|
||||||
OAuthTokenSet,
|
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
pub use error::ApiError;
|
||||||
pub use sse::{parse_frame, SseParser};
|
pub use sse::{parse_frame, SseParser};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::process::Command;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
resolve_saved_oauth_token, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
|
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
|
||||||
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
@@ -1878,20 +1878,13 @@ impl AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||||
match AuthSource::from_env() {
|
Ok(resolve_startup_auth_source(|| {
|
||||||
Ok(auth) => Ok(auth),
|
let cwd = env::current_dir().map_err(api::ApiError::from)?;
|
||||||
Err(api::ApiError::MissingApiKey) => {
|
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
|
||||||
let cwd = env::current_dir()?;
|
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
|
||||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
})?;
|
||||||
if let Some(oauth) = config.oauth() {
|
Ok(config.oauth().cloned())
|
||||||
if let Some(token_set) = resolve_saved_oauth_token(oauth)? {
|
})?)
|
||||||
return Ok(AuthSource::from(token_set));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(AuthSource::from_env_or_saved()?)
|
|
||||||
}
|
|
||||||
Err(error) => Err(Box::new(error)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiClient for AnthropicRuntimeClient {
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
|
|||||||
Reference in New Issue
Block a user