1use crate::error::{OAuthError, OAuthErrorCode};
19use crate::pkce::Pkce;
20use crate::provider::{ClientAuth, Provider};
21use crate::scope::Scopes;
22use crate::state::StateGuard;
23use crate::token::TokenResponse;
24use crate::url;
25use crate::user::SocialUser;
26use rustlavel_auth::base64;
27use rustlavel_client::{Client, ClientResponse};
28use rustlavel_core::Json;
29
30pub struct Authorization {
36 url: String,
37 state: String,
38 pkce: Pkce,
39}
40
41impl Authorization {
42 pub fn url(&self) -> &str {
43 &self.url
44 }
45
46 pub fn state(&self) -> &str {
47 &self.state
48 }
49
50 pub fn verifier(&self) -> &str {
51 self.pkce.verifier()
52 }
53
54 pub fn pkce(&self) -> &Pkce {
55 &self.pkce
56 }
57
58 pub fn into_url(self) -> String {
59 self.url
60 }
61}
62
63impl std::fmt::Debug for Authorization {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("Authorization")
66 .field("url", &self.url)
67 .field("state", &self.state)
68 .field("verifier", &"<redacted>")
69 .finish()
70 }
71}
72
73#[derive(Clone)]
79pub struct OAuthClient {
80 provider: Provider,
81 client_id: String,
82 client_secret: String,
83 redirect_uri: String,
84 scopes: Option<Scopes>,
85 params: Vec<(String, String)>,
86 http: Client,
87}
88
89impl OAuthClient {
90 pub fn new(provider: Provider) -> OAuthClient {
91 OAuthClient {
92 provider,
93 client_id: String::new(),
94 client_secret: String::new(),
95 redirect_uri: String::new(),
96 scopes: None,
97 params: Vec::new(),
98 http: Client::new(),
99 }
100 }
101
102 pub fn credentials(
103 mut self,
104 client_id: impl Into<String>,
105 client_secret: impl Into<String>,
106 ) -> OAuthClient {
107 self.client_id = client_id.into();
108 self.client_secret = client_secret.into();
109 self
110 }
111
112 pub fn redirect_uri(mut self, uri: impl Into<String>) -> OAuthClient {
119 self.redirect_uri = uri.into();
120 self
121 }
122
123 pub fn scopes(mut self, scopes: impl Into<Scopes>) -> OAuthClient {
125 self.scopes = Some(scopes.into());
126 self
127 }
128
129 pub fn scope(mut self, scope: impl Into<String>) -> OAuthClient {
131 let mut scopes = self.scopes.take().unwrap_or_else(|| self.provider.scopes.clone());
132 scopes.add(scope);
133 self.scopes = Some(scopes);
134 self
135 }
136
137 pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> OAuthClient {
144 let name = name.into();
145 self.params.retain(|(existing, _)| existing != &name);
146 self.params.push((name, value.into()));
147 self
148 }
149
150 pub fn using(mut self, http: Client) -> OAuthClient {
152 self.http = http;
153 self
154 }
155
156 pub fn provider(&self) -> &Provider {
157 &self.provider
158 }
159
160 pub fn http(&self) -> &Client {
161 &self.http
162 }
163
164 pub fn client_id(&self) -> &str {
165 &self.client_id
166 }
167
168 pub fn requested_scopes(&self) -> Scopes {
171 self.scopes.clone().unwrap_or_else(|| self.provider.scopes.clone())
172 }
173
174 pub fn authorize_url(&self) -> Authorization {
179 let pkce = Pkce::generate();
180 let state = base64::encode_url(&rustlavel_auth::random::bytes(16));
181 let url = self.authorize_url_for(&state, &pkce);
182
183 Authorization { url, state, pkce }
184 }
185
186 pub fn begin(&self, guard: &StateGuard) -> Result<Authorization, OAuthError> {
188 let pkce = Pkce::generate();
189 let state = guard.issue(&pkce)?;
190 let url = self.authorize_url_for(&state, &pkce);
191
192 Ok(Authorization { url, state, pkce })
193 }
194
195 pub fn authorize_url_for(&self, state: &str, pkce: &Pkce) -> String {
201 let scopes = self.requested_scopes().to_string();
202 let challenge = pkce.challenge();
203 let extras = self.authorize_params();
204
205 let mut pairs: Vec<(&str, &str)> = vec![
206 ("response_type", "code"),
207 ("client_id", &self.client_id),
208 ("redirect_uri", &self.redirect_uri),
209 ];
210 if !scopes.is_empty() {
211 pairs.push(("scope", &scopes));
212 }
213 pairs.push(("state", state));
214 pairs.push(("code_challenge", &challenge));
215 pairs.push(("code_challenge_method", pkce.method().as_str()));
216 pairs.extend(extras.iter().map(|(name, value)| (name.as_str(), value.as_str())));
217
218 url::append_query(&self.provider.authorize_url, &url::form_encode(&pairs))
219 }
220
221 fn authorize_params(&self) -> Vec<(String, String)> {
223 let mut params = self.provider.authorize_params.clone();
224 for (name, value) in &self.params {
225 params.retain(|(existing, _)| existing != name);
226 params.push((name.clone(), value.clone()));
227 }
228 params
229 }
230
231 pub async fn callback(
241 &self,
242 query: &str,
243 guard: &StateGuard,
244 ) -> Result<TokenResponse, OAuthError> {
245 let fields = url::form_decode(query);
246 let field = |name: &str| {
247 fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
248 };
249
250 let pkce = guard.verify(field("state"))?;
251
252 if let Some(code) = field("error") {
253 return Err(OAuthError {
254 code: OAuthErrorCode::parse(code).unwrap_or(OAuthErrorCode::ServerError),
255 description: field("error_description")
256 .map(str::to_string)
257 .or_else(|| Some(format!("the provider said: {code}"))),
258 uri: field("error_uri").map(str::to_string),
259 state: None,
260 });
261 }
262
263 let code = field("code").filter(|code| !code.is_empty()).ok_or_else(|| {
264 OAuthError::invalid_request("the callback carried neither a `code` nor an `error`")
265 })?;
266
267 self.exchange(code, pkce.verifier()).await
268 }
269
270 pub async fn exchange(
272 &self,
273 code: &str,
274 verifier: &str,
275 ) -> Result<TokenResponse, OAuthError> {
276 self.token_request(&[
277 ("grant_type", "authorization_code"),
278 ("code", code),
279 ("redirect_uri", &self.redirect_uri),
283 ("code_verifier", verifier),
284 ])
285 .await
286 }
287
288 pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResponse, OAuthError> {
295 self.token_request(&[("grant_type", "refresh_token"), ("refresh_token", refresh_token)])
296 .await
297 }
298
299 pub async fn revoke(&self, token: &str, hint: &str) -> Result<(), OAuthError> {
306 let Some(endpoint) = self.provider.revoke_url.clone() else {
307 return Err(OAuthError::server_error(format!(
308 "`{}` has no revocation endpoint. Set one with `Provider::revoke(url)` if it \
309 gained one, or drop the token and let it expire.",
310 self.provider.name
311 )));
312 };
313
314 let mut form: Vec<(&str, &str)> = vec![("token", token)];
315 if !hint.is_empty() {
316 form.push(("token_type_hint", hint));
317 }
318
319 let response = self.post_form(&endpoint, form).await?;
320
321 if response.status.is_success() {
325 return Ok(());
326 }
327 Err(self.error_from(&response))
328 }
329
330 pub async fn user(&self, token: &TokenResponse) -> Result<SocialUser, OAuthError> {
332 let Some(endpoint) = self.provider.userinfo_url.clone() else {
333 return Err(OAuthError::server_error(format!(
334 "`{}` has no userinfo endpoint. Set one with `Provider::userinfo(url)`.",
335 self.provider.name
336 )));
337 };
338
339 let mut request =
340 self.http.get(&endpoint).header("authorization", token.authorization_header());
341 for (name, value) in &self.provider.headers {
342 request = request.header(name, value.clone());
343 }
344
345 let response = request.send().await.map_err(|error| {
346 OAuthError::server_error(format!("could not reach {endpoint}: {error}"))
347 })?;
348
349 let body = decode_body(&response);
350 if !response.status.is_success() {
351 return Err(self.error_from(&response));
352 }
353
354 SocialUser::from_provider(&self.provider, &body).ok_or_else(|| {
355 OAuthError::server_error(format!(
356 "`{}` returned a profile with no `{}` field to identify it by",
357 self.provider.name,
358 self.provider.map.id.join("` or `")
359 ))
360 })
361 }
362
363 async fn token_request(&self, form: &[(&str, &str)]) -> Result<TokenResponse, OAuthError> {
366 let url = self.provider.token_url.clone();
367 let response = self.post_form(&url, form.to_vec()).await?;
368 let body = decode_body(&response);
369
370 if body.is_null() {
371 let text = response.text();
372 let excerpt: String = text.chars().take(200).collect();
373 return Err(OAuthError::server_error(format!(
374 "the token endpoint at {url} answered HTTP {} with a body that is neither JSON \
375 nor a form: {excerpt}",
376 response.status
377 )));
378 }
379
380 TokenResponse::from_json(&body)
383 }
384
385 async fn post_form(
386 &self,
387 url: &str,
388 mut form: Vec<(&str, &str)>,
389 ) -> Result<ClientResponse, OAuthError> {
390 if self.provider.client_auth == ClientAuth::Body {
391 form.push(("client_id", &self.client_id));
392 form.push(("client_secret", &self.client_secret));
393 }
394
395 let mut request = self
396 .http
397 .post(url)
398 .header("content-type", "application/x-www-form-urlencoded")
399 .body(url::form_encode(&form));
400
401 for (name, value) in &self.provider.headers {
402 request = request.header(name, value.clone());
403 }
404 if self.provider.client_auth == ClientAuth::Basic {
405 request = request.header("authorization", self.basic_credentials());
406 }
407
408 request
409 .send()
410 .await
411 .map_err(|error| OAuthError::server_error(format!("could not reach {url}: {error}")))
412 }
413
414 fn basic_credentials(&self) -> String {
420 let joined =
421 format!("{}:{}", url::encode(&self.client_id), url::encode(&self.client_secret));
422 format!("Basic {}", base64::encode(joined.as_bytes()))
423 }
424
425 fn error_from(&self, response: &ClientResponse) -> OAuthError {
428 if let Some(error) = OAuthError::from_json(&decode_body(response)) {
429 return error;
430 }
431
432 let text = response.text();
433 let excerpt: String = text.chars().take(200).collect();
434 OAuthError::because(
435 match response.status.code() {
436 401 => OAuthErrorCode::InvalidClient,
437 403 => OAuthErrorCode::AccessDenied,
438 503 => OAuthErrorCode::TemporarilyUnavailable,
439 _ => OAuthErrorCode::ServerError,
440 },
441 format!("`{}` answered HTTP {}: {excerpt}", self.provider.name, response.status),
442 )
443 }
444}
445
446impl std::fmt::Debug for OAuthClient {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 f.debug_struct("OAuthClient")
449 .field("provider", &self.provider.name)
450 .field("client_id", &self.client_id)
451 .field("client_secret", &"<redacted>")
452 .field("redirect_uri", &self.redirect_uri)
453 .field("scopes", &self.requested_scopes())
454 .finish()
455 }
456}
457
458fn decode_body(response: &ClientResponse) -> Json {
470 let text = response.text();
471 let trimmed = text.trim();
472 if trimmed.is_empty() {
473 return Json::Null;
474 }
475
476 let content_type = response.headers.content_type().unwrap_or("");
477 if content_type.contains("json") || trimmed.starts_with('{') {
478 return Json::parse(trimmed).unwrap_or(Json::Null);
479 }
480
481 if content_type.contains("form-urlencoded") || (trimmed.contains('=') && !trimmed.contains(' '))
482 {
483 let pairs = url::form_decode(trimmed);
484 if !pairs.is_empty() {
485 return Json::object(pairs.into_iter().map(|(k, v)| (k, Json::String(v))));
486 }
487 }
488
489 Json::Null
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::pkce;
496 use rustlavel_client::fake::{Fake, FakeResponse};
497 use rustlavel_http::Headers;
498
499 fn client(provider: Provider, fake: Fake) -> OAuthClient {
500 OAuthClient::new(provider)
501 .credentials("client-123", "s3cr+t/val=ue")
502 .redirect_uri("https://app.test/auth/callback")
503 .using(Client::new().faking(fake))
504 }
505
506 fn query_of(url: &str) -> Vec<(String, String)> {
508 url::form_decode(url.split_once('?').expect("a query string").1)
509 }
510
511 fn value<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> {
512 pairs.iter().find(|(key, _)| key == name).map(|(_, v)| v.as_str())
513 }
514
515 #[test]
516 fn the_authorisation_url_carries_exactly_the_parameters_the_rfc_asks_for() {
517 let client = OAuthClient::new(Provider::google())
518 .credentials("client-123", "secret")
519 .redirect_uri("https://app.test/auth/google/callback");
520 let start = client.authorize_url();
521
522 let pairs = query_of(start.url());
523 let names: Vec<&str> = pairs.iter().map(|(name, _)| name.as_str()).collect();
524 assert_eq!(
525 names,
526 vec![
527 "response_type",
528 "client_id",
529 "redirect_uri",
530 "scope",
531 "state",
532 "code_challenge",
533 "code_challenge_method",
534 ]
535 );
536
537 assert!(start.url().starts_with("https://accounts.google.com/o/oauth2/v2/auth?"));
538 assert_eq!(value(&pairs, "response_type"), Some("code"));
539 assert_eq!(value(&pairs, "client_id"), Some("client-123"));
540 assert_eq!(value(&pairs, "redirect_uri"), Some("https://app.test/auth/google/callback"));
541 assert_eq!(value(&pairs, "scope"), Some("email openid profile"));
542 assert_eq!(value(&pairs, "state"), Some(start.state()));
543 }
544
545 #[test]
546 fn the_redirect_uri_is_escaped_rather_than_pasted_into_the_url() {
547 let client = OAuthClient::new(Provider::google())
550 .credentials("id", "secret")
551 .redirect_uri("https://app.test/cb?tenant=acme&next=/x");
552
553 let url = client.authorize_url().into_url();
554 assert!(url.contains("redirect_uri=https%3A%2F%2Fapp.test%2Fcb%3Ftenant%3Dacme%26next%3D%2Fx"));
555 assert_eq!(url.matches("tenant").count(), 1);
556 }
557
558 #[test]
559 fn the_authorisation_url_commits_to_a_pkce_challenge_and_never_the_verifier() {
560 let start = OAuthClient::new(Provider::github())
561 .credentials("id", "secret")
562 .redirect_uri("https://app.test/cb")
563 .authorize_url();
564
565 let pairs = query_of(start.url());
566 assert_eq!(value(&pairs, "code_challenge_method"), Some("S256"));
567 assert_eq!(value(&pairs, "code_challenge"), Some(pkce::s256(start.verifier()).as_str()));
568 assert!(
569 !start.url().contains(start.verifier()),
570 "the verifier reached the URL, which defeats PKCE entirely"
571 );
572 }
573
574 #[test]
575 fn extra_parameters_are_appended_and_replace_rather_than_repeat() {
576 let url = OAuthClient::new(Provider::google())
577 .credentials("id", "secret")
578 .redirect_uri("https://app.test/cb")
579 .with("access_type", "offline")
580 .with("prompt", "select_account")
581 .with("prompt", "consent")
582 .authorize_url()
583 .into_url();
584
585 let pairs = query_of(&url);
586 assert_eq!(value(&pairs, "access_type"), Some("offline"));
587 assert_eq!(value(&pairs, "prompt"), Some("consent"));
588 assert_eq!(pairs.iter().filter(|(name, _)| name == "prompt").count(), 1);
589 }
590
591 #[test]
592 fn scopes_replace_or_extend_the_providers_defaults() {
593 let base = OAuthClient::new(Provider::github()).credentials("id", "secret");
594
595 assert_eq!(base.clone().requested_scopes().to_string(), "read:user user:email");
596 assert_eq!(base.clone().scopes("repo").requested_scopes().to_string(), "repo");
597 assert_eq!(
598 base.scope("gist").requested_scopes().to_string(),
599 "gist read:user user:email"
600 );
601 }
602
603 #[test]
604 fn two_authorisations_never_share_a_state_or_a_verifier() {
605 let client = OAuthClient::new(Provider::google()).credentials("id", "secret");
606 let (first, second) = (client.authorize_url(), client.authorize_url());
607
608 assert_ne!(first.state(), second.state());
609 assert_ne!(first.verifier(), second.verifier());
610 }
611
612 #[tokio::test]
613 async fn a_successful_exchange_sends_the_grant_and_returns_the_token() {
614 let client = client(
615 Provider::google(),
616 Fake::new().on(
617 "oauth2.googleapis.com/token",
618 FakeResponse::json(
619 Json::parse(
620 r#"{"access_token":"at-1","token_type":"Bearer","expires_in":3599,
621 "refresh_token":"rt-1","scope":"openid email"}"#,
622 )
623 .unwrap(),
624 ),
625 ),
626 );
627
628 let token = client.exchange("the-code", &"v".repeat(43)).await.unwrap();
629 assert_eq!(token.access_token, "at-1");
630 assert_eq!(token.expires_in, Some(3599));
631 assert_eq!(token.refresh_token.as_deref(), Some("rt-1"));
632
633 let sent = &client.http().fake().unwrap().recorded()[0];
634 let form = url::form_decode(&sent.body_text());
635 assert_eq!(value(&form, "grant_type"), Some("authorization_code"));
636 assert_eq!(value(&form, "code"), Some("the-code"));
637 assert_eq!(value(&form, "code_verifier"), Some("v".repeat(43).as_str()));
638 assert_eq!(value(&form, "redirect_uri"), Some("https://app.test/auth/callback"));
639 assert_eq!(sent.headers.get("content-type"), Some("application/x-www-form-urlencoded"));
640 }
641
642 #[tokio::test]
643 async fn basic_credentials_are_percent_encoded_before_they_are_joined() {
644 let client = client(
649 Provider::google(),
650 Fake::new().fallback(FakeResponse::json(
651 Json::parse(r#"{"access_token":"at"}"#).unwrap(),
652 )),
653 );
654 client.exchange("code", &"v".repeat(43)).await.unwrap();
655
656 let sent = &client.http().fake().unwrap().recorded()[0];
657 let header = sent.headers.get("authorization").unwrap();
658 let decoded = String::from_utf8(
659 base64::decode(header.strip_prefix("Basic ").unwrap()).unwrap(),
660 )
661 .unwrap();
662
663 assert_eq!(decoded, "client-123:s3cr%2Bt%2Fval%3Due");
664 let form = url::form_decode(&sent.body_text());
665 assert_eq!(value(&form, "client_secret"), None, "Basic means not in the body too");
666 }
667
668 #[tokio::test]
669 async fn a_body_authenticating_provider_puts_the_credentials_in_the_form() {
670 let client = client(
671 Provider::github(),
672 Fake::new().fallback(FakeResponse::json(
673 Json::parse(r#"{"access_token":"at"}"#).unwrap(),
674 )),
675 );
676 client.exchange("code", &"v".repeat(43)).await.unwrap();
677
678 let sent = &client.http().fake().unwrap().recorded()[0];
679 let form = url::form_decode(&sent.body_text());
680
681 assert_eq!(value(&form, "client_id"), Some("client-123"));
682 assert_eq!(value(&form, "client_secret"), Some("s3cr+t/val=ue"));
683 assert_eq!(sent.headers.get("authorization"), None);
684 }
685
686 #[tokio::test]
687 async fn github_is_asked_for_json_and_is_understood_anyway_when_it_sends_a_form() {
688 let asked = client(
692 Provider::github(),
693 Fake::new().fallback(FakeResponse::json(
694 Json::parse(r#"{"access_token":"at-json"}"#).unwrap(),
695 )),
696 );
697 assert_eq!(asked.exchange("c", &"v".repeat(43)).await.unwrap().access_token, "at-json");
698 assert_eq!(
699 asked.http().fake().unwrap().recorded()[0].headers.get("accept"),
700 Some("application/json")
701 );
702
703 let mut headers = Headers::new();
704 headers.set("content-type", "application/x-www-form-urlencoded; charset=utf-8");
705 let form_response = FakeResponse {
706 status: rustlavel_http::Status::OK,
707 headers,
708 body: b"access_token=at-form&scope=read%3Auser&token_type=bearer".to_vec(),
709 };
710
711 let unasked = client(
712 Provider::github().without_headers(),
713 Fake::new().fallback(form_response),
714 );
715 let token = unasked.exchange("c", &"v".repeat(43)).await.unwrap();
716
717 assert_eq!(token.access_token, "at-form");
718 assert_eq!(token.token_type, "bearer");
719 assert_eq!(token.scope.unwrap().to_string(), "read:user");
720 }
721
722 #[tokio::test]
723 async fn an_error_reported_with_a_200_is_still_an_error() {
724 let client = client(
727 Provider::github(),
728 Fake::new().fallback(FakeResponse::json(
729 Json::parse(
730 r#"{"error":"bad_verification_code",
731 "error_description":"The code passed is incorrect or expired."}"#,
732 )
733 .unwrap(),
734 )),
735 );
736
737 let error = client.exchange("stale", &"v".repeat(43)).await.unwrap_err();
738 assert_eq!(error.code, OAuthErrorCode::ServerError, "an unrecognised code, kept visible");
739 assert!(error.to_string().contains("incorrect or expired"), "{error}");
740 }
741
742 #[tokio::test]
743 async fn a_400_with_an_rfc_error_body_keeps_its_code() {
744 let client = client(
745 Provider::google(),
746 Fake::new().fallback(
747 FakeResponse::json(
748 Json::parse(
749 r#"{"error":"invalid_grant","error_description":"Code was already redeemed."}"#,
750 )
751 .unwrap(),
752 )
753 .status(400),
754 ),
755 );
756
757 let error = client.exchange("used", &"v".repeat(43)).await.unwrap_err();
758 assert_eq!(error.code, OAuthErrorCode::InvalidGrant);
759 assert!(error.to_string().contains("already redeemed"));
760 }
761
762 #[tokio::test]
763 async fn a_redirect_uri_mismatch_is_reported_with_the_providers_own_words() {
764 let client = client(
769 Provider::google(),
770 Fake::new().fallback(
771 FakeResponse::json(
772 Json::parse(r#"{"error":"redirect_uri_mismatch"}"#).unwrap(),
773 )
774 .status(400),
775 ),
776 );
777
778 let error = client.exchange("code", &"v".repeat(43)).await.unwrap_err();
779 assert!(error.to_string().contains("redirect_uri_mismatch"), "{error}");
780 }
781
782 #[tokio::test]
783 async fn a_token_with_no_stated_expiry_is_accepted_and_never_looks_expired() {
784 let client = client(
787 Provider::github(),
788 Fake::new().fallback(FakeResponse::json(
789 Json::parse(r#"{"access_token":"at","token_type":"bearer","scope":""}"#).unwrap(),
790 )),
791 );
792
793 let token = client.exchange("c", &"v".repeat(43)).await.unwrap();
794 assert_eq!(token.expires_in, None);
795 assert!(!token.expires_within(86_400, 60));
796 }
797
798 #[tokio::test]
799 async fn an_html_error_page_is_quoted_rather_than_reported_as_a_parse_error() {
800 let client = client(
801 Provider::google(),
802 Fake::new().fallback(FakeResponse::text("<html><body>502 Bad Gateway</body>").status(502)),
803 );
804
805 let error = client.exchange("c", &"v".repeat(43)).await.unwrap_err().to_string();
806 assert!(error.contains("502"), "{error}");
807 assert!(error.contains("Bad Gateway"), "{error}");
808 }
809
810 #[tokio::test]
811 async fn refreshing_sends_the_refresh_grant_and_keeps_the_new_refresh_token() {
812 let client = client(
813 Provider::google(),
814 Fake::new().fallback(FakeResponse::json(
815 Json::parse(
816 r#"{"access_token":"at-2","expires_in":3599,"refresh_token":"rt-2"}"#,
817 )
818 .unwrap(),
819 )),
820 );
821
822 let token = client.refresh("rt-1").await.unwrap();
823 assert_eq!(token.access_token, "at-2");
824 assert_eq!(token.refresh_token.as_deref(), Some("rt-2"), "rotated tokens must be kept");
825
826 let form = url::form_decode(&client.http().fake().unwrap().recorded()[0].body_text());
827 assert_eq!(value(&form, "grant_type"), Some("refresh_token"));
828 assert_eq!(value(&form, "refresh_token"), Some("rt-1"));
829 assert_eq!(value(&form, "code"), None);
830 }
831
832 #[tokio::test]
833 async fn a_refused_refresh_says_the_grant_is_invalid_rather_than_returning_nothing() {
834 let client = client(
835 Provider::google(),
836 Fake::new().fallback(
837 FakeResponse::json(Json::parse(r#"{"error":"invalid_grant"}"#).unwrap())
838 .status(400),
839 ),
840 );
841
842 assert_eq!(client.refresh("revoked").await.unwrap_err().code, OAuthErrorCode::InvalidGrant);
843 }
844
845 #[tokio::test]
846 async fn revoking_posts_the_token_to_the_revocation_endpoint() {
847 let client = client(
848 Provider::google(),
849 Fake::new().on("oauth2.googleapis.com/revoke", FakeResponse::text("")),
850 );
851
852 client.revoke("rt-1", "refresh_token").await.unwrap();
853
854 let sent = &client.http().fake().unwrap().recorded()[0];
855 let form = url::form_decode(&sent.body_text());
856 assert_eq!(value(&form, "token"), Some("rt-1"));
857 assert_eq!(value(&form, "token_type_hint"), Some("refresh_token"));
858 }
859
860 #[tokio::test]
861 async fn revoking_a_token_that_was_already_dead_is_a_success() {
862 let client =
865 client(Provider::google(), Fake::new().fallback(FakeResponse::text("").status(200)));
866
867 assert!(client.revoke("already-gone", "access_token").await.is_ok());
868 }
869
870 #[tokio::test]
871 async fn a_refused_revocation_is_reported() {
872 let client = client(
873 Provider::google(),
874 Fake::new().fallback(
875 FakeResponse::json(Json::parse(r#"{"error":"invalid_client"}"#).unwrap())
876 .status(401),
877 ),
878 );
879
880 assert_eq!(
881 client.revoke("t", "access_token").await.unwrap_err().code,
882 OAuthErrorCode::InvalidClient
883 );
884 }
885
886 #[tokio::test]
887 async fn revoking_at_a_provider_with_no_endpoint_says_so_instead_of_pretending() {
888 let client = client(Provider::github(), Fake::new());
889
890 let error = client.revoke("t", "access_token").await.unwrap_err().to_string();
891 assert!(error.contains("no revocation endpoint"), "{error}");
892 client.http().fake().unwrap().assert_count(0);
893 }
894
895 #[tokio::test]
896 async fn the_userinfo_call_is_bearer_authenticated_and_mapped() {
897 let client = client(
898 Provider::github(),
899 Fake::new().on(
900 "api.github.com/user",
901 FakeResponse::json(
902 Json::parse(
903 r#"{"id":1234,"login":"ada","name":null,
904 "avatar_url":"https://gh.test/a.png","email":null}"#,
905 )
906 .unwrap(),
907 ),
908 ),
909 );
910
911 let user = client.user(&TokenResponse::bearer("at-1")).await.unwrap();
912 assert_eq!(user.qualified_id(), "github:1234");
913 assert_eq!(user.name.as_deref(), Some("ada"));
914 assert_eq!(user.email, None, "GitHub hides a private address");
915
916 let sent = &client.http().fake().unwrap().recorded()[0];
917 assert_eq!(sent.headers.get("authorization"), Some("Bearer at-1"));
918 assert_eq!(sent.headers.get("accept"), Some("application/json"));
919 }
920
921 #[tokio::test]
922 async fn every_preset_maps_its_own_profile_shape() {
923 struct Case {
924 provider: Provider,
925 endpoint: &'static str,
926 body: &'static str,
927 id: &'static str,
928 name: &'static str,
929 email: Option<&'static str>,
930 avatar: Option<&'static str>,
931 }
932
933 let cases = vec![
934 Case {
935 provider: Provider::google(),
936 endpoint: "openidconnect.googleapis.com/v1/userinfo",
937 body: r#"{"sub":"107","name":"Ada","email":"ada@x.test",
938 "picture":"https://g.test/a.png"}"#,
939 id: "google:107",
940 name: "Ada",
941 email: Some("ada@x.test"),
942 avatar: Some("https://g.test/a.png"),
943 },
944 Case {
945 provider: Provider::gitlab(),
946 endpoint: "gitlab.com/api/v4/user",
947 body: r#"{"id":42,"username":"ada","name":"Ada","email":"ada@x.test",
948 "avatar_url":"https://gl.test/a.png"}"#,
949 id: "gitlab:42",
950 name: "Ada",
951 email: Some("ada@x.test"),
952 avatar: Some("https://gl.test/a.png"),
953 },
954 Case {
955 provider: Provider::microsoft(),
956 endpoint: "graph.microsoft.com/v1.0/me",
957 body: r#"{"id":"aad-9","displayName":"Ada","mail":null,
958 "userPrincipalName":"ada@corp.test"}"#,
959 id: "microsoft:aad-9",
960 name: "Ada",
961 email: Some("ada@corp.test"),
962 avatar: None,
963 },
964 Case {
965 provider: Provider::discord(),
966 endpoint: "discord.com/api/users/@me",
967 body: r#"{"id":"803","username":"nelly","global_name":"Nelly",
968 "avatar":"834","email":"n@x.test"}"#,
969 id: "discord:803",
970 name: "Nelly",
971 email: Some("n@x.test"),
972 avatar: Some("https://cdn.discordapp.com/avatars/803/834.png"),
973 },
974 ];
975
976 for case in cases {
977 let label = case.provider.name.clone();
978 let client = client(
979 case.provider,
980 Fake::new()
981 .on(case.endpoint, FakeResponse::json(Json::parse(case.body).unwrap())),
982 );
983
984 let user = client.user(&TokenResponse::bearer("at")).await.unwrap();
985 assert_eq!(user.qualified_id(), case.id, "{label}");
986 assert_eq!(user.name.as_deref(), Some(case.name), "{label}");
987 assert_eq!(user.email.as_deref(), case.email, "{label}");
988 assert_eq!(user.avatar.as_deref(), case.avatar, "{label}");
989 }
990 }
991
992 #[tokio::test]
993 async fn a_userinfo_call_with_a_dead_token_is_reported_as_such() {
994 let client = client(
995 Provider::google(),
996 Fake::new().fallback(
997 FakeResponse::json(Json::parse(r#"{"error":"invalid_token"}"#).unwrap())
998 .status(401),
999 ),
1000 );
1001
1002 assert_eq!(
1003 client.user(&TokenResponse::bearer("stale")).await.unwrap_err().code,
1004 OAuthErrorCode::InvalidToken
1005 );
1006 }
1007
1008 #[tokio::test]
1009 async fn a_profile_with_nothing_to_identify_it_by_is_an_error_not_an_empty_user() {
1010 let client = client(
1011 Provider::google(),
1012 Fake::new().fallback(FakeResponse::json(Json::parse(r#"{"name":"Ada"}"#).unwrap())),
1013 );
1014
1015 let error = client.user(&TokenResponse::bearer("at")).await.unwrap_err().to_string();
1016 assert!(error.contains("sub"), "the message should name the field it looked for: {error}");
1017 }
1018
1019 fn round_trip() -> (OAuthClient, StateGuard) {
1021 let client = client(
1022 Provider::google(),
1023 Fake::new().fallback(FakeResponse::json(
1024 Json::parse(r#"{"access_token":"at-1","expires_in":3599}"#).unwrap(),
1025 )),
1026 );
1027 let guard = StateGuard::session(&rustlavel_auth::SessionHandle::new(
1028 rustlavel_auth::Session::new(),
1029 ));
1030
1031 (client, guard)
1032 }
1033
1034 #[tokio::test]
1035 async fn a_whole_flow_goes_out_with_a_state_and_comes_back_through_it() {
1036 let (client, guard) = round_trip();
1037 let start = client.begin(&guard).unwrap();
1038
1039 let query = format!("code=the-code&state={}", url::encode(start.state()));
1040 let token = client.callback(&query, &guard).await.unwrap();
1041
1042 assert_eq!(token.access_token, "at-1");
1043 let form = url::form_decode(&client.http().fake().unwrap().recorded()[0].body_text());
1045 assert_eq!(value(&form, "code_verifier"), Some(start.verifier()));
1046 }
1047
1048 #[tokio::test]
1049 async fn a_callback_that_accepts_any_state_is_login_csrf_so_this_one_does_not() {
1050 let (client, guard) = round_trip();
1056 client.begin(&guard).unwrap();
1057
1058 for query in [
1059 "code=attackers-code",
1060 "code=attackers-code&state=",
1061 "code=attackers-code&state=guessed",
1062 ] {
1063 let error = client.callback(query, &guard).await.unwrap_err();
1064 assert_eq!(error.code, OAuthErrorCode::InvalidRequest, "{query}");
1065 client.http().fake().unwrap().assert_count(0);
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn a_replayed_callback_is_refused_before_the_code_is_exchanged() {
1071 let (client, guard) = round_trip();
1072 let start = client.begin(&guard).unwrap();
1073 let query = format!("code=the-code&state={}", url::encode(start.state()));
1074
1075 assert!(client.callback(&query, &guard).await.is_ok());
1076 assert!(client.callback(&query, &guard).await.is_err());
1077 client.http().fake().unwrap().assert_count(1);
1078 }
1079
1080 #[tokio::test]
1081 async fn a_callback_from_another_visitors_flow_is_refused() {
1082 let (client, mine) = round_trip();
1083 let (_, theirs) = round_trip();
1084
1085 let start = client.begin(&theirs).unwrap();
1086 let query = format!("code=c&state={}", url::encode(start.state()));
1087
1088 assert!(client.callback(&query, &mine).await.is_err());
1089 }
1090
1091 #[tokio::test]
1092 async fn a_visitor_who_declined_gets_the_providers_reason_and_no_exchange() {
1093 let (client, guard) = round_trip();
1094 let start = client.begin(&guard).unwrap();
1095
1096 let query = format!(
1097 "error=access_denied&error_description=The%20user%20denied&state={}",
1098 url::encode(start.state())
1099 );
1100 let error = client.callback(&query, &guard).await.unwrap_err();
1101
1102 assert_eq!(error.code, OAuthErrorCode::AccessDenied);
1103 assert!(error.to_string().contains("denied"));
1104 client.http().fake().unwrap().assert_count(0);
1105 }
1106
1107 #[tokio::test]
1108 async fn an_unsolicited_error_is_refused_by_the_state_check_first() {
1109 let (client, guard) = round_trip();
1113
1114 let error = client.callback("error=access_denied", &guard).await.unwrap_err();
1115 assert_eq!(error.code, OAuthErrorCode::InvalidRequest, "the state, not the denial");
1116 }
1117
1118 #[tokio::test]
1119 async fn a_callback_with_a_good_state_and_no_code_says_what_is_missing() {
1120 let (client, guard) = round_trip();
1121 let start = client.begin(&guard).unwrap();
1122
1123 let query = format!("state={}", url::encode(start.state()));
1124 let error = client.callback(&query, &guard).await.unwrap_err();
1125
1126 assert!(error.to_string().contains("neither a `code` nor an `error`"), "{error}");
1127 }
1128
1129 #[tokio::test]
1130 async fn the_stateless_mode_completes_a_flow_with_nothing_stored() {
1131 let key = rustlavel_auth::AppKey::from_base64(&rustlavel_auth::AppKey::generate()).unwrap();
1132 let guard = StateGuard::sealed(&key);
1133 let (client, _) = round_trip();
1134
1135 let start = client.begin(&guard).unwrap();
1136 let query = format!("code=c&state={}", url::encode(start.state()));
1137
1138 assert!(client.callback(&query, &guard).await.is_ok());
1139 assert!(client.callback(&query, &StateGuard::sealed(&key)).await.is_ok());
1142 }
1143
1144 #[test]
1145 fn debug_prints_the_client_id_and_never_the_secret() {
1146 let client = OAuthClient::new(Provider::google()).credentials("public-id", "TOP-SECRET");
1147 let printed = format!("{client:?}");
1148
1149 assert!(printed.contains("public-id"), "the id is not a secret and helps debugging");
1150 assert!(!printed.contains("TOP-SECRET"));
1151 }
1152
1153 #[test]
1154 fn debug_of_an_authorisation_never_prints_the_verifier() {
1155 let start = OAuthClient::new(Provider::google()).credentials("id", "s").authorize_url();
1156 let printed = format!("{start:?}");
1157
1158 assert!(!printed.contains(start.verifier()));
1159 assert!(printed.contains(start.state()), "the state is public and identifies the flow");
1160 }
1161}