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