Skip to main content

wiremux_auth/
lib.rs

1//! TokenProvider and vendor profile catalog.
2//!
3//! Feature `net` (default) enables HTTP TokenProviders. Maps-only hosts
4//! disable default features and keep profile parse/load without reqwest.
5//!
6//! ```
7//! use wiremux_auth::shipped_profile_ids;
8//!
9//! assert!(shipped_profile_ids().iter().any(|id| *id == "openai"));
10//! ```
11
12mod 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
73/// Crate version from Cargo.toml.
74pub const VERSION: &str = env!("CARGO_PKG_VERSION");
75
76/// `x-grok-client-version` for `cli-chat-proxy.grok.com`.
77///
78/// The proxy returns HTTP 426 for `0.1.202`. That response names
79/// `1.0.13` as the floor. Shipped `xai-grok-build` and
80/// `xai-grok-build-messages` use this exact string. The HTTP client
81/// fills the same value when a handmade proxy profile omits it.
82/// Hosts that send their own `x-grok-client-identifier` should still
83/// send this version.
84pub const GROK_CHAT_PROXY_CLIENT_VERSION: &str = "1.0.46";
85
86/// `x-grok-client-identifier` this crate sends to the Grok chat proxy.
87///
88/// This is not a Grok product client id. Other hosts send their own
89/// name with [`GROK_CHAT_PROXY_CLIENT_VERSION`].
90pub const GROK_CHAT_PROXY_CLIENT_IDENTIFIER: &str = "wiremux";
91
92/// A provider of short-lived Bearer tokens.
93///
94/// Implementations are cheaply cloneable (`Arc` state) and safe to share.
95#[cfg(feature = "net")]
96pub trait TokenProvider: Send + Sync + std::fmt::Debug {
97    /// Return a valid access token, refreshing if necessary.
98    fn get_token(&self) -> impl std::future::Future<Output = Result<String, AuthError>> + Send;
99
100    /// Drop the cached token so the next [`TokenProvider::get_token`] refreshes.
101    fn mark_stale(&self) {}
102
103    /// Host laptop-wake hook. Default calls [`TokenProvider::mark_stale`].
104    fn wake(&self) {
105        self.mark_stale();
106    }
107}
108
109/// Type-erased token provider.
110#[cfg(feature = "net")]
111#[derive(Debug, Clone)]
112pub enum AnyTokenProvider {
113    /// Static key, never refreshed.
114    Static(StaticToken),
115    /// Driven by a loaded `[oauth]` table.
116    Profile(ProfileTokenProvider),
117    /// Azure AD client credentials.
118    Azure(AzureTokenProvider),
119    /// GCP service-account JWT bearer.
120    Gcp(GcpTokenProvider),
121    /// AWS STS AssumeRole (not a Bearer API key).
122    AwsSts(AwsStsTokenProvider),
123}
124
125#[cfg(feature = "net")]
126impl AnyTokenProvider {
127    /// Skip token-URL POSTs on later [`TokenProvider::get_token`] calls.
128    /// No-op for static / cloud STS providers.
129    pub fn without_http_refresh(&self) {
130        if let Self::Profile(p) = self {
131            p.without_http_refresh();
132        }
133    }
134
135    /// ADC `quota_project_id` for Vertex, if this is a GCP authorized_user grant.
136    #[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    /// Local reason [`TokenProvider::get_token`] would fail with no network.
145    ///
146    /// `None` for static keys and for a profile token that is still inside
147    /// its lifetime or that has a refresh token. Status uses this so it does
148    /// not call the token URL.
149    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    /// True when a 401 can be retried after [`TokenProvider::mark_stale`]
157    /// and another `get_token`.
158    ///
159    /// Static keys never refresh. AWS STS session keys are not a bearer
160    /// token: `get_token` always fails, and `mark_stale` only affects
161    /// `get_credentials`.
162    #[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/// Load a catalog id (example `anthropic-oauth`) and return one access token.
237///
238/// This is not a cheap "is login present?" probe. It may block on OS
239/// keychain and on one or two HTTP refreshes (`token_url`, then
240/// `token_url_fallback`) when the stored access is expired. Use
241/// [`token_for_profile_cached`] when the host only wants the stored
242/// Bearer and will `mark_stale` on a later 401.
243///
244/// This helper does not accept a file path. Use [`load_profile_from_cli`] then
245/// [`provider_from_profile`] for a `.toml` / `.json` profile.
246#[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/// Same, with host LoadOptions (tests, IsolatedHome, no shipped).
252#[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/// Load a catalog id and return the stored Bearer without POSTing
258/// `token_url`. Empty access still fails closed. [`token_for_profile`]
259/// may still block on keychain plus one or two HTTP refreshes.
260#[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/// Same as [`token_for_profile_cached`], with host [`LoadOptions`].
266#[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/// Same load path as [`token_for_profile`], but keep the provider so the host
277/// can `mark_stale` / `wake`. Catalog id only; paths use
278/// [`load_profile_from_cli`] then [`provider_from_profile`].
279#[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}