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    /// Apply a refresh response.
37    ///
38    /// Many providers omit `refresh_token` when refreshing, meaning "keep using the one
39    /// you have". Dropping it there would log the user out on the next refresh.
40    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/// What a token endpoint returns (RFC 6749 §5.1).
56#[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    /// Lifetime in seconds from now.
62    pub expires_in: Option<i64>,
63    /// Space-separated list.
64    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/// On-disk shape. Kept separate from [`TokenSet`] so `Secret` never gains a
85/// `Serialize` implementation — that would make leaking one an accident away.
86#[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}