1mod error;
13mod helpers;
14mod profile;
15
16#[cfg(any(feature = "net", test, feature = "test-util"))]
17mod keychain_guard;
18
19#[cfg(feature = "net")]
20mod exchange;
21#[cfg(feature = "net")]
22mod providers;
23#[cfg(feature = "net")]
24mod writeback;
25
26#[cfg(feature = "device")]
27pub mod device_flow;
28#[cfg(feature = "pkce")]
29pub mod pkce;
30
31#[cfg(any(test, feature = "test-util"))]
32mod isolated_home;
33
34pub use error::AuthError;
35#[cfg(feature = "net")]
36pub use exchange::TokenExchangeResponse;
37#[cfg(feature = "net")]
38pub use helpers::format_oauth_transport_error;
39#[cfg(any(feature = "net", test))]
40pub use helpers::is_oauth_transport_error;
41pub use helpers::{redact_secret_looking, redact_url_origin, sanitize_oauth_error_text};
42pub use profile::{
43 AuthScheme, Betas, CredsFormat, Dialect, ExpiresUnit, Fingerprint, ForbiddenFieldPolicy, Http,
44 ListMerge, LoadOptions, Login, OauthPack, ProfileError, ResolvedProfile, SCHEMA_VERSION_MAX,
45 StreamUnknownPolicy, TokenRequestFormat, TokenResponse, ToolNameCase, ToolTypePolicy, Wire,
46 default_user_profile_dir, list_profiles, load_profile, load_profile_for_wire,
47 load_profile_from_cli, not_found_message, parse_profile_str, shipped_profile_ids,
48 user_profile_dirs,
49};
50#[cfg(feature = "net")]
51pub use providers::aws::{
52 AwsCredentials, AwsSignParams, AwsStsConfig, AwsStsTokenProvider, sign_aws_request,
53};
54#[cfg(feature = "net")]
55pub use providers::azure::AzureTokenProvider;
56#[cfg(feature = "net")]
57pub use providers::gcp::{GcpTokenProvider, default_adc_path};
58#[cfg(feature = "net")]
59pub use providers::oauth::{
60 ProfileTokenProvider, credential_options, missing_credential_field, provider_from_oauth,
61 provider_from_oauth_opts, provider_from_profile,
62};
63#[cfg(feature = "net")]
64pub use providers::static_token::StaticToken;
65#[cfg(feature = "net")]
66pub use writeback::{persist_login_tokens, remove_store_entry};
67
68#[cfg(feature = "test-util")]
69pub use isolated_home::{IsolatedHome, PlantCredentials};
70#[cfg(feature = "test-util")]
71pub use keychain_guard::KeychainIsolation;
72
73pub const VERSION: &str = env!("CARGO_PKG_VERSION");
75
76pub const GROK_CHAT_PROXY_CLIENT_VERSION: &str = "1.0.46";
85
86pub const GROK_CHAT_PROXY_CLIENT_IDENTIFIER: &str = "wiremux";
91
92#[cfg(feature = "net")]
96pub trait TokenProvider: Send + Sync + std::fmt::Debug {
97 fn get_token(&self) -> impl std::future::Future<Output = Result<String, AuthError>> + Send;
99
100 fn mark_stale(&self) {}
102
103 fn wake(&self) {
105 self.mark_stale();
106 }
107}
108
109#[cfg(feature = "net")]
111#[derive(Debug, Clone)]
112pub enum AnyTokenProvider {
113 Static(StaticToken),
115 Profile(ProfileTokenProvider),
117 Azure(AzureTokenProvider),
119 Gcp(GcpTokenProvider),
121 AwsSts(AwsStsTokenProvider),
123}
124
125#[cfg(feature = "net")]
126impl AnyTokenProvider {
127 pub fn without_http_refresh(&self) {
130 if let Self::Profile(p) = self {
131 p.without_http_refresh();
132 }
133 }
134
135 #[must_use]
137 pub fn quota_project_id(&self) -> Option<&str> {
138 match self {
139 Self::Gcp(p) => p.quota_project_id(),
140 _ => None,
141 }
142 }
143
144 pub async fn offline_block(&self) -> Option<String> {
150 match self {
151 Self::Profile(provider) => provider.offline_block().await,
152 Self::Static(_) | Self::Azure(_) | Self::Gcp(_) | Self::AwsSts(_) => None,
153 }
154 }
155
156 #[must_use]
163 pub fn can_refresh(&self) -> bool {
164 !matches!(self, Self::Static(_) | Self::AwsSts(_))
165 }
166}
167
168#[cfg(feature = "net")]
169impl TokenProvider for AnyTokenProvider {
170 fn mark_stale(&self) {
171 match self {
172 Self::Static(p) => p.mark_stale(),
173 Self::Profile(p) => p.mark_stale(),
174 Self::Azure(p) => p.mark_stale(),
175 Self::Gcp(p) => p.mark_stale(),
176 Self::AwsSts(p) => p.mark_stale(),
177 }
178 }
179
180 fn wake(&self) {
181 match self {
182 Self::Static(p) => p.wake(),
183 Self::Profile(p) => p.wake(),
184 Self::Azure(p) => p.wake(),
185 Self::Gcp(p) => p.wake(),
186 Self::AwsSts(p) => p.wake(),
187 }
188 }
189
190 async fn get_token(&self) -> Result<String, AuthError> {
191 match self {
192 Self::Static(p) => p.get_token().await,
193 Self::Profile(p) => p.get_token().await,
194 Self::Azure(p) => p.get_token().await,
195 Self::Gcp(p) => p.get_token().await,
196 Self::AwsSts(p) => p.get_token().await,
197 }
198 }
199}
200
201#[cfg(feature = "net")]
202impl From<StaticToken> for AnyTokenProvider {
203 fn from(t: StaticToken) -> Self {
204 Self::Static(t)
205 }
206}
207
208#[cfg(feature = "net")]
209impl From<ProfileTokenProvider> for AnyTokenProvider {
210 fn from(t: ProfileTokenProvider) -> Self {
211 Self::Profile(t)
212 }
213}
214
215#[cfg(feature = "net")]
216impl From<AzureTokenProvider> for AnyTokenProvider {
217 fn from(t: AzureTokenProvider) -> Self {
218 Self::Azure(t)
219 }
220}
221
222#[cfg(feature = "net")]
223impl From<GcpTokenProvider> for AnyTokenProvider {
224 fn from(t: GcpTokenProvider) -> Self {
225 Self::Gcp(t)
226 }
227}
228
229#[cfg(feature = "net")]
230impl From<AwsStsTokenProvider> for AnyTokenProvider {
231 fn from(t: AwsStsTokenProvider) -> Self {
232 Self::AwsSts(t)
233 }
234}
235
236#[cfg(feature = "net")]
247pub async fn token_for_profile(id: &str) -> Result<String, AuthError> {
248 token_for_profile_opts(id, &LoadOptions::default()).await
249}
250
251#[cfg(feature = "net")]
253pub async fn token_for_profile_opts(id: &str, opts: &LoadOptions<'_>) -> Result<String, AuthError> {
254 TokenProvider::get_token(&provider_for_profile_opts(id, opts)?).await
255}
256
257#[cfg(feature = "net")]
261pub async fn token_for_profile_cached(id: &str) -> Result<String, AuthError> {
262 token_for_profile_opts_cached(id, &LoadOptions::default()).await
263}
264
265#[cfg(feature = "net")]
267pub async fn token_for_profile_opts_cached(
268 id: &str,
269 opts: &LoadOptions<'_>,
270) -> Result<String, AuthError> {
271 let provider = provider_for_profile_opts(id, opts)?;
272 provider.without_http_refresh();
273 TokenProvider::get_token(&provider).await
274}
275
276#[cfg(feature = "net")]
280pub fn provider_for_profile(id: &str) -> Result<AnyTokenProvider, AuthError> {
281 provider_for_profile_opts(id, &LoadOptions::default())
282}
283
284#[cfg(feature = "net")]
285pub fn provider_for_profile_opts(
286 id: &str,
287 opts: &LoadOptions<'_>,
288) -> Result<AnyTokenProvider, AuthError> {
289 if looks_like_profile_path(id) {
290 return Err(AuthError::TokenProvider(
291 "token_for_profile / provider_for_profile take a catalog id (example `anthropic-oauth`); \
292 a file path should go through load_profile_from_cli then provider_from_profile"
293 .into(),
294 ));
295 }
296 let profile = load_profile(id, opts).map_err(|err| match err {
297 ProfileError::NotFound { id, known } => AuthError::TokenProvider(format!(
298 "{}; token_for_profile / provider_for_profile take a catalog id (example `anthropic-oauth`)",
299 not_found_message(&id, &known)
300 )),
301 other => other.into(),
302 })?;
303 provider_from_profile(&profile)
304}
305
306#[cfg(feature = "net")]
307fn looks_like_profile_path(id: &str) -> bool {
308 if id.contains('/') || id.contains('\\') {
309 return true;
310 }
311 let lower = id.to_ascii_lowercase();
312 lower.ends_with(".toml") || lower.ends_with(".json")
313}
314
315#[cfg(all(test, feature = "net"))]
316mod tests {
317 use super::*;
318 use crate::isolated_home::{IsolatedHome, PlantCredentials};
319
320 const PLANTED_ACCESS: &str = "sk-ant-oat01-issue59";
321
322 #[test]
323 fn version_matches_package() {
324 assert_eq!(super::VERSION, env!("CARGO_PKG_VERSION"));
325 }
326
327 #[tokio::test]
328 async fn token_for_profile_reads_xai_access_env() {
329 let home = IsolatedHome::new();
330 home.set_env("XAI_API_KEY", "xai-lib-key");
331 let token = token_for_profile("xai").await.expect("xai access_env");
332 assert_eq!(token, "xai-lib-key");
333 let _ = home;
334 }
335
336 #[tokio::test]
337 async fn token_for_profile_returns_planted_claude_access() {
338 let home = IsolatedHome::new();
339 home.plant_credentials(PlantCredentials::Claude {
340 access: PLANTED_ACCESS,
341 refresh: Some("rt"),
342 expires_at_ms: Some(4_000_000_000_000),
343 });
344 let token = token_for_profile("anthropic-oauth")
345 .await
346 .expect("planted claude access");
347 assert_eq!(token, PLANTED_ACCESS);
348 let _ = home;
349 }
350
351 #[tokio::test]
352 async fn token_for_profile_cached_does_not_refresh_expired_access() {
353 let home = IsolatedHome::new();
354 let creds = home.plant_credentials(PlantCredentials::JsonPointer {
355 relative_path: ".config/wiremux/auth.json",
356 document: serde_json::json!({
357 "tokens": {
358 "access": "expired-keep",
359 "refresh": "rt-keep",
360 "expiry_unix": 1
361 }
362 }),
363 });
364 let (url, handle) = spawn_http_server(
365 200,
366 r#"{"access_token":"must-not-refresh","refresh_token":"rt2","expires_in":3600}"#,
367 );
368 let dir = home.path().join("profiles-cached");
369 std::fs::create_dir_all(&dir).expect("mkdir extra profiles");
370 let creds_unix = creds.to_string_lossy().replace('\\', "/");
371 std::fs::write(
372 dir.join("cached-mock.toml"),
373 format!(
374 r#"
375schema_version = 1
376id = "cached-mock"
377[oauth]
378token_url = "{url}"
379client_id = "cached-client"
380token_request_format = "form"
381creds_format = "json-pointer"
382creds_path = "{creds_unix}"
383access_token_ptr = "/tokens/access"
384refresh_token_ptr = "/tokens/refresh"
385expires_ptr = "/tokens/expiry_unix"
386expires_unit = "s"
387login = "none"
388[oauth.token_response]
389access_token_ptr = "/access_token"
390refresh_token_ptr = "/refresh_token"
391expires_ptr = "/expires_in"
392expires_unit = "s"
393"#
394 ),
395 )
396 .expect("write mock profile");
397 let opts = LoadOptions {
398 include_shipped: false,
399 include_user_config: false,
400 extra_profile_dirs: vec![dir],
401 ..LoadOptions::default()
402 };
403 let token = token_for_profile_opts_cached("cached-mock", &opts)
404 .await
405 .expect("cached helper");
406 assert_eq!(token, "expired-keep");
407 drop(handle);
408 let _ = home;
409 }
410
411 #[tokio::test]
412 async fn token_for_profile_opts_without_catalog_is_not_found() {
413 let home = IsolatedHome::new();
414 let err = token_for_profile_opts(
415 "anthropic-oauth",
416 &LoadOptions {
417 include_shipped: false,
418 include_user_config: false,
419 ..LoadOptions::default()
420 },
421 )
422 .await
423 .expect_err("empty catalog must fail closed");
424 let display = err.to_string();
425 match &err {
426 AuthError::TokenProvider(msg) => {
427 assert!(
428 msg.contains("not found"),
429 "TokenProvider Display must include not found, got {msg}"
430 );
431 }
432 other => panic!("expected AuthError::TokenProvider, got {other}"),
433 }
434 assert!(
435 display.contains("not found"),
436 "Display must include not found, got {display}"
437 );
438 assert!(
439 display.contains("catalog id") || display.contains("anthropic-oauth"),
440 "Display must name catalog id or the anthropic-oauth example, got {display}"
441 );
442 assert!(
443 !display.contains("path also works"),
444 "typo catalog id must not inherit the CLI path hint, got {display}"
445 );
446 let _ = home;
447 }
448
449 #[tokio::test]
450 async fn token_for_profile_typo_suggests_close_match() {
451 let home = IsolatedHome::new();
452 let err = token_for_profile("anthropic-oath")
453 .await
454 .expect_err("near-miss id must fail");
455 let display = err.to_string();
456 assert!(
457 display.contains("did you mean `anthropic-oauth`"),
458 "token_for_profile must suggest anthropic-oauth, got {display}"
459 );
460 assert!(
461 !display.contains("path also works"),
462 "typo catalog id must not inherit the CLI path hint, got {display}"
463 );
464 let _ = home;
465 }
466
467 #[tokio::test]
468 async fn token_for_profile_path_shaped_id_is_catalog_id_error() {
469 let home = IsolatedHome::new();
470 for id in ["./mine.toml", "/tmp/mine.toml"] {
471 let err = token_for_profile(id)
472 .await
473 .expect_err("path-shaped id must not load as a catalog entry");
474 let display = err.to_string();
475 assert!(
476 display.contains("catalog id") || display.contains("load_profile_from_cli"),
477 "path-shaped `{id}` must name catalog id or load_profile_from_cli, got {display}"
478 );
479 assert!(
480 !display.contains("path also works"),
481 "path-shaped `{id}` must not inherit the CLI path hint, got {display}"
482 );
483 }
484 let _ = home;
485 }
486
487 #[test]
488 fn can_refresh_is_false_without_a_bearer_refresh() {
489 assert!(!AnyTokenProvider::from(StaticToken::new("sk")).can_refresh());
490 let sts = AwsStsTokenProvider::new(AwsStsConfig {
491 access_key_id: "AKIDEXAMPLE".into(),
492 secret_access_key: "secret".into(),
493 role_arn: "arn:aws:iam::123456789012:role/demo".into(),
494 role_session_name: "wiremux-test".into(),
495 region: "us-east-1".into(),
496 session_token: None,
497 endpoint: None,
498 })
499 .expect("sts");
500 assert!(!AnyTokenProvider::from(sts).can_refresh());
501 }
502
503 #[tokio::test]
504 async fn provider_for_profile_clone_returns_planted_access() {
505 let home = IsolatedHome::new();
506 home.plant_credentials(PlantCredentials::Claude {
507 access: PLANTED_ACCESS,
508 refresh: Some("rt"),
509 expires_at_ms: Some(4_000_000_000_000),
510 });
511 let provider = provider_for_profile("anthropic-oauth").expect("provider");
512 let cloned = provider.clone();
513 assert_eq!(
514 cloned.get_token().await.expect("clone get_token"),
515 PLANTED_ACCESS
516 );
517 let _ = home;
518 }
519
520 #[tokio::test]
521 async fn provider_for_profile_opts_mark_stale_refreshes_via_mock() {
522 let home = IsolatedHome::new();
523 let creds = home.plant_credentials(PlantCredentials::JsonPointer {
524 relative_path: ".config/wiremux/auth.json",
525 document: serde_json::json!({
526 "tokens": {
527 "access": "first-59",
528 "refresh": "rt-59",
529 "expiry_unix": 4_102_444_800_i64
530 }
531 }),
532 });
533 let (url, handle) = spawn_http_server(
534 200,
535 r#"{"access_token":"refreshed-59","refresh_token":"rt2","expires_in":3600}"#,
536 );
537 let dir = home.path().join("profiles-59");
538 std::fs::create_dir_all(&dir).expect("mkdir extra profiles");
539 let creds_unix = creds.to_string_lossy().replace('\\', "/");
540 std::fs::write(
541 dir.join("issue59-mock.toml"),
542 format!(
543 r#"
544schema_version = 1
545id = "issue59-mock"
546[oauth]
547token_url = "{url}"
548client_id = "issue59-client"
549token_request_format = "form"
550creds_format = "json-pointer"
551creds_path = "{creds_unix}"
552access_token_ptr = "/tokens/access"
553refresh_token_ptr = "/tokens/refresh"
554expires_ptr = "/tokens/expiry_unix"
555expires_unit = "s"
556login = "none"
557[oauth.token_response]
558access_token_ptr = "/access_token"
559refresh_token_ptr = "/refresh_token"
560expires_ptr = "/expires_in"
561expires_unit = "s"
562"#
563 ),
564 )
565 .expect("write mock profile");
566 let opts = LoadOptions {
567 include_shipped: false,
568 include_user_config: false,
569 extra_profile_dirs: vec![dir],
570 ..LoadOptions::default()
571 };
572 let provider = provider_for_profile_opts("issue59-mock", &opts).expect("provider");
573 assert_eq!(provider.get_token().await.expect("cached"), "first-59");
574 provider.mark_stale();
575 assert_eq!(
576 provider.get_token().await.expect("mock refresh"),
577 "refreshed-59"
578 );
579 let _ = handle.join();
580 let _ = home;
581 }
582
583 fn spawn_http_server(status: u16, body: &str) -> (String, std::thread::JoinHandle<String>) {
584 use std::io::{Read, Write};
585 use std::net::TcpListener;
586 use std::time::Duration;
587
588 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
589 let addr = listener.local_addr().expect("local_addr");
590 let body = body.to_owned();
591 let handle = std::thread::spawn(move || {
592 let (mut stream, _) = listener.accept().expect("accept");
593 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
594 let mut buf = Vec::new();
595 let mut tmp = [0u8; 1024];
596 loop {
597 match stream.read(&mut tmp) {
598 Ok(0) => break,
599 Ok(n) => {
600 buf.extend_from_slice(&tmp[..n]);
601 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
602 break;
603 }
604 }
605 Err(_) => break,
606 }
607 }
608 let req = String::from_utf8_lossy(&buf).into_owned();
609 let resp = format!(
610 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
611 body.len()
612 );
613 let _ = stream.write_all(resp.as_bytes());
614 req
615 });
616 (format!("http://{addr}/oauth/token"), handle)
617 }
618}