fxa_client/internal/
oauth.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5pub 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;
24// Special redirect urn based on the OAuth native spec, signals that the
25// WebChannel flow is used
26pub const OAUTH_WEBCHANNEL_REDIRECT: &str = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel";
27
28impl FirefoxAccount {
29    /// Extracts and stores the session token from a WebChannel login JSON payload.
30    /// The JSON payload is the `data` object from the `fxaccounts:login` WebChannel command.
31    pub fn handle_web_channel_login(&mut self, json_payload: &str) -> Result<()> {
32        let data: serde_json::Value = serde_json::from_str(json_payload)?;
33        let token = data
34            .get("sessionToken")
35            .and_then(|v| v.as_str())
36            .ok_or(Error::NoSessionToken)?;
37        self.state.set_session_token(token.to_string());
38        Ok(())
39    }
40
41    /// Extracts the session token from a WebChannel password change JSON payload and exchanges it
42    /// for a new refresh token via a network call.
43    pub fn handle_web_channel_password_change(&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.handle_session_token_change(token)
50    }
51
52    /// Retrieve the current session token from state
53    pub fn get_session_token(&self) -> Result<String> {
54        match self.state.session_token() {
55            Some(session_token) => Ok(session_token.to_string()),
56            None => Err(Error::NoSessionToken),
57        }
58    }
59
60    /// Builds a complete `signedInUser` JSON object for a WebChannel `fxaccounts:fxa_status`
61    /// response. Returns `None` if no session token is stored.
62    /// `email` and `uid` are read from the cached profile; `verified` is always true because
63    /// the account state machine only completes authentication for verified accounts.
64    pub fn get_signed_in_user_for_web_channel(&self) -> Option<String> {
65        let token = self.state.session_token()?;
66        let profile = self.state.last_seen_profile();
67        let email = profile.map(|p| p.response.email.as_str());
68        let uid = profile.map(|p| p.response.uid.as_str());
69        Some(
70            serde_json::json!({
71                "sessionToken": token,
72                "email": email,
73                "uid": uid,
74                "verified": true,
75            })
76            .to_string(),
77        )
78    }
79
80    /// Check whether user is authorized using our refresh token.
81    pub fn check_authorization_status(&mut self) -> Result<IntrospectInfo> {
82        let resp = match self.state.refresh_token() {
83            Some(refresh_token) => {
84                self.auth_circuit_breaker.check()?;
85                self.client
86                    .check_refresh_token_status(self.state.config(), &refresh_token.token)?
87            }
88            None => return Err(Error::NoRefreshToken),
89        };
90        Ok(IntrospectInfo {
91            active: resp.active,
92        })
93    }
94
95    /// Initiate a pairing flow and return a URL that should be navigated to.
96    ///
97    /// * `pairing_url` - A pairing URL obtained by scanning a QR code produced by
98    ///   the pairing authority.
99    /// * `scopes` - Space-separated list of requested scopes by the pairing supplicant.
100    /// * `entrypoint` - The entrypoint to be used for data collection
101    /// * `metrics` - Optional parameters for metrics
102    pub fn begin_pairing_flow(
103        &mut self,
104        pairing_url: &str,
105        service: &str,
106        scopes: &[&str],
107        entrypoint: &str,
108    ) -> Result<String> {
109        let mut url = self.state.config().pair_supp_url()?;
110        url.query_pairs_mut().append_pair("entrypoint", entrypoint);
111        if !service.is_empty() {
112            url.query_pairs_mut().append_pair("service", service);
113        }
114        let pairing_url = util::parse_url(pairing_url, "begin_pairing_flow")?;
115        if url.host_str() != pairing_url.host_str() {
116            let fxa_server = FxaServer::from(&url);
117            let pairing_fxa_server = FxaServer::from(&pairing_url);
118            return Err(Error::OriginMismatch(format!(
119                "fxa-server: {fxa_server}, pairing-url-fxa-server: {pairing_fxa_server}"
120            )));
121        }
122        url.set_fragment(pairing_url.fragment());
123        self.oauth_flow(url, scopes)
124    }
125
126    /// Initiate an OAuth login flow and return a URL that should be navigated to.
127    ///
128    /// * `scopes` - Space-separated list of requested scopes.
129    /// * `entrypoint` - The entrypoint to be used for metrics
130    /// * `metrics` - Optional metrics parameters
131    ///
132    /// Note that you can use this to either perform an initial signin, or use this on
133    /// an already signed in account to get more scopes for that account.
134    /// When obtaining more scopes, only the new scopes needed should be requested
135    /// rather than the union of all scopes - this is because asking for a scope with
136    /// keys (eg, sync) would force the UI to go through a different UI flow - eg, always
137    /// asking for your password, even though the new scopes requested doesn't actually
138    /// require that. This code therefore knows how to merge the scopes at the end of the
139    /// flow, so the end result remains a new refresh token with the union of scopes.
140    pub fn begin_oauth_flow(
141        &mut self,
142        service: &str,
143        scopes: &[&str],
144        entrypoint: &str,
145    ) -> Result<String> {
146        let needs_reauth =
147            self.state.last_seen_profile().is_some() && self.state.session_token().is_none();
148        let mut url = if needs_reauth {
149            // must be in a needs-reauth or other odd state. Not clear this is strictly needed.
150            // further, this is still somewhat wrong in a "needs reauth" state - there we will be
151            // looking to get back all scopes we previously had - and it's not really expected the client
152            // knows that. We probably need to stash the old scopes when we enter the needsreauth
153            // state. But that's a todo.
154            self.state.config().oauth_force_auth_url()?
155        } else {
156            self.state.config().authorization_endpoint()?
157        };
158
159        info!("starting oauth flow via {url} for service={service:?}, scopes={scopes:?}, entrypoint={entrypoint:?}");
160        url.query_pairs_mut()
161            .append_pair("action", "email")
162            .append_pair("response_type", "code")
163            .append_pair("entrypoint", entrypoint);
164
165        if !service.is_empty() {
166            url.query_pairs_mut().append_pair("service", service);
167        }
168        if let Some(cached_profile) = self.state.last_seen_profile() {
169            url.query_pairs_mut()
170                .append_pair("email", &cached_profile.response.email);
171        }
172
173        debug!("oauth flow final set of requested scopes now {scopes:?}");
174        self.oauth_flow(url, scopes)
175    }
176
177    /// Fetch an OAuth code for a particular client using a session token from the account state.
178    ///
179    /// * `auth_params` Authorization parameters  which includes:
180    ///     *  `client_id` - OAuth client id.
181    ///     *  `scope` - list of requested scopes.
182    ///     *  `state` - OAuth state.
183    ///     *  `access_type` - Type of OAuth access, can be "offline" and "online"
184    ///     *  `pkce_params` - Optional PKCE parameters for public clients (`code_challenge` and `code_challenge_method`)
185    ///     *  `keys_jwk` - Optional JWK used to encrypt scoped keys
186    pub fn authorize_code_using_session_token(
187        &self,
188        auth_params: AuthorizationParameters,
189    ) -> Result<String> {
190        let session_token = self.get_session_token()?;
191
192        // Validate request to ensure that the client is actually allowed to request
193        // the scopes they requested
194        let allowed_scopes = self.client.get_scoped_key_data(
195            self.state.config(),
196            &session_token,
197            &auth_params.client_id,
198            &auth_params.scope.join(" "),
199        )?;
200
201        if let Some(not_allowed_scope) = auth_params
202            .scope
203            .iter()
204            .find(|scope| !allowed_scopes.contains_key(*scope))
205        {
206            return Err(Error::ScopeNotAllowed(
207                auth_params.client_id.clone(),
208                not_allowed_scope.clone(),
209            ));
210        }
211
212        let keys_jwe = if let Some(keys_jwk) = auth_params.keys_jwk {
213            let mut scoped_keys = HashMap::new();
214            allowed_scopes
215                .iter()
216                .try_for_each(|(scope, _)| -> Result<()> {
217                    scoped_keys.insert(
218                        scope,
219                        self.state
220                            .get_scoped_key(scope)
221                            .ok_or_else(|| Error::NoScopedKey(scope.clone()))?,
222                    );
223                    Ok(())
224                })?;
225            let scoped_keys = serde_json::to_string(&scoped_keys)?;
226            let keys_jwk = URL_SAFE_NO_PAD.decode(keys_jwk)?;
227            let jwk = serde_json::from_slice(&keys_jwk)?;
228            Some(jwcrypto::encrypt_to_jwe(
229                scoped_keys.as_bytes(),
230                EncryptionParameters::ECDH_ES {
231                    enc: EncryptionAlgorithm::A256GCM,
232                    peer_jwk: &jwk,
233                },
234            )?)
235        } else {
236            None
237        };
238        let auth_request_params = AuthorizationRequestParameters {
239            client_id: auth_params.client_id,
240            scope: auth_params.scope.join(" "),
241            state: auth_params.state,
242            access_type: auth_params.access_type,
243            code_challenge: auth_params.code_challenge,
244            code_challenge_method: auth_params.code_challenge_method,
245            keys_jwe,
246        };
247
248        let resp = self.client.create_authorization_code_using_session_token(
249            self.state.config(),
250            &session_token,
251            auth_request_params,
252        )?;
253
254        Ok(resp.code)
255    }
256
257    fn oauth_flow(&mut self, mut url: Url, scopes: &[&str]) -> Result<String> {
258        self.clear_access_token_cache();
259        let state = util::random_base64_url_string(16)?;
260        let code_verifier = util::random_base64_url_string(43)?;
261        let code_challenge = digest::digest(&digest::SHA256, code_verifier.as_bytes())?;
262        let code_challenge = URL_SAFE_NO_PAD.encode(code_challenge);
263        let scoped_keys_flow = ScopedKeysFlow::with_random_key()?;
264        let jwk = scoped_keys_flow.get_public_key_jwk()?;
265        let jwk_json = serde_json::to_string(&jwk)?;
266        let keys_jwk = URL_SAFE_NO_PAD.encode(jwk_json);
267        url.query_pairs_mut()
268            .append_pair("client_id", &self.state.config().client_id)
269            .append_pair("scope", &scopes.join(" "))
270            .append_pair("state", &state)
271            .append_pair("code_challenge_method", "S256")
272            .append_pair("code_challenge", &code_challenge)
273            .append_pair("access_type", "offline")
274            .append_pair("keys_jwk", &keys_jwk);
275
276        if self.state.config().redirect_uri == OAUTH_WEBCHANNEL_REDIRECT {
277            url.query_pairs_mut()
278                .append_pair("context", "oauth_webchannel_v1");
279        } else {
280            url.query_pairs_mut()
281                .append_pair("redirect_uri", &self.state.config().redirect_uri);
282        }
283
284        self.state.begin_oauth_flow(
285            state,
286            OAuthFlow {
287                scoped_keys_flow: Some(scoped_keys_flow),
288                code_verifier,
289            },
290        );
291        Ok(url.to_string())
292    }
293
294    /// Complete an OAuth flow initiated in `begin_oauth_flow` or `begin_pairing_flow`.
295    /// The `code` and `state` parameters can be obtained by parsing out the
296    /// redirect URL after a successful login.
297    ///
298    /// **💾 This method alters the persisted account state.**
299    pub fn complete_oauth_flow(&mut self, code: &str, state: &str) -> Result<()> {
300        self.clear_access_token_cache();
301        let oauth_flow = match self.state.pop_oauth_flow(state) {
302            Some(oauth_flow) => oauth_flow,
303            None => return Err(Error::UnknownOAuthState),
304        };
305        // This new flow is going to end up with us having a refresh token, but with only the newly
306        // requested scopes. We'll then exchange that for one with the old scopes added.
307        let resp = self.client.create_refresh_token_using_authorization_code(
308            self.state.config(),
309            self.state.session_token(),
310            code,
311            &oauth_flow.code_verifier,
312        )?;
313        info!(
314            "complete oauth flow - new session token={}, new refresh token={}",
315            resp.session_token.is_some(),
316            resp.refresh_token.is_some()
317        );
318        self.handle_oauth_response(resp, oauth_flow.scoped_keys_flow)?;
319        Ok(())
320    }
321
322    /// Cancel any in-progress oauth flows
323    pub fn cancel_existing_oauth_flows(&mut self) {
324        self.state.clear_oauth_flows();
325    }
326
327    pub(crate) fn handle_oauth_response(
328        &mut self,
329        resp: OAuthTokenResponse,
330        scoped_keys_flow: Option<ScopedKeysFlow>,
331    ) -> Result<()> {
332        let sync_scope_granted = resp.scope.split(' ').any(|s| s == scopes::OLD_SYNC);
333        let scoped_keys = match resp.keys_jwe {
334            Some(ref jwe) => {
335                let scoped_keys_flow = scoped_keys_flow.ok_or(Error::ApiClientError(
336                    "Got a JWE but have no JWK to decrypt it.",
337                ))?;
338                let decrypted_keys = scoped_keys_flow.decrypt_keys_jwe(jwe)?;
339                let scoped_keys: serde_json::Map<String, serde_json::Value> =
340                    serde_json::from_str(&decrypted_keys)?;
341                if sync_scope_granted && !scoped_keys.contains_key(scopes::OLD_SYNC) {
342                    error_support::report_error!(
343                        "fxaclient-scoped-key",
344                        "Sync scope granted, but no sync scoped key (scope granted: {}, key scopes: {})",
345                        resp.scope,
346                        scoped_keys.keys().map(|s| s.as_ref()).collect::<Vec<&str>>().join(", ")
347                    );
348                }
349                scoped_keys
350                    .into_iter()
351                    .map(|(scope, key)| Ok((scope, serde_json::from_value(key)?)))
352                    .collect::<Result<Vec<_>>>()?
353            }
354            None => {
355                if sync_scope_granted {
356                    error_support::report_error!(
357                        "fxaclient-scoped-key",
358                        "Sync scope granted, but keys_jwe is None"
359                    );
360                }
361                vec![]
362            }
363        };
364
365        // We are only interested in the refresh token at this time because we
366        // don't want to return an over-scoped access token.
367        // Let's be good citizens and destroy this access token.
368        if let Err(err) = self
369            .client
370            .destroy_access_token(self.state.config(), &resp.access_token)
371        {
372            warn!("Access token destruction failure: {:?}", err);
373        }
374        let old_refresh_token = self.state.refresh_token().cloned();
375        let mut new_refresh_token = RefreshToken::new(
376            resp.refresh_token
377                .ok_or(Error::ApiClientError("No refresh token in response"))?,
378            resp.scope,
379        );
380        // Destroying a refresh token also destroys its associated device,
381        // grab the device information for replication later.
382        let old_device_info = match old_refresh_token {
383            Some(_) => match self.get_current_device() {
384                Ok(maybe_device) => maybe_device,
385                Err(err) => {
386                    warn!("Error while getting previous device information: {:?}", err);
387                    None
388                }
389            },
390            None => None,
391        };
392
393        if let Some(ref old_refresh_token) = old_refresh_token {
394            // As described in the docs for `begin_oauth_flow`, we now have a new refresh token,
395            // but only with new scopes we explicitly requested.
396            // We possibly had an old refresh token with only the scopes we had before.
397            // In that scenario, we need to create yet another refresh token with merged scopes.
398            let existing_scopes = &old_refresh_token.scopes;
399            let all_scopes: HashSet<_> = existing_scopes
400                .union(&new_refresh_token.scopes)
401                .cloned()
402                .collect();
403            if all_scopes != new_refresh_token.scopes {
404                if let Some(session_token) = self.state.session_token() {
405                    info!("New refresh token is missing some of our old scopes, upgrading");
406                    // We'd prefer to call `exchange_token_for_scope` instead of `create_refresh_token_using_session_token`,
407                    // but that's not currently setup correctly for this.
408                    // NOTE: when we *do* call `exchange_token_for_scope` we shouldn't need to do the device reregistration
409                    // this as that's handled by the server in that scenario.
410                    let scopes_slice = all_scopes.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
411                    let merged_refresh_token_resp =
412                        self.client.create_refresh_token_using_session_token(
413                            self.state.config(),
414                            session_token,
415                            &scopes_slice,
416                        )?;
417                    let Some(merged_refresh_token_str) = merged_refresh_token_resp.refresh_token
418                    else {
419                        log::error!("server failed to give a new refresh token");
420                        return Err(Error::NoRefreshToken);
421                    };
422
423                    // now destroy the one we got from this response.
424                    if let Err(err) = self
425                        .client
426                        .destroy_refresh_token(self.state.config(), &new_refresh_token.token)
427                    {
428                        warn!(
429                            "Refresh token destruction failure of new refresh token: {:?}",
430                            err
431                        );
432                    }
433
434                    new_refresh_token = RefreshToken::new(
435                        merged_refresh_token_str,
436                        merged_refresh_token_resp.scope,
437                    );
438                } else {
439                    warn!("New refresh token is missing some of our old scopes, but don't have a session token to use to upgrade");
440                }
441            } else {
442                // this seems odd, but I guess not bad?
443                info!("New refresh token has the same scopes we started with");
444            }
445
446            // In order to keep 1 and only 1 refresh token alive per client instance,
447            // we also destroy the old refresh token.
448            if let Err(err) = self
449                .client
450                .destroy_refresh_token(self.state.config(), &old_refresh_token.token)
451            {
452                warn!(
453                    "Refresh token destruction failure of old refresh token: {:?}",
454                    err
455                );
456            }
457            // and clear the old refresh token from our state, just in case we encounter an error before
458            // we've set the new one as current.
459            self.state.clear_refresh_token();
460        }
461
462        self.state
463            .complete_oauth_flow(scoped_keys, new_refresh_token, resp.session_token);
464        if let Some(ref device_info) = old_device_info {
465            if let Err(err) = self.replace_device(
466                &device_info.display_name,
467                &device_info.device_type,
468                &device_info.push_subscription,
469                &device_info.available_commands,
470            ) {
471                warn!("Device information restoration failed: {:?}", err);
472            }
473            info!("restored device information with new refresh token");
474        }
475        Ok(())
476    }
477
478    /// Typically called during a password change flow.
479    /// Invalidates all tokens and fetches a new refresh token.
480    /// Because the old refresh token is not valid anymore, we can't do like `handle_oauth_response`
481    /// and re-create the device, so it is the responsibility of the caller to do so after we're
482    /// done.
483    ///
484    /// **💾 This method alters the persisted account state.**
485    pub fn handle_session_token_change(&mut self, session_token: &str) -> Result<()> {
486        let old_refresh_token = self.state.refresh_token().ok_or(Error::NoRefreshToken)?;
487        let scopes: Vec<&str> = old_refresh_token.scopes.iter().map(AsRef::as_ref).collect();
488        let resp = self.client.create_refresh_token_using_session_token(
489            self.state.config(),
490            session_token,
491            &scopes,
492        )?;
493        let new_refresh_token = resp
494            .refresh_token
495            .ok_or(Error::ApiClientError("No refresh token in response"))?;
496        self.state.update_tokens(
497            session_token.to_owned(),
498            RefreshToken {
499                token: new_refresh_token,
500                scopes: resp.scope.split(' ').map(ToString::to_string).collect(),
501            },
502        );
503        self.clear_devices_and_attached_clients_cache();
504        Ok(())
505    }
506}
507
508const AUTH_CIRCUIT_BREAKER_CAPACITY: u8 = 5;
509const AUTH_CIRCUIT_BREAKER_RENEWAL_RATE: f32 = 3.0 / 60.0 / 1000.0; // 3 tokens every minute.
510
511#[derive(Clone, Copy)]
512pub(crate) struct AuthCircuitBreaker {
513    rate_limiter: RateLimiter,
514}
515
516impl Default for AuthCircuitBreaker {
517    fn default() -> Self {
518        AuthCircuitBreaker {
519            rate_limiter: RateLimiter::new(
520                AUTH_CIRCUIT_BREAKER_CAPACITY,
521                AUTH_CIRCUIT_BREAKER_RENEWAL_RATE,
522            ),
523        }
524    }
525}
526
527impl AuthCircuitBreaker {
528    pub(crate) fn check(&mut self) -> Result<()> {
529        if !self.rate_limiter.check() {
530            return Err(Error::AuthCircuitBreakerError);
531        }
532        Ok(())
533    }
534}
535
536impl TryFrom<Url> for AuthorizationParameters {
537    type Error = Error;
538
539    fn try_from(url: Url) -> Result<Self> {
540        let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();
541        let scope = query_map
542            .get("scope")
543            .cloned()
544            .ok_or(Error::MissingUrlParameter("scope"))?;
545        let client_id = query_map
546            .get("client_id")
547            .cloned()
548            .ok_or(Error::MissingUrlParameter("client_id"))?;
549        let state = query_map
550            .get("state")
551            .cloned()
552            .ok_or(Error::MissingUrlParameter("state"))?;
553        let access_type = query_map
554            .get("access_type")
555            .cloned()
556            .ok_or(Error::MissingUrlParameter("access_type"))?;
557        let code_challenge = query_map.get("code_challenge").cloned();
558        let code_challenge_method = query_map.get("code_challenge_method").cloned();
559        let keys_jwk = query_map.get("keys_jwk").cloned();
560        Ok(Self {
561            client_id,
562            scope: scope.split_whitespace().map(|s| s.to_string()).collect(),
563            state,
564            access_type,
565            code_challenge,
566            code_challenge_method,
567            keys_jwk,
568        })
569    }
570}
571
572#[derive(Clone, Serialize, Deserialize)]
573pub struct RefreshToken {
574    pub token: String,
575    pub scopes: HashSet<String>,
576}
577
578impl RefreshToken {
579    pub fn new(token: String, scopes: String) -> Self {
580        Self {
581            token,
582            scopes: scopes
583                .split_ascii_whitespace()
584                .map(ToString::to_string)
585                .collect(),
586        }
587    }
588}
589
590impl std::fmt::Debug for RefreshToken {
591    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
592        f.debug_struct("RefreshToken")
593            .field("scopes", &self.scopes)
594            .finish()
595    }
596}
597
598pub struct OAuthFlow {
599    pub scoped_keys_flow: Option<ScopedKeysFlow>,
600    pub code_verifier: String,
601}
602
603impl From<IntrospectInfo> for crate::AuthorizationInfo {
604    fn from(r: IntrospectInfo) -> Self {
605        crate::AuthorizationInfo { active: r.active }
606    }
607}
608
609#[cfg(test)]
610impl FirefoxAccount {
611    pub fn set_session_token(&mut self, session_token: &str) {
612        self.state.set_session_token(session_token.to_owned());
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::super::{http_client::*, Config};
619    use super::*;
620    use mockall::predicate::always;
621    use mockall::predicate::eq;
622    use std::borrow::Cow;
623    use std::collections::HashMap;
624    use std::sync::Arc;
625
626    #[test]
627    fn test_oauth_flow_url() {
628        nss::ensure_initialized();
629        let config = Config::new_with_mock_well_known_fxa_client_configuration(
630            "https://mock-fxa.example.com",
631            "12345678",
632            "https://foo.bar",
633        );
634        let mut fxa = FirefoxAccount::with_config(config);
635        let url = fxa
636            .begin_oauth_flow("", &["profile"], "test_oauth_flow_url")
637            .unwrap();
638        let flow_url = Url::parse(&url).unwrap();
639
640        assert_eq!(flow_url.path(), "/authorization");
641
642        let mut pairs = flow_url.query_pairs();
643        assert_eq!(pairs.count(), 11);
644        assert_eq!(
645            pairs.next(),
646            Some((Cow::Borrowed("action"), Cow::Borrowed("email")))
647        );
648        assert_eq!(
649            pairs.next(),
650            Some((Cow::Borrowed("response_type"), Cow::Borrowed("code")))
651        );
652        assert_eq!(
653            pairs.next(),
654            Some((
655                Cow::Borrowed("entrypoint"),
656                Cow::Borrowed("test_oauth_flow_url")
657            ))
658        );
659        assert_eq!(
660            pairs.next(),
661            Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
662        );
663
664        assert_eq!(
665            pairs.next(),
666            Some((Cow::Borrowed("scope"), Cow::Borrowed("profile")))
667        );
668        let state_param = pairs.next().unwrap();
669        assert_eq!(state_param.0, Cow::Borrowed("state"));
670        assert_eq!(state_param.1.len(), 22);
671        assert_eq!(
672            pairs.next(),
673            Some((
674                Cow::Borrowed("code_challenge_method"),
675                Cow::Borrowed("S256")
676            ))
677        );
678        let code_challenge_param = pairs.next().unwrap();
679        assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
680        assert_eq!(code_challenge_param.1.len(), 43);
681        assert_eq!(
682            pairs.next(),
683            Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
684        );
685        let keys_jwk = pairs.next().unwrap();
686        assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
687        assert_eq!(keys_jwk.1.len(), 168);
688
689        assert_eq!(
690            pairs.next(),
691            Some((
692                Cow::Borrowed("redirect_uri"),
693                Cow::Borrowed("https://foo.bar")
694            ))
695        );
696    }
697
698    #[test]
699    fn test_force_auth_url() {
700        nss::ensure_initialized();
701        let config = Config::stable_dev("12345678", "https://foo.bar");
702        let mut fxa = FirefoxAccount::with_config(config);
703        let email = "test@example.com";
704        fxa.add_cached_profile("123", email);
705        let url = fxa
706            .begin_oauth_flow("", &["profile"], "test_force_auth_url")
707            .unwrap();
708        let url = Url::parse(&url).unwrap();
709        assert_eq!(url.path(), "/oauth/force_auth");
710        let mut pairs = url.query_pairs();
711        assert_eq!(
712            pairs.find(|e| e.0 == "email"),
713            Some((Cow::Borrowed("email"), Cow::Borrowed(email),))
714        );
715    }
716
717    #[test]
718    fn test_webchannel_context_url() {
719        nss::ensure_initialized();
720        const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
721        let config = Config::new_with_mock_well_known_fxa_client_configuration(
722            "https://mock-fxa.example.com",
723            "12345678",
724            "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
725        );
726        let mut fxa = FirefoxAccount::with_config(config);
727        let url = fxa
728            .begin_oauth_flow("", SCOPES, "test_webchannel_context_url")
729            .unwrap();
730        let url = Url::parse(&url).unwrap();
731        let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
732        let context = &query_params["context"];
733        assert_eq!(context, "oauth_webchannel_v1");
734        assert_eq!(query_params.get("redirect_uri"), None);
735    }
736
737    #[test]
738    fn test_webchannel_pairing_context_url() {
739        nss::ensure_initialized();
740        const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
741        const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
742
743        let config = Config::new(
744            "https://accounts.firefox.com",
745            "12345678",
746            "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
747        );
748        let mut fxa = FirefoxAccount::with_config(config);
749        let url = fxa
750            .begin_pairing_flow(
751                PAIRING_URL,
752                "service",
753                SCOPES,
754                "test_webchannel_pairing_context_url",
755            )
756            .unwrap();
757        let url = Url::parse(&url).unwrap();
758        let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
759        let context = &query_params["context"];
760        assert_eq!(context, "oauth_webchannel_v1");
761        assert_eq!(query_params.get("redirect_uri"), None);
762    }
763
764    #[test]
765    fn test_pairing_flow_url() {
766        nss::ensure_initialized();
767        const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
768        const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
769        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";
770
771        let config = Config::new(
772            "https://accounts.firefox.com",
773            "12345678",
774            "https://foo.bar",
775        );
776
777        let mut fxa = FirefoxAccount::with_config(config);
778        let url = fxa
779            .begin_pairing_flow(PAIRING_URL, "", SCOPES, "test_pairing_flow_url")
780            .unwrap();
781        let flow_url = Url::parse(&url).unwrap();
782        let expected_parsed_url = Url::parse(EXPECTED_URL).unwrap();
783
784        assert_eq!(flow_url.host_str(), Some("accounts.firefox.com"));
785        assert_eq!(flow_url.path(), "/pair/supp");
786        assert_eq!(flow_url.fragment(), expected_parsed_url.fragment());
787
788        let mut pairs = flow_url.query_pairs();
789        assert_eq!(pairs.count(), 9);
790        assert_eq!(
791            pairs.next(),
792            Some((
793                Cow::Borrowed("entrypoint"),
794                Cow::Borrowed("test_pairing_flow_url")
795            ))
796        );
797        assert_eq!(
798            pairs.next(),
799            Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
800        );
801        assert_eq!(
802            pairs.next(),
803            Some((
804                Cow::Borrowed("scope"),
805                Cow::Borrowed("https://identity.mozilla.com/apps/oldsync")
806            ))
807        );
808
809        let state_param = pairs.next().unwrap();
810        assert_eq!(state_param.0, Cow::Borrowed("state"));
811        assert_eq!(state_param.1.len(), 22);
812        assert_eq!(
813            pairs.next(),
814            Some((
815                Cow::Borrowed("code_challenge_method"),
816                Cow::Borrowed("S256")
817            ))
818        );
819        let code_challenge_param = pairs.next().unwrap();
820        assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
821        assert_eq!(code_challenge_param.1.len(), 43);
822        assert_eq!(
823            pairs.next(),
824            Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
825        );
826        let keys_jwk = pairs.next().unwrap();
827        assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
828        assert_eq!(keys_jwk.1.len(), 168);
829
830        assert_eq!(
831            pairs.next(),
832            Some((
833                Cow::Borrowed("redirect_uri"),
834                Cow::Borrowed("https://foo.bar")
835            ))
836        );
837    }
838
839    #[test]
840    fn test_pairing_flow_origin_mismatch() {
841        nss::ensure_initialized();
842        static PAIRING_URL: &str = "https://bad.origin.com/pair#channel_id=foo&channel_key=bar";
843        let config = Config::stable_dev("12345678", "https://foo.bar");
844        let mut fxa = FirefoxAccount::with_config(config);
845        let url = fxa.begin_pairing_flow(
846            PAIRING_URL,
847            "service",
848            &["https://identity.mozilla.com/apps/oldsync"],
849            "test_pairiong_flow_origin_mismatch",
850        );
851
852        assert!(url.is_err());
853
854        match url {
855            Ok(_) => {
856                panic!("should have error");
857            }
858            Err(err) => match err {
859                Error::OriginMismatch { .. } => {}
860                _ => panic!("error not OriginMismatch"),
861            },
862        }
863    }
864
865    #[test]
866    fn test_check_authorization_status() {
867        nss::ensure_initialized();
868        let config = Config::stable_dev("12345678", "https://foo.bar");
869        let mut fxa = FirefoxAccount::with_config(config);
870
871        let refresh_token_scopes = std::collections::HashSet::new();
872        fxa.state.force_refresh_token(RefreshToken {
873            token: "refresh_token".to_owned(),
874            scopes: refresh_token_scopes,
875        });
876
877        let mut client = MockFxAClient::new();
878        client
879            .expect_check_refresh_token_status()
880            .with(always(), eq("refresh_token"))
881            .times(1)
882            .returning(|_, _| Ok(IntrospectResponse { active: true }));
883        fxa.set_client(Arc::new(client));
884
885        let auth_status = fxa.check_authorization_status().unwrap();
886        assert!(auth_status.active);
887    }
888
889    #[test]
890    fn test_check_authorization_status_circuit_breaker() {
891        nss::ensure_initialized();
892        let config = Config::stable_dev("12345678", "https://foo.bar");
893        let mut fxa = FirefoxAccount::with_config(config);
894
895        let refresh_token_scopes = std::collections::HashSet::new();
896        fxa.state.force_refresh_token(RefreshToken {
897            token: "refresh_token".to_owned(),
898            scopes: refresh_token_scopes,
899        });
900
901        let mut client = MockFxAClient::new();
902        // This copy-pasta (equivalent to `.returns(..).times(5)`) is there
903        // because `Error` is not cloneable :/
904        client
905            .expect_check_refresh_token_status()
906            .with(always(), eq("refresh_token"))
907            .returning(|_, _| Ok(IntrospectResponse { active: true }));
908        client
909            .expect_check_refresh_token_status()
910            .with(always(), eq("refresh_token"))
911            .returning(|_, _| Ok(IntrospectResponse { active: true }));
912        client
913            .expect_check_refresh_token_status()
914            .with(always(), eq("refresh_token"))
915            .returning(|_, _| Ok(IntrospectResponse { active: true }));
916        client
917            .expect_check_refresh_token_status()
918            .with(always(), eq("refresh_token"))
919            .returning(|_, _| Ok(IntrospectResponse { active: true }));
920        client
921            .expect_check_refresh_token_status()
922            .with(always(), eq("refresh_token"))
923            .returning(|_, _| Ok(IntrospectResponse { active: true }));
924        //mockall expects calls to be processed in the order they are registered. So, no need for to use a method like expect_check_refresh_token_status_calls_in_order()
925        fxa.set_client(Arc::new(client));
926
927        for _ in 0..5 {
928            assert!(fxa.check_authorization_status().is_ok());
929        }
930        match fxa.check_authorization_status() {
931            Ok(_) => unreachable!("should not happen"),
932            Err(err) => assert!(matches!(err, Error::AuthCircuitBreakerError)),
933        }
934    }
935
936    use crate::internal::scopes::{self, OLD_SYNC};
937
938    #[test]
939    fn test_auth_code_pair_valid_not_allowed_scope() {
940        nss::ensure_initialized();
941        let config = Config::stable_dev("12345678", "https://foo.bar");
942        let mut fxa = FirefoxAccount::with_config(config);
943        fxa.set_session_token("session");
944        let mut client = MockFxAClient::new();
945        let not_allowed_scope = "https://identity.mozilla.com/apps/lockbox";
946        let expected_scopes = scopes::OLD_SYNC
947            .chars()
948            .chain(std::iter::once(' '))
949            .chain(not_allowed_scope.chars())
950            .collect::<String>();
951        client
952            .expect_get_scoped_key_data()
953            .with(always(), eq("session"), eq("12345678"), eq(expected_scopes))
954            .times(1)
955            .returning(|_, _, _, _| {
956                Err(Error::RemoteError {
957                    code: 400,
958                    errno: 163,
959                    error: "Invalid Scopes".to_string(),
960                    message: "Not allowed to request scopes".to_string(),
961                    info: "fyi, there was a server error".to_string(),
962                })
963            });
964        fxa.set_client(Arc::new(client));
965        let auth_params = AuthorizationParameters {
966            client_id: "12345678".to_string(),
967            scope: vec![scopes::OLD_SYNC.to_string(), not_allowed_scope.to_string()],
968            state: "somestate".to_string(),
969            access_type: "offline".to_string(),
970            code_challenge: None,
971            code_challenge_method: None,
972            keys_jwk: None,
973        };
974        let res = fxa.authorize_code_using_session_token(auth_params);
975        assert!(res.is_err());
976        let err = res.unwrap_err();
977        if let Error::RemoteError {
978            code,
979            errno,
980            error: _,
981            message: _,
982            info: _,
983        } = err
984        {
985            assert_eq!(code, 400);
986            assert_eq!(errno, 163); // Requested scopes not allowed
987        } else {
988            panic!("Should return an error from the server specifying that the requested scopes are not allowed");
989        }
990    }
991
992    #[test]
993    fn test_auth_code_pair_invalid_scope_not_allowed() {
994        nss::ensure_initialized();
995        let config = Config::stable_dev("12345678", "https://foo.bar");
996        let mut fxa = FirefoxAccount::with_config(config);
997        fxa.set_session_token("session");
998        let mut client = MockFxAClient::new();
999        let invalid_scope = "IamAnInvalidScope";
1000        let expected_scopes = scopes::OLD_SYNC
1001            .chars()
1002            .chain(std::iter::once(' '))
1003            .chain(invalid_scope.chars())
1004            .collect::<String>();
1005        client
1006            .expect_get_scoped_key_data()
1007            .with(always(), eq("session"), eq("12345678"), eq(expected_scopes))
1008            .times(1)
1009            .returning(|_, _, _, _| {
1010                let mut server_ret = HashMap::new();
1011                server_ret.insert(
1012                    scopes::OLD_SYNC.to_string(),
1013                    ScopedKeyDataResponse {
1014                        key_rotation_secret: "IamASecret".to_string(),
1015                        key_rotation_timestamp: 100,
1016                        identifier: "".to_string(),
1017                    },
1018                );
1019                Ok(server_ret)
1020            });
1021        fxa.set_client(Arc::new(client));
1022
1023        let auth_params = AuthorizationParameters {
1024            client_id: "12345678".to_string(),
1025            scope: vec![scopes::OLD_SYNC.to_string(), invalid_scope.to_string()],
1026            state: "somestate".to_string(),
1027            access_type: "offline".to_string(),
1028            code_challenge: None,
1029            code_challenge_method: None,
1030            keys_jwk: None,
1031        };
1032        let res = fxa.authorize_code_using_session_token(auth_params);
1033        assert!(res.is_err());
1034        let err = res.unwrap_err();
1035        if let Error::ScopeNotAllowed(client_id, scope) = err {
1036            assert_eq!(client_id, "12345678");
1037            assert_eq!(scope, "IamAnInvalidScope");
1038        } else {
1039            panic!("Should return an error that specifies the scope that is not allowed");
1040        }
1041    }
1042
1043    #[test]
1044    fn test_auth_code_pair_scope_not_in_state() {
1045        nss::ensure_initialized();
1046        let config = Config::stable_dev("12345678", "https://foo.bar");
1047        let mut fxa = FirefoxAccount::with_config(config);
1048        fxa.set_session_token("session");
1049        let mut client = MockFxAClient::new();
1050        client
1051            .expect_get_scoped_key_data()
1052            .with(
1053                always(),
1054                eq("session"),
1055                eq("12345678"),
1056                eq(scopes::OLD_SYNC),
1057            )
1058            .times(1)
1059            .returning(|_, _, _, _| {
1060                let mut server_ret = HashMap::new();
1061                server_ret.insert(
1062                    scopes::OLD_SYNC.to_string(),
1063                    ScopedKeyDataResponse {
1064                        key_rotation_secret: "IamASecret".to_string(),
1065                        key_rotation_timestamp: 100,
1066                        identifier: "".to_string(),
1067                    },
1068                );
1069                Ok(server_ret)
1070            });
1071        fxa.set_client(Arc::new(client));
1072        let auth_params = AuthorizationParameters {
1073            client_id: "12345678".to_string(),
1074            scope: vec![scopes::OLD_SYNC.to_string()],
1075            state: "somestate".to_string(),
1076            access_type: "offline".to_string(),
1077            code_challenge: None,
1078            code_challenge_method: None,
1079            keys_jwk: Some("IAmAVerySecretKeysJWkInBase64".to_string()),
1080        };
1081        let res = fxa.authorize_code_using_session_token(auth_params);
1082        assert!(res.is_err());
1083        let err = res.unwrap_err();
1084        if let Error::NoScopedKey(scope) = err {
1085            assert_eq!(scope, scopes::OLD_SYNC.to_string());
1086        } else {
1087            panic!("Should return an error that specifies the scope that is not in the state");
1088        }
1089    }
1090
1091    #[test]
1092    fn test_handle_web_channel_login_sets_session_token() {
1093        nss::ensure_initialized();
1094        let config = Config::stable_dev("12345678", "https://foo.bar");
1095        let mut fxa = FirefoxAccount::with_config(config);
1096        fxa.handle_web_channel_login(
1097            r#"{"sessionToken":"mock_session_token","uid":"mock_uid","email":"mock@example.com","verified":true}"#,
1098        )
1099        .unwrap();
1100        assert_eq!(fxa.get_session_token().unwrap(), "mock_session_token");
1101    }
1102
1103    #[test]
1104    fn test_oauth_request_sent_with_session_when_available() {
1105        nss::ensure_initialized();
1106        let config = Config::new_with_mock_well_known_fxa_client_configuration(
1107            "mock-fxa.example.com",
1108            "12345678",
1109            "https://foo.bar",
1110        );
1111        let mut fxa = FirefoxAccount::with_config(config);
1112        let url = fxa
1113            .begin_oauth_flow("", &[OLD_SYNC, "profile"], "test_entrypoint")
1114            .unwrap();
1115        let url = Url::parse(&url).unwrap();
1116        let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1117        let mut client = MockFxAClient::new();
1118
1119        client
1120            .expect_create_refresh_token_using_authorization_code()
1121            .withf(|_, session_token, code, _| {
1122                matches!(session_token, Some("mock_session_token")) && code == "mock_code"
1123            })
1124            .times(1)
1125            .returning(|_, _, _, _| {
1126                Ok(OAuthTokenResponse {
1127                    keys_jwe: None,
1128                    refresh_token: Some("refresh_token".to_string()),
1129                    session_token: None,
1130                    expires_in: 1,
1131                    scope: "profile".to_string(),
1132                    access_token: "access_token".to_string(),
1133                })
1134            });
1135        client
1136            .expect_destroy_access_token()
1137            .with(always(), always())
1138            .times(1)
1139            .returning(|_, _| Ok(()));
1140        fxa.set_client(Arc::new(client));
1141        fxa.set_session_token("mock_session_token");
1142
1143        fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1144            .unwrap();
1145    }
1146
1147    fn make_mock_device(name: &str) -> GetDeviceResponse {
1148        use sync15::DeviceType;
1149        GetDeviceResponse {
1150            common: DeviceResponseCommon {
1151                id: "device1".into(),
1152                display_name: name.to_string(),
1153                device_type: DeviceType::Desktop,
1154                push_subscription: None,
1155                available_commands: HashMap::new(),
1156                push_endpoint_expired: false,
1157            },
1158            is_current_device: true,
1159            location: DeviceLocation {
1160                city: None,
1161                country: None,
1162                state: None,
1163                state_code: None,
1164            },
1165            last_access_time: None,
1166        }
1167    }
1168
1169    fn make_mock_update_device_response() -> UpdateDeviceResponse {
1170        use sync15::DeviceType;
1171        UpdateDeviceResponse {
1172            id: "device1".into(),
1173            display_name: "Test Device".to_string(),
1174            device_type: DeviceType::Desktop,
1175            push_subscription: None,
1176            available_commands: HashMap::new(),
1177            push_endpoint_expired: false,
1178        }
1179    }
1180
1181    // Test that when we complete an oauth flow while already having a refresh token with
1182    // different scopes, the new token is merged with the old scopes and the device is restored.
1183    #[test]
1184    fn test_complete_oauth_flow_merges_scopes_and_restores_device() {
1185        nss::ensure_initialized();
1186        let config = Config::new_with_mock_well_known_fxa_client_configuration(
1187            "mock-fxa.example.com",
1188            "12345678",
1189            "https://foo.bar",
1190        );
1191        let mut fxa = FirefoxAccount::with_config(config);
1192
1193        // Start a flow before setting state, to register the pending oauth flow.
1194        let url = fxa
1195            .begin_oauth_flow("", &["new_scope"], "test_entrypoint")
1196            .unwrap();
1197        let url = Url::parse(&url).unwrap();
1198        let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1199
1200        // Pre-populate: existing refresh token (different scope) and a session token.
1201        fxa.state.force_refresh_token(RefreshToken {
1202            token: "old_refresh".to_string(),
1203            scopes: ["profile".to_string()].into(),
1204        });
1205        fxa.set_session_token("mock_session_token");
1206
1207        let mut client = MockFxAClient::new();
1208
1209        // 1. Exchange auth code — returns narrow token with only the new scope.
1210        client
1211            .expect_create_refresh_token_using_authorization_code()
1212            .times(1)
1213            .returning(|_, _, _, _| {
1214                Ok(OAuthTokenResponse {
1215                    keys_jwe: None,
1216                    refresh_token: Some("new_narrow_refresh".to_string()),
1217                    session_token: None,
1218                    expires_in: 3600,
1219                    scope: "new_scope".to_string(),
1220                    access_token: "access_token".to_string(),
1221                })
1222            });
1223
1224        // 2. Destroy the over-scoped access token.
1225        client
1226            .expect_destroy_access_token()
1227            .with(always(), always())
1228            .times(1)
1229            .returning(|_, _| Ok(()));
1230
1231        // 3. Fetch current device so it can be restored after token swap.
1232        client
1233            .expect_get_devices()
1234            .with(always(), eq("old_refresh"))
1235            .times(1)
1236            .returning(|_, _| Ok(vec![make_mock_device("Test Device")]));
1237
1238        // 4. Get merged refresh token covering both old and new scopes.
1239        client
1240            .expect_create_refresh_token_using_session_token()
1241            .withf(|_, session_token, _| session_token == "mock_session_token")
1242            .times(1)
1243            .returning(|_, _, _| {
1244                Ok(OAuthTokenResponse {
1245                    keys_jwe: None,
1246                    refresh_token: Some("merged_refresh".to_string()),
1247                    session_token: None,
1248                    expires_in: 3600,
1249                    scope: "profile new_scope".to_string(),
1250                    access_token: "access_token2".to_string(),
1251                })
1252            });
1253
1254        // 5. Destroy the narrow new token (replaced by the merged one).
1255        client
1256            .expect_destroy_refresh_token()
1257            .with(always(), eq("new_narrow_refresh"))
1258            .times(1)
1259            .returning(|_, _| Ok(()));
1260
1261        // 6. Destroy the old refresh token.
1262        client
1263            .expect_destroy_refresh_token()
1264            .with(always(), eq("old_refresh"))
1265            .times(1)
1266            .returning(|_, _| Ok(()));
1267
1268        // 7. Restore the device record using the new merged refresh token.
1269        client
1270            .expect_update_device_record()
1271            .times(1)
1272            .returning(|_, _, _| Ok(make_mock_update_device_response()));
1273
1274        fxa.set_client(Arc::new(client));
1275
1276        fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1277            .unwrap();
1278
1279        let scopes = &fxa.state.refresh_token().unwrap().scopes;
1280        assert!(
1281            scopes.contains("profile"),
1282            "expected profile scope, got {scopes:?}"
1283        );
1284        assert!(
1285            scopes.contains("new_scope"),
1286            "expected new_scope, got {scopes:?}"
1287        );
1288        assert_eq!(scopes.len(), 2);
1289    }
1290
1291    // Test that when the new refresh token already covers all existing scopes, no merge
1292    // is performed (no extra token request), but the old token is still destroyed and
1293    // the device is restored.
1294    #[test]
1295    fn test_complete_oauth_flow_no_merge_when_scopes_match() {
1296        nss::ensure_initialized();
1297        let config = Config::new_with_mock_well_known_fxa_client_configuration(
1298            "mock-fxa.example.com",
1299            "12345678",
1300            "https://foo.bar",
1301        );
1302        let mut fxa = FirefoxAccount::with_config(config);
1303
1304        let url = fxa
1305            .begin_oauth_flow("", &["profile"], "test_entrypoint")
1306            .unwrap();
1307        let url = Url::parse(&url).unwrap();
1308        let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1309
1310        fxa.state.force_refresh_token(RefreshToken {
1311            token: "old_refresh".to_string(),
1312            scopes: ["profile".to_string()].into(),
1313        });
1314        fxa.set_session_token("mock_session_token");
1315
1316        let mut client = MockFxAClient::new();
1317
1318        // 1. Exchange auth code — returns token with same scopes as before.
1319        client
1320            .expect_create_refresh_token_using_authorization_code()
1321            .times(1)
1322            .returning(|_, _, _, _| {
1323                Ok(OAuthTokenResponse {
1324                    keys_jwe: None,
1325                    refresh_token: Some("new_refresh".to_string()),
1326                    session_token: None,
1327                    expires_in: 3600,
1328                    scope: "profile".to_string(),
1329                    access_token: "access_token".to_string(),
1330                })
1331            });
1332
1333        // 2. Destroy the over-scoped access token.
1334        client
1335            .expect_destroy_access_token()
1336            .with(always(), always())
1337            .times(1)
1338            .returning(|_, _| Ok(()));
1339
1340        // 3. Fetch current device for restoration.
1341        client
1342            .expect_get_devices()
1343            .with(always(), eq("old_refresh"))
1344            .times(1)
1345            .returning(|_, _| Ok(vec![make_mock_device("Test Device")]));
1346
1347        // No create_refresh_token_using_session_token — scopes already match.
1348        // No destroy of the new token — it becomes our token directly.
1349
1350        // 4. Destroy only the old refresh token.
1351        client
1352            .expect_destroy_refresh_token()
1353            .with(always(), eq("old_refresh"))
1354            .times(1)
1355            .returning(|_, _| Ok(()));
1356
1357        // 5. Restore the device record.
1358        client
1359            .expect_update_device_record()
1360            .times(1)
1361            .returning(|_, _, _| Ok(make_mock_update_device_response()));
1362
1363        fxa.set_client(Arc::new(client));
1364
1365        fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1366            .unwrap();
1367
1368        let scopes = &fxa.state.refresh_token().unwrap().scopes;
1369        assert_eq!(scopes, &["profile".to_string()].into());
1370    }
1371}