1use crate::error::OAuthError;
4use crate::scope::Scopes;
5use rustlavel_core::Json;
6
7#[derive(Clone)]
12pub struct TokenResponse {
13 pub access_token: String,
14 pub token_type: String,
15 pub expires_in: Option<u64>,
17 pub refresh_token: Option<String>,
18 pub scope: Option<Scopes>,
20 pub id_token: Option<String>,
22 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 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 token_type: body
80 .get("token_type")
81 .and_then(Json::as_str)
82 .unwrap_or("Bearer")
83 .to_string(),
84 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 pub fn authorization_header(&self) -> String {
119 format!("{} {}", self.token_type, self.access_token)
120 }
121
122 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 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 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 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}