Skip to content
Draft
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 @@ -160,6 +160,30 @@ class FxaClient(inner: FirefoxAccount, persistCallback: PersistCallback?) : Auto
this.tryPersistState()
}

/**
* Constructs a URL used to begin an OAuth scope authorization flow for an already-connected
* account, to authorize additional scopes without risking disconnection.
*
* This performs network requests, and should not be used on the main thread.
*
* @param scopes List of OAuth scopes for which the client wants access
* @param entrypoint to be used for metrics
* @return String URL to present to the user for authorization
*/
fun beginOAuthScopeAuthorizationFlow(scopes: Array<String>, entrypoint: String): String {
return this.inner.beginOauthScopeAuthorizationFlow(scopes.toList(), entrypoint)
}

/**
* Completes an OAuth scope authorization flow initiated by [beginOAuthScopeAuthorizationFlow].
*
* This performs network requests, and should not be used on the main thread.
*/
fun completeOAuthScopeAuthorizationFlow(code: String, state: String) {
this.inner.completeOauthScopeAuthorizationFlow(code, state)
this.tryPersistState()
}

/**
* Fetches the profile object for the current client either from the existing cached account,
* or from the server (requires the client to have access to the profile scope).
Expand Down
80 changes: 78 additions & 2 deletions components/fxa-client/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,59 @@ impl FirefoxAccount {
self.internal.lock().complete_oauth_flow(code, state)
}

/// Initiate an OAuth scope authorization flow for an already-connected account.
///
/// Call this when the application needs to request additional scopes from a user who is
/// already signed in. It returns a URL at which the user may authorize the additional
/// scopes. The application should direct the user to that URL.
///
/// Unlike [`begin_oauth_flow`](FirefoxAccount::begin_oauth_flow), if this flow fails or
/// is cancelled the user remains connected — they are not signed out.
///
/// When the resulting flow redirects back to the configured `redirect_uri`, the query
/// parameters should be extracted from the URL and passed to
/// [`complete_oauth_scope_authorization_flow`](FirefoxAccount::complete_oauth_scope_authorization_flow)
/// to finalize the authorization.
///
/// # Arguments
///
/// - `scopes` - list of additional OAuth scopes to request.
/// - `entrypoint` - metrics identifier for the UX entrypoint.
#[handle_error(Error)]
pub fn begin_oauth_scope_authorization_flow<T: AsRef<str>>(
&self,
scopes: &[T],
entrypoint: &str,
) -> ApiResult<String> {
let scopes = scopes.iter().map(T::as_ref).collect::<Vec<_>>();
self.internal
.lock()
.begin_oauth_scope_authorization_flow(&scopes, entrypoint)
}

/// Complete an OAuth scope authorization flow.
///
/// **💾 This method alters the persisted account state.**
///
/// At the conclusion of a scope authorization flow, the user will be redirected to the
/// application's registered `redirect_uri`. It should extract the `code` and `state`
/// parameters from the resulting URL and pass them to this method.
///
/// # Arguments
///
/// - `code` - the OAuth authorization code obtained from the redirect URI.
/// - `state` - the OAuth state parameter obtained from the redirect URI.
#[handle_error(Error)]
pub fn complete_oauth_scope_authorization_flow(
&self,
code: &str,
state: &str,
) -> ApiResult<()> {
self.internal
.lock()
.complete_oauth_scope_authorization_flow(code, state)
}

/// Check authorization status for this application.
///
/// **💾 This method alters the persisted account state.**
Expand Down Expand Up @@ -236,8 +289,10 @@ pub enum FxaState {
Uninitialized,
/// User has not connected to FxA or has logged out
Disconnected,
/// User is currently performing an OAuth flow
/// User is disconnected and currently performing an initial OAuth flow to authenticate this device.
Authenticating { oauth_url: String },
/// User is connected and currently authorizing additional scopes.
Authorizing { oauth_url: String },
/// User is currently connected to FxA
Connected,
/// User was connected to FxA, but we observed issues with the auth tokens.
Expand Down Expand Up @@ -290,8 +345,29 @@ pub enum FxaEvent {
/// Use this to cancel an in-progress OAuth, returning to [FxaState::Disconnected] so the
/// process can begin again.
///
/// This event is valid for the `Authenticating` state.
/// This event is valid for the `Authenticating` and `Authorizing` states.
CancelOAuthFlow,
/// Begin an OAuth scope authorization flow for an already connected account.
///
/// If successful, the state machine will transition to [FxaState::Authorizing]. The next
/// step is to navigate the user to the `oauth_url` and let them authorize the additional scopes.
///
/// On failure or cancellation, the state machine returns to [FxaState::Connected].
///
/// This event is valid for the `Connected` and `Authorizing` states. If the state machine
/// is in the `Authorizing` state, then this will forget the current flow and start a new one.
BeginOAuthScopeAuthorizationFlow {
scopes: Vec<String>,
entrypoint: String,
},
/// Complete an OAuth scope authorization flow.
///
/// Send this event after the user has navigated through the scope authorization flow and has
/// reached the redirect URI. Extract `code` and `state` from the query parameters or web
/// channel. The state machine will transition to [FxaState::Connected].
///
/// This event is valid for the `Authorizing` state.
CompleteOAuthScopeAuthorizationFlow { code: String, state: String },
/// Check the authorization status for a connected account.
///
/// Send this when issues are detected with the auth tokens for a connected account. It will
Expand Down
38 changes: 37 additions & 1 deletion components/fxa-client/src/fxa_client.udl
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,40 @@ interface FirefoxAccount {
///
[Throws=FxaError]
void complete_oauth_flow([ByRef] string code, [ByRef] string state );


/// Initiate an OAuth scope authorization flow for an already-connected account.
///
/// Call this when the application needs to request additional scopes from a user who is
/// already signed in. It returns a URL at which the user may authorize the additional
/// scopes. The application should direct the user to that URL.
///
/// Unlike `begin_oauth_flow`, if this flow fails or is cancelled the user remains
/// connected — they are not signed out.
///
/// When the resulting flow redirects back to the configured `redirect_uri`, the query
/// parameters should be extracted and passed to `complete_oauth_scope_authorization_flow`.
///
/// # Arguments
///
/// - `scopes` - list of additional OAuth scopes to request.
/// - `entrypoint` - metrics identifier for the UX entrypoint.
[Throws=FxaError]
string begin_oauth_scope_authorization_flow([ByRef] sequence<string> scopes, [ByRef] string entrypoint);

/// Complete an OAuth scope authorization flow.
///
/// **💾 This method alters the persisted account state.**
///
/// At the conclusion of a scope authorization flow, the user will be redirected to the
/// application's registered `redirect_uri`. It should extract the `code` and `state`
/// parameters from the resulting URL and pass them to this method.
///
/// # Arguments
///
/// - `code` - the OAuth authorization code obtained from the redirect URI.
/// - `state` - the OAuth state parameter obtained from the redirect URI.
[Throws=FxaError]
void complete_oauth_scope_authorization_flow([ByRef] string code, [ByRef] string state);

/// Check authorization status for this application.
///
Expand Down Expand Up @@ -998,6 +1031,7 @@ interface FxaState {
Uninitialized();
Disconnected();
Authenticating(string oauth_url);
Authorizing(string oauth_url);
Connected();
AuthIssues();
};
Expand All @@ -1009,6 +1043,8 @@ interface FxaEvent {
BeginPairingFlow(string pairing_url, sequence<string> scopes, string entrypoint);
CompleteOAuthFlow(string code, string state);
CancelOAuthFlow();
BeginOAuthScopeAuthorizationFlow(sequence<string> scopes, string entrypoint);
CompleteOAuthScopeAuthorizationFlow(string code, string state);
CheckAuthorizationStatus();
Disconnect();
CallGetProfile();
Expand Down
59 changes: 45 additions & 14 deletions components/fxa-client/src/internal/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::{
util, FirefoxAccount,
};
use crate::auth::UserData;
use crate::{warn, AuthorizationParameters, Error, FxaServer, Result, ScopedKey};
use crate::{debug, info, warn, AuthorizationParameters, Error, FxaServer, Result, ScopedKey};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use jwcrypto::{EncryptionAlgorithm, EncryptionParameters};
use rate_limiter::RateLimiter;
Expand Down Expand Up @@ -172,19 +172,15 @@ impl FirefoxAccount {
self.oauth_flow(url, scopes)
}

/// Initiate an OAuth login flow and return a URL that should be navigated to.
///
/// * `scopes` - Space-separated list of requested scopes.
/// * `entrypoint` - The entrypoint to be used for metrics
/// * `metrics` - Optional metrics parameters
pub fn begin_oauth_flow(&mut self, scopes: &[&str], entrypoint: &str) -> Result<String> {
self.state.on_begin_oauth();
let mut url = if self.state.last_seen_profile().is_some() {
self.state.config().oauth_force_auth_url()?
} else {
self.state.config().authorization_endpoint()?
};

// We support oauth flows for an initial signin, and also to authorize additional scopes.
// This is code common between the 2.
fn begin_general_oauth_flow(
&mut self,
mut url: Url,
scopes: &[&str],
entrypoint: &str,
) -> Result<String> {
info!("starting oauth flow via {url} for scopes={scopes:?}, entrypoint={entrypoint:?}");
url.query_pairs_mut()
.append_pair("action", "email")
.append_pair("response_type", "code")
Expand All @@ -209,9 +205,44 @@ impl FirefoxAccount {
None => scopes.iter().map(ToString::to_string).collect(),
};
let scopes: Vec<&str> = scopes.iter().map(<_>::as_ref).collect();
debug!("oauth flow final set of requested scopes now {scopes:?}");
self.oauth_flow(url, &scopes)
}

/// Initiate an OAuth login flow and return a URL that should be navigated to.
///
/// * `scopes` - Space-separated list of requested scopes.
/// * `entrypoint` - The entrypoint to be used for metrics
/// * `metrics` - Optional metrics parameters
pub fn begin_oauth_flow(&mut self, scopes: &[&str], entrypoint: &str) -> Result<String> {
self.state.on_begin_oauth();
let url = if self.state.last_seen_profile().is_some() {
self.state.config().oauth_force_auth_url()?
} else {
self.state.config().authorization_endpoint()?
};
self.begin_general_oauth_flow(url, scopes, entrypoint)
}

pub fn begin_oauth_scope_authorization_flow(
&mut self,
scopes: &[&str],
entrypoint: &str,
) -> Result<String> {
// We do not want to kill our refresh token or access tokens in this flow.
let url = self.state.config().authorization_endpoint()?;
self.begin_general_oauth_flow(url, scopes, entrypoint)
}

pub fn complete_oauth_scope_authorization_flow(
&mut self,
code: &str,
state: &str,
) -> Result<()> {
// `complete_oauth_flow` already handles everything we need.
self.complete_oauth_flow(code, state)
}

/// Fetch an OAuth code for a particular client using a session token from the account state.
///
/// * `auth_params` Authorization parameters which includes:
Expand Down
9 changes: 9 additions & 0 deletions components/fxa-client/src/state_machine/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ impl fmt::Display for FxaState {
Self::Uninitialized => "Uninitialized",
Self::Disconnected => "Disconnected",
Self::Authenticating { .. } => "Athenticating",
Self::Authorizing { .. } => "Athorizing",
Self::Connected => "Connected",
Self::AuthIssues => "AthIssues",
};
Expand All @@ -34,6 +35,8 @@ impl fmt::Display for FxaEvent {
Self::BeginPairingFlow { .. } => "BeginPairingFlow",
Self::CompleteOAuthFlow { .. } => "CompleteOAthFlow",
Self::CancelOAuthFlow => "CancelOAthFlow",
Self::BeginOAuthScopeAuthorizationFlow { .. } => "BeginOAthScopeAthorizationFlow",
Self::CompleteOAuthScopeAuthorizationFlow { .. } => "CompleteOAthScopeAthorizationFlow",
Self::CheckAuthorizationStatus => "CheckAuthorizationStatus",
Self::Disconnect => "Disconnect",
Self::CallGetProfile => "CallGetProfile",
Expand All @@ -49,6 +52,12 @@ impl fmt::Display for internal_machines::State {
Self::BeginOAuthFlow { .. } => write!(f, "BeginOAthFlow"),
Self::BeginPairingFlow { .. } => write!(f, "BeginPairingFlow"),
Self::CompleteOAuthFlow { .. } => write!(f, "CompleteOAthFlow"),
Self::BeginOAuthScopeAuthorizationFlow { .. } => {
write!(f, "BeginOAthScopeAthorizationFlow")
}
Self::CompleteOAuthScopeAuthorizationFlow { .. } => {
write!(f, "CompleteOAthScopeAthorizationFlow")
}
Self::InitializeDevice => write!(f, "InitializeDevice"),
Self::EnsureDeviceCapabilities => write!(f, "EnsureDeviceCapabilities"),
Self::CheckAuthorizationStatus => write!(f, "CheckAuthorizationStatus"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ mod test {
);
}

#[test]
fn test_begin_oauth_scope_authorization_flow_is_invalid() {
// Scope authorization requires an already-connected account; it is not valid from
// the Authenticating state where the user has not yet completed sign-in.
let result =
AuthenticatingStateMachine.initial_state(FxaEvent::BeginOAuthScopeAuthorizationFlow {
scopes: vec!["profile".to_owned()],
entrypoint: "test-entrypoint".to_owned(),
});
assert!(result.is_err());
}

/// Same as `test_begin_oauth_flow`, but for a paring flow
#[test]
fn test_begin_pairing_flow() {
Expand Down
Loading