Skip to main content

pipedash_core/domain/
credential.rs

1use std::fmt;
2
3use chrono::{
4    DateTime,
5    Duration,
6    Utc,
7};
8use pipedash_plugin_api::SignInToken;
9use serde::{
10    Deserialize,
11    Serialize,
12};
13
14use super::error::{
15    DomainError,
16    DomainResult,
17};
18
19pub const SIGN_IN_CONFIG_KEY: &str = "sign_in";
20pub const SIGN_IN_EXPIRED: &str = "Your sign-in expired. Sign in again.";
21
22const STORED_PREFIX: &str = "pipedash-sign-in:v1:";
23const RENEW_MARGIN_SECONDS: i64 = 60;
24const PROVIDER_REF_PREFIX: &str = "provider:";
25
26#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct SignInCredential {
28    pub token: String,
29    pub refresh_token: Option<String>,
30    pub expires_at: Option<DateTime<Utc>>,
31    pub token_endpoint: String,
32    pub client_id: String,
33    pub kind: SignInToken,
34    pub account: Option<String>,
35}
36
37impl SignInCredential {
38    pub fn encode(&self) -> DomainResult<String> {
39        serde_json::to_string(self)
40            .map(|json| format!("{STORED_PREFIX}{json}"))
41            .map_err(|e| DomainError::InternalError(format!("Couldn't store the sign-in: {e}")))
42    }
43
44    pub fn decode(stored: &str) -> Option<Self> {
45        serde_json::from_str(stored.strip_prefix(STORED_PREFIX)?).ok()
46    }
47
48    pub fn bearer(stored: &str) -> String {
49        Self::decode(stored)
50            .map(|credential| credential.token)
51            .unwrap_or_else(|| stored.to_string())
52    }
53
54    pub fn renew_at(&self) -> Option<DateTime<Utc>> {
55        self.refresh_token.as_ref()?;
56        Some(self.expires_at? - Duration::seconds(RENEW_MARGIN_SECONDS))
57    }
58}
59
60impl fmt::Debug for SignInCredential {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.debug_struct("SignInCredential")
63            .field("account", &self.account)
64            .field("expires_at", &self.expires_at)
65            .finish_non_exhaustive()
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum SignInRef {
71    Flow(String),
72    Provider(i64),
73}
74
75impl SignInRef {
76    pub fn parse(reference: &str) -> Self {
77        reference
78            .strip_prefix(PROVIDER_REF_PREFIX)
79            .and_then(|id| id.parse().ok())
80            .map_or_else(|| Self::Flow(reference.to_string()), Self::Provider)
81    }
82}
83
84impl fmt::Display for SignInRef {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::Flow(id) => f.write_str(id),
88            Self::Provider(id) => write!(f, "{PROVIDER_REF_PREFIX}{id}"),
89        }
90    }
91}
92
93#[derive(Debug, Clone, Serialize)]
94pub struct SignInStart {
95    pub id: String,
96    pub authorize_url: String,
97    pub label: String,
98    pub manual: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102#[serde(tag = "state", rename_all = "snake_case")]
103pub enum SignInStatus {
104    Pending,
105    Completed { account: Option<String> },
106    Failed { message: String },
107    Expired,
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn credential(refresh_token: Option<&str>) -> SignInCredential {
115        SignInCredential {
116            token: "id-token-secret".into(),
117            refresh_token: refresh_token.map(Into::into),
118            expires_at: Some(DateTime::from_timestamp(1_800_000_000, 0).unwrap()),
119            token_endpoint: "https://idp.example.com/token".into(),
120            client_id: "cli".into(),
121            kind: SignInToken::IdToken,
122            account: Some("dev@example.com".into()),
123        }
124    }
125
126    #[test]
127    fn encoded_credential_round_trips_and_exposes_bearer() {
128        let stored = credential(Some("refresh-secret")).encode().unwrap();
129        assert_eq!(
130            SignInCredential::decode(&stored),
131            Some(credential(Some("refresh-secret")))
132        );
133        assert_eq!(SignInCredential::bearer(&stored), "id-token-secret");
134    }
135
136    #[test]
137    fn plain_token_is_not_a_credential() {
138        assert_eq!(SignInCredential::decode("ghp_plain"), None);
139        assert_eq!(SignInCredential::bearer("ghp_plain"), "ghp_plain");
140    }
141
142    #[test]
143    fn debug_hides_tokens() {
144        let printed = format!("{:?}", credential(Some("refresh-secret")));
145        assert!(!printed.contains("id-token-secret"));
146        assert!(!printed.contains("refresh-secret"));
147        assert!(printed.contains("dev@example.com"));
148    }
149
150    #[test]
151    fn renews_a_minute_before_expiry_only_with_refresh_token() {
152        assert_eq!(credential(None).renew_at(), None);
153        let with_refresh = credential(Some("r"));
154        assert_eq!(
155            with_refresh.renew_at(),
156            Some(with_refresh.expires_at.unwrap() - Duration::seconds(60))
157        );
158    }
159
160    #[test]
161    fn reference_parses_provider_and_flow() {
162        assert_eq!(SignInRef::parse("provider:7"), SignInRef::Provider(7));
163        assert_eq!(SignInRef::Provider(7).to_string(), "provider:7");
164        assert_eq!(
165            SignInRef::parse("Xy-z_09"),
166            SignInRef::Flow("Xy-z_09".into())
167        );
168    }
169}