1pub mod access_token;
6pub mod attached_clients;
7use super::scopes;
8use super::{
9 http_client::{
10 AuthorizationRequestParameters, IntrospectResponse as IntrospectInfo, OAuthTokenResponse,
11 },
12 scoped_keys::ScopedKeysFlow,
13 util, FirefoxAccount,
14};
15use crate::{debug, info, warn, AuthorizationParameters, Error, FxaServer, Result};
16pub use access_token::AccessTokenInfo;
17use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
18use jwcrypto::{EncryptionAlgorithm, EncryptionParameters};
19use rate_limiter::RateLimiter;
20use rc_crypto::digest;
21use serde_derive::*;
22use std::collections::{HashMap, HashSet};
23use url::Url;
24pub const OAUTH_WEBCHANNEL_REDIRECT: &str = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel";
27
28impl FirefoxAccount {
29 pub fn has_scope(&self, scope: &str) -> bool {
31 let mut requested = scope.split_ascii_whitespace().peekable();
32 if requested.peek().is_none() {
33 return false;
34 }
35 match self.state.refresh_token() {
36 Some(refresh_token) => requested.all(|s| refresh_token.scopes.contains(s)),
37 None => false,
38 }
39 }
40
41 pub fn handle_web_channel_login(&mut self, json_payload: &str) -> Result<()> {
44 let data: serde_json::Value = serde_json::from_str(json_payload)?;
45 let token = data
46 .get("sessionToken")
47 .and_then(|v| v.as_str())
48 .ok_or(Error::NoSessionToken)?;
49 self.state.set_session_token(token.to_string());
50 Ok(())
51 }
52
53 pub fn handle_web_channel_password_change(&mut self, json_payload: &str) -> Result<()> {
56 let data: serde_json::Value = serde_json::from_str(json_payload)?;
57 let token = data
58 .get("sessionToken")
59 .and_then(|v| v.as_str())
60 .ok_or(Error::NoSessionToken)?;
61 let old_device_info = match self.get_current_device() {
64 Ok(maybe_device) => maybe_device,
65 Err(err) => {
66 warn!(
67 "Error fetching current device before password change: {:?}",
68 err
69 );
70 None
71 }
72 };
73 self.handle_session_token_change(token)?;
74 if let Some(ref device_info) = old_device_info {
75 if let Err(err) = self.replace_device(
76 &device_info.display_name,
77 &device_info.device_type,
78 &device_info.push_subscription,
79 &device_info.available_commands,
80 ) {
81 warn!(
82 "Device information restoration failed after password change: {:?}",
83 err
84 );
85 } else {
86 info!("Restored device information with new refresh token");
87 }
88 }
89 Ok(())
90 }
91
92 pub fn get_session_token(&self) -> Result<String> {
94 match self.state.session_token() {
95 Some(session_token) => Ok(session_token.to_string()),
96 None => Err(Error::NoSessionToken),
97 }
98 }
99
100 pub fn get_signed_in_user_for_web_channel(&self) -> Option<String> {
105 let token = self.state.session_token()?;
106 let profile = self.state.last_seen_profile();
107 let email = profile.map(|p| p.response.email.as_str());
108 let uid = profile.map(|p| p.response.uid.as_str());
109 Some(
110 serde_json::json!({
111 "sessionToken": token,
112 "email": email,
113 "uid": uid,
114 "verified": true,
115 })
116 .to_string(),
117 )
118 }
119
120 pub fn check_authorization_status(&mut self) -> Result<IntrospectInfo> {
122 let resp = match self.state.refresh_token() {
123 Some(refresh_token) => {
124 self.auth_circuit_breaker.check()?;
125 self.client
126 .check_refresh_token_status(self.state.config(), &refresh_token.token)?
127 }
128 None => return Err(Error::NoRefreshToken),
129 };
130 Ok(IntrospectInfo {
131 active: resp.active,
132 })
133 }
134
135 pub fn begin_pairing_flow(
143 &mut self,
144 pairing_url: &str,
145 service: &str,
146 scopes: &[&str],
147 entrypoint: &str,
148 ) -> Result<String> {
149 let mut url = self.state.config().pair_supp_url()?;
150 url.query_pairs_mut().append_pair("entrypoint", entrypoint);
151 if !service.is_empty() {
152 url.query_pairs_mut().append_pair("service", service);
153 }
154 let pairing_url = util::parse_url(pairing_url, "begin_pairing_flow")?;
155 if url.host_str() != pairing_url.host_str() {
156 let fxa_server = FxaServer::from(&url);
157 let pairing_fxa_server = FxaServer::from(&pairing_url);
158 return Err(Error::OriginMismatch(format!(
159 "fxa-server: {fxa_server}, pairing-url-fxa-server: {pairing_fxa_server}"
160 )));
161 }
162 url.set_fragment(pairing_url.fragment());
163 self.oauth_flow(url, scopes)
164 }
165
166 pub fn begin_oauth_flow(
181 &mut self,
182 service: &str,
183 scopes: &[&str],
184 entrypoint: &str,
185 ) -> Result<String> {
186 let needs_reauth =
187 self.state.last_seen_profile().is_some() && self.state.session_token().is_none();
188 let mut url = if needs_reauth {
189 self.state.config().oauth_force_auth_url()?
195 } else {
196 self.state.config().authorization_endpoint()?
197 };
198
199 info!("starting oauth flow via {url} for service={service:?}, scopes={scopes:?}, entrypoint={entrypoint:?}");
200 url.query_pairs_mut()
201 .append_pair("action", "email")
202 .append_pair("response_type", "code")
203 .append_pair("entrypoint", entrypoint);
204
205 if !service.is_empty() {
206 url.query_pairs_mut().append_pair("service", service);
207 }
208 if let Some(cached_profile) = self.state.last_seen_profile() {
209 url.query_pairs_mut()
210 .append_pair("email", &cached_profile.response.email);
211 }
212
213 debug!("oauth flow final set of requested scopes now {scopes:?}");
214 self.oauth_flow(url, scopes)
215 }
216
217 pub fn authorize_code_using_session_token(
227 &self,
228 auth_params: AuthorizationParameters,
229 ) -> Result<String> {
230 let session_token = self.get_session_token()?;
231
232 let allowed_scopes = self.client.get_scoped_key_data(
235 self.state.config(),
236 &session_token,
237 &auth_params.client_id,
238 &auth_params.scope.join(" "),
239 )?;
240
241 if let Some(not_allowed_scope) = auth_params
242 .scope
243 .iter()
244 .find(|scope| !allowed_scopes.contains_key(*scope))
245 {
246 return Err(Error::ScopeNotAllowed(
247 auth_params.client_id.clone(),
248 not_allowed_scope.clone(),
249 ));
250 }
251
252 let keys_jwe = if let Some(keys_jwk) = auth_params.keys_jwk {
253 let mut scoped_keys = HashMap::new();
254 allowed_scopes
255 .iter()
256 .try_for_each(|(scope, _)| -> Result<()> {
257 scoped_keys.insert(
258 scope,
259 self.state
260 .get_scoped_key(scope)
261 .ok_or_else(|| Error::NoScopedKey(scope.clone()))?,
262 );
263 Ok(())
264 })?;
265 let scoped_keys = serde_json::to_string(&scoped_keys)?;
266 let keys_jwk = URL_SAFE_NO_PAD.decode(keys_jwk)?;
267 let jwk = serde_json::from_slice(&keys_jwk)?;
268 Some(jwcrypto::encrypt_to_jwe(
269 scoped_keys.as_bytes(),
270 EncryptionParameters::ECDH_ES {
271 enc: EncryptionAlgorithm::A256GCM,
272 peer_jwk: &jwk,
273 },
274 )?)
275 } else {
276 None
277 };
278 let auth_request_params = AuthorizationRequestParameters {
279 client_id: auth_params.client_id,
280 scope: auth_params.scope.join(" "),
281 state: auth_params.state,
282 access_type: auth_params.access_type,
283 code_challenge: auth_params.code_challenge,
284 code_challenge_method: auth_params.code_challenge_method,
285 keys_jwe,
286 };
287
288 let resp = self.client.create_authorization_code_using_session_token(
289 self.state.config(),
290 &session_token,
291 auth_request_params,
292 )?;
293
294 Ok(resp.code)
295 }
296
297 fn oauth_flow(&mut self, mut url: Url, scopes: &[&str]) -> Result<String> {
298 self.clear_access_token_cache();
299 let state = util::random_base64_url_string(16)?;
300 let code_verifier = util::random_base64_url_string(43)?;
301 let code_challenge = digest::digest(&digest::SHA256, code_verifier.as_bytes())?;
302 let code_challenge = URL_SAFE_NO_PAD.encode(code_challenge);
303 let scoped_keys_flow = ScopedKeysFlow::with_random_key()?;
304 let jwk = scoped_keys_flow.get_public_key_jwk()?;
305 let jwk_json = serde_json::to_string(&jwk)?;
306 let keys_jwk = URL_SAFE_NO_PAD.encode(jwk_json);
307 url.query_pairs_mut()
308 .append_pair("client_id", &self.state.config().client_id)
309 .append_pair("scope", &scopes.join(" "))
310 .append_pair("state", &state)
311 .append_pair("code_challenge_method", "S256")
312 .append_pair("code_challenge", &code_challenge)
313 .append_pair("access_type", "offline")
314 .append_pair("keys_jwk", &keys_jwk);
315
316 if self.state.config().redirect_uri == OAUTH_WEBCHANNEL_REDIRECT {
317 url.query_pairs_mut()
318 .append_pair("context", "oauth_webchannel_v1");
319 } else {
320 url.query_pairs_mut()
321 .append_pair("redirect_uri", &self.state.config().redirect_uri);
322 }
323
324 self.state.begin_oauth_flow(
325 state,
326 OAuthFlow {
327 scoped_keys_flow: Some(scoped_keys_flow),
328 code_verifier,
329 },
330 );
331 Ok(url.to_string())
332 }
333
334 pub fn complete_oauth_flow(&mut self, code: &str, state: &str) -> Result<()> {
340 self.clear_access_token_cache();
341 let oauth_flow = match self.state.pop_oauth_flow(state) {
342 Some(oauth_flow) => oauth_flow,
343 None => return Err(Error::UnknownOAuthState),
344 };
345 let resp = self.client.create_refresh_token_using_authorization_code(
348 self.state.config(),
349 self.state.session_token(),
350 code,
351 &oauth_flow.code_verifier,
352 )?;
353 info!(
354 "complete oauth flow - new session token={}, new refresh token={}",
355 resp.session_token.is_some(),
356 resp.refresh_token.is_some()
357 );
358 self.handle_oauth_response(resp, oauth_flow.scoped_keys_flow)?;
359 Ok(())
360 }
361
362 pub fn cancel_existing_oauth_flows(&mut self) {
364 self.state.clear_oauth_flows();
365 }
366
367 pub(crate) fn handle_oauth_response(
368 &mut self,
369 resp: OAuthTokenResponse,
370 scoped_keys_flow: Option<ScopedKeysFlow>,
371 ) -> Result<()> {
372 let scoped_keys = match resp.keys_jwe {
375 Some(ref jwe) => {
376 let scoped_keys_flow = scoped_keys_flow.ok_or(Error::ApiClientError(
377 "Got a JWE but have no JWK to decrypt it.",
378 ))?;
379 let decrypted_keys = scoped_keys_flow.decrypt_keys_jwe(jwe)?;
380 let scoped_keys: serde_json::Map<String, serde_json::Value> =
381 serde_json::from_str(&decrypted_keys)?;
382 scoped_keys
383 .into_iter()
384 .map(|(scope, key)| Ok((scope, serde_json::from_value(key)?)))
385 .collect::<Result<Vec<_>>>()?
386 }
387 None => vec![],
388 };
389
390 if let Err(err) = self
394 .client
395 .destroy_access_token(self.state.config(), &resp.access_token)
396 {
397 warn!("Access token destruction failure: {:?}", err);
398 }
399 let old_refresh_token = self.state.refresh_token().cloned();
400 let mut new_refresh_token = RefreshToken::new(
401 resp.refresh_token
402 .ok_or(Error::ApiClientError("No refresh token in response"))?,
403 resp.scope,
404 );
405 let old_device_info = match old_refresh_token {
408 Some(_) => match self.get_current_device() {
409 Ok(maybe_device) => maybe_device,
410 Err(err) => {
411 warn!("Error while getting previous device information: {:?}", err);
412 None
413 }
414 },
415 None => None,
416 };
417
418 if let Some(ref old_refresh_token) = old_refresh_token {
419 let existing_scopes = &old_refresh_token.scopes;
424 let all_scopes: HashSet<_> = existing_scopes
425 .union(&new_refresh_token.scopes)
426 .cloned()
427 .collect();
428 if all_scopes != new_refresh_token.scopes {
429 if let Some(session_token) = self.state.session_token() {
430 info!("New refresh token is missing some of our old scopes, upgrading");
431 let scopes_slice = all_scopes.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
436 let merged_refresh_token_resp =
437 self.client.create_refresh_token_using_session_token(
438 self.state.config(),
439 session_token,
440 &scopes_slice,
441 )?;
442 let Some(merged_refresh_token_str) = merged_refresh_token_resp.refresh_token
443 else {
444 log::error!("server failed to give a new refresh token");
445 return Err(Error::NoRefreshToken);
446 };
447
448 if let Err(err) = self
450 .client
451 .destroy_refresh_token(self.state.config(), &new_refresh_token.token)
452 {
453 warn!(
454 "Refresh token destruction failure of new refresh token: {:?}",
455 err
456 );
457 }
458
459 new_refresh_token = RefreshToken::new(
460 merged_refresh_token_str,
461 merged_refresh_token_resp.scope,
462 );
463 } else {
464 warn!("New refresh token is missing some of our old scopes, but don't have a session token to use to upgrade");
465 }
466 } else {
467 info!("New refresh token has the same scopes we started with");
469 }
470
471 if let Err(err) = self
474 .client
475 .destroy_refresh_token(self.state.config(), &old_refresh_token.token)
476 {
477 warn!(
478 "Refresh token destruction failure of old refresh token: {:?}",
479 err
480 );
481 }
482 self.state.clear_refresh_token();
485 }
486
487 let sync_scope_granted = new_refresh_token.scopes.contains(scopes::OLD_SYNC);
491 let have_sync_key = scoped_keys
492 .iter()
493 .any(|(scope, _)| scope == scopes::OLD_SYNC)
494 || self.state.get_scoped_key(scopes::OLD_SYNC).is_some();
495 if sync_scope_granted && !have_sync_key {
496 error_support::report_error!(
497 "fxaclient-scoped-key",
498 "Sync scope granted, but no sync scoped key held (final scopes: {})",
499 new_refresh_token
500 .scopes
501 .iter()
502 .cloned()
503 .collect::<Vec<_>>()
504 .join(", ")
505 );
506 }
507
508 self.state
509 .complete_oauth_flow(scoped_keys, new_refresh_token, resp.session_token);
510 if let Some(ref device_info) = old_device_info {
511 if let Err(err) = self.replace_device(
512 &device_info.display_name,
513 &device_info.device_type,
514 &device_info.push_subscription,
515 &device_info.available_commands,
516 ) {
517 warn!("Device information restoration failed: {:?}", err);
518 }
519 info!("restored device information with new refresh token");
520 }
521 Ok(())
522 }
523
524 pub fn handle_session_token_change(&mut self, session_token: &str) -> Result<()> {
532 let old_refresh_token = self.state.refresh_token().ok_or(Error::NoRefreshToken)?;
533 let scopes: Vec<&str> = old_refresh_token.scopes.iter().map(AsRef::as_ref).collect();
534 let resp = self.client.create_refresh_token_using_session_token(
535 self.state.config(),
536 session_token,
537 &scopes,
538 )?;
539 let new_refresh_token = resp
540 .refresh_token
541 .ok_or(Error::ApiClientError("No refresh token in response"))?;
542 self.state.update_tokens(
543 session_token.to_owned(),
544 RefreshToken {
545 token: new_refresh_token,
546 scopes: resp.scope.split(' ').map(ToString::to_string).collect(),
547 },
548 );
549 self.clear_devices_and_attached_clients_cache();
550 Ok(())
551 }
552}
553
554const AUTH_CIRCUIT_BREAKER_CAPACITY: u8 = 5;
555const AUTH_CIRCUIT_BREAKER_RENEWAL_RATE: f32 = 3.0 / 60.0 / 1000.0; #[derive(Clone, Copy)]
558pub(crate) struct AuthCircuitBreaker {
559 rate_limiter: RateLimiter,
560}
561
562impl Default for AuthCircuitBreaker {
563 fn default() -> Self {
564 AuthCircuitBreaker {
565 rate_limiter: RateLimiter::new(
566 AUTH_CIRCUIT_BREAKER_CAPACITY,
567 AUTH_CIRCUIT_BREAKER_RENEWAL_RATE,
568 ),
569 }
570 }
571}
572
573impl AuthCircuitBreaker {
574 pub(crate) fn check(&mut self) -> Result<()> {
575 if !self.rate_limiter.check() {
576 return Err(Error::AuthCircuitBreakerError);
577 }
578 Ok(())
579 }
580}
581
582impl TryFrom<Url> for AuthorizationParameters {
583 type Error = Error;
584
585 fn try_from(url: Url) -> Result<Self> {
586 let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();
587 let scope = query_map
588 .get("scope")
589 .cloned()
590 .ok_or(Error::MissingUrlParameter("scope"))?;
591 let client_id = query_map
592 .get("client_id")
593 .cloned()
594 .ok_or(Error::MissingUrlParameter("client_id"))?;
595 let state = query_map
596 .get("state")
597 .cloned()
598 .ok_or(Error::MissingUrlParameter("state"))?;
599 let access_type = query_map
600 .get("access_type")
601 .cloned()
602 .ok_or(Error::MissingUrlParameter("access_type"))?;
603 let code_challenge = query_map.get("code_challenge").cloned();
604 let code_challenge_method = query_map.get("code_challenge_method").cloned();
605 let keys_jwk = query_map.get("keys_jwk").cloned();
606 Ok(Self {
607 client_id,
608 scope: scope.split_whitespace().map(|s| s.to_string()).collect(),
609 state,
610 access_type,
611 code_challenge,
612 code_challenge_method,
613 keys_jwk,
614 })
615 }
616}
617
618#[derive(Clone, Serialize, Deserialize)]
619pub struct RefreshToken {
620 pub token: String,
621 pub scopes: HashSet<String>,
622}
623
624impl RefreshToken {
625 pub fn new(token: String, scopes: String) -> Self {
626 Self {
627 token,
628 scopes: scopes
629 .split_ascii_whitespace()
630 .map(ToString::to_string)
631 .collect(),
632 }
633 }
634}
635
636impl std::fmt::Debug for RefreshToken {
637 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638 f.debug_struct("RefreshToken")
639 .field("scopes", &self.scopes)
640 .finish()
641 }
642}
643
644pub struct OAuthFlow {
645 pub scoped_keys_flow: Option<ScopedKeysFlow>,
646 pub code_verifier: String,
647}
648
649impl From<IntrospectInfo> for crate::AuthorizationInfo {
650 fn from(r: IntrospectInfo) -> Self {
651 crate::AuthorizationInfo { active: r.active }
652 }
653}
654
655#[cfg(test)]
656impl FirefoxAccount {
657 pub fn set_session_token(&mut self, session_token: &str) {
658 self.state.set_session_token(session_token.to_owned());
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::super::{http_client::*, Config};
665 use super::*;
666 use mockall::predicate::always;
667 use mockall::predicate::eq;
668 use std::borrow::Cow;
669 use std::collections::HashMap;
670 use std::sync::Arc;
671
672 #[test]
673 fn test_has_scope() {
674 nss_as::ensure_initialized();
675 let mut fxa =
676 FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
677 assert!(!fxa.has_scope("profile"));
679 fxa.state.force_refresh_token(RefreshToken {
680 token: "rt".to_owned(),
681 scopes: ["profile", "sync"].iter().map(|s| s.to_string()).collect(),
682 });
683 assert!(fxa.has_scope("profile"));
684 assert!(fxa.has_scope("sync profile"));
685 assert!(fxa.has_scope("profile sync ")); assert!(!fxa.has_scope("sync unknown")); assert!(!fxa.has_scope("")); }
689
690 #[test]
691 fn test_oauth_flow_url() {
692 nss_as::ensure_initialized();
693 let config = Config::new_with_mock_well_known_fxa_client_configuration(
694 "https://mock-fxa.example.com",
695 "12345678",
696 "https://foo.bar",
697 );
698 let mut fxa = FirefoxAccount::with_config(config);
699 let url = fxa
700 .begin_oauth_flow("", &["profile"], "test_oauth_flow_url")
701 .unwrap();
702 let flow_url = Url::parse(&url).unwrap();
703
704 assert_eq!(flow_url.path(), "/authorization");
705
706 let mut pairs = flow_url.query_pairs();
707 assert_eq!(pairs.count(), 11);
708 assert_eq!(
709 pairs.next(),
710 Some((Cow::Borrowed("action"), Cow::Borrowed("email")))
711 );
712 assert_eq!(
713 pairs.next(),
714 Some((Cow::Borrowed("response_type"), Cow::Borrowed("code")))
715 );
716 assert_eq!(
717 pairs.next(),
718 Some((
719 Cow::Borrowed("entrypoint"),
720 Cow::Borrowed("test_oauth_flow_url")
721 ))
722 );
723 assert_eq!(
724 pairs.next(),
725 Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
726 );
727
728 assert_eq!(
729 pairs.next(),
730 Some((Cow::Borrowed("scope"), Cow::Borrowed("profile")))
731 );
732 let state_param = pairs.next().unwrap();
733 assert_eq!(state_param.0, Cow::Borrowed("state"));
734 assert_eq!(state_param.1.len(), 22);
735 assert_eq!(
736 pairs.next(),
737 Some((
738 Cow::Borrowed("code_challenge_method"),
739 Cow::Borrowed("S256")
740 ))
741 );
742 let code_challenge_param = pairs.next().unwrap();
743 assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
744 assert_eq!(code_challenge_param.1.len(), 43);
745 assert_eq!(
746 pairs.next(),
747 Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
748 );
749 let keys_jwk = pairs.next().unwrap();
750 assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
751 assert_eq!(keys_jwk.1.len(), 168);
752
753 assert_eq!(
754 pairs.next(),
755 Some((
756 Cow::Borrowed("redirect_uri"),
757 Cow::Borrowed("https://foo.bar")
758 ))
759 );
760 }
761
762 #[test]
763 fn test_force_auth_url() {
764 nss_as::ensure_initialized();
765 let config = Config::stable_dev("12345678", "https://foo.bar");
766 let mut fxa = FirefoxAccount::with_config(config);
767 let email = "test@example.com";
768 fxa.add_cached_profile("123", email);
769 let url = fxa
770 .begin_oauth_flow("", &["profile"], "test_force_auth_url")
771 .unwrap();
772 let url = Url::parse(&url).unwrap();
773 assert_eq!(url.path(), "/oauth/force_auth");
774 let mut pairs = url.query_pairs();
775 assert_eq!(
776 pairs.find(|e| e.0 == "email"),
777 Some((Cow::Borrowed("email"), Cow::Borrowed(email),))
778 );
779 }
780
781 #[test]
782 fn test_webchannel_context_url() {
783 nss_as::ensure_initialized();
784 const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
785 let config = Config::new_with_mock_well_known_fxa_client_configuration(
786 "https://mock-fxa.example.com",
787 "12345678",
788 "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
789 );
790 let mut fxa = FirefoxAccount::with_config(config);
791 let url = fxa
792 .begin_oauth_flow("", SCOPES, "test_webchannel_context_url")
793 .unwrap();
794 let url = Url::parse(&url).unwrap();
795 let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
796 let context = &query_params["context"];
797 assert_eq!(context, "oauth_webchannel_v1");
798 assert_eq!(query_params.get("redirect_uri"), None);
799 }
800
801 #[test]
802 fn test_webchannel_pairing_context_url() {
803 nss_as::ensure_initialized();
804 const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
805 const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
806
807 let config = Config::new(
808 "https://accounts.firefox.com",
809 "12345678",
810 "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
811 );
812 let mut fxa = FirefoxAccount::with_config(config);
813 let url = fxa
814 .begin_pairing_flow(
815 PAIRING_URL,
816 "service",
817 SCOPES,
818 "test_webchannel_pairing_context_url",
819 )
820 .unwrap();
821 let url = Url::parse(&url).unwrap();
822 let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
823 let context = &query_params["context"];
824 assert_eq!(context, "oauth_webchannel_v1");
825 assert_eq!(query_params.get("redirect_uri"), None);
826 }
827
828 #[test]
829 fn test_pairing_flow_url() {
830 nss_as::ensure_initialized();
831 const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
832 const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
833 const EXPECTED_URL: &str = "https://accounts.firefox.com/pair/supp?client_id=12345678&redirect_uri=https%3A%2F%2Ffoo.bar&scope=https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&state=SmbAA_9EA5v1R2bgIPeWWw&code_challenge_method=S256&code_challenge=ZgHLPPJ8XYbXpo7VIb7wFw0yXlTa6MUOVfGiADt0JSM&access_type=offline&keys_jwk=eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6Ing5LUltQjJveDM0LTV6c1VmbW5sNEp0Ti14elV2eFZlZXJHTFRXRV9BT0kiLCJ5IjoiNXBKbTB3WGQ4YXdHcm0zREl4T1pWMl9qdl9tZEx1TWlMb1RkZ1RucWJDZyJ9#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
834
835 let config = Config::new(
836 "https://accounts.firefox.com",
837 "12345678",
838 "https://foo.bar",
839 );
840
841 let mut fxa = FirefoxAccount::with_config(config);
842 let url = fxa
843 .begin_pairing_flow(PAIRING_URL, "", SCOPES, "test_pairing_flow_url")
844 .unwrap();
845 let flow_url = Url::parse(&url).unwrap();
846 let expected_parsed_url = Url::parse(EXPECTED_URL).unwrap();
847
848 assert_eq!(flow_url.host_str(), Some("accounts.firefox.com"));
849 assert_eq!(flow_url.path(), "/pair/supp");
850 assert_eq!(flow_url.fragment(), expected_parsed_url.fragment());
851
852 let mut pairs = flow_url.query_pairs();
853 assert_eq!(pairs.count(), 9);
854 assert_eq!(
855 pairs.next(),
856 Some((
857 Cow::Borrowed("entrypoint"),
858 Cow::Borrowed("test_pairing_flow_url")
859 ))
860 );
861 assert_eq!(
862 pairs.next(),
863 Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
864 );
865 assert_eq!(
866 pairs.next(),
867 Some((
868 Cow::Borrowed("scope"),
869 Cow::Borrowed("https://identity.mozilla.com/apps/oldsync")
870 ))
871 );
872
873 let state_param = pairs.next().unwrap();
874 assert_eq!(state_param.0, Cow::Borrowed("state"));
875 assert_eq!(state_param.1.len(), 22);
876 assert_eq!(
877 pairs.next(),
878 Some((
879 Cow::Borrowed("code_challenge_method"),
880 Cow::Borrowed("S256")
881 ))
882 );
883 let code_challenge_param = pairs.next().unwrap();
884 assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
885 assert_eq!(code_challenge_param.1.len(), 43);
886 assert_eq!(
887 pairs.next(),
888 Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
889 );
890 let keys_jwk = pairs.next().unwrap();
891 assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
892 assert_eq!(keys_jwk.1.len(), 168);
893
894 assert_eq!(
895 pairs.next(),
896 Some((
897 Cow::Borrowed("redirect_uri"),
898 Cow::Borrowed("https://foo.bar")
899 ))
900 );
901 }
902
903 #[test]
904 fn test_pairing_flow_origin_mismatch() {
905 nss_as::ensure_initialized();
906 static PAIRING_URL: &str = "https://bad.origin.com/pair#channel_id=foo&channel_key=bar";
907 let config = Config::stable_dev("12345678", "https://foo.bar");
908 let mut fxa = FirefoxAccount::with_config(config);
909 let url = fxa.begin_pairing_flow(
910 PAIRING_URL,
911 "service",
912 &["https://identity.mozilla.com/apps/oldsync"],
913 "test_pairiong_flow_origin_mismatch",
914 );
915
916 assert!(url.is_err());
917
918 match url {
919 Ok(_) => {
920 panic!("should have error");
921 }
922 Err(err) => match err {
923 Error::OriginMismatch { .. } => {}
924 _ => panic!("error not OriginMismatch"),
925 },
926 }
927 }
928
929 #[test]
930 fn test_check_authorization_status() {
931 nss_as::ensure_initialized();
932 let config = Config::stable_dev("12345678", "https://foo.bar");
933 let mut fxa = FirefoxAccount::with_config(config);
934
935 let refresh_token_scopes = std::collections::HashSet::new();
936 fxa.state.force_refresh_token(RefreshToken {
937 token: "refresh_token".to_owned(),
938 scopes: refresh_token_scopes,
939 });
940
941 let mut client = MockFxAClient::new();
942 client
943 .expect_check_refresh_token_status()
944 .with(always(), eq("refresh_token"))
945 .times(1)
946 .returning(|_, _| Ok(IntrospectResponse { active: true }));
947 fxa.set_client(Arc::new(client));
948
949 let auth_status = fxa.check_authorization_status().unwrap();
950 assert!(auth_status.active);
951 }
952
953 #[test]
954 fn test_check_authorization_status_circuit_breaker() {
955 nss_as::ensure_initialized();
956 let config = Config::stable_dev("12345678", "https://foo.bar");
957 let mut fxa = FirefoxAccount::with_config(config);
958
959 let refresh_token_scopes = std::collections::HashSet::new();
960 fxa.state.force_refresh_token(RefreshToken {
961 token: "refresh_token".to_owned(),
962 scopes: refresh_token_scopes,
963 });
964
965 let mut client = MockFxAClient::new();
966 client
969 .expect_check_refresh_token_status()
970 .with(always(), eq("refresh_token"))
971 .returning(|_, _| Ok(IntrospectResponse { active: true }));
972 client
973 .expect_check_refresh_token_status()
974 .with(always(), eq("refresh_token"))
975 .returning(|_, _| Ok(IntrospectResponse { active: true }));
976 client
977 .expect_check_refresh_token_status()
978 .with(always(), eq("refresh_token"))
979 .returning(|_, _| Ok(IntrospectResponse { active: true }));
980 client
981 .expect_check_refresh_token_status()
982 .with(always(), eq("refresh_token"))
983 .returning(|_, _| Ok(IntrospectResponse { active: true }));
984 client
985 .expect_check_refresh_token_status()
986 .with(always(), eq("refresh_token"))
987 .returning(|_, _| Ok(IntrospectResponse { active: true }));
988 fxa.set_client(Arc::new(client));
990
991 for _ in 0..5 {
992 assert!(fxa.check_authorization_status().is_ok());
993 }
994 match fxa.check_authorization_status() {
995 Ok(_) => unreachable!("should not happen"),
996 Err(err) => assert!(matches!(err, Error::AuthCircuitBreakerError)),
997 }
998 }
999
1000 use crate::internal::scopes::{self, OLD_SYNC};
1001
1002 #[test]
1003 fn test_auth_code_pair_valid_not_allowed_scope() {
1004 nss_as::ensure_initialized();
1005 let config = Config::stable_dev("12345678", "https://foo.bar");
1006 let mut fxa = FirefoxAccount::with_config(config);
1007 fxa.set_session_token("session");
1008 let mut client = MockFxAClient::new();
1009 let not_allowed_scope = "https://identity.mozilla.com/apps/lockbox";
1010 let expected_scopes = scopes::OLD_SYNC
1011 .chars()
1012 .chain(std::iter::once(' '))
1013 .chain(not_allowed_scope.chars())
1014 .collect::<String>();
1015 client
1016 .expect_get_scoped_key_data()
1017 .with(always(), eq("session"), eq("12345678"), eq(expected_scopes))
1018 .times(1)
1019 .returning(|_, _, _, _| {
1020 Err(Error::RemoteError {
1021 code: 400,
1022 errno: 163,
1023 error: "Invalid Scopes".to_string(),
1024 message: "Not allowed to request scopes".to_string(),
1025 info: "fyi, there was a server error".to_string(),
1026 })
1027 });
1028 fxa.set_client(Arc::new(client));
1029 let auth_params = AuthorizationParameters {
1030 client_id: "12345678".to_string(),
1031 scope: vec![scopes::OLD_SYNC.to_string(), not_allowed_scope.to_string()],
1032 state: "somestate".to_string(),
1033 access_type: "offline".to_string(),
1034 code_challenge: None,
1035 code_challenge_method: None,
1036 keys_jwk: None,
1037 };
1038 let res = fxa.authorize_code_using_session_token(auth_params);
1039 assert!(res.is_err());
1040 let err = res.unwrap_err();
1041 if let Error::RemoteError {
1042 code,
1043 errno,
1044 error: _,
1045 message: _,
1046 info: _,
1047 } = err
1048 {
1049 assert_eq!(code, 400);
1050 assert_eq!(errno, 163); } else {
1052 panic!("Should return an error from the server specifying that the requested scopes are not allowed");
1053 }
1054 }
1055
1056 #[test]
1057 fn test_auth_code_pair_invalid_scope_not_allowed() {
1058 nss_as::ensure_initialized();
1059 let config = Config::stable_dev("12345678", "https://foo.bar");
1060 let mut fxa = FirefoxAccount::with_config(config);
1061 fxa.set_session_token("session");
1062 let mut client = MockFxAClient::new();
1063 let invalid_scope = "IamAnInvalidScope";
1064 let expected_scopes = scopes::OLD_SYNC
1065 .chars()
1066 .chain(std::iter::once(' '))
1067 .chain(invalid_scope.chars())
1068 .collect::<String>();
1069 client
1070 .expect_get_scoped_key_data()
1071 .with(always(), eq("session"), eq("12345678"), eq(expected_scopes))
1072 .times(1)
1073 .returning(|_, _, _, _| {
1074 let mut server_ret = HashMap::new();
1075 server_ret.insert(
1076 scopes::OLD_SYNC.to_string(),
1077 ScopedKeyDataResponse {
1078 key_rotation_secret: "IamASecret".to_string(),
1079 key_rotation_timestamp: 100,
1080 identifier: "".to_string(),
1081 },
1082 );
1083 Ok(server_ret)
1084 });
1085 fxa.set_client(Arc::new(client));
1086
1087 let auth_params = AuthorizationParameters {
1088 client_id: "12345678".to_string(),
1089 scope: vec![scopes::OLD_SYNC.to_string(), invalid_scope.to_string()],
1090 state: "somestate".to_string(),
1091 access_type: "offline".to_string(),
1092 code_challenge: None,
1093 code_challenge_method: None,
1094 keys_jwk: None,
1095 };
1096 let res = fxa.authorize_code_using_session_token(auth_params);
1097 assert!(res.is_err());
1098 let err = res.unwrap_err();
1099 if let Error::ScopeNotAllowed(client_id, scope) = err {
1100 assert_eq!(client_id, "12345678");
1101 assert_eq!(scope, "IamAnInvalidScope");
1102 } else {
1103 panic!("Should return an error that specifies the scope that is not allowed");
1104 }
1105 }
1106
1107 #[test]
1108 fn test_auth_code_pair_scope_not_in_state() {
1109 nss_as::ensure_initialized();
1110 let config = Config::stable_dev("12345678", "https://foo.bar");
1111 let mut fxa = FirefoxAccount::with_config(config);
1112 fxa.set_session_token("session");
1113 let mut client = MockFxAClient::new();
1114 client
1115 .expect_get_scoped_key_data()
1116 .with(
1117 always(),
1118 eq("session"),
1119 eq("12345678"),
1120 eq(scopes::OLD_SYNC),
1121 )
1122 .times(1)
1123 .returning(|_, _, _, _| {
1124 let mut server_ret = HashMap::new();
1125 server_ret.insert(
1126 scopes::OLD_SYNC.to_string(),
1127 ScopedKeyDataResponse {
1128 key_rotation_secret: "IamASecret".to_string(),
1129 key_rotation_timestamp: 100,
1130 identifier: "".to_string(),
1131 },
1132 );
1133 Ok(server_ret)
1134 });
1135 fxa.set_client(Arc::new(client));
1136 let auth_params = AuthorizationParameters {
1137 client_id: "12345678".to_string(),
1138 scope: vec![scopes::OLD_SYNC.to_string()],
1139 state: "somestate".to_string(),
1140 access_type: "offline".to_string(),
1141 code_challenge: None,
1142 code_challenge_method: None,
1143 keys_jwk: Some("IAmAVerySecretKeysJWkInBase64".to_string()),
1144 };
1145 let res = fxa.authorize_code_using_session_token(auth_params);
1146 assert!(res.is_err());
1147 let err = res.unwrap_err();
1148 if let Error::NoScopedKey(scope) = err {
1149 assert_eq!(scope, scopes::OLD_SYNC.to_string());
1150 } else {
1151 panic!("Should return an error that specifies the scope that is not in the state");
1152 }
1153 }
1154
1155 #[test]
1156 fn test_handle_web_channel_login_sets_session_token() {
1157 nss_as::ensure_initialized();
1158 let config = Config::stable_dev("12345678", "https://foo.bar");
1159 let mut fxa = FirefoxAccount::with_config(config);
1160 fxa.handle_web_channel_login(
1161 r#"{"sessionToken":"mock_session_token","uid":"mock_uid","email":"mock@example.com","verified":true}"#,
1162 )
1163 .unwrap();
1164 assert_eq!(fxa.get_session_token().unwrap(), "mock_session_token");
1165 }
1166
1167 #[test]
1168 fn test_oauth_request_sent_with_session_when_available() {
1169 nss_as::ensure_initialized();
1170 let config = Config::new_with_mock_well_known_fxa_client_configuration(
1171 "mock-fxa.example.com",
1172 "12345678",
1173 "https://foo.bar",
1174 );
1175 let mut fxa = FirefoxAccount::with_config(config);
1176 let url = fxa
1177 .begin_oauth_flow("", &[OLD_SYNC, "profile"], "test_entrypoint")
1178 .unwrap();
1179 let url = Url::parse(&url).unwrap();
1180 let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1181 let mut client = MockFxAClient::new();
1182
1183 client
1184 .expect_create_refresh_token_using_authorization_code()
1185 .withf(|_, session_token, code, _| {
1186 matches!(session_token, Some("mock_session_token")) && code == "mock_code"
1187 })
1188 .times(1)
1189 .returning(|_, _, _, _| {
1190 Ok(OAuthTokenResponse {
1191 keys_jwe: None,
1192 refresh_token: Some("refresh_token".to_string()),
1193 session_token: None,
1194 expires_in: 1,
1195 scope: "profile".to_string(),
1196 access_token: "access_token".to_string(),
1197 })
1198 });
1199 client
1200 .expect_destroy_access_token()
1201 .with(always(), always())
1202 .times(1)
1203 .returning(|_, _| Ok(()));
1204 fxa.set_client(Arc::new(client));
1205 fxa.set_session_token("mock_session_token");
1206
1207 fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1208 .unwrap();
1209 }
1210
1211 fn make_mock_device(name: &str) -> GetDeviceResponse {
1212 use sync15::DeviceType;
1213 GetDeviceResponse {
1214 common: DeviceResponseCommon {
1215 id: "device1".into(),
1216 display_name: name.to_string(),
1217 device_type: DeviceType::Desktop,
1218 push_subscription: None,
1219 available_commands: HashMap::new(),
1220 push_endpoint_expired: false,
1221 },
1222 is_current_device: true,
1223 location: DeviceLocation {
1224 city: None,
1225 country: None,
1226 state: None,
1227 state_code: None,
1228 },
1229 last_access_time: None,
1230 }
1231 }
1232
1233 fn make_mock_update_device_response() -> UpdateDeviceResponse {
1234 use sync15::DeviceType;
1235 UpdateDeviceResponse {
1236 id: "device1".into(),
1237 display_name: "Test Device".to_string(),
1238 device_type: DeviceType::Desktop,
1239 push_subscription: None,
1240 available_commands: HashMap::new(),
1241 push_endpoint_expired: false,
1242 }
1243 }
1244
1245 #[test]
1248 fn test_complete_oauth_flow_merges_scopes_and_restores_device() {
1249 nss_as::ensure_initialized();
1250 let config = Config::new_with_mock_well_known_fxa_client_configuration(
1251 "mock-fxa.example.com",
1252 "12345678",
1253 "https://foo.bar",
1254 );
1255 let mut fxa = FirefoxAccount::with_config(config);
1256
1257 let url = fxa
1259 .begin_oauth_flow("", &["new_scope"], "test_entrypoint")
1260 .unwrap();
1261 let url = Url::parse(&url).unwrap();
1262 let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1263
1264 fxa.state.force_refresh_token(RefreshToken {
1266 token: "old_refresh".to_string(),
1267 scopes: ["profile".to_string()].into(),
1268 });
1269 fxa.set_session_token("mock_session_token");
1270
1271 let mut client = MockFxAClient::new();
1272
1273 client
1275 .expect_create_refresh_token_using_authorization_code()
1276 .times(1)
1277 .returning(|_, _, _, _| {
1278 Ok(OAuthTokenResponse {
1279 keys_jwe: None,
1280 refresh_token: Some("new_narrow_refresh".to_string()),
1281 session_token: None,
1282 expires_in: 3600,
1283 scope: "new_scope".to_string(),
1284 access_token: "access_token".to_string(),
1285 })
1286 });
1287
1288 client
1290 .expect_destroy_access_token()
1291 .with(always(), always())
1292 .times(1)
1293 .returning(|_, _| Ok(()));
1294
1295 client
1297 .expect_get_devices()
1298 .with(always(), eq("old_refresh"))
1299 .times(1)
1300 .returning(|_, _| Ok(vec![make_mock_device("Test Device")]));
1301
1302 client
1304 .expect_create_refresh_token_using_session_token()
1305 .withf(|_, session_token, _| session_token == "mock_session_token")
1306 .times(1)
1307 .returning(|_, _, _| {
1308 Ok(OAuthTokenResponse {
1309 keys_jwe: None,
1310 refresh_token: Some("merged_refresh".to_string()),
1311 session_token: None,
1312 expires_in: 3600,
1313 scope: "profile new_scope".to_string(),
1314 access_token: "access_token2".to_string(),
1315 })
1316 });
1317
1318 client
1320 .expect_destroy_refresh_token()
1321 .with(always(), eq("new_narrow_refresh"))
1322 .times(1)
1323 .returning(|_, _| Ok(()));
1324
1325 client
1327 .expect_destroy_refresh_token()
1328 .with(always(), eq("old_refresh"))
1329 .times(1)
1330 .returning(|_, _| Ok(()));
1331
1332 client
1334 .expect_update_device_record()
1335 .times(1)
1336 .returning(|_, _, _| Ok(make_mock_update_device_response()));
1337
1338 fxa.set_client(Arc::new(client));
1339
1340 fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1341 .unwrap();
1342
1343 let scopes = &fxa.state.refresh_token().unwrap().scopes;
1344 assert!(
1345 scopes.contains("profile"),
1346 "expected profile scope, got {scopes:?}"
1347 );
1348 assert!(
1349 scopes.contains("new_scope"),
1350 "expected new_scope, got {scopes:?}"
1351 );
1352 assert_eq!(scopes.len(), 2);
1353 }
1354
1355 #[test]
1359 fn test_complete_oauth_flow_no_merge_when_scopes_match() {
1360 nss_as::ensure_initialized();
1361 let config = Config::new_with_mock_well_known_fxa_client_configuration(
1362 "mock-fxa.example.com",
1363 "12345678",
1364 "https://foo.bar",
1365 );
1366 let mut fxa = FirefoxAccount::with_config(config);
1367
1368 let url = fxa
1369 .begin_oauth_flow("", &["profile"], "test_entrypoint")
1370 .unwrap();
1371 let url = Url::parse(&url).unwrap();
1372 let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1373
1374 fxa.state.force_refresh_token(RefreshToken {
1375 token: "old_refresh".to_string(),
1376 scopes: ["profile".to_string()].into(),
1377 });
1378 fxa.set_session_token("mock_session_token");
1379
1380 let mut client = MockFxAClient::new();
1381
1382 client
1384 .expect_create_refresh_token_using_authorization_code()
1385 .times(1)
1386 .returning(|_, _, _, _| {
1387 Ok(OAuthTokenResponse {
1388 keys_jwe: None,
1389 refresh_token: Some("new_refresh".to_string()),
1390 session_token: None,
1391 expires_in: 3600,
1392 scope: "profile".to_string(),
1393 access_token: "access_token".to_string(),
1394 })
1395 });
1396
1397 client
1399 .expect_destroy_access_token()
1400 .with(always(), always())
1401 .times(1)
1402 .returning(|_, _| Ok(()));
1403
1404 client
1406 .expect_get_devices()
1407 .with(always(), eq("old_refresh"))
1408 .times(1)
1409 .returning(|_, _| Ok(vec![make_mock_device("Test Device")]));
1410
1411 client
1416 .expect_destroy_refresh_token()
1417 .with(always(), eq("old_refresh"))
1418 .times(1)
1419 .returning(|_, _| Ok(()));
1420
1421 client
1423 .expect_update_device_record()
1424 .times(1)
1425 .returning(|_, _, _| Ok(make_mock_update_device_response()));
1426
1427 fxa.set_client(Arc::new(client));
1428
1429 fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1430 .unwrap();
1431
1432 let scopes = &fxa.state.refresh_token().unwrap().scopes;
1433 assert_eq!(scopes, &["profile".to_string()].into());
1434 }
1435
1436 #[test]
1439 fn test_complete_oauth_flow_retains_existing_sync_key_when_adding_scope() {
1440 nss_as::ensure_initialized();
1441 let config = Config::new_with_mock_well_known_fxa_client_configuration(
1442 "mock-fxa.example.com",
1443 "12345678",
1444 "https://foo.bar",
1445 );
1446 let mut fxa = FirefoxAccount::with_config(config);
1447
1448 let url = fxa
1450 .begin_oauth_flow("", &["new_scope"], "test_entrypoint")
1451 .unwrap();
1452 let url = Url::parse(&url).unwrap();
1453 let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1454
1455 fxa.state.force_refresh_token(RefreshToken {
1458 token: "old_refresh".to_string(),
1459 scopes: [OLD_SYNC.to_string()].into(),
1460 });
1461 fxa.state.insert_scoped_key(
1462 OLD_SYNC,
1463 crate::ScopedKey {
1464 kty: "oct".to_string(),
1465 scope: OLD_SYNC.to_string(),
1466 k: "existing_sync_key_material".to_string(),
1467 kid: "existing_sync_kid".to_string(),
1468 },
1469 );
1470 fxa.set_session_token("mock_session_token");
1471
1472 let mut client = MockFxAClient::new();
1473
1474 client
1476 .expect_create_refresh_token_using_authorization_code()
1477 .times(1)
1478 .returning(|_, _, _, _| {
1479 Ok(OAuthTokenResponse {
1480 keys_jwe: None,
1481 refresh_token: Some("new_narrow_refresh".to_string()),
1482 session_token: None,
1483 expires_in: 3600,
1484 scope: "new_scope".to_string(),
1485 access_token: "access_token".to_string(),
1486 })
1487 });
1488
1489 client
1491 .expect_destroy_access_token()
1492 .with(always(), always())
1493 .times(1)
1494 .returning(|_, _| Ok(()));
1495
1496 client
1498 .expect_get_devices()
1499 .with(always(), eq("old_refresh"))
1500 .times(1)
1501 .returning(|_, _| Ok(vec![make_mock_device("Test Device")]));
1502
1503 client
1505 .expect_create_refresh_token_using_session_token()
1506 .withf(|_, session_token, _| session_token == "mock_session_token")
1507 .times(1)
1508 .returning(|_, _, _| {
1509 Ok(OAuthTokenResponse {
1510 keys_jwe: None,
1511 refresh_token: Some("merged_refresh".to_string()),
1512 session_token: None,
1513 expires_in: 3600,
1514 scope: format!("{OLD_SYNC} new_scope"),
1515 access_token: "access_token2".to_string(),
1516 })
1517 });
1518
1519 client
1521 .expect_destroy_refresh_token()
1522 .with(always(), eq("new_narrow_refresh"))
1523 .times(1)
1524 .returning(|_, _| Ok(()));
1525
1526 client
1528 .expect_destroy_refresh_token()
1529 .with(always(), eq("old_refresh"))
1530 .times(1)
1531 .returning(|_, _| Ok(()));
1532
1533 client
1535 .expect_update_device_record()
1536 .times(1)
1537 .returning(|_, _, _| Ok(make_mock_update_device_response()));
1538
1539 fxa.set_client(Arc::new(client));
1540
1541 fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1542 .unwrap();
1543
1544 let sync_key = fxa
1546 .state
1547 .get_scoped_key(OLD_SYNC)
1548 .expect("sync scoped key should be retained");
1549 assert_eq!(sync_key.k, "existing_sync_key_material");
1550
1551 let scopes = &fxa.state.refresh_token().unwrap().scopes;
1553 assert!(
1554 scopes.contains(OLD_SYNC),
1555 "expected sync scope, got {scopes:?}"
1556 );
1557 assert!(
1558 scopes.contains("new_scope"),
1559 "expected new_scope, got {scopes:?}"
1560 );
1561 assert_eq!(scopes.len(), 2);
1562 }
1563}