entelix_auth_claude_code/
credential.rs1use chrono::{DateTime, TimeZone, Utc};
9use secrecy::SecretString;
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct OAuthCredential {
23 #[serde(rename = "accessToken")]
26 pub access_token: String,
27 #[serde(
30 rename = "refreshToken",
31 default,
32 skip_serializing_if = "Option::is_none"
33 )]
34 pub refresh_token: Option<String>,
35 #[serde(rename = "expiresAt")]
37 pub expires_at_ms: i64,
38 #[serde(
42 default,
43 skip_serializing_if = "Option::is_none",
44 rename = "subscriptionType"
45 )]
46 pub subscription_type: Option<String>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub scopes: Vec<String>,
50}
51
52impl OAuthCredential {
53 #[must_use]
56 pub fn new(access_token: impl Into<String>, expires_at_ms: i64) -> Self {
57 Self {
58 access_token: access_token.into(),
59 refresh_token: None,
60 expires_at_ms,
61 subscription_type: None,
62 scopes: Vec::new(),
63 }
64 }
65
66 #[must_use]
68 pub fn with_refresh_token(mut self, token: impl Into<String>) -> Self {
69 self.refresh_token = Some(token.into());
70 self
71 }
72
73 #[must_use]
75 pub fn with_subscription_type(mut self, tier: impl Into<String>) -> Self {
76 self.subscription_type = Some(tier.into());
77 self
78 }
79
80 #[must_use]
82 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
83 where
84 I: IntoIterator<Item = S>,
85 S: Into<String>,
86 {
87 self.scopes = scopes.into_iter().map(Into::into).collect();
88 self
89 }
90
91 #[must_use]
97 pub fn expires_at(&self) -> Option<DateTime<Utc>> {
98 Utc.timestamp_millis_opt(self.expires_at_ms).single()
99 }
100
101 #[must_use]
109 pub fn needs_refresh(&self) -> bool {
110 let Some(expires_at) = self.expires_at() else {
111 return true;
112 };
113 Utc::now() + chrono::Duration::seconds(60) >= expires_at
114 }
115
116 #[must_use]
121 pub fn to_bearer_secret(&self) -> SecretString {
122 SecretString::from(format!("Bearer {}", self.access_token))
123 }
124}
125
126#[derive(Clone, Debug, Default, Serialize, Deserialize)]
134#[non_exhaustive]
135pub struct CredentialFile {
136 #[serde(
138 default,
139 rename = "claudeAiOauth",
140 skip_serializing_if = "Option::is_none"
141 )]
142 pub claude_ai_oauth: Option<OAuthCredential>,
143}
144
145impl CredentialFile {
146 #[must_use]
148 pub fn empty() -> Self {
149 Self::default()
150 }
151
152 #[must_use]
154 pub const fn with_oauth(credential: OAuthCredential) -> Self {
155 Self {
156 claude_ai_oauth: Some(credential),
157 }
158 }
159}
160
161#[cfg(test)]
162#[allow(clippy::unwrap_used)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn deserialize_minimal_credential_file() {
168 let json = r#"{
169 "claudeAiOauth": {
170 "accessToken": "sk-ant-oat01-x",
171 "refreshToken": "sk-ant-ort01-y",
172 "expiresAt": 9999999999000,
173 "subscriptionType": "pro",
174 "scopes": ["user:inference"]
175 }
176 }"#;
177 let file: CredentialFile = serde_json::from_str(json).unwrap();
178 let oauth = file.claude_ai_oauth.unwrap();
179 assert_eq!(oauth.access_token, "sk-ant-oat01-x");
180 assert_eq!(oauth.refresh_token.as_deref(), Some("sk-ant-ort01-y"));
181 assert_eq!(oauth.subscription_type.as_deref(), Some("pro"));
182 assert!(!oauth.needs_refresh());
183 }
184
185 #[test]
186 fn needs_refresh_when_within_skew_window() {
187 let near_expiry = OAuthCredential::new(
188 "tok",
189 (Utc::now() - chrono::Duration::seconds(1)).timestamp_millis(),
190 )
191 .with_refresh_token("ref");
192 assert!(near_expiry.needs_refresh());
193 }
194
195 #[test]
196 fn empty_envelope_has_no_oauth() {
197 let file: CredentialFile = serde_json::from_str("{}").unwrap();
198 assert!(file.claude_ai_oauth.is_none());
199 }
200
201 #[test]
202 fn expires_at_returns_none_for_unrepresentable_millis() {
203 let cred = OAuthCredential::new("tok", i64::MAX);
208 assert!(cred.expires_at().is_none());
209 }
210
211 #[test]
212 fn unrepresentable_expires_treated_as_already_expired() {
213 let past = OAuthCredential::new("tok", i64::MIN);
219 assert!(past.needs_refresh());
220 let future = OAuthCredential::new("tok", i64::MAX);
221 assert!(future.needs_refresh());
222 }
223
224 #[test]
225 fn builder_chain_populates_optional_fields() {
226 let cred = OAuthCredential::new("acc", 9_999_999_999_000)
227 .with_refresh_token("ref")
228 .with_subscription_type("team")
229 .with_scopes(["user:inference", "user:profile"]);
230 assert_eq!(cred.access_token, "acc");
231 assert_eq!(cred.refresh_token.as_deref(), Some("ref"));
232 assert_eq!(cred.subscription_type.as_deref(), Some("team"));
233 assert_eq!(cred.scopes, vec!["user:inference", "user:profile"]);
234 }
235}