Skip to content

Commit 2690f26

Browse files
committed
feat(tui): stealth oauth login
1 parent 8ae0380 commit 2690f26

4 files changed

Lines changed: 150 additions & 28 deletions

File tree

src-rust/crates/api/src/lib.rs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,55 @@ pub mod client {
468468
self.config.api_key.is_empty()
469469
}
470470

471+
/// Returns `true` when this client is configured to use a Claude Code
472+
/// OAuth Bearer token (Claude.ai Pro/Max). The query path and the
473+
/// request builders check this to enable stealth-impersonation.
474+
pub fn is_oauth(&self) -> bool {
475+
self.config.use_bearer_auth
476+
}
477+
478+
/// Mutate the outgoing request so it looks like Claude Code when the
479+
/// client is authenticated with an OAuth Bearer token:
480+
///
481+
/// 1. Prepend the required `"You are Claude Code, …"` system block.
482+
/// Existing system content is preserved as a second block so the
483+
/// rest of Claurst's prompt assembly still reaches the model.
484+
///
485+
/// No-op when `use_bearer_auth` is false (API-key flow).
486+
fn apply_oauth_stealth(&self, request: &mut CreateMessageRequest) {
487+
if !self.config.use_bearer_auth {
488+
return;
489+
}
490+
491+
let identity_block = SystemBlock {
492+
block_type: "text".to_string(),
493+
text: claurst_core::oauth_config::CLAUDE_CODE_SYSTEM_PROMPT_PREFIX.to_string(),
494+
cache_control: None,
495+
};
496+
497+
request.system = match request.system.take() {
498+
None => Some(SystemPrompt::Blocks(vec![identity_block])),
499+
Some(SystemPrompt::Text(existing)) => {
500+
let existing_block = SystemBlock {
501+
block_type: "text".to_string(),
502+
text: existing,
503+
cache_control: None,
504+
};
505+
Some(SystemPrompt::Blocks(vec![identity_block, existing_block]))
506+
}
507+
Some(SystemPrompt::Blocks(mut blocks)) => {
508+
// Avoid duplicating the identity block on retries / re-sends.
509+
let already_first = blocks.first().is_some_and(|b| {
510+
b.text == claurst_core::oauth_config::CLAUDE_CODE_SYSTEM_PROMPT_PREFIX
511+
});
512+
if !already_first {
513+
blocks.insert(0, identity_block);
514+
}
515+
Some(SystemPrompt::Blocks(blocks))
516+
}
517+
};
518+
}
519+
471520
/// Build a new client. Panics if `config.api_key` is empty.
472521
pub fn new(config: ClientConfig) -> anyhow::Result<Self> {
473522
// Allow empty key at construction — validation is deferred to
@@ -556,6 +605,7 @@ pub mod client {
556605
}
557606

558607
request.stream = false;
608+
self.apply_oauth_stealth(&mut request);
559609
let body = serde_json::to_value(&request).map_err(ClaudeError::Json)?;
560610

561611
let resp = self.send_with_retry(&body).await?;
@@ -661,6 +711,7 @@ pub mod client {
661711
}
662712

663713
request.stream = true;
714+
self.apply_oauth_stealth(&mut request);
664715
let body = serde_json::to_value(&request).map_err(ClaudeError::Json)?;
665716

666717
let resp = self.send_with_retry(&body).await?;
@@ -704,11 +755,19 @@ pub mod client {
704755
.get(&url)
705756
.header("anthropic-version", &self.config.api_version)
706757
.header("content-type", "application/json");
707-
req = if self.config.use_bearer_auth {
708-
req.header("Authorization", format!("Bearer {}", &self.config.api_key))
758+
if self.config.use_bearer_auth {
759+
let ua = format!(
760+
"claude-cli/{}",
761+
claurst_core::oauth_config::CLAUDE_CODE_VERSION_FOR_OAUTH
762+
);
763+
req = req
764+
.header("anthropic-beta", claurst_core::oauth_config::OAUTH_BETA_FLAGS.join(","))
765+
.header("user-agent", ua)
766+
.header("x-app", "cli")
767+
.header("Authorization", format!("Bearer {}", &self.config.api_key));
709768
} else {
710-
req.header("x-api-key", &self.config.api_key)
711-
};
769+
req = req.header("x-api-key", &self.config.api_key);
770+
}
712771

713772
let resp = req.send().await?;
714773

@@ -744,24 +803,57 @@ pub mod client {
744803
loop {
745804
attempts += 1;
746805

747-
// Compute CCH hash and build billing header
748-
let cch_hash = cch::compute_cch(body_bytes);
749-
let billing_header = format!("cc_version=0.1; cc_entrypoint=claude_code; {}; cc_workload=claude_code;", cch_hash);
750-
751806
// Use Bearer auth for Claude.ai OAuth tokens; x-api-key for regular keys.
807+
let use_oauth = self.config.use_bearer_auth;
808+
809+
// On the OAuth path we impersonate Claude Code: prepend the
810+
// required beta flags, advertise `claude-cli/<ver>` as the
811+
// user-agent, and drop the CCH billing header (the real
812+
// Claude Code client sends it but the API does not require
813+
// it for OAuth tokens, and emitting it would fingerprint
814+
// mismatch against the impersonated UA).
815+
let anthropic_beta = if use_oauth {
816+
let mut flags: Vec<&str> =
817+
claurst_core::oauth_config::OAUTH_BETA_FLAGS.to_vec();
818+
if !self.config.beta_features.is_empty() {
819+
flags.push(&self.config.beta_features);
820+
}
821+
flags.join(",")
822+
} else {
823+
self.config.beta_features.clone()
824+
};
825+
752826
let mut req = self
753827
.http
754828
.post(&url)
755829
.header("anthropic-version", &self.config.api_version)
756-
.header("anthropic-beta", &self.config.beta_features)
830+
.header("anthropic-beta", &anthropic_beta)
757831
.header("content-type", "application/json")
758-
.header("accept", "text/event-stream")
759-
.header("x-anthropic-billing-header", billing_header);
760-
req = if self.config.use_bearer_auth {
761-
req.header("Authorization", format!("Bearer {}", &self.config.api_key))
832+
.header("accept", "text/event-stream");
833+
834+
if use_oauth {
835+
let ua = format!(
836+
"claude-cli/{}",
837+
claurst_core::oauth_config::CLAUDE_CODE_VERSION_FOR_OAUTH
838+
);
839+
req = req
840+
.header("user-agent", ua)
841+
.header("x-app", "cli")
842+
.header("Authorization", format!("Bearer {}", &self.config.api_key));
762843
} else {
763-
req.header("x-api-key", &self.config.api_key)
764-
};
844+
// Compute CCH billing hash and attach on the API-key path
845+
// only — this is the codepath the official client uses
846+
// for direct API customers.
847+
let cch_hash = cch::compute_cch(body_bytes);
848+
let billing_header = format!(
849+
"cc_version=0.1; cc_entrypoint=claude_code; {}; cc_workload=claude_code;",
850+
cch_hash
851+
);
852+
req = req
853+
.header("x-anthropic-billing-header", billing_header)
854+
.header("x-api-key", &self.config.api_key);
855+
}
856+
765857
let req = req.body(body_str.clone());
766858

767859
let resp = req.send().await.map_err(ClaudeError::Http)?;

src-rust/crates/cli/src/oauth_flow.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
// WARNING: The OAuth client IDs in this module are registered to Anthropic's Claude Code CLI.
2-
// They will not work for Claurst. This module is preserved for reference but disabled.
3-
// Users should authenticate via API key (/connect → Anthropic → paste key).
4-
//
51
// OAuth 2.0 PKCE login flow for the Claurst CLI.
62
//
3+
// Uses the Claude Code client ID and impersonates Claude Code at request time
4+
// (see `claurst_core::oauth_config` for the impersonation constants and
5+
// `claurst_api::AnthropicClient::apply_oauth_stealth` for how they're applied).
6+
// Claude Pro/Max tokens used through Claurst draw from the account's "extra
7+
// usage" pool, not subscription quota — users should be aware of this before
8+
// switching from API-key auth.
9+
//
710
// Implements the same flow as the TypeScript OAuthService + authLogin():
811
// 1. Generate PKCE code_verifier / code_challenge / state
912
// 2. Start a temporary localhost HTTP server on a random port

src-rust/crates/core/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3496,9 +3496,10 @@ pub mod oauth {
34963496

34973497
// ---- Production OAuth endpoints & constants ----
34983498

3499-
// NOTE: This client ID is registered to Anthropic's official Claude Code CLI.
3500-
// It will NOT work for Claurst. Users should use an API key from console.anthropic.com.
3501-
pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; // Anthropic's — will not work for Claurst
3499+
// Claude Code client ID, used in stealth-impersonation mode (see
3500+
// `claurst_core::oauth_config` for the matching request-time headers and
3501+
// system-prompt prefix wired into `claurst_api::AnthropicClient`).
3502+
pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
35023503
pub const CONSOLE_AUTHORIZE_URL: &str = "https://platform.claude.com/oauth/authorize";
35033504
pub const CLAUDE_AI_AUTHORIZE_URL: &str = "https://claude.com/cai/oauth/authorize";
35043505
pub const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";

src-rust/crates/core/src/oauth_config.rs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ pub const ALL_OAUTH_SCOPES: &[&str] = &[
4747
/// Minimum scopes required for basic operation.
4848
pub const MINIMUM_SCOPES: &[&str] = &[CLAUDE_AI_INFERENCE_SCOPE, CLAUDE_AI_PROFILE_SCOPE];
4949

50+
// ---------------------------------------------------------------------------
51+
// Claude Code stealth-impersonation constants
52+
// ---------------------------------------------------------------------------
53+
54+
/// User-Agent advertised to Anthropic's API on OAuth-authenticated requests.
55+
/// Must match a Claude Code version the server still accepts; bump when
56+
/// Anthropic invalidates the current value.
57+
pub const CLAUDE_CODE_VERSION_FOR_OAUTH: &str = "2.1.75";
58+
59+
/// `anthropic-beta` flags that must be present on every OAuth-authenticated
60+
/// request. Without these the API server rejects subscription tokens.
61+
pub const OAUTH_BETA_FLAGS: &[&str] = &["claude-code-20250219", "oauth-2025-04-20"];
62+
63+
/// System-prompt prefix that must appear as the first system block on every
64+
/// OAuth-authenticated request. Anthropic's gate refuses requests whose system
65+
/// prompt does not start with this identity string.
66+
pub const CLAUDE_CODE_SYSTEM_PROMPT_PREFIX: &str =
67+
"You are Claude Code, Anthropic's official CLI for Claude.";
68+
5069
// ---------------------------------------------------------------------------
5170
// OAuthConfig struct
5271
// ---------------------------------------------------------------------------
@@ -76,10 +95,17 @@ pub struct OAuthConfig {
7695
// Production config (mirrors PROD_OAUTH_CONFIG in oauth.ts)
7796
// ---------------------------------------------------------------------------
7897

79-
// NOTE: These OAuth client IDs are registered to Anthropic's official Claude Code CLI.
80-
// They will NOT work for Claurst — Anthropic's auth server will reject or misattribute requests.
81-
// Users should use an API key from console.anthropic.com instead.
82-
// To use OAuth, Claurst would need its own registered OAuth application with Anthropic.
98+
// Claude Code OAuth client ID, used in stealth-impersonation mode so that
99+
// Anthropic's auth server accepts Claude Pro/Max tokens through Claurst.
100+
// The matching request-time impersonation (user-agent, x-app, anthropic-beta,
101+
// and the Claude Code system-prompt prefix) is wired up in
102+
// `claurst_api::client::AnthropicClient` and is required for these tokens to
103+
// be honoured by the API.
104+
//
105+
// Billing note: tokens minted by a Pro/Max subscription draw from the
106+
// account's "extra usage" pool when used by a third-party client — they do
107+
// not consume subscription quota. Users should be aware of this before
108+
// switching from API-key auth.
83109
pub const PROD_OAUTH: OAuthConfig = OAuthConfig {
84110
base_api_url: "https://api.anthropic.com",
85111
// Routes through claude.com/cai/* for attribution, 307s to claude.ai in
@@ -93,7 +119,7 @@ pub const PROD_OAUTH: OAuthConfig = OAuthConfig {
93119
console_success_url: "https://platform.claude.com/buy_credits?returnUrl=/oauth/code/success%3Fapp%3Dclaude-code",
94120
claudeai_success_url: "https://platform.claude.com/oauth/code/success?app=claude-code",
95121
manual_redirect_url: "https://platform.claude.com/oauth/code/callback",
96-
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", // Anthropic's Claude Code — will not work for Claurst
122+
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", // Claude Code client ID (stealth)
97123
oauth_file_suffix: "",
98124
mcp_proxy_url: "https://mcp-proxy.anthropic.com",
99125
mcp_proxy_path: "/v1/mcp/{server_id}",
@@ -114,7 +140,7 @@ pub const STAGING_OAUTH: OAuthConfig = OAuthConfig {
114140
console_success_url: "https://platform.staging.ant.dev/buy_credits?returnUrl=/oauth/code/success%3Fapp%3Dclaude-code",
115141
claudeai_success_url: "https://platform.staging.ant.dev/oauth/code/success?app=claude-code",
116142
manual_redirect_url: "https://platform.staging.ant.dev/oauth/code/callback",
117-
client_id: "22422756-60c9-4084-8eb7-27705fd5cf9a", // Anthropic's Claude Code staging — will not work for Claurst
143+
client_id: "22422756-60c9-4084-8eb7-27705fd5cf9a", // Claude Code staging client ID (stealth)
118144
oauth_file_suffix: "-staging-oauth",
119145
mcp_proxy_url: "https://mcp-proxy-staging.anthropic.com",
120146
mcp_proxy_path: "/v1/mcp/{server_id}",

0 commit comments

Comments
 (0)