diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt index 31265a4b67..6b52fc1e23 100644 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt @@ -267,6 +267,15 @@ class FxaClient(inner: FirefoxAccount, persistCallback: PersistCallback?) : Auto } } + /** + * Check whether the account has already been granted every given OAuth scope(s). + * + * @param scope space-separated list of OAuth scopes. Order is not significant. + */ + fun hasScope(scope: String): Boolean { + return this.inner.hasScope(scope) + } + fun checkAuthorizationStatus(): AuthorizationInfo { return this.inner.checkAuthorizationStatus() } diff --git a/components/fxa-client/src/fxa_client.udl b/components/fxa-client/src/fxa_client.udl index 115907ea6d..1f3b45483b 100644 --- a/components/fxa-client/src/fxa_client.udl +++ b/components/fxa-client/src/fxa_client.udl @@ -597,6 +597,14 @@ interface FirefoxAccount { [Throws=FxaError] AccessTokenInfo get_access_token([ByRef] string scope, optional boolean use_cache = true); + /// Check whether the account has already been granted the given OAuth scope(s). + /// + /// This checks whether the refresh token has *every* specified scope. + /// + /// # Arguments + /// - `scope` - space-separated list of OAuth scopes. Order is not significant. + boolean has_scope([ByRef] string scope); + /// Create a new OAuth authorization code using the stored session token. /// /// When a signed-in application receives an incoming device pairing request, it can diff --git a/components/fxa-client/src/internal/oauth.rs b/components/fxa-client/src/internal/oauth.rs index 8cf2aa913e..64460a9e02 100644 --- a/components/fxa-client/src/internal/oauth.rs +++ b/components/fxa-client/src/internal/oauth.rs @@ -26,6 +26,18 @@ use url::Url; pub const OAUTH_WEBCHANNEL_REDIRECT: &str = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel"; impl FirefoxAccount { + /// Check whether every requested scope has been granted to the account's refresh token. + pub fn has_scope(&self, scope: &str) -> bool { + let mut requested = scope.split_ascii_whitespace().peekable(); + if requested.peek().is_none() { + return false; + } + match self.state.refresh_token() { + Some(refresh_token) => requested.all(|s| refresh_token.scopes.contains(s)), + None => false, + } + } + /// Extracts and stores the session token from a WebChannel login JSON payload. /// The JSON payload is the `data` object from the `fxaccounts:login` WebChannel command. pub fn handle_web_channel_login(&mut self, json_payload: &str) -> Result<()> { @@ -651,6 +663,24 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; + #[test] + fn test_has_scope() { + nss_as::ensure_initialized(); + let mut fxa = + FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar")); + // No refresh token -> false. + assert!(!fxa.has_scope("profile")); + fxa.state.force_refresh_token(RefreshToken { + token: "rt".to_owned(), + scopes: ["profile", "sync"].iter().map(|s| s.to_string()).collect(), + }); + assert!(fxa.has_scope("profile")); + assert!(fxa.has_scope("sync profile")); + assert!(fxa.has_scope("profile sync ")); // trailing whitespace too + assert!(!fxa.has_scope("sync unknown")); // one missing -> false + assert!(!fxa.has_scope("")); // empty -> false + } + #[test] fn test_oauth_flow_url() { nss_as::ensure_initialized(); diff --git a/components/fxa-client/src/token.rs b/components/fxa-client/src/token.rs index e84f2152f8..d06e5df0a6 100644 --- a/components/fxa-client/src/token.rs +++ b/components/fxa-client/src/token.rs @@ -57,6 +57,16 @@ impl FirefoxAccount { .try_into() } + /// Check whether the account has already been granted the given OAuth scope(s). + /// + /// This checks whether the refresh token has *every* specified scope. + /// + /// # Arguments + /// - `scope` - space-separated list of OAuth scopes. Order is not significant. + pub fn has_scope(&self, scope: &str) -> bool { + self.internal.lock().has_scope(scope) + } + /// Builds a complete `signedInUser` JSON object for a WebChannel `fxaccounts:fxa_status` /// response, embedding the session token without exposing it to the browser layer. Email and /// uid are read from the cached profile in internal state. Returns `None` if no session token diff --git a/examples/cli-support/src/fxa_creds.rs b/examples/cli-support/src/fxa_creds.rs index 7bfcccc26f..287096ad6b 100644 --- a/examples/cli-support/src/fxa_creds.rs +++ b/examples/cli-support/src/fxa_creds.rs @@ -8,9 +8,7 @@ use std::{collections::HashMap, fs, io::Write}; use anyhow::Result; use url::Url; -use fxa_client::{ - DeviceConfig, DeviceType, FirefoxAccount, FxaConfig, FxaError, FxaEvent, FxaState, -}; +use fxa_client::{DeviceConfig, DeviceType, FirefoxAccount, FxaConfig, FxaEvent, FxaState}; use sync15::{client::Sync15StorageClientInit, KeyBundle}; use crate::{prompt::prompt_string, workspace_root_dir}; @@ -133,26 +131,11 @@ impl CliFxa { match state { FxaState::Connected => { - crate::info!("FxA: already connected - checking if we have all the scopes."); - let mut have_all_scopes = true; - for scope in scopes { - match account.get_access_token(scope, true) { - Ok(_) => crate::debug!("Do already have the {scope:?} scope"), - Err(FxaError::Forbidden) => { - crate::info!("Don't have the {scope:?} scope, re-authenticating"); - have_all_scopes = false; - break; - } - Err(e) => { - crate::error!("Error checking for the {scope:?} scope: {e}"); - return Err(e.into()); - } - } - } + let have_all_scopes = account.has_scope(&scopes.join(" ")); + crate::info!("FxA: already connected, all scopes is {have_all_scopes}"); if !have_all_scopes { self.handle_oauth_flow(service, scopes)?; } - self.persist()?; } FxaState::Disconnected | FxaState::AuthIssues => { crate::info!("FxA: need to authenticate (state was {state:?})");