Skip to main content

origin_auth/
token.rs

1use origin_domain::Clock;
2use origin_secrets::Secret;
3use serde::{Deserialize, Serialize};
4use time::{Duration, OffsetDateTime};
5
6/// The credentials of one authenticated account.
7#[derive(Debug, Clone)]
8pub struct TokenSet {
9    pub access_token: Secret,
10    /// Absent when the provider issues no refresh token — the user then has to
11    /// re-authenticate once the access token expires.
12    pub refresh_token: Option<Secret>,
13    pub token_type: String,
14    /// Absent when the provider does not say; such tokens are treated as long-lived.
15    pub expires_at: Option<OffsetDateTime>,
16    /// Scopes the provider actually granted, which can be fewer than requested.
17    pub scopes: Vec<String>,
18}
19
20impl TokenSet {
21    /// Whether the access token is expired, or will be within `skew`.
22    ///
23    /// The skew matters: a token that is valid for another two seconds when checked
24    /// will be rejected by the time the request arrives.
25    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    /// Credentials built from a token the user pasted in (B7/C1).
37    ///
38    /// A personal access token has neither a refresh token nor a stated expiry: it is
39    /// treated as long-lived until the service rejects it, at which point the account
40    /// is marked expired and the user pastes a new one. No OAuth flow is involved.
41    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    /// Apply a refresh response.
52    ///
53    /// Many providers omit `refresh_token` when refreshing, meaning "keep using the one
54    /// you have". Dropping it there would log the user out on the next refresh.
55    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/// What a token endpoint returns (RFC 6749 §5.1).
71#[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    /// Lifetime in seconds from now.
77    pub expires_in: Option<i64>,
78    /// Space-separated list.
79    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/// On-disk shape. Kept separate from [`TokenSet`] so `Secret` never gains a
100/// `Serialize` implementation — that would make leaking one an accident away.
101#[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}