From c672d1e1d13b32afc5bae47781776a834b32d442 Mon Sep 17 00:00:00 2001 From: Mark Hammond Date: Mon, 13 Apr 2026 10:07:51 +1000 Subject: [PATCH] fxa-client: Don't expose the session token directly to consumers. This was exposed primarily for use with the web channel, but the fact it is exposed means that consumers are free to use it in ways we'd like to better control. We do this by adding a couple of new methods which are explicitly used only for webchannels, and the data moved over those APIs are abstracted away - the consumers just pass the raw JSON data from the webchannel message and the component knows what format it is in and decodes it appropriately. This is a breaking change for Android and iOS: TODO: link to PRs for them. --- .../appservices/fxaclient/FxaClient.kt | 45 +++++++----- components/fxa-client/src/auth.rs | 25 +++---- components/fxa-client/src/fxa_client.udl | 69 +++++------------- components/fxa-client/src/internal/oauth.rs | 70 +++++++++++++------ components/fxa-client/src/lib.rs | 2 +- components/fxa-client/src/token.rs | 19 +++++ 6 files changed, 125 insertions(+), 105 deletions(-) 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 0c9833e4f2..703c607775 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 @@ -135,18 +135,38 @@ class FxaClient(inner: FirefoxAccount, persistCallback: PersistCallback?) : Auto } /** - * Sets user data from the web content. - * NOTE: this is only useful for applications that are user agents - * and require the user's session token - * @param userData: The user data including session token, email and uid + * Stores anything necessary to login from a WebChannel login JSON payload. This includes the session + * token, but that is abstracted because the consuming apps should not be aware of the + * specific payload format returned, nor should they get access to the session token + * directly if possible. + * + * @param jsonPayload The `data` object from the `fxaccounts:login` WebChannel command. */ - fun setUserData( - userData: UserData, - ) { - this.inner.setUserData(userData) + fun handleWebChannelLogin(jsonPayload: String) { + this.inner.handleWebChannelLogin(jsonPayload) tryPersistState() } + /** + * Handle a WebChannel password-change notification by exchanging the new session token + * for a new refresh token via a network call. + * + * @param jsonPayload is the `data` object from the `fxaccounts:change_password` WebChannel command. + */ + fun handleWebChannelPasswordChange(jsonPayload: String) { + this.inner.handleWebChannelPasswordChange(jsonPayload) + tryPersistState() + } + + /** + * Returns a complete signedInUser JSON object for a WebChannel fxaccounts:fxa_status response. + * + * @return An opaque string which holds JSON data and can be directly supplied to the WebChannel. + */ + fun getSignedInUserForWebChannel(): String? { + return this.inner.getSignedInUserForWebChannel() + } + /** * Authenticates the current account using the code and state parameters fetched from the * redirect URL reached after completing the sign in flow triggered by [beginOAuthFlow]. @@ -296,15 +316,6 @@ class FxaClient(inner: FirefoxAccount, persistCallback: PersistCallback?) : Auto return this.inner.checkAuthorizationStatus() } - /** - * Tries to return a session token - * - * @throws FxaException Will send you an exception if there is no session token set - */ - fun getSessionToken(): String { - return this.inner.getSessionToken() - } - /** * Get the current device id * diff --git a/components/fxa-client/src/auth.rs b/components/fxa-client/src/auth.rs index 1487fd265f..39c894ed8c 100644 --- a/components/fxa-client/src/auth.rs +++ b/components/fxa-client/src/auth.rs @@ -49,11 +49,17 @@ impl FirefoxAccount { self.internal.lock().get_auth_state() } - /// Sets the user data for a user agent - /// **Important**: This should only be used on user agents such as Firefox - /// that require the user's session token - pub fn set_user_data(&self, user_data: UserData) { - self.internal.lock().set_user_data(user_data) + /// Stores the session token from a WebChannel login JSON payload without exposing it + /// to the browser layer. + /// + /// The `json_payload` is the `data` object from the `fxaccounts:login` WebChannel + /// command. The session token is extracted and stored internally; callers never hold + /// the raw token value. + /// + /// **💾 This method alters the persisted account state.** + #[handle_error(Error)] + pub fn handle_web_channel_login(&self, json_payload: String) -> ApiResult<()> { + self.internal.lock().handle_web_channel_login(&json_payload) } /// Initiate a web-based OAuth sign-in flow. @@ -316,12 +322,3 @@ pub enum FxaEvent { /// This event is valid for the `Connected` state. CallGetProfile, } - -/// User data provided by the web content, meant to be consumed by user agents -#[derive(Debug, Clone)] -pub struct UserData { - pub(crate) session_token: String, - pub(crate) uid: String, - pub(crate) email: String, - pub(crate) verified: bool, -} diff --git a/components/fxa-client/src/fxa_client.udl b/components/fxa-client/src/fxa_client.udl index 9576570e1f..f286a7b2f3 100644 --- a/components/fxa-client/src/fxa_client.udl +++ b/components/fxa-client/src/fxa_client.udl @@ -168,12 +168,25 @@ interface FirefoxAccount { /// [Throws=FxaError] string to_json(); - - /// Sets the users information based on the web content's login information - /// This is intended to only be used by user agents (eg: Firefox) to set the users - /// session token and tie it to the refresh token that will be issued at the end of the - /// oauth flow. - void set_user_data(UserData user_data); + + /// Stores anything necessary from a WebChannel login JSON payload. This includes the session + /// token, but that is abstracted because the consuming apps should not be aware of the + /// specific payload format returned, nor should they get access to the session token + /// directly if possible. + /// The [json_payload] is the `data` object from the `fxaccounts:login` WebChannel command. + [Throws=FxaError] + void handle_web_channel_login(string json_payload); + + /// Handle a WebChannel password-change notification by exchanging the new session token + /// for a new refresh token via a network call. + /// The [json_payload] is the `data` object from the `fxaccounts:change_password` WebChannel command. + [Throws=FxaError] + void handle_web_channel_password_change(string json_payload); + + /// Returns a complete signedInUser JSON object for a WebChannel fxaccounts:fxa_status response, + /// embedding the session token privately. Email and uid come from the cached profile in internal + /// state. Returns null if no session token is set. + string? get_signed_in_user_for_web_channel(); /// Initiate a web-based OAuth sign-in flow. /// @@ -654,43 +667,6 @@ interface FirefoxAccount { [Throws=FxaError] AccessTokenInfo get_access_token([ByRef] string scope, optional boolean use_cache = true); - /// Get the session token for the user's account, if one is available. - /// - /// **💾 This method alters the persisted account state.** - /// - /// Applications that function as a web browser may need to hold on to a session token - /// on behalf of Firefox Accounts web content. This method exists so that they can retrieve - /// it an pass it back to said web content when required. - /// - /// # Notes - /// - /// - Please do not attempt to use the resulting token to directly make calls to the - /// Firefox Accounts servers! All account management functionality should be performed - /// in web content. - /// - A session token is only available to applications that have requested the - /// `https:///identity.mozilla.com/tokens/session` scope. - /// - [Throws=FxaError] - string get_session_token(); - - - /// Update the stored session token for the user's account. - /// - /// **💾 This method alters the persisted account state.** - /// - /// Applications that function as a web browser may need to hold on to a session token - /// on behalf of Firefox Accounts web content. This method exists so that said web content - /// signals that it has generated a new session token, the stored value can be updated - /// to match. - /// - /// # Arguments - /// - /// - `session_token` - the new session token value provided from web content. - /// - [Throws=FxaError] - void handle_session_token_change([ByRef] string session_token ); - - /// Create a new OAuth authorization code using the stored session token. /// /// When a signed-in application receives an incoming device pairing request, it can @@ -1114,10 +1090,3 @@ interface IncomingDeviceCommand { /// Indicates that the sender wants to close one or more tabs on this device. TabsClosed(Device? sender, CloseTabsPayload payload); }; - -dictionary UserData { - string session_token; - string uid; - string email; - boolean verified; -}; diff --git a/components/fxa-client/src/internal/oauth.rs b/components/fxa-client/src/internal/oauth.rs index 607699c321..d2dc160b09 100644 --- a/components/fxa-client/src/internal/oauth.rs +++ b/components/fxa-client/src/internal/oauth.rs @@ -11,7 +11,6 @@ use super::{ scoped_keys::ScopedKeysFlow, util, FirefoxAccount, }; -use crate::auth::UserData; use crate::{error, warn, AuthorizationParameters, Error, FxaServer, Result, ScopedKey}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use jwcrypto::{EncryptionAlgorithm, EncryptionParameters}; @@ -124,12 +123,27 @@ impl FirefoxAccount { Ok(token_info) } - /// Sets the user data (session token, email, uid) - pub fn set_user_data(&mut self, user_data: UserData) { - // for now, we only have use for the session token - // if we'd like to implement a "Signed in but not verified" state - // we would also consume the other parts of the user data - self.state.set_session_token(user_data.session_token) + /// 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<()> { + let data: serde_json::Value = serde_json::from_str(json_payload)?; + let token = data + .get("sessionToken") + .and_then(|v| v.as_str()) + .ok_or(Error::NoSessionToken)?; + self.state.set_session_token(token.to_string()); + Ok(()) + } + + /// Extracts the session token from a WebChannel password change JSON payload and exchanges it + /// for a new refresh token via a network call. + pub fn handle_web_channel_password_change(&mut self, json_payload: &str) -> Result<()> { + let data: serde_json::Value = serde_json::from_str(json_payload)?; + let token = data + .get("sessionToken") + .and_then(|v| v.as_str()) + .ok_or(Error::NoSessionToken)?; + self.handle_session_token_change(token) } /// Retrieve the current session token from state @@ -140,6 +154,26 @@ impl FirefoxAccount { } } + /// Builds a complete `signedInUser` JSON object for a WebChannel `fxaccounts:fxa_status` + /// response. Returns `None` if no session token is stored. + /// `email` and `uid` are read from the cached profile; `verified` is always true because + /// the account state machine only completes authentication for verified accounts. + pub fn get_signed_in_user_for_web_channel(&self) -> Option { + let token = self.state.session_token()?; + let profile = self.state.last_seen_profile(); + let email = profile.map(|p| p.response.email.as_str()); + let uid = profile.map(|p| p.response.uid.as_str()); + Some( + serde_json::json!({ + "sessionToken": token, + "email": email, + "uid": uid, + "verified": true, + }) + .to_string(), + ) + } + /// Check whether user is authorized using our refresh token. pub fn check_authorization_status(&mut self) -> Result { let resp = match self.state.refresh_token() { @@ -1108,17 +1142,14 @@ mod tests { } #[test] - fn test_set_user_data_sets_session_token() { + fn test_handle_web_channel_login_sets_session_token() { nss::ensure_initialized(); let config = Config::stable_dev("12345678", "https://foo.bar"); let mut fxa = FirefoxAccount::with_config(config); - let user_data = UserData { - session_token: String::from("mock_session_token"), - uid: String::from("mock_uid_unused"), - email: String::from("mock_email_usued"), - verified: true, - }; - fxa.set_user_data(user_data); + fxa.handle_web_channel_login( + r#"{"sessionToken":"mock_session_token","uid":"mock_uid","email":"mock@example.com","verified":true}"#, + ) + .unwrap(); assert_eq!(fxa.get_session_token().unwrap(), "mock_session_token"); } @@ -1136,12 +1167,6 @@ mod tests { .unwrap(); let url = Url::parse(&url).unwrap(); let state = url.query_pairs().find(|(name, _)| name == "state").unwrap(); - let user_data = UserData { - session_token: String::from("mock_session_token"), - uid: String::from("mock_uid_unused"), - email: String::from("mock_email_usued"), - verified: true, - }; let mut client = MockFxAClient::new(); client @@ -1166,8 +1191,7 @@ mod tests { .times(1) .returning(|_, _| Ok(())); fxa.set_client(Arc::new(client)); - - fxa.set_user_data(user_data); + fxa.set_session_token("mock_session_token"); fxa.complete_oauth_flow("mock_code", state.1.as_ref()) .unwrap(); diff --git a/components/fxa-client/src/lib.rs b/components/fxa-client/src/lib.rs index f0e819654b..40e2fdd9f6 100644 --- a/components/fxa-client/src/lib.rs +++ b/components/fxa-client/src/lib.rs @@ -53,7 +53,7 @@ use std::fmt; pub use sync15::DeviceType; use url::Url; -pub use auth::{AuthorizationInfo, FxaEvent, FxaRustAuthState, FxaState, UserData}; +pub use auth::{AuthorizationInfo, FxaEvent, FxaRustAuthState, FxaState}; pub use device::{ AttachedClient, CloseTabsResult, Device, DeviceCapability, DeviceConfig, LocalDevice, }; diff --git a/components/fxa-client/src/token.rs b/components/fxa-client/src/token.rs index 44208a336c..8048527ab2 100644 --- a/components/fxa-client/src/token.rs +++ b/components/fxa-client/src/token.rs @@ -53,6 +53,25 @@ impl FirefoxAccount { .try_into() } + /// 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 + /// is available. + pub fn get_signed_in_user_for_web_channel(&self) -> Option { + self.internal.lock().get_signed_in_user_for_web_channel() + } + + /// Handle a WebChannel password-change notification by exchanging the new session token + /// for a new refresh token. + /// + /// **💾 This method alters the persisted account state.** + #[handle_error(Error)] + pub fn handle_web_channel_password_change(&self, json_payload: String) -> ApiResult<()> { + self.internal + .lock() + .handle_web_channel_password_change(&json_payload) + } + /// Get the session token for the user's account, if one is available. /// /// **💾 This method alters the persisted account state.**