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 attached_clients;
6use super::scopes;
7use super::{
8    http_client::{
9        AuthorizationRequestParameters, IntrospectResponse as IntrospectInfo, OAuthTokenResponse,
10    },
11    scoped_keys::ScopedKeysFlow,
12    util, FirefoxAccount,
13};
14use crate::auth::UserData;
15use crate::{warn, AuthorizationParameters, Error, FxaServer, Result, ScopedKey};
16use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
17use jwcrypto::{EncryptionAlgorithm, EncryptionParameters};
18use rate_limiter::RateLimiter;
19use rc_crypto::digest;
20use serde_derive::*;
21use std::{
22    collections::{HashMap, HashSet},
23    iter::FromIterator,
24    time::{SystemTime, UNIX_EPOCH},
25};
26use url::Url;
27// If a cached token has less than `OAUTH_MIN_TIME_LEFT` seconds left to live,
28// it will be considered already expired.
29const OAUTH_MIN_TIME_LEFT: u64 = 60;
30// Special redirect urn based on the OAuth native spec, signals that the
31// WebChannel flow is used
32pub const OAUTH_WEBCHANNEL_REDIRECT: &str = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel";
33
34impl FirefoxAccount {
35    /// Fetch a short-lived access token using the saved refresh token.
36    /// If there is no refresh token held or if it is not authorized for some of the requested
37    /// scopes, this method will error-out and a login flow will need to be initiated
38    /// using `begin_oauth_flow`.
39    ///
40    /// * `scopes` - Space-separated list of requested scopes.
41    /// * `use_cache` - optionally set to false to force a new token request.  The fetched
42    ///   token will still be cached for later `get_access_token` calls.
43    ///
44    /// **💾 This method may alter the persisted account state.**
45    pub fn get_access_token(&mut self, scope: &str, use_cache: bool) -> Result<AccessTokenInfo> {
46        if scope.contains(' ') {
47            return Err(Error::MultipleScopesRequested);
48        }
49        if use_cache {
50            if let Some(oauth_info) = self.state.get_cached_access_token(scope) {
51                if oauth_info.expires_at > util::now_secs() + OAUTH_MIN_TIME_LEFT {
52                    // If the cached key is missing the required sync scoped key, try to fetch it again
53                    if oauth_info.check_missing_sync_scoped_key().is_ok() {
54                        return Ok(oauth_info.clone());
55                    }
56                }
57            }
58        }
59        let resp = match self.state.refresh_token() {
60            Some(mut refresh_token) => {
61                if !refresh_token.scopes.contains(scope) {
62                    // We don't currently have this scope - try token exchange to upgrade.
63                    let exchange_resp = self.client.exchange_token_for_scope(
64                        self.state.config(),
65                        &refresh_token.token,
66                        scope,
67                    )?;
68                    // Update state with the new refresh token that has combined scopes.
69                    if let Some(new_refresh_token) = exchange_resp.refresh_token {
70                        self.state.update_refresh_token(RefreshToken::new(
71                            new_refresh_token,
72                            exchange_resp.scope,
73                        ));
74                    }
75                    // Get the updated refresh token from state.
76                    refresh_token = match self.state.refresh_token() {
77                        None => return Err(Error::NoCachedToken(scope.to_string())),
78                        Some(token) => token,
79                    };
80                }
81                if refresh_token.scopes.contains(scope) {
82                    self.client.create_access_token_using_refresh_token(
83                        self.state.config(),
84                        &refresh_token.token,
85                        None,
86                        &[scope],
87                    )?
88                } else {
89                    return Err(Error::NoCachedToken(scope.to_string()));
90                }
91            }
92            None => match self.state.session_token() {
93                Some(session_token) => self.client.create_access_token_using_session_token(
94                    self.state.config(),
95                    session_token,
96                    &[scope],
97                )?,
98                None => return Err(Error::NoCachedToken(scope.to_string())),
99            },
100        };
101        let since_epoch = SystemTime::now()
102            .duration_since(UNIX_EPOCH)
103            .map_err(|_| Error::IllegalState("Current date before Unix Epoch."))?;
104        let expires_at = since_epoch.as_secs() + resp.expires_in;
105        let token_info = AccessTokenInfo {
106            scope: resp.scope,
107            token: resp.access_token,
108            key: self.state.get_scoped_key(scope).cloned(),
109            expires_at,
110        };
111        self.state
112            .add_cached_access_token(scope, token_info.clone());
113        token_info.check_missing_sync_scoped_key()?;
114        Ok(token_info)
115    }
116
117    /// Sets the user data (session token, email, uid)
118    pub fn set_user_data(&mut self, user_data: UserData) {
119        // for now, we only have use for the session token
120        // if we'd like to implement a "Signed in but not verified" state
121        // we would also consume the other parts of the user data
122        self.state.set_session_token(user_data.session_token)
123    }
124
125    /// Retrieve the current session token from state
126    pub fn get_session_token(&self) -> Result<String> {
127        match self.state.session_token() {
128            Some(session_token) => Ok(session_token.to_string()),
129            None => Err(Error::NoSessionToken),
130        }
131    }
132
133    /// Check whether user is authorized using our refresh token.
134    pub fn check_authorization_status(&mut self) -> Result<IntrospectInfo> {
135        let resp = match self.state.refresh_token() {
136            Some(refresh_token) => {
137                self.auth_circuit_breaker.check()?;
138                self.client
139                    .check_refresh_token_status(self.state.config(), &refresh_token.token)?
140            }
141            None => return Err(Error::NoRefreshToken),
142        };
143        Ok(IntrospectInfo {
144            active: resp.active,
145        })
146    }
147
148    /// Initiate a pairing flow and return a URL that should be navigated to.
149    ///
150    /// * `pairing_url` - A pairing URL obtained by scanning a QR code produced by
151    ///   the pairing authority.
152    /// * `scopes` - Space-separated list of requested scopes by the pairing supplicant.
153    /// * `entrypoint` - The entrypoint to be used for data collection
154    /// * `metrics` - Optional parameters for metrics
155    pub fn begin_pairing_flow(
156        &mut self,
157        pairing_url: &str,
158        scopes: &[&str],
159        entrypoint: &str,
160    ) -> Result<String> {
161        let mut url = self.state.config().pair_supp_url()?;
162        url.query_pairs_mut().append_pair("entrypoint", entrypoint);
163        let pairing_url = util::parse_url(pairing_url, "begin_pairing_flow")?;
164        if url.host_str() != pairing_url.host_str() {
165            let fxa_server = FxaServer::from(&url);
166            let pairing_fxa_server = FxaServer::from(&pairing_url);
167            return Err(Error::OriginMismatch(format!(
168                "fxa-server: {fxa_server}, pairing-url-fxa-server: {pairing_fxa_server}"
169            )));
170        }
171        url.set_fragment(pairing_url.fragment());
172        self.oauth_flow(url, scopes)
173    }
174
175    /// Initiate an OAuth login flow and return a URL that should be navigated to.
176    ///
177    /// * `scopes` - Space-separated list of requested scopes.
178    /// * `entrypoint` - The entrypoint to be used for metrics
179    /// * `metrics` - Optional metrics parameters
180    pub fn begin_oauth_flow(&mut self, scopes: &[&str], entrypoint: &str) -> Result<String> {
181        self.state.on_begin_oauth();
182        let mut url = if self.state.last_seen_profile().is_some() {
183            self.state.config().oauth_force_auth_url()?
184        } else {
185            self.state.config().authorization_endpoint()?
186        };
187
188        url.query_pairs_mut()
189            .append_pair("action", "email")
190            .append_pair("response_type", "code")
191            .append_pair("entrypoint", entrypoint);
192
193        if let Some(cached_profile) = self.state.last_seen_profile() {
194            url.query_pairs_mut()
195                .append_pair("email", &cached_profile.response.email);
196        }
197
198        let scopes: Vec<String> = match self.state.refresh_token() {
199            Some(refresh_token) => {
200                // Union of the already held scopes and the one requested.
201                let mut all_scopes: Vec<String> = vec![];
202                all_scopes.extend(scopes.iter().map(ToString::to_string));
203                let existing_scopes = refresh_token.scopes.clone();
204                all_scopes.extend(existing_scopes);
205                HashSet::<String>::from_iter(all_scopes)
206                    .into_iter()
207                    .collect()
208            }
209            None => scopes.iter().map(ToString::to_string).collect(),
210        };
211        let scopes: Vec<&str> = scopes.iter().map(<_>::as_ref).collect();
212        self.oauth_flow(url, &scopes)
213    }
214
215    /// Fetch an OAuth code for a particular client using a session token from the account state.
216    ///
217    /// * `auth_params` Authorization parameters  which includes:
218    ///     *  `client_id` - OAuth client id.
219    ///     *  `scope` - list of requested scopes.
220    ///     *  `state` - OAuth state.
221    ///     *  `access_type` - Type of OAuth access, can be "offline" and "online"
222    ///     *  `pkce_params` - Optional PKCE parameters for public clients (`code_challenge` and `code_challenge_method`)
223    ///     *  `keys_jwk` - Optional JWK used to encrypt scoped keys
224    pub fn authorize_code_using_session_token(
225        &self,
226        auth_params: AuthorizationParameters,
227    ) -> Result<String> {
228        let session_token = self.get_session_token()?;
229
230        // Validate request to ensure that the client is actually allowed to request
231        // the scopes they requested
232        let allowed_scopes = self.client.get_scoped_key_data(
233            self.state.config(),
234            &session_token,
235            &auth_params.client_id,
236            &auth_params.scope.join(" "),
237        )?;
238
239        if let Some(not_allowed_scope) = auth_params
240            .scope
241            .iter()
242            .find(|scope| !allowed_scopes.contains_key(*scope))
243        {
244            return Err(Error::ScopeNotAllowed(
245                auth_params.client_id.clone(),
246                not_allowed_scope.clone(),
247            ));
248        }
249
250        let keys_jwe = if let Some(keys_jwk) = auth_params.keys_jwk {
251            let mut scoped_keys = HashMap::new();
252            allowed_scopes
253                .iter()
254                .try_for_each(|(scope, _)| -> Result<()> {
255                    scoped_keys.insert(
256                        scope,
257                        self.state
258                            .get_scoped_key(scope)
259                            .ok_or_else(|| Error::NoScopedKey(scope.clone()))?,
260                    );
261                    Ok(())
262                })?;
263            let scoped_keys = serde_json::to_string(&scoped_keys)?;
264            let keys_jwk = URL_SAFE_NO_PAD.decode(keys_jwk)?;
265            let jwk = serde_json::from_slice(&keys_jwk)?;
266            Some(jwcrypto::encrypt_to_jwe(
267                scoped_keys.as_bytes(),
268                EncryptionParameters::ECDH_ES {
269                    enc: EncryptionAlgorithm::A256GCM,
270                    peer_jwk: &jwk,
271                },
272            )?)
273        } else {
274            None
275        };
276        let auth_request_params = AuthorizationRequestParameters {
277            client_id: auth_params.client_id,
278            scope: auth_params.scope.join(" "),
279            state: auth_params.state,
280            access_type: auth_params.access_type,
281            code_challenge: auth_params.code_challenge,
282            code_challenge_method: auth_params.code_challenge_method,
283            keys_jwe,
284        };
285
286        let resp = self.client.create_authorization_code_using_session_token(
287            self.state.config(),
288            &session_token,
289            auth_request_params,
290        )?;
291
292        Ok(resp.code)
293    }
294
295    fn oauth_flow(&mut self, mut url: Url, scopes: &[&str]) -> Result<String> {
296        self.clear_access_token_cache();
297        let state = util::random_base64_url_string(16)?;
298        let code_verifier = util::random_base64_url_string(43)?;
299        let code_challenge = digest::digest(&digest::SHA256, code_verifier.as_bytes())?;
300        let code_challenge = URL_SAFE_NO_PAD.encode(code_challenge);
301        let scoped_keys_flow = ScopedKeysFlow::with_random_key()?;
302        let jwk = scoped_keys_flow.get_public_key_jwk()?;
303        let jwk_json = serde_json::to_string(&jwk)?;
304        let keys_jwk = URL_SAFE_NO_PAD.encode(jwk_json);
305        url.query_pairs_mut()
306            .append_pair("client_id", &self.state.config().client_id)
307            .append_pair("scope", &scopes.join(" "))
308            .append_pair("state", &state)
309            .append_pair("code_challenge_method", "S256")
310            .append_pair("code_challenge", &code_challenge)
311            .append_pair("access_type", "offline")
312            .append_pair("keys_jwk", &keys_jwk);
313
314        if self.state.config().redirect_uri == OAUTH_WEBCHANNEL_REDIRECT {
315            url.query_pairs_mut()
316                .append_pair("context", "oauth_webchannel_v1");
317        } else {
318            url.query_pairs_mut()
319                .append_pair("redirect_uri", &self.state.config().redirect_uri);
320        }
321
322        self.state.begin_oauth_flow(
323            state,
324            OAuthFlow {
325                scoped_keys_flow: Some(scoped_keys_flow),
326                code_verifier,
327            },
328        );
329        Ok(url.to_string())
330    }
331
332    /// Complete an OAuth flow initiated in `begin_oauth_flow` or `begin_pairing_flow`.
333    /// The `code` and `state` parameters can be obtained by parsing out the
334    /// redirect URL after a successful login.
335    ///
336    /// **💾 This method alters the persisted account state.**
337    pub fn complete_oauth_flow(&mut self, code: &str, state: &str) -> Result<()> {
338        self.clear_access_token_cache();
339        let oauth_flow = match self.state.pop_oauth_flow(state) {
340            Some(oauth_flow) => oauth_flow,
341            None => return Err(Error::UnknownOAuthState),
342        };
343        let resp = self.client.create_refresh_token_using_authorization_code(
344            self.state.config(),
345            self.state.session_token(),
346            code,
347            &oauth_flow.code_verifier,
348        )?;
349        self.handle_oauth_response(resp, oauth_flow.scoped_keys_flow)
350    }
351
352    /// Cancel any in-progress oauth flows
353    pub fn cancel_existing_oauth_flows(&mut self) {
354        self.state.clear_oauth_flows();
355    }
356
357    pub(crate) fn handle_oauth_response(
358        &mut self,
359        resp: OAuthTokenResponse,
360        scoped_keys_flow: Option<ScopedKeysFlow>,
361    ) -> Result<()> {
362        let sync_scope_granted = resp.scope.split(' ').any(|s| s == scopes::OLD_SYNC);
363        let scoped_keys = match resp.keys_jwe {
364            Some(ref jwe) => {
365                let scoped_keys_flow = scoped_keys_flow.ok_or(Error::ApiClientError(
366                    "Got a JWE but have no JWK to decrypt it.",
367                ))?;
368                let decrypted_keys = scoped_keys_flow.decrypt_keys_jwe(jwe)?;
369                let scoped_keys: serde_json::Map<String, serde_json::Value> =
370                    serde_json::from_str(&decrypted_keys)?;
371                if sync_scope_granted && !scoped_keys.contains_key(scopes::OLD_SYNC) {
372                    error_support::report_error!(
373                        "fxaclient-scoped-key",
374                        "Sync scope granted, but no sync scoped key (scope granted: {}, key scopes: {})",
375                        resp.scope,
376                        scoped_keys.keys().map(|s| s.as_ref()).collect::<Vec<&str>>().join(", ")
377                    );
378                }
379                scoped_keys
380                    .into_iter()
381                    .map(|(scope, key)| Ok((scope, serde_json::from_value(key)?)))
382                    .collect::<Result<Vec<_>>>()?
383            }
384            None => {
385                if sync_scope_granted {
386                    error_support::report_error!(
387                        "fxaclient-scoped-key",
388                        "Sync scope granted, but keys_jwe is None"
389                    );
390                }
391                vec![]
392            }
393        };
394
395        // We are only interested in the refresh token at this time because we
396        // don't want to return an over-scoped access token.
397        // Let's be good citizens and destroy this access token.
398        if let Err(err) = self
399            .client
400            .destroy_access_token(self.state.config(), &resp.access_token)
401        {
402            warn!("Access token destruction failure: {:?}", err);
403        }
404        let old_refresh_token = self.state.refresh_token().cloned();
405        let new_refresh_token = resp
406            .refresh_token
407            .ok_or(Error::ApiClientError("No refresh token in response"))?;
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        // In order to keep 1 and only 1 refresh token alive per client instance,
421        // we also destroy the existing refresh token.
422        if let Some(ref refresh_token) = old_refresh_token {
423            if let Err(err) = self
424                .client
425                .destroy_refresh_token(self.state.config(), &refresh_token.token)
426            {
427                warn!("Refresh token destruction failure: {:?}", err);
428            }
429        }
430        if let Some(ref device_info) = old_device_info {
431            if let Err(err) = self.replace_device(
432                &device_info.display_name,
433                &device_info.device_type,
434                &device_info.push_subscription,
435                &device_info.available_commands,
436            ) {
437                warn!("Device information restoration failed: {:?}", err);
438            }
439        }
440        self.state.complete_oauth_flow(
441            scoped_keys,
442            RefreshToken::new(new_refresh_token, resp.scope),
443            resp.session_token,
444        );
445        Ok(())
446    }
447
448    /// Typically called during a password change flow.
449    /// Invalidates all tokens and fetches a new refresh token.
450    /// Because the old refresh token is not valid anymore, we can't do like `handle_oauth_response`
451    /// and re-create the device, so it is the responsibility of the caller to do so after we're
452    /// done.
453    ///
454    /// **💾 This method alters the persisted account state.**
455    pub fn handle_session_token_change(&mut self, session_token: &str) -> Result<()> {
456        let old_refresh_token = self.state.refresh_token().ok_or(Error::NoRefreshToken)?;
457        let scopes: Vec<&str> = old_refresh_token.scopes.iter().map(AsRef::as_ref).collect();
458        let resp = self.client.create_refresh_token_using_session_token(
459            self.state.config(),
460            session_token,
461            &scopes,
462        )?;
463        let new_refresh_token = resp
464            .refresh_token
465            .ok_or(Error::ApiClientError("No refresh token in response"))?;
466        self.state.update_tokens(
467            session_token.to_owned(),
468            RefreshToken {
469                token: new_refresh_token,
470                scopes: resp.scope.split(' ').map(ToString::to_string).collect(),
471            },
472        );
473        self.clear_devices_and_attached_clients_cache();
474        Ok(())
475    }
476
477    /// **💾 This method may alter the persisted account state.**
478    pub fn clear_access_token_cache(&mut self) {
479        self.state.clear_access_token_cache();
480    }
481}
482
483const AUTH_CIRCUIT_BREAKER_CAPACITY: u8 = 5;
484const AUTH_CIRCUIT_BREAKER_RENEWAL_RATE: f32 = 3.0 / 60.0 / 1000.0; // 3 tokens every minute.
485
486#[derive(Clone, Copy)]
487pub(crate) struct AuthCircuitBreaker {
488    rate_limiter: RateLimiter,
489}
490
491impl Default for AuthCircuitBreaker {
492    fn default() -> Self {
493        AuthCircuitBreaker {
494            rate_limiter: RateLimiter::new(
495                AUTH_CIRCUIT_BREAKER_CAPACITY,
496                AUTH_CIRCUIT_BREAKER_RENEWAL_RATE,
497            ),
498        }
499    }
500}
501
502impl AuthCircuitBreaker {
503    pub(crate) fn check(&mut self) -> Result<()> {
504        if !self.rate_limiter.check() {
505            return Err(Error::AuthCircuitBreakerError);
506        }
507        Ok(())
508    }
509}
510
511impl TryFrom<Url> for AuthorizationParameters {
512    type Error = Error;
513
514    fn try_from(url: Url) -> Result<Self> {
515        let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();
516        let scope = query_map
517            .get("scope")
518            .cloned()
519            .ok_or(Error::MissingUrlParameter("scope"))?;
520        let client_id = query_map
521            .get("client_id")
522            .cloned()
523            .ok_or(Error::MissingUrlParameter("client_id"))?;
524        let state = query_map
525            .get("state")
526            .cloned()
527            .ok_or(Error::MissingUrlParameter("state"))?;
528        let access_type = query_map
529            .get("access_type")
530            .cloned()
531            .ok_or(Error::MissingUrlParameter("access_type"))?;
532        let code_challenge = query_map.get("code_challenge").cloned();
533        let code_challenge_method = query_map.get("code_challenge_method").cloned();
534        let keys_jwk = query_map.get("keys_jwk").cloned();
535        Ok(Self {
536            client_id,
537            scope: scope.split_whitespace().map(|s| s.to_string()).collect(),
538            state,
539            access_type,
540            code_challenge,
541            code_challenge_method,
542            keys_jwk,
543        })
544    }
545}
546
547#[derive(Clone, Serialize, Deserialize)]
548pub struct RefreshToken {
549    pub token: String,
550    pub scopes: HashSet<String>,
551}
552
553impl RefreshToken {
554    pub fn new(token: String, scopes: String) -> Self {
555        Self {
556            token,
557            scopes: scopes.split(' ').map(ToString::to_string).collect(),
558        }
559    }
560}
561
562impl std::fmt::Debug for RefreshToken {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        f.debug_struct("RefreshToken")
565            .field("scopes", &self.scopes)
566            .finish()
567    }
568}
569
570pub struct OAuthFlow {
571    pub scoped_keys_flow: Option<ScopedKeysFlow>,
572    pub code_verifier: String,
573}
574
575#[derive(Clone, Serialize, Deserialize)]
576pub struct AccessTokenInfo {
577    pub scope: String,
578    pub token: String,
579    pub key: Option<ScopedKey>,
580    pub expires_at: u64, // seconds since epoch
581}
582
583impl AccessTokenInfo {
584    pub fn check_missing_sync_scoped_key(&self) -> Result<()> {
585        if self.scope == scopes::OLD_SYNC && self.key.is_none() {
586            Err(Error::SyncScopedKeyMissingInServerResponse)
587        } else {
588            Ok(())
589        }
590    }
591}
592
593impl TryFrom<AccessTokenInfo> for crate::AccessTokenInfo {
594    type Error = Error;
595    fn try_from(info: AccessTokenInfo) -> Result<Self> {
596        Ok(crate::AccessTokenInfo {
597            scope: info.scope,
598            token: info.token,
599            key: info.key,
600            expires_at: info.expires_at.try_into()?,
601        })
602    }
603}
604
605impl std::fmt::Debug for AccessTokenInfo {
606    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
607        f.debug_struct("AccessTokenInfo")
608            .field("scope", &self.scope)
609            .field("key", &self.key)
610            .field("expires_at", &self.expires_at)
611            .finish()
612    }
613}
614
615impl From<IntrospectInfo> for crate::AuthorizationInfo {
616    fn from(r: IntrospectInfo) -> Self {
617        crate::AuthorizationInfo { active: r.active }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::super::{http_client::*, Config};
624    use super::*;
625    use mockall::predicate::always;
626    use mockall::predicate::eq;
627    use std::borrow::Cow;
628    use std::collections::HashMap;
629    use std::sync::Arc;
630
631    impl FirefoxAccount {
632        pub fn add_cached_token(&mut self, scope: &str, token_info: AccessTokenInfo) {
633            self.state.add_cached_access_token(scope, token_info);
634        }
635
636        pub fn set_session_token(&mut self, session_token: &str) {
637            self.state.set_session_token(session_token.to_owned());
638        }
639    }
640
641    #[test]
642    fn test_oauth_flow_url() {
643        nss::ensure_initialized();
644        let config = Config::new_with_mock_well_known_fxa_client_configuration(
645            "https://mock-fxa.example.com",
646            "12345678",
647            "https://foo.bar",
648        );
649        let mut fxa = FirefoxAccount::with_config(config);
650        let url = fxa
651            .begin_oauth_flow(&["profile"], "test_oauth_flow_url")
652            .unwrap();
653        let flow_url = Url::parse(&url).unwrap();
654
655        assert_eq!(flow_url.path(), "/authorization");
656
657        let mut pairs = flow_url.query_pairs();
658        assert_eq!(pairs.count(), 11);
659        assert_eq!(
660            pairs.next(),
661            Some((Cow::Borrowed("action"), Cow::Borrowed("email")))
662        );
663        assert_eq!(
664            pairs.next(),
665            Some((Cow::Borrowed("response_type"), Cow::Borrowed("code")))
666        );
667        assert_eq!(
668            pairs.next(),
669            Some((
670                Cow::Borrowed("entrypoint"),
671                Cow::Borrowed("test_oauth_flow_url")
672            ))
673        );
674        assert_eq!(
675            pairs.next(),
676            Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
677        );
678
679        assert_eq!(
680            pairs.next(),
681            Some((Cow::Borrowed("scope"), Cow::Borrowed("profile")))
682        );
683        let state_param = pairs.next().unwrap();
684        assert_eq!(state_param.0, Cow::Borrowed("state"));
685        assert_eq!(state_param.1.len(), 22);
686        assert_eq!(
687            pairs.next(),
688            Some((
689                Cow::Borrowed("code_challenge_method"),
690                Cow::Borrowed("S256")
691            ))
692        );
693        let code_challenge_param = pairs.next().unwrap();
694        assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
695        assert_eq!(code_challenge_param.1.len(), 43);
696        assert_eq!(
697            pairs.next(),
698            Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
699        );
700        let keys_jwk = pairs.next().unwrap();
701        assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
702        assert_eq!(keys_jwk.1.len(), 168);
703
704        assert_eq!(
705            pairs.next(),
706            Some((
707                Cow::Borrowed("redirect_uri"),
708                Cow::Borrowed("https://foo.bar")
709            ))
710        );
711    }
712
713    #[test]
714    fn test_force_auth_url() {
715        nss::ensure_initialized();
716        let config = Config::stable_dev("12345678", "https://foo.bar");
717        let mut fxa = FirefoxAccount::with_config(config);
718        let email = "test@example.com";
719        fxa.add_cached_profile("123", email);
720        let url = fxa
721            .begin_oauth_flow(&["profile"], "test_force_auth_url")
722            .unwrap();
723        let url = Url::parse(&url).unwrap();
724        assert_eq!(url.path(), "/oauth/force_auth");
725        let mut pairs = url.query_pairs();
726        assert_eq!(
727            pairs.find(|e| e.0 == "email"),
728            Some((Cow::Borrowed("email"), Cow::Borrowed(email),))
729        );
730    }
731
732    #[test]
733    fn test_webchannel_context_url() {
734        nss::ensure_initialized();
735        const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
736        let config = Config::new_with_mock_well_known_fxa_client_configuration(
737            "https://mock-fxa.example.com",
738            "12345678",
739            "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
740        );
741        let mut fxa = FirefoxAccount::with_config(config);
742        let url = fxa
743            .begin_oauth_flow(SCOPES, "test_webchannel_context_url")
744            .unwrap();
745        let url = Url::parse(&url).unwrap();
746        let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
747        let context = &query_params["context"];
748        assert_eq!(context, "oauth_webchannel_v1");
749        assert_eq!(query_params.get("redirect_uri"), None);
750    }
751
752    #[test]
753    fn test_webchannel_pairing_context_url() {
754        nss::ensure_initialized();
755        const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
756        const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
757
758        let config = Config::new(
759            "https://accounts.firefox.com",
760            "12345678",
761            "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
762        );
763        let mut fxa = FirefoxAccount::with_config(config);
764        let url = fxa
765            .begin_pairing_flow(PAIRING_URL, SCOPES, "test_webchannel_pairing_context_url")
766            .unwrap();
767        let url = Url::parse(&url).unwrap();
768        let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
769        let context = &query_params["context"];
770        assert_eq!(context, "oauth_webchannel_v1");
771        assert_eq!(query_params.get("redirect_uri"), None);
772    }
773
774    #[test]
775    fn test_pairing_flow_url() {
776        nss::ensure_initialized();
777        const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
778        const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
779        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";
780
781        let config = Config::new(
782            "https://accounts.firefox.com",
783            "12345678",
784            "https://foo.bar",
785        );
786
787        let mut fxa = FirefoxAccount::with_config(config);
788        let url = fxa
789            .begin_pairing_flow(PAIRING_URL, SCOPES, "test_pairing_flow_url")
790            .unwrap();
791        let flow_url = Url::parse(&url).unwrap();
792        let expected_parsed_url = Url::parse(EXPECTED_URL).unwrap();
793
794        assert_eq!(flow_url.host_str(), Some("accounts.firefox.com"));
795        assert_eq!(flow_url.path(), "/pair/supp");
796        assert_eq!(flow_url.fragment(), expected_parsed_url.fragment());
797
798        let mut pairs = flow_url.query_pairs();
799        assert_eq!(pairs.count(), 9);
800        assert_eq!(
801            pairs.next(),
802            Some((
803                Cow::Borrowed("entrypoint"),
804                Cow::Borrowed("test_pairing_flow_url")
805            ))
806        );
807        assert_eq!(
808            pairs.next(),
809            Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
810        );
811        assert_eq!(
812            pairs.next(),
813            Some((
814                Cow::Borrowed("scope"),
815                Cow::Borrowed("https://identity.mozilla.com/apps/oldsync")
816            ))
817        );
818
819        let state_param = pairs.next().unwrap();
820        assert_eq!(state_param.0, Cow::Borrowed("state"));
821        assert_eq!(state_param.1.len(), 22);
822        assert_eq!(
823            pairs.next(),
824            Some((
825                Cow::Borrowed("code_challenge_method"),
826                Cow::Borrowed("S256")
827            ))
828        );
829        let code_challenge_param = pairs.next().unwrap();
830        assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
831        assert_eq!(code_challenge_param.1.len(), 43);
832        assert_eq!(
833            pairs.next(),
834            Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
835        );
836        let keys_jwk = pairs.next().unwrap();
837        assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
838        assert_eq!(keys_jwk.1.len(), 168);
839
840        assert_eq!(
841            pairs.next(),
842            Some((
843                Cow::Borrowed("redirect_uri"),
844                Cow::Borrowed("https://foo.bar")
845            ))
846        );
847    }
848
849    #[test]
850    fn test_pairing_flow_origin_mismatch() {
851        nss::ensure_initialized();
852        static PAIRING_URL: &str = "https://bad.origin.com/pair#channel_id=foo&channel_key=bar";
853        let config = Config::stable_dev("12345678", "https://foo.bar");
854        let mut fxa = FirefoxAccount::with_config(config);
855        let url = fxa.begin_pairing_flow(
856            PAIRING_URL,
857            &["https://identity.mozilla.com/apps/oldsync"],
858            "test_pairiong_flow_origin_mismatch",
859        );
860
861        assert!(url.is_err());
862
863        match url {
864            Ok(_) => {
865                panic!("should have error");
866            }
867            Err(err) => match err {
868                Error::OriginMismatch { .. } => {}
869                _ => panic!("error not OriginMismatch"),
870            },
871        }
872    }
873
874    #[test]
875    fn test_check_authorization_status() {
876        nss::ensure_initialized();
877        let config = Config::stable_dev("12345678", "https://foo.bar");
878        let mut fxa = FirefoxAccount::with_config(config);
879
880        let refresh_token_scopes = std::collections::HashSet::new();
881        fxa.state.force_refresh_token(RefreshToken {
882            token: "refresh_token".to_owned(),
883            scopes: refresh_token_scopes,
884        });
885
886        let mut client = MockFxAClient::new();
887        client
888            .expect_check_refresh_token_status()
889            .with(always(), eq("refresh_token"))
890            .times(1)
891            .returning(|_, _| Ok(IntrospectResponse { active: true }));
892        fxa.set_client(Arc::new(client));
893
894        let auth_status = fxa.check_authorization_status().unwrap();
895        assert!(auth_status.active);
896    }
897
898    #[test]
899    fn test_check_authorization_status_circuit_breaker() {
900        nss::ensure_initialized();
901        let config = Config::stable_dev("12345678", "https://foo.bar");
902        let mut fxa = FirefoxAccount::with_config(config);
903
904        let refresh_token_scopes = std::collections::HashSet::new();
905        fxa.state.force_refresh_token(RefreshToken {
906            token: "refresh_token".to_owned(),
907            scopes: refresh_token_scopes,
908        });
909
910        let mut client = MockFxAClient::new();
911        // This copy-pasta (equivalent to `.returns(..).times(5)`) is there
912        // because `Error` is not cloneable :/
913        client
914            .expect_check_refresh_token_status()
915            .with(always(), eq("refresh_token"))
916            .returning(|_, _| Ok(IntrospectResponse { active: true }));
917        client
918            .expect_check_refresh_token_status()
919            .with(always(), eq("refresh_token"))
920            .returning(|_, _| Ok(IntrospectResponse { active: true }));
921        client
922            .expect_check_refresh_token_status()
923            .with(always(), eq("refresh_token"))
924            .returning(|_, _| Ok(IntrospectResponse { active: true }));
925        client
926            .expect_check_refresh_token_status()
927            .with(always(), eq("refresh_token"))
928            .returning(|_, _| Ok(IntrospectResponse { active: true }));
929        client
930            .expect_check_refresh_token_status()
931            .with(always(), eq("refresh_token"))
932            .returning(|_, _| Ok(IntrospectResponse { active: true }));
933        //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()
934        fxa.set_client(Arc::new(client));
935
936        for _ in 0..5 {
937            assert!(fxa.check_authorization_status().is_ok());
938        }
939        match fxa.check_authorization_status() {
940            Ok(_) => unreachable!("should not happen"),
941            Err(err) => assert!(matches!(err, Error::AuthCircuitBreakerError)),
942        }
943    }
944
945    use crate::internal::scopes::{self, OLD_SYNC};
946
947    #[test]
948    fn test_auth_code_pair_valid_not_allowed_scope() {
949        nss::ensure_initialized();
950        let config = Config::stable_dev("12345678", "https://foo.bar");
951        let mut fxa = FirefoxAccount::with_config(config);
952        fxa.set_session_token("session");
953        let mut client = MockFxAClient::new();
954        let not_allowed_scope = "https://identity.mozilla.com/apps/lockbox";
955        let expected_scopes = scopes::OLD_SYNC
956            .chars()
957            .chain(std::iter::once(' '))
958            .chain(not_allowed_scope.chars())
959            .collect::<String>();
960        client
961            .expect_get_scoped_key_data()
962            .with(always(), eq("session"), eq("12345678"), eq(expected_scopes))
963            .times(1)
964            .returning(|_, _, _, _| {
965                Err(Error::RemoteError {
966                    code: 400,
967                    errno: 163,
968                    error: "Invalid Scopes".to_string(),
969                    message: "Not allowed to request scopes".to_string(),
970                    info: "fyi, there was a server error".to_string(),
971                })
972            });
973        fxa.set_client(Arc::new(client));
974        let auth_params = AuthorizationParameters {
975            client_id: "12345678".to_string(),
976            scope: vec![scopes::OLD_SYNC.to_string(), not_allowed_scope.to_string()],
977            state: "somestate".to_string(),
978            access_type: "offline".to_string(),
979            code_challenge: None,
980            code_challenge_method: None,
981            keys_jwk: None,
982        };
983        let res = fxa.authorize_code_using_session_token(auth_params);
984        assert!(res.is_err());
985        let err = res.unwrap_err();
986        if let Error::RemoteError {
987            code,
988            errno,
989            error: _,
990            message: _,
991            info: _,
992        } = err
993        {
994            assert_eq!(code, 400);
995            assert_eq!(errno, 163); // Requested scopes not allowed
996        } else {
997            panic!("Should return an error from the server specifying that the requested scopes are not allowed");
998        }
999    }
1000
1001    #[test]
1002    fn test_auth_code_pair_invalid_scope_not_allowed() {
1003        nss::ensure_initialized();
1004        let config = Config::stable_dev("12345678", "https://foo.bar");
1005        let mut fxa = FirefoxAccount::with_config(config);
1006        fxa.set_session_token("session");
1007        let mut client = MockFxAClient::new();
1008        let invalid_scope = "IamAnInvalidScope";
1009        let expected_scopes = scopes::OLD_SYNC
1010            .chars()
1011            .chain(std::iter::once(' '))
1012            .chain(invalid_scope.chars())
1013            .collect::<String>();
1014        client
1015            .expect_get_scoped_key_data()
1016            .with(always(), eq("session"), eq("12345678"), eq(expected_scopes))
1017            .times(1)
1018            .returning(|_, _, _, _| {
1019                let mut server_ret = HashMap::new();
1020                server_ret.insert(
1021                    scopes::OLD_SYNC.to_string(),
1022                    ScopedKeyDataResponse {
1023                        key_rotation_secret: "IamASecret".to_string(),
1024                        key_rotation_timestamp: 100,
1025                        identifier: "".to_string(),
1026                    },
1027                );
1028                Ok(server_ret)
1029            });
1030        fxa.set_client(Arc::new(client));
1031
1032        let auth_params = AuthorizationParameters {
1033            client_id: "12345678".to_string(),
1034            scope: vec![scopes::OLD_SYNC.to_string(), invalid_scope.to_string()],
1035            state: "somestate".to_string(),
1036            access_type: "offline".to_string(),
1037            code_challenge: None,
1038            code_challenge_method: None,
1039            keys_jwk: None,
1040        };
1041        let res = fxa.authorize_code_using_session_token(auth_params);
1042        assert!(res.is_err());
1043        let err = res.unwrap_err();
1044        if let Error::ScopeNotAllowed(client_id, scope) = err {
1045            assert_eq!(client_id, "12345678");
1046            assert_eq!(scope, "IamAnInvalidScope");
1047        } else {
1048            panic!("Should return an error that specifies the scope that is not allowed");
1049        }
1050    }
1051
1052    #[test]
1053    fn test_auth_code_pair_scope_not_in_state() {
1054        nss::ensure_initialized();
1055        let config = Config::stable_dev("12345678", "https://foo.bar");
1056        let mut fxa = FirefoxAccount::with_config(config);
1057        fxa.set_session_token("session");
1058        let mut client = MockFxAClient::new();
1059        client
1060            .expect_get_scoped_key_data()
1061            .with(
1062                always(),
1063                eq("session"),
1064                eq("12345678"),
1065                eq(scopes::OLD_SYNC),
1066            )
1067            .times(1)
1068            .returning(|_, _, _, _| {
1069                let mut server_ret = HashMap::new();
1070                server_ret.insert(
1071                    scopes::OLD_SYNC.to_string(),
1072                    ScopedKeyDataResponse {
1073                        key_rotation_secret: "IamASecret".to_string(),
1074                        key_rotation_timestamp: 100,
1075                        identifier: "".to_string(),
1076                    },
1077                );
1078                Ok(server_ret)
1079            });
1080        fxa.set_client(Arc::new(client));
1081        let auth_params = AuthorizationParameters {
1082            client_id: "12345678".to_string(),
1083            scope: vec![scopes::OLD_SYNC.to_string()],
1084            state: "somestate".to_string(),
1085            access_type: "offline".to_string(),
1086            code_challenge: None,
1087            code_challenge_method: None,
1088            keys_jwk: Some("IAmAVerySecretKeysJWkInBase64".to_string()),
1089        };
1090        let res = fxa.authorize_code_using_session_token(auth_params);
1091        assert!(res.is_err());
1092        let err = res.unwrap_err();
1093        if let Error::NoScopedKey(scope) = err {
1094            assert_eq!(scope, scopes::OLD_SYNC.to_string());
1095        } else {
1096            panic!("Should return an error that specifies the scope that is not in the state");
1097        }
1098    }
1099
1100    #[test]
1101    fn test_set_user_data_sets_session_token() {
1102        nss::ensure_initialized();
1103        let config = Config::stable_dev("12345678", "https://foo.bar");
1104        let mut fxa = FirefoxAccount::with_config(config);
1105        let user_data = UserData {
1106            session_token: String::from("mock_session_token"),
1107            uid: String::from("mock_uid_unused"),
1108            email: String::from("mock_email_usued"),
1109            verified: true,
1110        };
1111        fxa.set_user_data(user_data);
1112        assert_eq!(fxa.get_session_token().unwrap(), "mock_session_token");
1113    }
1114
1115    #[test]
1116    fn test_oauth_request_sent_with_session_when_available() {
1117        nss::ensure_initialized();
1118        let config = Config::new_with_mock_well_known_fxa_client_configuration(
1119            "mock-fxa.example.com",
1120            "12345678",
1121            "https://foo.bar",
1122        );
1123        let mut fxa = FirefoxAccount::with_config(config);
1124        let url = fxa
1125            .begin_oauth_flow(&[OLD_SYNC, "profile"], "test_entrypoint")
1126            .unwrap();
1127        let url = Url::parse(&url).unwrap();
1128        let state = url.query_pairs().find(|(name, _)| name == "state").unwrap();
1129        let user_data = UserData {
1130            session_token: String::from("mock_session_token"),
1131            uid: String::from("mock_uid_unused"),
1132            email: String::from("mock_email_usued"),
1133            verified: true,
1134        };
1135        let mut client = MockFxAClient::new();
1136
1137        client
1138            .expect_create_refresh_token_using_authorization_code()
1139            .withf(|_, session_token, code, _| {
1140                matches!(session_token, Some("mock_session_token")) && code == "mock_code"
1141            })
1142            .times(1)
1143            .returning(|_, _, _, _| {
1144                Ok(OAuthTokenResponse {
1145                    keys_jwe: None,
1146                    refresh_token: Some("refresh_token".to_string()),
1147                    session_token: None,
1148                    expires_in: 1,
1149                    scope: "profile".to_string(),
1150                    access_token: "access_token".to_string(),
1151                })
1152            });
1153        client
1154            .expect_destroy_access_token()
1155            .with(always(), always())
1156            .times(1)
1157            .returning(|_, _| Ok(()));
1158        fxa.set_client(Arc::new(client));
1159
1160        fxa.set_user_data(user_data);
1161
1162        fxa.complete_oauth_flow("mock_code", state.1.as_ref())
1163            .unwrap();
1164    }
1165}