use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::error::AuthErrorKind;
use crate::handles::{AuthLeasePhase, AuthLeaseSnapshot};
use crate::provider::Provider;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AuthStatusPhase {
Valid,
Expiring,
Expired,
ReauthRequired,
RefreshFailed,
Released,
Absent,
MissingCredential,
}
impl AuthStatusPhase {
pub const ALL: &'static [Self] = &[
Self::Valid,
Self::Expiring,
Self::Expired,
Self::ReauthRequired,
Self::RefreshFailed,
Self::Released,
Self::Absent,
Self::MissingCredential,
];
pub const fn as_public_str(self) -> &'static str {
match self {
Self::Valid => "valid",
Self::Expiring => "expiring",
Self::Expired => "expired",
Self::ReauthRequired => "reauth_required",
Self::RefreshFailed => "refresh_failed",
Self::Released => "released",
Self::Absent => "absent",
Self::MissingCredential => "missing_credential",
}
}
pub const fn is_no_live_lease(self) -> bool {
matches!(
self,
Self::Released | Self::Absent | Self::MissingCredential
)
}
pub const fn from_lease_phase(phase: Option<AuthLeasePhase>) -> Self {
match phase {
Some(AuthLeasePhase::Valid) => Self::Valid,
Some(AuthLeasePhase::Expiring | AuthLeasePhase::Refreshing) => Self::Expiring,
Some(AuthLeasePhase::Expired) => Self::Expired,
Some(AuthLeasePhase::ReauthRequired) => Self::ReauthRequired,
Some(AuthLeasePhase::Released) => Self::Released,
None => Self::Absent,
}
}
pub fn from_lease_snapshot(_now: DateTime<Utc>, snapshot: &AuthLeaseSnapshot) -> Self {
if !snapshot.credential_present {
return Self::MissingCredential;
}
match snapshot.phase {
Some(AuthLeasePhase::Valid) => Self::Valid,
Some(AuthLeasePhase::Expiring | AuthLeasePhase::Refreshing) => Self::Expiring,
Some(AuthLeasePhase::Expired) => Self::Expired,
Some(AuthLeasePhase::ReauthRequired) => Self::ReauthRequired,
Some(AuthLeasePhase::Released) => Self::Released,
None => Self::Absent,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AuthStatus {
pub profile_id: String,
pub provider: Provider,
pub backend_kind: String,
pub auth_method: String,
pub source_label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub organization_id: Option<String>,
#[serde(default)]
pub needs_reauth: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<AuthErrorSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AuthErrorSummary {
pub kind: AuthErrorKind,
pub message: String,
#[cfg_attr(feature = "schema", schemars(with = "String"))]
pub occurred_at: DateTime<Utc>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
#[test]
fn auth_status_roundtrip() {
let status = AuthStatus {
profile_id: "openai_api_key".into(),
provider: Provider::OpenAI,
backend_kind: crate::provider_matrix::OpenAiBackendKind::OpenAiApi
.as_str()
.into(),
auth_method: crate::provider_matrix::OpenAiAuthMethod::ApiKey
.as_str()
.into(),
source_label: "env:OPENAI_API_KEY".into(),
expires_at: None,
account_id: None,
workspace_id: None,
organization_id: Some("org-abc".into()),
needs_reauth: false,
last_error: None,
};
let s = serde_json::to_string(&status).unwrap();
let back: AuthStatus = serde_json::from_str(&s).unwrap();
assert_eq!(back, status);
}
#[test]
fn auth_error_summary_roundtrip() {
let summary = AuthErrorSummary {
kind: AuthErrorKind::MissingSecret,
message: "env var not set".into(),
occurred_at: Utc::now(),
};
let s = serde_json::to_string(&summary).unwrap();
let back: AuthErrorSummary = serde_json::from_str(&s).unwrap();
assert_eq!(back.kind, summary.kind);
assert_eq!(back.message, summary.message);
}
#[test]
fn auth_status_phase_maps_machine_lease_phase_to_public_values() {
let now = Utc.with_ymd_and_hms(2026, 4, 28, 12, 0, 0).unwrap();
let valid = AuthLeaseSnapshot {
phase: Some(AuthLeasePhase::Valid),
expires_at: Some((now + Duration::minutes(10)).timestamp() as u64),
credential_present: true,
generation: 1,
credential_published_at_millis: None,
};
assert_eq!(
AuthStatusPhase::from_lease_snapshot(now, &valid).as_public_str(),
"valid"
);
let expired = AuthLeaseSnapshot {
phase: Some(AuthLeasePhase::Valid),
expires_at: Some((now - Duration::seconds(1)).timestamp() as u64),
credential_present: true,
generation: 1,
credential_published_at_millis: None,
};
assert_eq!(
AuthStatusPhase::from_lease_snapshot(now, &expired).as_public_str(),
"valid"
);
let machine_expired = AuthLeaseSnapshot {
phase: Some(AuthLeasePhase::Expired),
expires_at: Some((now - Duration::seconds(1)).timestamp() as u64),
credential_present: true,
generation: 1,
credential_published_at_millis: None,
};
assert_eq!(
AuthStatusPhase::from_lease_snapshot(now, &machine_expired).as_public_str(),
"expired"
);
let stale_token_without_machine = AuthLeaseSnapshot {
phase: None,
expires_at: Some((now + Duration::minutes(10)).timestamp() as u64),
credential_present: false,
generation: 1,
credential_published_at_millis: None,
};
assert_eq!(
AuthStatusPhase::from_lease_snapshot(now, &stale_token_without_machine).as_public_str(),
"missing_credential"
);
}
#[test]
fn auth_status_phase_maps_typed_lease_phase_to_public_values() {
assert_eq!(
AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Valid)).as_public_str(),
"valid"
);
assert_eq!(
AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Expiring)).as_public_str(),
"expiring"
);
assert_eq!(
AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Expired)).as_public_str(),
"expired"
);
assert_eq!(
AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::ReauthRequired)).as_public_str(),
"reauth_required"
);
assert_eq!(
AuthStatusPhase::from_lease_phase(None).as_public_str(),
"absent"
);
assert_eq!(
AuthStatusPhase::from_lease_phase(Some(AuthLeasePhase::Released)).as_public_str(),
"released"
);
}
#[test]
fn auth_status_phase_is_no_live_lease_classifies_three_states() {
assert!(AuthStatusPhase::Released.is_no_live_lease());
assert!(AuthStatusPhase::Absent.is_no_live_lease());
assert!(AuthStatusPhase::MissingCredential.is_no_live_lease());
assert!(!AuthStatusPhase::Valid.is_no_live_lease());
}
#[cfg(feature = "schema")]
#[test]
fn auth_status_emits_json_schema() {
let schema = schemars::schema_for!(AuthStatus);
let json = serde_json::to_value(&schema).unwrap();
assert!(json.is_object());
let props = json
.get("properties")
.expect("AuthStatus schema has properties");
assert!(props.get("profile_id").is_some());
assert!(props.get("provider").is_some());
assert!(props.get("needs_reauth").is_some());
}
}