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}