1use origin_domain::Clock;
2use origin_secrets::Secret;
3use serde::{Deserialize, Serialize};
4use time::{Duration, OffsetDateTime};
5
6#[derive(Debug, Clone)]
8pub struct TokenSet {
9 pub access_token: Secret,
10 pub refresh_token: Option<Secret>,
13 pub token_type: String,
14 pub expires_at: Option<OffsetDateTime>,
16 pub scopes: Vec<String>,
18}
19
20impl TokenSet {
21 pub fn expires_within(&self, clock: &dyn Clock, skew: Duration) -> bool {
26 match self.expires_at {
27 None => false,
28 Some(expires_at) => clock.now() + skew >= expires_at,
29 }
30 }
31
32 pub fn can_refresh(&self) -> bool {
33 self.refresh_token.is_some()
34 }
35
36 pub fn personal_access_token(token: impl Into<String>, scopes: Vec<String>) -> Self {
42 Self {
43 access_token: Secret::new(token),
44 refresh_token: None,
45 token_type: "Bearer".to_owned(),
46 expires_at: None,
47 scopes,
48 }
49 }
50
51 pub(crate) fn merge_refreshed(&self, refreshed: TokenSet) -> TokenSet {
56 TokenSet {
57 refresh_token: refreshed
58 .refresh_token
59 .or_else(|| self.refresh_token.clone()),
60 scopes: if refreshed.scopes.is_empty() {
61 self.scopes.clone()
62 } else {
63 refreshed.scopes
64 },
65 ..refreshed
66 }
67 }
68}
69
70#[derive(Debug, Deserialize)]
72pub(crate) struct TokenResponse {
73 pub access_token: String,
74 pub refresh_token: Option<String>,
75 pub token_type: Option<String>,
76 pub expires_in: Option<i64>,
78 pub scope: Option<String>,
80}
81
82impl TokenResponse {
83 pub(crate) fn into_token_set(self, now: OffsetDateTime) -> TokenSet {
84 TokenSet {
85 access_token: Secret::new(self.access_token),
86 refresh_token: self.refresh_token.map(Secret::new),
87 token_type: self.token_type.unwrap_or_else(|| "Bearer".to_owned()),
88 expires_at: self
89 .expires_in
90 .map(|seconds| now + Duration::seconds(seconds)),
91 scopes: self
92 .scope
93 .map(|scope| scope.split_whitespace().map(str::to_owned).collect())
94 .unwrap_or_default(),
95 }
96 }
97}
98
99#[derive(Debug, Serialize, Deserialize)]
102pub(crate) struct StoredTokenSet {
103 access_token: String,
104 refresh_token: Option<String>,
105 token_type: String,
106 #[serde(with = "time::serde::rfc3339::option")]
107 expires_at: Option<OffsetDateTime>,
108 scopes: Vec<String>,
109}
110
111impl From<&TokenSet> for StoredTokenSet {
112 fn from(tokens: &TokenSet) -> Self {
113 Self {
114 access_token: tokens.access_token.expose().to_owned(),
115 refresh_token: tokens
116 .refresh_token
117 .as_ref()
118 .map(|token| token.expose().to_owned()),
119 token_type: tokens.token_type.clone(),
120 expires_at: tokens.expires_at,
121 scopes: tokens.scopes.clone(),
122 }
123 }
124}
125
126impl From<StoredTokenSet> for TokenSet {
127 fn from(stored: StoredTokenSet) -> Self {
128 Self {
129 access_token: Secret::new(stored.access_token),
130 refresh_token: stored.refresh_token.map(Secret::new),
131 token_type: stored.token_type,
132 expires_at: stored.expires_at,
133 scopes: stored.scopes,
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use origin_domain::testing::FakeClock;
142 use time::macros::datetime;
143
144 const NOW: OffsetDateTime = datetime!(2026-08-23 10:00 UTC);
145
146 fn token_set(expires_in: Option<i64>, refresh: Option<&str>) -> TokenSet {
147 TokenResponse {
148 access_token: "access".to_owned(),
149 refresh_token: refresh.map(str::to_owned),
150 token_type: None,
151 expires_in,
152 scope: Some("repo read:org".to_owned()),
153 }
154 .into_token_set(NOW)
155 }
156
157 #[test]
158 fn expiry_accounts_for_clock_skew() {
159 let clock = FakeClock::new(NOW);
160 let tokens = token_set(Some(120), None);
161
162 assert!(!tokens.expires_within(&clock, Duration::seconds(60)));
163
164 clock.advance(Duration::seconds(61));
165 assert!(
166 tokens.expires_within(&clock, Duration::seconds(60)),
167 "a token expiring within the skew must count as expired"
168 );
169 }
170
171 #[test]
172 fn a_token_without_an_expiry_never_expires() {
173 let clock = FakeClock::new(NOW);
174 clock.advance(Duration::days(400));
175
176 assert!(!token_set(None, None).expires_within(&clock, Duration::seconds(60)));
177 }
178
179 #[test]
180 fn scopes_are_split_on_whitespace() {
181 assert_eq!(token_set(None, None).scopes, vec!["repo", "read:org"]);
182 }
183
184 #[test]
185 fn refreshing_keeps_the_old_refresh_token_when_the_provider_omits_it() {
186 let original = token_set(Some(60), Some("refresh-1"));
187 let refreshed = TokenResponse {
188 access_token: "access-2".to_owned(),
189 refresh_token: None,
190 token_type: None,
191 expires_in: Some(3600),
192 scope: None,
193 }
194 .into_token_set(NOW);
195
196 let merged = original.merge_refreshed(refreshed);
197
198 assert_eq!(merged.access_token.expose(), "access-2");
199 assert_eq!(
200 merged.refresh_token.as_ref().map(|t| t.expose()),
201 Some("refresh-1"),
202 "dropping the refresh token here would log the user out on the next refresh"
203 );
204 assert_eq!(merged.scopes, vec!["repo", "read:org"]);
205 }
206
207 #[test]
208 fn a_rotated_refresh_token_replaces_the_old_one() {
209 let original = token_set(Some(60), Some("refresh-1"));
210 let refreshed = TokenResponse {
211 access_token: "access-2".to_owned(),
212 refresh_token: Some("refresh-2".to_owned()),
213 token_type: None,
214 expires_in: Some(3600),
215 scope: None,
216 }
217 .into_token_set(NOW);
218
219 let merged = original.merge_refreshed(refreshed);
220
221 assert_eq!(
222 merged.refresh_token.as_ref().map(|t| t.expose()),
223 Some("refresh-2")
224 );
225 }
226
227 #[test]
228 fn the_stored_shape_round_trips() {
229 let tokens = token_set(Some(3600), Some("refresh-1"));
230 let encoded = serde_json::to_string(&StoredTokenSet::from(&tokens)).unwrap();
231 let decoded: TokenSet = serde_json::from_str::<StoredTokenSet>(&encoded)
232 .unwrap()
233 .into();
234
235 assert_eq!(decoded.access_token.expose(), "access");
236 assert_eq!(decoded.expires_at, tokens.expires_at);
237 assert_eq!(decoded.scopes, tokens.scopes);
238 }
239}