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