Skip to main content

infinity_provider_models_dev/
oauth.rs

1//! Claude Pro/Max subscription auth (`OAuth`) for the `Anthropic` backend.
2//!
3//! Implements the same subscription sign-in the community bridges use
4//! ([`pi-claude-bridge`](https://github.com/elidickinson/pi-claude-bridge),
5//! [`anthropic-auth`](https://github.com/cortexkit/anthropic-auth)): a PKCE
6//! authorize URL the user opens in a browser, a pasted-back code exchanged
7//! for tokens, and transparent refreshes afterwards. Inference requests then
8//! carry `Bearer` tokens with the `oauth-2025-04-20` beta instead of an API
9//! key, so usage bills to the subscription instead of API credits.
10//!
11//! > **At your own risk.** This project is not affiliated with or endorsed
12//! > by `Anthropic`. Using a subscription outside `Anthropic`'s official
13//! > clients may violate their terms and can lead to account restrictions or
14//! > suspension. The preset stays dormant unless explicitly opted in.
15
16use std::path::PathBuf;
17use std::sync::{Arc, PoisonError, RwLock};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use base64::Engine as _;
21use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22use reqwest::Client;
23use serde::{Deserialize, Serialize};
24use sha2::Digest as _;
25
26/// `OAuth` client id embedded in `Claude Code` (public, shared by bridges).
27pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
28
29/// Authorize endpoints by account type.
30pub const AUTHORIZE_URL_CONSOLE: &str = "https://platform.claude.com/oauth/authorize";
31/// Authorize endpoints by account type.
32pub const AUTHORIZE_URL_MAX: &str = "https://claude.com/cai/oauth/authorize";
33
34/// Callback the browser flow redirects to (code is pasted back manually).
35pub const CODE_CALLBACK_URL: &str = "https://platform.claude.com/oauth/code/callback";
36
37/// Token endpoint for both code exchange and refresh.
38pub const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
39
40/// Scopes requested at sign-in.
41pub const OAUTH_SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
42/// Narrower scopes sent on refresh.
43pub const REFRESH_SCOPE: &str =
44    "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
45
46/// Beta flag billing inference to the subscription.
47pub const OAUTH_BETA: &str = "oauth-2025-04-20";
48/// `Anthropic` API version header.
49pub const ANTHROPIC_VERSION: &str = "2023-06-01";
50
51/// `User-Agent` used for token endpoint calls.
52pub const TOKEN_USER_AGENT: &str = "axios/1.15.2";
53/// `User-Agent` identifying requests as `Claude Code`, mirroring the bridges.
54pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.302 (external, cli)";
55
56/// Refresh this far ahead of expiry (5 minutes).
57pub const REFRESH_SKEW_MS: i64 = 300_000;
58/// Network timeout for token endpoint calls.
59pub const TOKEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
60
61/// Explicit opt-in env var enabling the subscription preset.
62pub const OPT_IN_ENV: &str = "INFINITY_CLAUDE_OAUTH";
63/// Static bearer token (no refresh); implies opt-in.
64pub const STATIC_TOKEN_ENV: &str = "ANTHROPIC_OAUTH_TOKEN";
65/// Base URL override for subscription inference.
66pub const BASE_URL_ENV: &str = "CLAUDE_OAUTH_BASE_URL";
67
68type BoxError = Box<dyn std::error::Error + Send + Sync>;
69
70/// Failure talking to the token endpoint.
71#[derive(Debug)]
72pub struct OAuthError {
73    /// Human-readable failure.
74    pub message: String,
75    /// HTTP status when the endpoint answered.
76    pub status: Option<u16>,
77    /// Whether retrying later might succeed (network/5xx).
78    pub retryable: bool,
79}
80
81impl std::fmt::Display for OAuthError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.message)
84    }
85}
86
87impl std::error::Error for OAuthError {}
88
89/// Freshly issued tokens.
90#[derive(Debug)]
91pub struct IssuedTokens {
92    /// Bearer token for inference.
93    pub access_token: String,
94    /// Replacement refresh token, when the server rotated it.
95    pub refresh_token: Option<String>,
96    /// Lifetime in seconds.
97    pub expires_in_secs: i64,
98}
99
100/// Credential set backing one subscription session.
101#[derive(Debug)]
102pub struct OAuthSession {
103    /// Current bearer token.
104    pub access_token: String,
105    /// Long-lived token used to mint new bearer tokens; empty for static
106    /// (`env`) tokens, which are never refreshed.
107    pub refresh_token: String,
108    /// Bearer expiry as unix millis; `i64::MAX` for non-expiring static tokens.
109    pub expires_at_ms: i64,
110    /// Our credentials file to rewrite after refresh; `None` means never
111    /// persist (env tokens and `Claude Code`'s own file).
112    pub persist_path: Option<PathBuf>,
113}
114
115/// Shareable handle for concurrent completions.
116pub type OAuthSessionHandle = Arc<RwLock<OAuthSession>>;
117
118/// Current time as unix millis; `0` when the clock is unavailable.
119#[must_use]
120pub fn now_ms() -> i64 {
121    SystemTime::now()
122        .duration_since(UNIX_EPOCH)
123        .map(|d| d.as_millis())
124        .ok()
125        .and_then(|millis| i64::try_from(millis).ok())
126        .unwrap_or(0)
127}
128
129/// Whether the bearer token should be refreshed before use.
130#[must_use]
131pub const fn needs_refresh(expires_at_ms: i64, now: i64) -> bool {
132    expires_at_ms.saturating_sub(now) < REFRESH_SKEW_MS
133}
134
135/// Read a poisoned lock's contents instead of failing.
136fn unlocked<T>(result: Result<T, PoisonError<T>>) -> T {
137    result.unwrap_or_else(PoisonError::into_inner)
138}
139
140/// Tokens as stored in our own credentials file.
141#[derive(Debug, Serialize, Deserialize)]
142struct StoredCredentials {
143    access_token: String,
144    #[serde(default)]
145    refresh_token: String,
146    expires_at_ms: i64,
147    #[serde(default)]
148    mode: Option<String>,
149}
150
151/// `Claude Code`'s `~/.claude/.credentials.json` (tolerant subset).
152#[derive(Debug, Deserialize)]
153struct ClaudeCodeFile {
154    #[serde(rename = "claudeAiOauth")]
155    oauth: Option<ClaudeCodeOAuth>,
156}
157
158/// `claudeAiOauth` object inside `Claude Code`'s credentials file.
159#[derive(Debug, Deserialize)]
160struct ClaudeCodeOAuth {
161    #[serde(rename = "accessToken")]
162    access_token: Option<String>,
163    #[serde(rename = "refreshToken")]
164    refresh_token: Option<String>,
165    #[serde(rename = "expiresAt")]
166    expires_at_ms: Option<i64>,
167}
168
169/// Loaded credentials plus where to persist refreshed tokens (`None` = don't).
170#[derive(Debug)]
171pub struct LoadedCredentials {
172    /// Bearer token.
173    pub access_token: String,
174    /// Refresh token (empty when unavailable).
175    pub refresh_token: String,
176    /// Bearer expiry as unix millis.
177    pub expires_at_ms: i64,
178    /// File to rewrite after refresh.
179    pub persist_path: Option<PathBuf>,
180}
181
182/// Path of our own credentials file (`~/.infinity/claude-oauth.json`).
183#[must_use]
184pub fn own_credentials_path() -> Option<PathBuf> {
185    dirs::home_dir().map(|home| home.join(".infinity").join("claude-oauth.json"))
186}
187
188/// Path of `Claude Code`'s credentials file (`claude auth login` target).
189#[must_use]
190pub fn claude_code_credentials_path() -> Option<PathBuf> {
191    if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
192        if !dir.trim().is_empty() {
193            return Some(PathBuf::from(dir).join(".credentials.json"));
194        }
195    }
196    dirs::home_dir().map(|home| home.join(".claude").join(".credentials.json"))
197}
198
199/// Load subscription credentials: our file first, then `Claude Code`'s login.
200#[must_use]
201pub fn load_credentials() -> Option<LoadedCredentials> {
202    if let Some(path) = own_credentials_path() {
203        if let Ok(contents) = std::fs::read_to_string(&path) {
204            if let Ok(stored) = serde_json::from_str::<StoredCredentials>(&contents) {
205                if !stored.access_token.trim().is_empty() {
206                    return Some(LoadedCredentials {
207                        access_token: stored.access_token,
208                        refresh_token: stored.refresh_token,
209                        expires_at_ms: stored.expires_at_ms,
210                        persist_path: Some(path),
211                    });
212                }
213            }
214        }
215    }
216    if let Some(path) = claude_code_credentials_path() {
217        if let Ok(contents) = std::fs::read_to_string(&path) {
218            if let Ok(file) = serde_json::from_str::<ClaudeCodeFile>(&contents) {
219                if let Some(oauth) = file.oauth {
220                    if let Some(access) = oauth.access_token {
221                        if !access.trim().is_empty() {
222                            return Some(LoadedCredentials {
223                                access_token: access,
224                                refresh_token: oauth.refresh_token.unwrap_or_default(),
225                                expires_at_ms: oauth.expires_at_ms.unwrap_or(0),
226                                persist_path: None,
227                            });
228                        }
229                    }
230                }
231            }
232        }
233    }
234    None
235}
236
237/// Write our credentials file with owner-only permissions.
238fn write_credentials_file(path: &PathBuf, creds: &StoredCredentials) -> Result<(), BoxError> {
239    if let Some(parent) = path.parent() {
240        std::fs::create_dir_all(parent)?;
241    }
242    let contents = serde_json::to_string_pretty(creds)?;
243    std::fs::write(path, contents)?;
244    #[cfg(unix)]
245    {
246        use std::os::unix::fs::PermissionsExt as _;
247        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
248    }
249    Ok(())
250}
251
252/// Persist freshly exchanged login tokens; returns the file path.
253///
254/// # Errors
255///
256/// Returns an error when the home directory is unknown or the file cannot
257/// be written.
258pub fn store_login(
259    mode: AccountMode,
260    issued: &IssuedTokens,
261    now: i64,
262) -> Result<PathBuf, BoxError> {
263    let Some(path) = own_credentials_path() else {
264        return Err("could not determine home directory".into());
265    };
266    let mode_name = match mode {
267        AccountMode::Max => "max".to_owned(),
268        AccountMode::Console => "console".to_owned(),
269    };
270    let creds = StoredCredentials {
271        access_token: issued.access_token.clone(),
272        refresh_token: issued.refresh_token.clone().unwrap_or_default(),
273        expires_at_ms: now.saturating_add(issued.expires_in_secs.saturating_mul(1000)),
274        mode: Some(mode_name),
275    };
276    write_credentials_file(&path, &creds)?;
277    Ok(path)
278}
279
280/// Persist refreshed tokens to our credentials file when configured.
281fn persist(handle: &OAuthSession, now: i64, expires_in_secs: i64) {
282    let Some(path) = handle.persist_path.clone() else {
283        return;
284    };
285    let creds = StoredCredentials {
286        access_token: handle.access_token.clone(),
287        refresh_token: handle.refresh_token.clone(),
288        expires_at_ms: now.saturating_add(expires_in_secs.saturating_mul(1000)),
289        mode: None,
290    };
291    if let Err(e) = write_credentials_file(&path, &creds) {
292        tracing::warn!("oauth: failed to persist refreshed tokens: {e}");
293    }
294}
295
296/// Exchange a refresh token for new tokens.
297async fn refresh_tokens(client: &Client, refresh_token: &str) -> Result<IssuedTokens, OAuthError> {
298    #[derive(Serialize)]
299    struct RefreshRequest<'a> {
300        grant_type: &'a str,
301        refresh_token: &'a str,
302        client_id: &'a str,
303        scope: &'a str,
304    }
305    #[derive(Deserialize)]
306    struct RefreshResponse {
307        access_token: String,
308        #[serde(default)]
309        refresh_token: Option<String>,
310        expires_in: i64,
311    }
312    let resp = client
313        .post(TOKEN_URL)
314        .timeout(TOKEN_TIMEOUT)
315        .header("Content-Type", "application/json")
316        .header("Accept", "application/json, text/plain, */*")
317        .header("User-Agent", TOKEN_USER_AGENT)
318        .json(&RefreshRequest {
319            grant_type: "refresh_token",
320            refresh_token,
321            client_id: CLIENT_ID,
322            scope: REFRESH_SCOPE,
323        })
324        .send()
325        .await
326        .map_err(|e| OAuthError {
327            message: format!("oauth refresh request failed: {e}"),
328            status: None,
329            retryable: true,
330        })?;
331    if !resp.status().is_success() {
332        let status = resp.status().as_u16();
333        let text = resp.text().await.unwrap_or_default();
334        return Err(OAuthError {
335            message: format!("oauth refresh failed: HTTP {status}: {}", truncate(&text)),
336            status: Some(status),
337            retryable: status >= 500,
338        });
339    }
340    let body: RefreshResponse = resp.json().await.map_err(|e| OAuthError {
341        message: format!("oauth refresh returned invalid JSON: {e}"),
342        status: None,
343        retryable: true,
344    })?;
345    Ok(IssuedTokens {
346        access_token: body.access_token,
347        refresh_token: body.refresh_token,
348        expires_in_secs: body.expires_in,
349    })
350}
351
352fn truncate(text: &str) -> String {
353    text.trim().chars().take(300).collect()
354}
355
356/// Ensure the session holds a fresh bearer token, refreshing when needed.
357///
358/// Static (refresh-less) sessions return their token as-is.
359///
360/// # Errors
361///
362/// Returns [`OAuthError`] when the session has no usable token or the
363/// refresh endpoint rejects the request.
364pub async fn ensure_fresh_token(
365    client: &Client,
366    handle: &OAuthSessionHandle,
367) -> Result<String, OAuthError> {
368    let refresh_token = {
369        let session = unlocked(handle.read());
370        if session.access_token.trim().is_empty() {
371            return Err(OAuthError {
372                message: "oauth session has no access token; sign in again".to_owned(),
373                status: None,
374                retryable: false,
375            });
376        }
377        if session.refresh_token.trim().is_empty()
378            || !needs_refresh(session.expires_at_ms, now_ms())
379        {
380            return Ok(session.access_token.clone());
381        }
382        session.refresh_token.clone()
383    };
384    store_refreshed(client, handle, &refresh_token).await
385}
386
387/// Refresh unconditionally and store the result (used after a `401`).
388///
389/// # Errors
390///
391/// Returns [`OAuthError`] when no refresh token is available or the refresh
392/// endpoint rejects the request.
393pub async fn force_refresh(
394    client: &Client,
395    handle: &OAuthSessionHandle,
396) -> Result<String, OAuthError> {
397    let refresh_token = {
398        let session = unlocked(handle.read());
399        if session.refresh_token.trim().is_empty() {
400            return Err(OAuthError {
401                message: "oauth token rejected and no refresh token available".to_owned(),
402                status: None,
403                retryable: false,
404            });
405        }
406        session.refresh_token.clone()
407    };
408    store_refreshed(client, handle, &refresh_token).await
409}
410
411async fn store_refreshed(
412    client: &Client,
413    handle: &OAuthSessionHandle,
414    refresh_token: &str,
415) -> Result<String, OAuthError> {
416    let issued = refresh_tokens(client, refresh_token).await?;
417    let now = now_ms();
418    let mut session = unlocked(handle.write());
419    // Another task may have refreshed while the network call was in flight.
420    if !needs_refresh(session.expires_at_ms, now) && !session.access_token.trim().is_empty() {
421        return Ok(session.access_token.clone());
422    }
423    session.access_token.clone_from(&issued.access_token);
424    if let Some(rotated) = issued.refresh_token {
425        session.refresh_token = rotated;
426    }
427    session.expires_at_ms = now.saturating_add(issued.expires_in_secs.saturating_mul(1000));
428    persist(&session, now, issued.expires_in_secs);
429    Ok(session.access_token.clone())
430}
431
432/// PKCE pair for the authorize redirect.
433#[derive(Debug)]
434pub struct Pkce {
435    /// Secret sent at code exchange.
436    pub verifier: String,
437    /// `SHA-256` challenge sent in the authorize URL.
438    pub challenge: String,
439}
440
441/// Generate a PKCE pair (64 random bytes, `S256`).
442fn generate_pkce() -> Result<Pkce, BoxError> {
443    let mut random = [0_u8; 64];
444    getrandom::fill(&mut random).map_err(|e| format!("oauth: randomness unavailable: {e}"))?;
445    let verifier = URL_SAFE_NO_PAD.encode(random);
446    let digest = sha2::Sha256::digest(verifier.as_bytes());
447    Ok(Pkce {
448        verifier,
449        challenge: URL_SAFE_NO_PAD.encode(digest),
450    })
451}
452
453/// Random `state` (32 hex chars) binding the callback to this sign-in.
454fn generate_state() -> Result<String, BoxError> {
455    let mut random = [0_u8; 16];
456    getrandom::fill(&mut random).map_err(|e| format!("oauth: randomness unavailable: {e}"))?;
457    Ok(random
458        .iter()
459        .fold(String::with_capacity(32), |mut hex, byte| {
460            use std::fmt::Write as _;
461            let _ = write!(hex, "{byte:02x}");
462            hex
463        }))
464}
465
466/// Authorize redirect for one sign-in attempt.
467#[derive(Debug)]
468pub struct Authorization {
469    /// URL to open in a browser.
470    pub url: String,
471    /// Expected `state` in the pasted callback.
472    pub state: String,
473    /// Secret for the code exchange.
474    pub verifier: String,
475}
476
477/// Subscription account type selecting the authorize endpoint.
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum AccountMode {
480    /// `Max` / `Pro` subscription via `claude.com`.
481    Max,
482    /// `Console` billing via `platform.claude.com`.
483    Console,
484}
485
486impl AccountMode {
487    #[must_use]
488    pub const fn authorize_url(&self) -> &'static str {
489        match self {
490            Self::Max => AUTHORIZE_URL_MAX,
491            Self::Console => AUTHORIZE_URL_CONSOLE,
492        }
493    }
494}
495
496impl std::str::FromStr for AccountMode {
497    type Err = String;
498
499    fn from_str(s: &str) -> Result<Self, Self::Err> {
500        match s.trim().to_ascii_lowercase().as_str() {
501            "max" => Ok(Self::Max),
502            "console" => Ok(Self::Console),
503            other => Err(format!(
504                "unknown account mode: {other} (expected max|console)"
505            )),
506        }
507    }
508}
509
510/// Build the authorize URL, PKCE pair, and `state` for a sign-in attempt.
511///
512/// # Errors
513///
514/// Returns an error when OS randomness is unavailable for PKCE/`state`.
515pub fn authorize(mode: AccountMode) -> Result<Authorization, BoxError> {
516    let pkce = generate_pkce()?;
517    let state = generate_state()?;
518    let scopes = OAUTH_SCOPES.replace(' ', "%20");
519    let url = format!(
520        "{}?code=true&client_id={CLIENT_ID}&response_type=code&redirect_uri={CODE_CALLBACK_URL}\
521         &scope={scopes}&code_challenge={}&code_challenge_method=S256&state={state}",
522        mode.authorize_url(),
523        pkce.challenge,
524    );
525    Ok(Authorization {
526        url,
527        state,
528        verifier: pkce.verifier,
529    })
530}
531
532/// Pasted callback: full redirect URL, `code#state`, or `code=&state=` pairs.
533#[derive(Debug, PartialEq, Eq)]
534pub struct Callback {
535    /// Authorization code.
536    pub code: String,
537    /// Round-tripped `state`.
538    pub state: String,
539}
540
541/// Parse pasted callback input, rejecting blank fields.
542pub fn parse_callback(input: &str) -> Option<Callback> {
543    let trimmed = input.trim();
544    if trimmed.is_empty() {
545        return None;
546    }
547    // Full redirect URL: take the query string.
548    let query = trimmed.split_once('?').map_or(trimmed, |(_, query)| {
549        query.split('#').next().unwrap_or(query)
550    });
551    if let Some(found) = from_pairs(query) {
552        return Some(found);
553    }
554    // Legacy `code#state` shape.
555    if let Some((code, state)) = trimmed.split_once('#') {
556        let code = code.trim().to_owned();
557        let state = state.trim().to_owned();
558        if !code.is_empty() && !state.is_empty() && !code.contains(char::is_whitespace) {
559            return Some(Callback { code, state });
560        }
561    }
562    None
563}
564
565fn from_pairs(query: &str) -> Option<Callback> {
566    let mut code: Option<String> = None;
567    let mut state: Option<String> = None;
568    for pair in query.split('&') {
569        let (key, value) = pair.split_once('=')?;
570        match key.trim() {
571            "code" => code = Some(value.trim().to_owned()),
572            "state" => state = Some(value.trim().to_owned()),
573            _ => {}
574        }
575    }
576    match (code, state) {
577        (Some(code), Some(state)) if !code.is_empty() && !state.is_empty() => {
578            Some(Callback { code, state })
579        }
580        _ => None,
581    }
582}
583
584/// Exchange a verified callback for tokens.
585///
586/// Returns `None` when the endpoint rejects the code.
587///
588/// # Errors
589///
590/// Returns [`OAuthError`] when the token endpoint is unreachable or answers
591/// with undecodable JSON.
592pub async fn exchange_code(
593    client: &Client,
594    callback: &Callback,
595    verifier: &str,
596) -> Result<Option<IssuedTokens>, OAuthError> {
597    #[derive(Serialize)]
598    struct ExchangeRequest<'a> {
599        code: &'a str,
600        state: &'a str,
601        grant_type: &'a str,
602        client_id: &'a str,
603        redirect_uri: &'a str,
604        code_verifier: &'a str,
605    }
606    #[derive(Deserialize)]
607    struct ExchangeResponse {
608        access_token: String,
609        #[serde(default)]
610        refresh_token: Option<String>,
611        expires_in: i64,
612    }
613    let resp = client
614        .post(TOKEN_URL)
615        .timeout(TOKEN_TIMEOUT)
616        .header("Content-Type", "application/json")
617        .header("Accept", "application/json, text/plain, */*")
618        .header("User-Agent", TOKEN_USER_AGENT)
619        .json(&ExchangeRequest {
620            code: &callback.code,
621            state: &callback.state,
622            grant_type: "authorization_code",
623            client_id: CLIENT_ID,
624            redirect_uri: CODE_CALLBACK_URL,
625            code_verifier: verifier,
626        })
627        .send()
628        .await
629        .map_err(|e| OAuthError {
630            message: format!("oauth exchange request failed: {e}"),
631            status: None,
632            retryable: true,
633        })?;
634    if !resp.status().is_success() {
635        let status = resp.status().as_u16();
636        let text = resp.text().await.unwrap_or_default();
637        if status >= 500 {
638            return Err(OAuthError {
639                message: format!("oauth exchange failed: HTTP {status}: {}", truncate(&text)),
640                status: Some(status),
641                retryable: true,
642            });
643        }
644        return Ok(None);
645    }
646    let body: ExchangeResponse = resp.json().await.map_err(|e| OAuthError {
647        message: format!("oauth exchange returned invalid JSON: {e}"),
648        status: None,
649        retryable: true,
650    })?;
651    Ok(Some(IssuedTokens {
652        access_token: body.access_token,
653        refresh_token: body.refresh_token,
654        expires_in_secs: body.expires_in,
655    }))
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn parses_full_callback_url() {
664        let callback = parse_callback(
665            "https://platform.claude.com/oauth/code/callback?code=abc123&state=xyz789",
666        );
667        assert_eq!(
668            callback,
669            Some(Callback {
670                code: "abc123".to_owned(),
671                state: "xyz789".to_owned(),
672            })
673        );
674    }
675
676    #[test]
677    fn parses_query_pairs_without_url() {
678        let callback = parse_callback("code=abc123&state=xyz789");
679        assert_eq!(
680            callback,
681            Some(Callback {
682                code: "abc123".to_owned(),
683                state: "xyz789".to_owned(),
684            })
685        );
686    }
687
688    #[test]
689    fn parses_legacy_hash_shape() {
690        let callback = parse_callback("abc123#xyz789");
691        assert_eq!(
692            callback,
693            Some(Callback {
694                code: "abc123".to_owned(),
695                state: "xyz789".to_owned(),
696            })
697        );
698    }
699
700    #[test]
701    fn rejects_blank_and_partial_input() {
702        assert!(parse_callback("").is_none());
703        assert!(parse_callback("   ").is_none());
704        assert!(parse_callback("code=only").is_none());
705        assert!(parse_callback("abc123#").is_none());
706        assert!(parse_callback("#xyz789").is_none());
707    }
708
709    #[test]
710    fn refresh_skew_applies() {
711        let now = 1_000_000;
712        assert!(needs_refresh(now, now));
713        assert!(needs_refresh(now.saturating_add(60_000), now));
714        assert!(!needs_refresh(
715            now.saturating_add(REFRESH_SKEW_MS).saturating_add(1),
716            now
717        ));
718        assert!(needs_refresh(0, now));
719    }
720
721    #[test]
722    fn authorize_url_carries_oauth_params() {
723        let auth = authorize(AccountMode::Max).unwrap();
724        assert!(auth.url.starts_with(AUTHORIZE_URL_MAX));
725        assert!(auth.url.contains(CLIENT_ID));
726        assert!(auth.url.contains("code_challenge_method=S256"));
727        assert!(auth.url.contains("code_challenge="));
728        assert_ne!(auth.verifier, String::new());
729        assert_ne!(auth.state, String::new());
730    }
731}