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