cli_engine/auth/credential.rs
1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Cache TTL used when a credential has `cached_at`.
5pub const CACHE_TTL: Duration = Duration::minutes(30);
6
7/// Credential returned by an auth provider.
8///
9/// Field names and omission behavior match the provider JSON contract. Empty
10/// strings are accepted because some providers omit optional values.
11#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
12pub struct Credential {
13 /// Access token used by transport injectors.
14 #[serde(default)]
15 pub token: String,
16 /// Explicit expiration timestamp.
17 #[serde(default)]
18 pub expires_at: String,
19 /// Cache creation timestamp. When present, [`CACHE_TTL`] determines expiry.
20 #[serde(default, skip_serializing_if = "String::is_empty")]
21 pub cached_at: String,
22 /// Provider that produced this credential.
23 #[serde(default, skip_serializing_if = "String::is_empty")]
24 pub provider: String,
25 /// Environment this credential targets.
26 #[serde(default, skip_serializing_if = "String::is_empty")]
27 pub env: String,
28 /// Environment alias accepted from provider responses.
29 #[serde(default, skip_serializing_if = "String::is_empty")]
30 pub realm: String,
31 /// Human-readable identity.
32 #[serde(default, skip_serializing_if = "String::is_empty")]
33 pub identity: String,
34 /// Subject identifier.
35 #[serde(default, skip_serializing_if = "String::is_empty")]
36 pub sub: String,
37 /// Account type associated with the credential.
38 #[serde(default, skip_serializing_if = "String::is_empty")]
39 pub account_type: String,
40 /// OAuth scopes granted to this credential's token. Empty for providers
41 /// that don't expose scope data (e.g. PATs, whose scopes are opaque and
42 /// enforced server-side).
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub scopes: Vec<String>,
45 /// Whether a renewal mechanism (e.g. an OAuth refresh token) is present
46 /// for this credential, so an expired access token is *expected* to
47 /// renew without an interactive re-login. This reflects presence, not
48 /// verified validity — the mechanism itself may have been revoked or
49 /// expired server-side, which is only discoverable by attempting a
50 /// renewal. `false` for providers with no such mechanism (e.g. PATs) or
51 /// when the credential has none, even if the access token itself is
52 /// still valid.
53 #[serde(default)]
54 pub refreshable: bool,
55}
56
57impl Credential {
58 /// Returns the timestamp used for status display.
59 #[must_use]
60 pub fn effective_expiry(&self) -> String {
61 if let Ok(cached_at) = DateTime::parse_from_rfc3339(&self.cached_at) {
62 return (cached_at.with_timezone(&Utc) + CACHE_TTL)
63 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
64 }
65 self.expires_at.clone()
66 }
67
68 /// Reports whether the credential is expired.
69 ///
70 /// Invalid `expires_at` values are treated as expired. Credentials without
71 /// either `expires_at` or `cached_at` are treated as not expired.
72 #[must_use]
73 pub fn is_expired(&self) -> bool {
74 if let Ok(cached_at) = DateTime::parse_from_rfc3339(&self.cached_at) {
75 return Utc::now() > cached_at.with_timezone(&Utc) + CACHE_TTL;
76 }
77 if self.expires_at.is_empty() {
78 return false;
79 }
80 match DateTime::parse_from_rfc3339(&self.expires_at) {
81 Ok(expires_at) => Utc::now() > expires_at.with_timezone(&Utc),
82 Err(_) => true,
83 }
84 }
85}