pipedash_core/domain/
credential.rs1use 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}