Skip to main content

entelix_auth_claude_code/
credential.rs

1//! `OAuthCredential` — the on-disk shape for Claude Code's OAuth
2//! state, plus the refresh-flow data carrier.
3//!
4//! Wire-format reference: the file the `claude` CLI writes at
5//! `~/.claude/.credentials.json` (or platform equivalent). Format
6//! adapted from the public `claude` CLI.
7
8use chrono::{DateTime, TimeZone, Utc};
9use secrecy::SecretString;
10use serde::{Deserialize, Serialize};
11
12/// A refreshable OAuth credential read from / written to the
13/// `claude` CLI's credential file.
14///
15/// Field naming mirrors the on-disk JSON exactly so deserialization
16/// stays a one-line `serde_json::from_slice`. `#[non_exhaustive]`
17/// closes off direct struct-literal construction so future
18/// server-returned fields ship as additive minor releases — use
19/// [`OAuthCredential::new`] plus the `with_*` chain.
20#[derive(Clone, Debug, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct OAuthCredential {
23    /// Bearer token forwarded to Anthropic in the `Authorization`
24    /// header.
25    #[serde(rename = "accessToken")]
26    pub access_token: String,
27    /// Long-lived refresh token. `None` means the credential cannot
28    /// renew itself once the access token expires.
29    #[serde(
30        rename = "refreshToken",
31        default,
32        skip_serializing_if = "Option::is_none"
33    )]
34    pub refresh_token: Option<String>,
35    /// Unix-millis epoch at which the access token expires.
36    #[serde(rename = "expiresAt")]
37    pub expires_at_ms: i64,
38    /// `pro` / `team` / etc. — informational, not load-bearing for
39    /// transport. Preserved across refresh so storage round-trips
40    /// keep the original metadata.
41    #[serde(
42        default,
43        skip_serializing_if = "Option::is_none",
44        rename = "subscriptionType"
45    )]
46    pub subscription_type: Option<String>,
47    /// OAuth scopes granted to this token. Preserved across refresh.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub scopes: Vec<String>,
50}
51
52impl OAuthCredential {
53    /// Construct a credential from the two mandatory fields. Use
54    /// the `with_*` chain for the optional ones.
55    #[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    /// Set the long-lived refresh token.
67    #[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    /// Set the subscription tier (`pro` / `team` / …).
74    #[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    /// Replace the granted-scopes list.
81    #[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    /// Wall-clock instant the access token expires, or `None` when
92    /// `expires_at_ms` does not represent a valid Unix-millis epoch
93    /// (storage corruption / server bug). Surfaces the loss
94    /// explicitly rather than coercing to "now" — the credential
95    /// is then treated as already expired by [`Self::needs_refresh`].
96    #[must_use]
97    pub fn expires_at(&self) -> Option<DateTime<Utc>> {
98        Utc.timestamp_millis_opt(self.expires_at_ms).single()
99    }
100
101    /// True when the access token is past its expiry — `resolve`
102    /// must trigger a refresh before handing the token to a
103    /// transport. An unrepresentable `expires_at_ms` (storage
104    /// corruption, server bug) reads as already-expired so the
105    /// provider refreshes rather than handing out a stale token.
106    /// Includes a 60-second skew window so a token about to expire
107    /// mid-flight refreshes proactively.
108    #[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    /// Wrap the access token in a [`SecretString`] formatted as
117    /// `Bearer <token>` for transport use. Allocates a fresh secret
118    /// per call so callers can hand it straight to
119    /// [`entelix_core::auth::Credentials::header_value`].
120    #[must_use]
121    pub fn to_bearer_secret(&self) -> SecretString {
122        SecretString::from(format!("Bearer {}", self.access_token))
123    }
124}
125
126/// On-disk envelope for the credential file.
127///
128/// The file currently houses one OAuth credential under
129/// `claudeAiOauth`. Future schema additions land here additively —
130/// `#[non_exhaustive]` keeps external construction on
131/// [`CredentialFile::with_oauth`] / [`CredentialFile::empty`] so
132/// new fields are non-breaking.
133#[derive(Clone, Debug, Default, Serialize, Deserialize)]
134#[non_exhaustive]
135pub struct CredentialFile {
136    /// The Claude.ai OAuth credential, if present.
137    #[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    /// Empty envelope.
147    #[must_use]
148    pub fn empty() -> Self {
149        Self::default()
150    }
151
152    /// Envelope wrapping one OAuth credential.
153    #[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        // i64::MAX as Unix-millis is past the chrono representable
204        // range — the helper surfaces this as `None` instead of
205        // silently coercing to "now" (which would fool
206        // `needs_refresh` into thinking the credential is fresh).
207        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        // Even when the timestamp can't be rendered, the credential
214        // must read as expired so the provider triggers a refresh
215        // rather than handing out a stale token. Both i64 extremes
216        // round-trip through Option<DateTime>::None and surface as
217        // needs_refresh — the security-relevant default.
218        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}