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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# v149.0 (In progress)

## ✨ What's New ✨

### FxA Client
- Support for the token exchange API, which we plan to use for getting access tokens for Relay.
([#7179](https://github.com/mozilla/application-services/pull/7179)).

### AdsClient
* Try to reset cache database schema on connection initialization failure.
* Reset cache on context ID rotation.
Expand Down
10 changes: 10 additions & 0 deletions components/fxa-client/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ use crate::{ApiResult, Error, FirefoxAccount};
use error_support::handle_error;

impl FirefoxAccount {
/// Get the token server URL
///
/// The token server URL can be used to get the URL and access token for the user's sync data.
///
/// **💾 This method alters the persisted account state.**
Copy link

@Gela Gela Jan 23, 2026

Choose a reason for hiding this comment

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

Apologies if this is normal in the app-services rust code, but I think the floppy disk emoji should be removed here.

#[handle_error(Error)]
pub fn get_content_url(&self) -> ApiResult<String> {
self.internal.lock().get_content_url()
}

/// Get the token server URL
///
/// The token server URL can be used to get the URL and access token for the user's sync data.
Expand Down
4 changes: 2 additions & 2 deletions components/fxa-client/src/internal/close_tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ use super::{
},
device::COMMAND_MAX_PAYLOAD_SIZE,
http_client::GetDeviceResponse,
scopes, telemetry, FirefoxAccount,
telemetry, FirefoxAccount,
};
use crate::{warn, CloseTabsResult, Error, Result};
use crate::{scopes, warn, CloseTabsResult, Error, Result};

impl FirefoxAccount {
pub fn close_tabs<T>(&mut self, target_device_id: &str, urls: Vec<T>) -> Result<CloseTabsResult>
Expand Down
2 changes: 1 addition & 1 deletion components/fxa-client/src/internal/commands/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use serde::{de::DeserializeOwned, Deserialize, Serialize};

use super::super::device::Device;
use super::super::scopes;
use crate::scopes;
use crate::{Error, Result, ScopedKey};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rc_crypto::ece::{self, EcKeyComponents};
Expand Down
4 changes: 2 additions & 2 deletions components/fxa-client/src/internal/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ use super::{
http_client::{
DeviceUpdateRequest, DeviceUpdateRequestBuilder, PendingCommand, UpdateDeviceResponse,
},
scopes, telemetry, util, CachedResponse, FirefoxAccount,
telemetry, util, CachedResponse, FirefoxAccount,
};
use crate::{info, warn, DeviceCapability, Error, LocalDevice, Result};
use crate::{info, scopes, warn, DeviceCapability, Error, LocalDevice, Result};
use sync15::DeviceType;

// An devices response is considered fresh for `DEVICES_FRESHNESS_THRESHOLD` ms.
Expand Down
46 changes: 45 additions & 1 deletion components/fxa-client/src/internal/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! live objects that can be inspected by other parts of the code.

use super::{config::Config, util};
use crate::{Error, Result};
use crate::{trace, Error, Result};
use error_support::breadcrumb;
use parking_lot::Mutex;
use rc_crypto::{
Expand Down Expand Up @@ -146,6 +146,14 @@ pub(crate) trait FxAClient {
client_id: &str,
scope: &str,
) -> Result<HashMap<String, ScopedKeyDataResponse>>;
/// Exchange a refresh token for a new one with additional scopes (RFC 8693 Token Exchange).
/// This is used to upgrade a refresh token's scope without re-authentication.
fn exchange_token_for_scope(
&self,
config: &Config,
refresh_token: &str,
scope: &str,
) -> Result<OAuthTokenResponse>;
#[allow(dead_code)]
fn get_fxa_client_configuration(&self, config: &Config) -> Result<ClientConfigurationResponse>;
#[allow(dead_code)]
Expand Down Expand Up @@ -279,6 +287,20 @@ impl FxAClient for Client {
self.make_request(request)?.json().map_err(Into::into)
}

fn exchange_token_for_scope(
&self,
config: &Config,
refresh_token: &str,
scope: &str,
) -> Result<OAuthTokenResponse> {
let req = OAauthTokenRequest::TokenExchange {
subject_token: refresh_token.to_string(),
subject_token_type: "urn:ietf:params:oauth:token-type:refresh_token".to_string(),
scope: scope.to_string(),
};
self.make_oauth_token_request(config, None, serde_json::to_value(req).unwrap())
}

fn create_authorization_code_using_session_token(
&self,
config: &Config,
Expand Down Expand Up @@ -567,7 +589,9 @@ impl Client {
}
}
self.state.lock().insert(url, HttpClientState::Ok);
trace!("Making request: {request:?}");
let resp = request.send()?;
trace!("Response: {resp:?}");
if resp.is_success() || resp.status == status_codes::NOT_MODIFIED {
Ok(resp)
} else {
Expand Down Expand Up @@ -904,6 +928,13 @@ enum OAauthTokenRequest {
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u64>,
},
/// RFC 8693 Token Exchange - exchange a refresh token for a new one with additional scopes.
#[serde(rename = "urn:ietf:params:oauth:grant-type:token-exchange")]
TokenExchange {
subject_token: String,
subject_token_type: String,
scope: String,
},
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -988,7 +1019,9 @@ struct InvokeCommandRequest<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::scopes;
use mockito::mock;

#[test]
#[allow(non_snake_case)]
fn check_OAauthTokenRequest_serialization() {
Expand All @@ -1007,6 +1040,17 @@ mod tests {
ttl: Some(123),
};
assert_eq!("{\"grant_type\":\"refresh_token\",\"client_id\":\"bar\",\"refresh_token\":\"foo\",\"scope\":\"bobo\",\"ttl\":123}", serde_json::to_string(&using_code).unwrap());

// Token exchange (RFC 8693)
let token_exchange = OAauthTokenRequest::TokenExchange {
subject_token: "my_refresh_token".to_owned(),
subject_token_type: "urn:ietf:params:oauth:token-type:refresh_token".to_owned(),
scope: scopes::RELAY.to_owned(),
};
assert_eq!(
"{\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\",\"subject_token\":\"my_refresh_token\",\"subject_token_type\":\"urn:ietf:params:oauth:token-type:refresh_token\",\"scope\":\"https://identity.mozilla.com/apps/relay\"}",
serde_json::to_string(&token_exchange).unwrap()
);
}

#[test]
Expand Down
6 changes: 5 additions & 1 deletion components/fxa-client/src/internal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ mod oauth;
mod profile;
mod push;
mod scoped_keys;
mod scopes;
mod send_tab;
mod state_manager;
mod state_persistence;
Expand Down Expand Up @@ -124,6 +123,11 @@ impl FirefoxAccount {
self.devices_cache = None;
}

/// Get the Sync Token Server endpoint URL.
pub fn get_content_url(&self) -> Result<String> {
Ok(self.state.config().content_url()?.into())
}

/// Get the Sync Token Server endpoint URL.
pub fn get_token_server_endpoint_url(&self) -> Result<String> {
Ok(self.state.config().token_server_endpoint_url()?.into())
Expand Down
41 changes: 33 additions & 8 deletions components/fxa-client/src/internal/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

pub mod attached_clients;
use super::scopes;
use super::{
http_client::{
AuthorizationRequestParameters, IntrospectResponse as IntrospectInfo, OAuthTokenResponse,
Expand All @@ -12,7 +11,7 @@ use super::{
util, FirefoxAccount,
};
use crate::auth::UserData;
use crate::{warn, AuthorizationParameters, Error, FxaServer, Result, ScopedKey};
use crate::{scopes, 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 @@ -57,7 +56,27 @@ impl FirefoxAccount {
}
}
let resp = match self.state.refresh_token() {
Some(refresh_token) => {
Some(mut refresh_token) => {
if !refresh_token.scopes.contains(scope) {
// We don't currently have this scope - try token exchange to upgrade.
let exchange_resp = self.client.exchange_token_for_scope(
self.state.config(),
&refresh_token.token,
scope,
)?;
// Update state with the new refresh token that has combined scopes.
if let Some(new_refresh_token) = exchange_resp.refresh_token {
self.state.update_refresh_token(RefreshToken::new(
new_refresh_token,
exchange_resp.scope,
));
}
// Get the updated refresh token from state.
refresh_token = match self.state.refresh_token() {
None => return Err(Error::NoCachedToken(scope.to_string())),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It feels like this error variant is misnamed, but it's already used to for scope issues in the code below so I guess we can just stick with it for now.

Some(token) => token,
};
}
if refresh_token.scopes.contains(scope) {
self.client.create_access_token_using_refresh_token(
self.state.config(),
Expand Down Expand Up @@ -419,10 +438,7 @@ impl FirefoxAccount {
}
self.state.complete_oauth_flow(
scoped_keys,
RefreshToken {
token: new_refresh_token,
scopes: resp.scope.split(' ').map(ToString::to_string).collect(),
},
RefreshToken::new(new_refresh_token, resp.scope),
resp.session_token,
);
Ok(())
Expand Down Expand Up @@ -533,6 +549,15 @@ pub struct RefreshToken {
pub scopes: HashSet<String>,
}

impl RefreshToken {
pub fn new(token: String, scopes: String) -> Self {
Self {
token,
scopes: scopes.split(' ').map(ToString::to_string).collect(),
}
}
}

impl std::fmt::Debug for RefreshToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RefreshToken")
Expand Down Expand Up @@ -916,7 +941,7 @@ mod tests {
}
}

use crate::internal::scopes::{self, OLD_SYNC};
use crate::scopes::{self, OLD_SYNC};

#[test]
fn test_auth_code_pair_valid_not_allowed_scope() {
Expand Down
4 changes: 2 additions & 2 deletions components/fxa-client/src/internal/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

pub use super::http_client::ProfileResponse as Profile;
use super::{scopes, util, CachedResponse, FirefoxAccount};
use crate::{Error, Result};
use super::{util, CachedResponse, FirefoxAccount};
use crate::{scopes, Error, Result};

// A cached profile response is considered fresh for `PROFILE_FRESHNESS_THRESHOLD` ms.
const PROFILE_FRESHNESS_THRESHOLD: u64 = 120_000; // 2 minutes
Expand Down
4 changes: 2 additions & 2 deletions components/fxa-client/src/internal/send_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use super::{
PublicCommandKeys as PublicSendTabKeys,
},
http_client::GetDeviceResponse,
scopes, telemetry, FirefoxAccount,
telemetry, FirefoxAccount,
};
use crate::{Error, Result};
use crate::{scopes, Error, Result};

impl FirefoxAccount {
pub(crate) fn load_or_generate_send_tab_keys(&mut self) -> Result<PrivateSendTabKeys> {
Expand Down
6 changes: 6 additions & 0 deletions components/fxa-client/src/internal/state_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ impl StateManager {
self.persisted_state.server_local_device_info = None;
}

/// Update the refresh token only
pub fn update_refresh_token(&mut self, token: RefreshToken) {
self.persisted_state.refresh_token = Some(token);
self.persisted_state.access_token_cache.clear();
}

/// Used by the application to test auth token issues
pub fn simulate_temporary_auth_token_issue(&mut self) {
for (_, access_token) in self.persisted_state.access_token_cache.iter_mut() {
Expand Down
3 changes: 2 additions & 1 deletion components/fxa-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mod error;
mod internal;
mod profile;
mod push;
pub mod scopes;
mod state_machine;
mod storage;
mod telemetry;
Expand Down Expand Up @@ -135,7 +136,7 @@ pub enum FxaServer {
}

impl FxaServer {
fn content_url(&self) -> &str {
pub fn content_url(&self) -> &str {
match self {
Self::Release | Self::China => "https://accounts.firefox.com",
Self::Stable => "https://stable.dev.lcip.org",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

pub const PROFILE: &str = "profile";
pub const OLD_SYNC: &str = "https://identity.mozilla.com/apps/oldsync";
pub const RELAY: &str = "https://identity.mozilla.com/apps/relay";
10 changes: 9 additions & 1 deletion components/viaduct/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ impl std::fmt::Display for Header {
}

/// A list of headers.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
#[derive(Clone, PartialEq, Eq, Default)]
pub struct Headers {
headers: Vec<Header>,
}
Expand Down Expand Up @@ -336,6 +336,14 @@ impl Headers {
}
}

impl std::fmt::Debug for Headers {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_map()
.entries(self.headers.iter().map(|h| (h.name().as_str(), &h.value)))
.finish()
}
}

impl std::iter::IntoIterator for Headers {
type IntoIter = <Vec<Header> as IntoIterator>::IntoIter;
type Item = Header;
Expand Down
32 changes: 30 additions & 2 deletions components/viaduct/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ impl std::fmt::Display for Method {
}

#[must_use = "`Request`'s \"builder\" functions take by move, not by `&mut self`"]
#[derive(Clone, Debug, uniffi::Record)]
#[derive(Clone, uniffi::Record)]
pub struct Request {
pub method: Method,
pub url: Url,
Expand Down Expand Up @@ -237,8 +237,23 @@ impl Request {
}
}

// Hand-written `Debug` impl for nicer logging
impl std::fmt::Debug for Request {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Request")
.field("method", &self.method)
.field("url", &self.url.to_string())
.field("headers", &self.headers)
.field(
"body",
&self.body.as_ref().map(|body| String::from_utf8_lossy(body)),
)
.finish()
}
}

/// A response from the server.
#[derive(Clone, Debug, uniffi::Record)]
#[derive(Clone, uniffi::Record)]
pub struct Response {
/// The method used to request this response.
pub request_method: Method,
Expand Down Expand Up @@ -303,6 +318,19 @@ impl Response {
}
}

// Hand-written `Debug` impl for nicer logging
impl std::fmt::Debug for Response {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Response")
.field("request_method", &self.request_method)
.field("url", &self.url.to_string())
.field("status", &self.status)
.field("headers", &self.headers)
.field("body", &String::from_utf8_lossy(&self.body))
.finish()
}
}

/// A module containing constants for all HTTP status codes.
pub mod status_codes {

Expand Down
Loading