Revision control
Copy as Markdown
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
//! FxA state machine transition function.
//!
//! Each `match` arm reads top-to-bottom as imperative Rust. Use
//! `.to_state_machine_err(|| target)?` to attach the landing state on failure.
use crate::{Error, FxaError, FxaEvent, FxaRustAuthState, FxaState};
use error_support::{convert_log_report_error, GetErrorHandling};
use super::helpers::{ResultExt, RetryingAccount, StateMachineErr};
/// Transition the FSM from `from` given `event`.
pub fn transition(
account: &mut RetryingAccount<'_>,
from: FxaState,
event: FxaEvent,
) -> std::result::Result<FxaState, StateMachineErr> {
use FxaState as S;
match (from, event) {
// ── From Uninitialized ──────────────────────────────────────────
(S::Uninitialized, FxaEvent::Initialize { device_config }) => {
match account.get_auth_state() {
FxaRustAuthState::Disconnected => Ok(S::Disconnected),
FxaRustAuthState::AuthIssues => Ok(S::AuthIssues),
FxaRustAuthState::Connected => {
// Auth errors from ensure_capabilities recover via CheckAuthorizationStatus
// rather than bailing to Disconnected.
// FIXME: should re-run ensure_capabilities after recovery succeeds.
match account.ensure_capabilities(&device_config.capabilities) {
Ok(_) => Ok(S::Connected),
Err(e) if is_auth_error(&e) => {
// Report inline since we don't propagate this error to the driver.
let _: FxaError = convert_log_report_error(e);
let active = account
.check_authorization_status()
.to_state_machine_err(|| S::AuthIssues)?;
Ok(if active { S::Connected } else { S::AuthIssues })
}
Err(cause) => Err(StateMachineErr::new(cause, S::Disconnected)),
}
}
}
}
// ── From Disconnected ───────────────────────────────────────────
(
S::Disconnected,
FxaEvent::BeginOAuthFlow {
service,
scopes,
entrypoint,
},
) => {
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let oauth_url = account
.begin_oauth_flow(&service, &scope_refs, &entrypoint)
.to_state_machine_err(|| S::Disconnected)?;
Ok(S::Authenticating {
oauth_url,
initial_state: FxaRustAuthState::Disconnected,
})
}
(
S::Disconnected,
FxaEvent::BeginPairingFlow {
pairing_url,
service,
scopes,
entrypoint,
},
) => {
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let oauth_url = account
.begin_pairing_flow(&pairing_url, &service, &scope_refs, &entrypoint)
.to_state_machine_err(|| S::Disconnected)?;
Ok(S::Authenticating {
oauth_url,
initial_state: FxaRustAuthState::Disconnected,
})
}
// ── From Authenticating ─────────────────────────────────────────
(S::Authenticating { initial_state, .. }, FxaEvent::CompleteOAuthFlow { code, state }) => {
account
.complete_oauth_flow(&code, &state)
.to_state_machine_err(|| initial_state.into())?;
// Initial state was Connected: device is already initialized, skip the call.
if !matches!(initial_state, FxaRustAuthState::Connected) {
let dc = account.device_config().clone();
account
.initialize_device(&dc.name, dc.device_type, &dc.capabilities)
.to_state_machine_err(|| FxaState::Disconnected)?;
}
Ok(S::Connected)
}
(S::Authenticating { initial_state, .. }, FxaEvent::CancelOAuthFlow) => {
Ok(initial_state.into())
}
(S::Authenticating { .. }, FxaEvent::Disconnect) => {
account.disconnect();
Ok(S::Disconnected)
}
(
S::Authenticating { initial_state, .. },
FxaEvent::BeginOAuthFlow {
service,
scopes,
entrypoint,
},
) => {
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let oauth_url = account
.begin_oauth_flow(&service, &scope_refs, &entrypoint)
.to_state_machine_err(|| initial_state.into())?;
Ok(S::Authenticating {
oauth_url,
initial_state,
})
}
(
S::Authenticating { initial_state, .. },
FxaEvent::BeginPairingFlow {
pairing_url,
service,
scopes,
entrypoint,
},
) => {
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let oauth_url = account
.begin_pairing_flow(&pairing_url, &service, &scope_refs, &entrypoint)
.to_state_machine_err(|| initial_state.into())?;
Ok(S::Authenticating {
oauth_url,
initial_state,
})
}
// A WebChannel password change while an OAuth flow is in progress
// is a no-op; let the flow finish. Should be rare in practice.
(s @ S::Authenticating { .. }, FxaEvent::WebChannelPasswordChange { .. }) => {
crate::warn!("WebChannel password change received while Authenticating; ignoring");
Ok(s)
}
// ── From Connected ──────────────────────────────────────────────
(S::Connected, FxaEvent::Disconnect) => {
account.disconnect();
Ok(S::Disconnected)
}
(S::Connected, FxaEvent::CheckAuthorizationStatus) => {
let active = account
.check_authorization_status()
.to_state_machine_err(|| S::AuthIssues)?;
Ok(if active { S::Connected } else { S::AuthIssues })
}
(S::Connected, FxaEvent::CallGetProfile) => {
account
.get_profile()
.to_state_machine_err(|| S::AuthIssues)?;
Ok(S::Connected)
}
(
S::Connected,
FxaEvent::BeginOAuthFlow {
service,
scopes,
entrypoint,
},
) => {
// OAuth flow from a connected user (e.g. authorizing additional scopes).
// Lands back at Connected after CompleteOAuthFlow since the device is
// already initialized.
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let oauth_url = account
.begin_oauth_flow(&service, &scope_refs, &entrypoint)
.to_state_machine_err(|| S::Connected)?;
Ok(S::Authenticating {
oauth_url,
initial_state: FxaRustAuthState::Connected,
})
}
(S::Connected, FxaEvent::WebChannelPasswordChange { json_payload }) => {
// The inner call swaps the session token for a new refresh token and re-registers
// the device record (push subscription, commands, etc) against the new token.
account
.handle_web_channel_password_change(&json_payload)
.to_state_machine_err(|| S::AuthIssues)?;
Ok(S::Connected)
}
// ── From AuthIssues ─────────────────────────────────────────────
(
S::AuthIssues,
FxaEvent::BeginOAuthFlow {
service,
scopes,
entrypoint,
},
) => {
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let oauth_url = account
.begin_oauth_flow(&service, &scope_refs, &entrypoint)
.to_state_machine_err(|| S::AuthIssues)?;
Ok(S::Authenticating {
oauth_url,
initial_state: FxaRustAuthState::AuthIssues,
})
}
(S::AuthIssues, FxaEvent::Disconnect) => {
account.disconnect();
Ok(S::Disconnected)
}
(S::AuthIssues, FxaEvent::WebChannelPasswordChange { json_payload }) => {
// A concurrent sync/401 may have pushed us here before the webchannel ran. The new
// session token recovers us; device re-registration will be handled inside the inner call.
account
.handle_web_channel_password_change(&json_payload)
.to_state_machine_err(|| S::AuthIssues)?;
Ok(S::Connected)
}
// ── Invalid (state, event) pair ─────────────────────────────────
(state, event) => Err(StateMachineErr::Fatal(Box::new(
Error::InvalidStateTransition(format!("{state} -> {event}")),
))),
}
}
fn is_auth_error(e: &Error) -> bool {
matches!(e.get_error_handling().err, FxaError::Authentication)
}
#[cfg(test)]
mod tests {
//! Tests for the I/O-free transition arms (cancel-oauth, invalid combos).
use super::*;
use crate::{DeviceConfig, DeviceType};
fn mock_account() -> crate::internal::FirefoxAccount {
use crate::internal::config::Config;
crate::internal::FirefoxAccount::with_config(
Config::new_with_mock_well_known_fxa_client_configuration(
"12345678",
),
)
}
fn authenticating_from(initial_state: FxaRustAuthState) -> FxaState {
FxaState::Authenticating {
initial_state,
}
}
#[test]
fn cancel_oauth_returns_to_initial_state() {
nss_as::ensure_initialized();
for (initial, expected) in [
(FxaRustAuthState::Disconnected, FxaState::Disconnected),
(FxaRustAuthState::AuthIssues, FxaState::AuthIssues),
(FxaRustAuthState::Connected, FxaState::Connected),
] {
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let result = transition(
&mut wrapper,
authenticating_from(initial),
FxaEvent::CancelOAuthFlow,
);
assert_eq!(result.unwrap(), expected);
}
}
fn assert_fatal_invalid_transition(result: std::result::Result<FxaState, StateMachineErr>) {
match result {
Err(StateMachineErr::Fatal(cause)) => {
assert!(matches!(*cause, Error::InvalidStateTransition(_)));
}
Err(StateMachineErr::Handled { .. }) => {
panic!("expected Fatal(InvalidStateTransition), got Handled")
}
Ok(s) => panic!("expected InvalidStateTransition, got Ok({s:?})"),
}
}
#[test]
fn invalid_state_event_pair_returns_fatal_invalid_state_transition() {
nss_as::ensure_initialized();
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let device_config = DeviceConfig {
name: "test-device".to_owned(),
device_type: DeviceType::Mobile,
capabilities: vec![],
};
let result = transition(
&mut wrapper,
FxaState::Connected,
FxaEvent::Initialize { device_config },
);
assert_fatal_invalid_transition(result);
}
#[test]
fn uninitialized_disconnect_invalid() {
nss_as::ensure_initialized();
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let result = transition(&mut wrapper, FxaState::Uninitialized, FxaEvent::Disconnect);
assert_fatal_invalid_transition(result);
}
#[test]
fn disconnected_invalid_event_returns_fatal_invalid_state_transition() {
nss_as::ensure_initialized();
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let result = transition(&mut wrapper, FxaState::Disconnected, FxaEvent::Disconnect);
assert_fatal_invalid_transition(result);
}
fn assert_handled_lands_at(
result: std::result::Result<FxaState, StateMachineErr>,
expected: FxaState,
) {
match result {
Err(StateMachineErr::Handled { target, .. }) => assert_eq!(target, expected),
Err(StateMachineErr::Fatal(cause)) => panic!("expected Handled, got Fatal({cause:?})"),
Ok(s) => panic!("expected Handled, got Ok({s:?})"),
}
}
#[test]
fn connected_web_channel_password_change_is_valid_transition() {
nss_as::ensure_initialized();
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let result = transition(
&mut wrapper,
FxaState::Connected,
FxaEvent::WebChannelPasswordChange {
json_payload: "{}".to_owned(),
},
);
assert_handled_lands_at(result, FxaState::AuthIssues);
}
#[test]
fn auth_issues_web_channel_password_change_is_valid_transition() {
nss_as::ensure_initialized();
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let result = transition(
&mut wrapper,
FxaState::AuthIssues,
FxaEvent::WebChannelPasswordChange {
json_payload: "{}".to_owned(),
},
);
assert_handled_lands_at(result, FxaState::AuthIssues);
}
#[test]
fn authenticating_web_channel_password_change_stays_in_authenticating() {
nss_as::ensure_initialized();
let mut account = mock_account();
let mut wrapper = RetryingAccount::new(&mut account);
let from = authenticating_from(FxaRustAuthState::Connected);
let result = transition(
&mut wrapper,
from.clone(),
FxaEvent::WebChannelPasswordChange {
json_payload: "{}".to_owned(),
},
);
assert_eq!(result.unwrap(), from);
}
}