Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
8 changes: 8 additions & 0 deletions components/fxa-client/src/fxa_client.udl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions components/fxa-client/src/internal/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this an accurate expectation? In the persisted state, we have an array of individual scopes, so a space-delimited list of scopes wouldn't work there.

Image

Our access tokens are based on the grouped scopes, so that tracks if we were looking at that.

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();
Expand Down
10 changes: 10 additions & 0 deletions components/fxa-client/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 3 additions & 20 deletions examples/cli-support/src/fxa_creds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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:?})");
Expand Down