use chrono::{DateTime, TimeZone, Utc};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OAuthCredential {
#[serde(rename = "accessToken")]
pub access_token: String,
#[serde(
rename = "refreshToken",
default,
skip_serializing_if = "Option::is_none"
)]
pub refresh_token: Option<String>,
#[serde(rename = "expiresAt")]
pub expires_at_ms: i64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "subscriptionType"
)]
pub subscription_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
}
impl OAuthCredential {
#[must_use]
pub fn new(access_token: impl Into<String>, expires_at_ms: i64) -> Self {
Self {
access_token: access_token.into(),
refresh_token: None,
expires_at_ms,
subscription_type: None,
scopes: Vec::new(),
}
}
#[must_use]
pub fn with_refresh_token(mut self, token: impl Into<String>) -> Self {
self.refresh_token = Some(token.into());
self
}
#[must_use]
pub fn with_subscription_type(mut self, tier: impl Into<String>) -> Self {
self.subscription_type = Some(tier.into());
self
}
#[must_use]
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = scopes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn expires_at(&self) -> Option<DateTime<Utc>> {
Utc.timestamp_millis_opt(self.expires_at_ms).single()
}
#[must_use]
pub fn needs_refresh(&self) -> bool {
let Some(expires_at) = self.expires_at() else {
return true;
};
Utc::now() + chrono::Duration::seconds(60) >= expires_at
}
#[must_use]
pub fn to_bearer_secret(&self) -> SecretString {
SecretString::from(format!("Bearer {}", self.access_token))
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CredentialFile {
#[serde(
default,
rename = "claudeAiOauth",
skip_serializing_if = "Option::is_none"
)]
pub claude_ai_oauth: Option<OAuthCredential>,
}
impl CredentialFile {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub const fn with_oauth(credential: OAuthCredential) -> Self {
Self {
claude_ai_oauth: Some(credential),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn deserialize_minimal_credential_file() {
let json = r#"{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-x",
"refreshToken": "sk-ant-ort01-y",
"expiresAt": 9999999999000,
"subscriptionType": "pro",
"scopes": ["user:inference"]
}
}"#;
let file: CredentialFile = serde_json::from_str(json).unwrap();
let oauth = file.claude_ai_oauth.unwrap();
assert_eq!(oauth.access_token, "sk-ant-oat01-x");
assert_eq!(oauth.refresh_token.as_deref(), Some("sk-ant-ort01-y"));
assert_eq!(oauth.subscription_type.as_deref(), Some("pro"));
assert!(!oauth.needs_refresh());
}
#[test]
fn needs_refresh_when_within_skew_window() {
let near_expiry = OAuthCredential::new(
"tok",
(Utc::now() - chrono::Duration::seconds(1)).timestamp_millis(),
)
.with_refresh_token("ref");
assert!(near_expiry.needs_refresh());
}
#[test]
fn empty_envelope_has_no_oauth() {
let file: CredentialFile = serde_json::from_str("{}").unwrap();
assert!(file.claude_ai_oauth.is_none());
}
#[test]
fn expires_at_returns_none_for_unrepresentable_millis() {
let cred = OAuthCredential::new("tok", i64::MAX);
assert!(cred.expires_at().is_none());
}
#[test]
fn unrepresentable_expires_treated_as_already_expired() {
let past = OAuthCredential::new("tok", i64::MIN);
assert!(past.needs_refresh());
let future = OAuthCredential::new("tok", i64::MAX);
assert!(future.needs_refresh());
}
#[test]
fn builder_chain_populates_optional_fields() {
let cred = OAuthCredential::new("acc", 9_999_999_999_000)
.with_refresh_token("ref")
.with_subscription_type("team")
.with_scopes(["user:inference", "user:profile"]);
assert_eq!(cred.access_token, "acc");
assert_eq!(cred.refresh_token.as_deref(), Some("ref"));
assert_eq!(cred.subscription_type.as_deref(), Some("team"));
assert_eq!(cred.scopes, vec!["user:inference", "user:profile"]);
}
}