Skip to main content

rustlavel_oauth/
token.rs

1//! The token endpoint's response, RFC 6749 §5.1.
2
3use crate::error::OAuthError;
4use crate::scope::Scopes;
5use rustlavel_core::Json;
6
7/// What a token endpoint returns on success.
8///
9/// `Debug` redacts the secrets. This type is what a developer prints while
10/// debugging a grant, and an access token in a log file is a live credential.
11#[derive(Clone)]
12pub struct TokenResponse {
13    pub access_token: String,
14    pub token_type: String,
15    /// Seconds from issue, not an absolute time — that is what the wire carries.
16    pub expires_in: Option<u64>,
17    pub refresh_token: Option<String>,
18    /// Present when the provider granted something other than what was asked.
19    pub scope: Option<Scopes>,
20    /// OpenID Connect's identity token, when `openid` was requested.
21    pub id_token: Option<String>,
22    /// Everything else the provider sent, kept so provider-specific fields are
23    /// not lost.
24    pub extra: Json,
25}
26
27impl TokenResponse {
28    pub fn bearer(access_token: impl Into<String>) -> TokenResponse {
29        TokenResponse {
30            access_token: access_token.into(),
31            token_type: "Bearer".to_string(),
32            expires_in: None,
33            refresh_token: None,
34            scope: None,
35            id_token: None,
36            extra: Json::Null,
37        }
38    }
39
40    pub fn expiring_in(mut self, seconds: u64) -> TokenResponse {
41        self.expires_in = Some(seconds);
42        self
43    }
44
45    pub fn with_refresh_token(mut self, token: impl Into<String>) -> TokenResponse {
46        self.refresh_token = Some(token.into());
47        self
48    }
49
50    pub fn with_scopes(mut self, scopes: Scopes) -> TokenResponse {
51        self.scope = Some(scopes);
52        self
53    }
54
55    /// Parse a token endpoint's JSON response.
56    ///
57    /// An `error` field wins over everything else, whatever the HTTP status —
58    /// enough providers return 200 with an error body that trusting the status
59    /// alone means silently treating a failure as a success.
60    pub fn from_json(body: &Json) -> Result<TokenResponse, OAuthError> {
61        if let Some(error) = OAuthError::from_json(body) {
62            return Err(error);
63        }
64
65        let access_token = body
66            .get("access_token")
67            .and_then(Json::as_str)
68            .ok_or_else(|| {
69                OAuthError::server_error(
70                    "the token response had neither an `access_token` nor an `error` field",
71                )
72            })?
73            .to_string();
74
75        Ok(TokenResponse {
76            access_token,
77            // RFC 6749 §5.1 requires `token_type`, but providers omit it often
78            // enough that refusing the grant over it helps nobody.
79            token_type: body
80                .get("token_type")
81                .and_then(Json::as_str)
82                .unwrap_or("Bearer")
83                .to_string(),
84            // Some providers send this as a JSON string rather than a number.
85            expires_in: body.get("expires_in").and_then(|value| match value {
86                Json::Number(seconds) if *seconds >= 0.0 => Some(*seconds as u64),
87                Json::String(text) => text.parse().ok(),
88                _ => None,
89            }),
90            refresh_token: body.get("refresh_token").and_then(Json::as_str).map(str::to_string),
91            scope: body.get("scope").and_then(Json::as_str).map(Scopes::parse),
92            id_token: body.get("id_token").and_then(Json::as_str).map(str::to_string),
93            extra: body.clone(),
94        })
95    }
96
97    pub fn to_json(&self) -> Json {
98        let mut pairs = vec![
99            ("access_token".to_string(), Json::String(self.access_token.clone())),
100            ("token_type".to_string(), Json::String(self.token_type.clone())),
101        ];
102        if let Some(expires_in) = self.expires_in {
103            pairs.push(("expires_in".into(), Json::Number(expires_in as f64)));
104        }
105        if let Some(refresh_token) = &self.refresh_token {
106            pairs.push(("refresh_token".into(), Json::String(refresh_token.clone())));
107        }
108        if let Some(scope) = &self.scope {
109            pairs.push(("scope".into(), Json::String(scope.to_string())));
110        }
111        if let Some(id_token) = &self.id_token {
112            pairs.push(("id_token".into(), Json::String(id_token.clone())));
113        }
114        Json::object(pairs)
115    }
116
117    /// The value for an `Authorization` header.
118    pub fn authorization_header(&self) -> String {
119        format!("{} {}", self.token_type, self.access_token)
120    }
121
122    /// Whether the token is within `slack` seconds of expiring.
123    ///
124    /// Refreshing early is deliberate: a token that expires while a request is
125    /// in flight fails, and the caller has no way to tell that apart from a
126    /// revoked token.
127    pub fn expires_within(&self, elapsed_seconds: u64, slack: u64) -> bool {
128        match self.expires_in {
129            Some(expires_in) => elapsed_seconds + slack >= expires_in,
130            None => false,
131        }
132    }
133}
134
135impl std::fmt::Debug for TokenResponse {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("TokenResponse")
138            .field("access_token", &"<redacted>")
139            .field("token_type", &self.token_type)
140            .field("expires_in", &self.expires_in)
141            .field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
142            .field("scope", &self.scope)
143            .field("id_token", &self.id_token.as_ref().map(|_| "<redacted>"))
144            .finish()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn parses_a_full_response() {
154        let body = Json::parse(
155            r#"{"access_token":"at","token_type":"Bearer","expires_in":3600,
156                "refresh_token":"rt","scope":"read write"}"#,
157        )
158        .unwrap();
159
160        let token = TokenResponse::from_json(&body).unwrap();
161        assert_eq!(token.access_token, "at");
162        assert_eq!(token.expires_in, Some(3600));
163        assert_eq!(token.refresh_token.as_deref(), Some("rt"));
164        assert_eq!(token.scope.unwrap().to_string(), "read write");
165    }
166
167    #[test]
168    fn an_error_body_is_an_error_even_with_a_200() {
169        // GitHub does exactly this. Trusting the status code would hand the
170        // caller a TokenResponse with no token in it.
171        let body = Json::parse(
172            r#"{"error":"bad_verification_code","error_description":"expired"}"#,
173        )
174        .unwrap();
175
176        let error = TokenResponse::from_json(&body).unwrap_err();
177        assert!(error.to_string().contains("expired"));
178    }
179
180    #[test]
181    fn a_response_with_no_token_and_no_error_says_so() {
182        let error = TokenResponse::from_json(&Json::parse("{}").unwrap()).unwrap_err();
183        assert!(error.to_string().contains("access_token"), "got {error}");
184    }
185
186    #[test]
187    fn expires_in_is_accepted_as_a_string_too() {
188        // Some providers quote it. Dropping it would make every token look
189        // like it never expires.
190        let body = Json::parse(r#"{"access_token":"at","expires_in":"3600"}"#).unwrap();
191        assert_eq!(TokenResponse::from_json(&body).unwrap().expires_in, Some(3600));
192    }
193
194    #[test]
195    fn a_nonsense_expiry_is_dropped_rather_than_wrapped() {
196        // -1 as u64 is 18 quintillion seconds; a token that "never expires" is
197        // worse than one with no stated expiry.
198        let body = Json::parse(r#"{"access_token":"at","expires_in":-1}"#).unwrap();
199        assert_eq!(TokenResponse::from_json(&body).unwrap().expires_in, None);
200    }
201
202    #[test]
203    fn a_missing_token_type_defaults_to_bearer() {
204        let body = Json::parse(r#"{"access_token":"at"}"#).unwrap();
205        assert_eq!(TokenResponse::from_json(&body).unwrap().token_type, "Bearer");
206    }
207
208    #[test]
209    fn provider_specific_fields_are_kept() {
210        let body = Json::parse(r#"{"access_token":"at","team_id":"T1"}"#).unwrap();
211        let token = TokenResponse::from_json(&body).unwrap();
212
213        assert_eq!(token.extra.get("team_id").and_then(Json::as_str), Some("T1"));
214    }
215
216    #[test]
217    fn round_trips_through_json() {
218        let original = TokenResponse::bearer("at")
219            .expiring_in(3600)
220            .with_refresh_token("rt")
221            .with_scopes(Scopes::of(["read"]));
222
223        let parsed = TokenResponse::from_json(&original.to_json()).unwrap();
224        assert_eq!(parsed.access_token, "at");
225        assert_eq!(parsed.expires_in, Some(3600));
226        assert_eq!(parsed.refresh_token.as_deref(), Some("rt"));
227    }
228
229    #[test]
230    fn builds_an_authorization_header() {
231        assert_eq!(TokenResponse::bearer("at").authorization_header(), "Bearer at");
232    }
233
234    #[test]
235    fn refreshes_before_the_token_actually_dies() {
236        let token = TokenResponse::bearer("at").expiring_in(3600);
237
238        assert!(!token.expires_within(3000, 60));
239        assert!(token.expires_within(3550, 60), "should refresh inside the slack window");
240        assert!(!TokenResponse::bearer("at").expires_within(99_999, 60), "no stated expiry");
241    }
242
243    #[test]
244    fn debug_prints_no_credentials() {
245        let token = TokenResponse::bearer("secret-access").with_refresh_token("secret-refresh");
246        let printed = format!("{token:?}");
247
248        assert!(!printed.contains("secret-access"));
249        assert!(!printed.contains("secret-refresh"));
250    }
251}