Skip to main content

shunt/
credential.rs

1//! Credential abstraction — supports OAuth (with refresh) and static API keys.
2//!
3//! All provider-specific auth is gated behind this enum so the rest of the
4//! codebase stays credential-type-agnostic.
5
6use serde::{Deserialize, Serialize};
7use zeroize::Zeroize;
8
9use crate::oauth::OAuthCredential;
10
11// ---------------------------------------------------------------------------
12// Credential enum
13// ---------------------------------------------------------------------------
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "lowercase")]
17pub enum Credential {
18    /// OAuth credential with access + refresh tokens and an expiry.
19    /// Used by Anthropic (claude.ai) and OpenAI (chatgpt.com) accounts.
20    Oauth(OAuthCredential),
21    /// Static API key — no expiry, no refresh.
22    /// Used by Groq, Mistral, OpenRouter, Gemini, Ollama Cloud, etc.
23    Apikey { key: String },
24}
25
26impl Drop for Credential {
27    fn drop(&mut self) {
28        // #20: zero out the API key on drop so it doesn't linger in freed memory.
29        if let Credential::Apikey { key } = self {
30            key.zeroize();
31        }
32        // OAuthCredential already derives ZeroizeOnDrop for its token fields.
33    }
34}
35
36impl Credential {
37    /// The bearer token to send in `Authorization: Bearer <token>`.
38    ///
39    /// For OAuth accounts: prefers `id_token` over `access_token` when
40    /// present (required by chatgpt.com / Codex). Falls back to
41    /// `access_token` for standard Anthropic OAuth.
42    ///
43    /// For API-key accounts: returns the raw key directly.
44    pub fn bearer_token(&self) -> &str {
45        match self {
46            Credential::Oauth(c) => c.id_token.as_deref().unwrap_or(&c.access_token),
47            Credential::Apikey { key } => key,
48        }
49    }
50
51    /// The raw `access_token` string.
52    ///
53    /// Used when you need the access_token specifically (e.g. token-rotation
54    /// comparison in the 401 handler, Anthropic auth headers).
55    ///
56    /// For API-key accounts returns the key (same as `bearer_token`).
57    pub fn access_token(&self) -> &str {
58        match self {
59            Credential::Oauth(c) => &c.access_token,
60            Credential::Apikey { key } => key,
61        }
62    }
63
64    /// True if the credential should be refreshed before use.
65    /// Always false for API-key credentials.
66    pub fn needs_refresh(&self) -> bool {
67        match self {
68            Credential::Oauth(c) => c.needs_refresh(),
69            Credential::Apikey { .. } => false,
70        }
71    }
72
73    /// Account email, if known. None for API-key credentials.
74    pub fn email(&self) -> Option<&str> {
75        match self {
76            Credential::Oauth(c) => c.email.as_deref(),
77            Credential::Apikey { .. } => None,
78        }
79    }
80
81    /// True when a refresh_token is available to attempt recovery.
82    /// Always false for API-key credentials.
83    pub fn has_refresh_token(&self) -> bool {
84        match self {
85            Credential::Oauth(c) => !c.refresh_token.is_empty(),
86            Credential::Apikey { .. } => false,
87        }
88    }
89
90    /// Borrow the inner OAuthCredential, if this is an OAuth credential.
91    pub fn as_oauth(&self) -> Option<&OAuthCredential> {
92        match self {
93            Credential::Oauth(c) => Some(c),
94            Credential::Apikey { .. } => None,
95        }
96    }
97
98    /// Mutably borrow the inner OAuthCredential.
99    pub fn as_oauth_mut(&mut self) -> Option<&mut OAuthCredential> {
100        match self {
101            Credential::Oauth(c) => Some(c),
102            Credential::Apikey { .. } => None,
103        }
104    }
105
106    /// Display string for status/monitor output.
107    /// Shows email for OAuth accounts, masked key for API-key accounts.
108    pub fn masked_display(&self) -> String {
109        match self {
110            Credential::Oauth(c) => c.email.clone().unwrap_or_else(|| "oauth".to_owned()),
111            Credential::Apikey { key } => {
112                let suffix = &key[key.len().saturating_sub(4)..];
113                format!("···{suffix}")
114            }
115        }
116    }
117}
118
119impl From<OAuthCredential> for Credential {
120    fn from(c: OAuthCredential) -> Self {
121        Credential::Oauth(c)
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Backwards-compatible deserialization for CredentialsStore
127// ---------------------------------------------------------------------------
128
129/// Deserialize a `HashMap<String, Credential>` that may contain old-format
130/// entries (written before the `"type"` tag was introduced).
131///
132/// Old format: `{ "access_token": "...", "refresh_token": "...", ... }`
133/// New format: `{ "type": "oauth", "access_token": "...", ... }`
134///             `{ "type": "apikey", "key": "..." }`
135pub fn deserialize_credential_map<'de, D>(
136    deserializer: D,
137) -> Result<std::collections::HashMap<String, Credential>, D::Error>
138where
139    D: serde::Deserializer<'de>,
140{
141    use std::collections::HashMap;
142    let raw: HashMap<String, serde_json::Value> = HashMap::deserialize(deserializer)?;
143    let mut out = HashMap::with_capacity(raw.len());
144    for (k, v) in raw {
145        let cred = if v.get("type").is_some() {
146            // New tagged format — deserialize directly.
147            serde_json::from_value::<Credential>(v).map_err(serde::de::Error::custom)?
148        } else {
149            // Legacy format — treat as OAuth.
150            serde_json::from_value::<OAuthCredential>(v)
151                .map(Credential::Oauth)
152                .map_err(serde::de::Error::custom)?
153        };
154        out.insert(k, cred);
155    }
156    Ok(out)
157}