diff --git a/CHANGELOG.md b/CHANGELOG.md index 4143757e04..84978679a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/components/fxa-client/src/account.rs b/components/fxa-client/src/account.rs index c2463197e9..088b2fc4ab 100644 --- a/components/fxa-client/src/account.rs +++ b/components/fxa-client/src/account.rs @@ -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.** + #[handle_error(Error)] + pub fn get_content_url(&self) -> ApiResult { + 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. diff --git a/components/fxa-client/src/internal/close_tabs.rs b/components/fxa-client/src/internal/close_tabs.rs index d95de442fa..90e42b0beb 100644 --- a/components/fxa-client/src/internal/close_tabs.rs +++ b/components/fxa-client/src/internal/close_tabs.rs @@ -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(&mut self, target_device_id: &str, urls: Vec) -> Result diff --git a/components/fxa-client/src/internal/commands/keys.rs b/components/fxa-client/src/internal/commands/keys.rs index 1e3e4dfd0c..bcfc127d79 100644 --- a/components/fxa-client/src/internal/commands/keys.rs +++ b/components/fxa-client/src/internal/commands/keys.rs @@ -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}; diff --git a/components/fxa-client/src/internal/device.rs b/components/fxa-client/src/internal/device.rs index 48552e95f7..4194c487e8 100644 --- a/components/fxa-client/src/internal/device.rs +++ b/components/fxa-client/src/internal/device.rs @@ -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. diff --git a/components/fxa-client/src/internal/http_client.rs b/components/fxa-client/src/internal/http_client.rs index 5eff414bc0..5c1a577ba8 100644 --- a/components/fxa-client/src/internal/http_client.rs +++ b/components/fxa-client/src/internal/http_client.rs @@ -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::{ @@ -146,6 +146,14 @@ pub(crate) trait FxAClient { client_id: &str, scope: &str, ) -> Result>; + /// 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; #[allow(dead_code)] fn get_fxa_client_configuration(&self, config: &Config) -> Result; #[allow(dead_code)] @@ -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 { + 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, @@ -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 { @@ -904,6 +928,13 @@ enum OAauthTokenRequest { #[serde(skip_serializing_if = "Option::is_none")] ttl: Option, }, + /// 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)] @@ -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() { @@ -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] diff --git a/components/fxa-client/src/internal/mod.rs b/components/fxa-client/src/internal/mod.rs index 4a38da8c91..409fba43a5 100644 --- a/components/fxa-client/src/internal/mod.rs +++ b/components/fxa-client/src/internal/mod.rs @@ -31,7 +31,6 @@ mod oauth; mod profile; mod push; mod scoped_keys; -mod scopes; mod send_tab; mod state_manager; mod state_persistence; @@ -124,6 +123,11 @@ impl FirefoxAccount { self.devices_cache = None; } + /// Get the Sync Token Server endpoint URL. + pub fn get_content_url(&self) -> Result { + Ok(self.state.config().content_url()?.into()) + } + /// Get the Sync Token Server endpoint URL. pub fn get_token_server_endpoint_url(&self) -> Result { Ok(self.state.config().token_server_endpoint_url()?.into()) diff --git a/components/fxa-client/src/internal/oauth.rs b/components/fxa-client/src/internal/oauth.rs index b4d4f1b65b..70eac1ca7c 100644 --- a/components/fxa-client/src/internal/oauth.rs +++ b/components/fxa-client/src/internal/oauth.rs @@ -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, @@ -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; @@ -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())), + Some(token) => token, + }; + } if refresh_token.scopes.contains(scope) { self.client.create_access_token_using_refresh_token( self.state.config(), @@ -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(()) @@ -533,6 +549,15 @@ pub struct RefreshToken { pub scopes: HashSet, } +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") @@ -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() { diff --git a/components/fxa-client/src/internal/profile.rs b/components/fxa-client/src/internal/profile.rs index 53a1f3b515..83be0df453 100644 --- a/components/fxa-client/src/internal/profile.rs +++ b/components/fxa-client/src/internal/profile.rs @@ -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 diff --git a/components/fxa-client/src/internal/send_tab.rs b/components/fxa-client/src/internal/send_tab.rs index 7bd5b5d642..cd6fa4a5b3 100644 --- a/components/fxa-client/src/internal/send_tab.rs +++ b/components/fxa-client/src/internal/send_tab.rs @@ -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 { diff --git a/components/fxa-client/src/internal/state_manager.rs b/components/fxa-client/src/internal/state_manager.rs index d42a0656ee..2ebdf6f2b7 100644 --- a/components/fxa-client/src/internal/state_manager.rs +++ b/components/fxa-client/src/internal/state_manager.rs @@ -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() { diff --git a/components/fxa-client/src/lib.rs b/components/fxa-client/src/lib.rs index bf273969ab..7b7a3cb0b6 100644 --- a/components/fxa-client/src/lib.rs +++ b/components/fxa-client/src/lib.rs @@ -43,6 +43,7 @@ mod error; mod internal; mod profile; mod push; +pub mod scopes; mod state_machine; mod storage; mod telemetry; @@ -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", diff --git a/components/fxa-client/src/internal/scopes.rs b/components/fxa-client/src/scopes.rs similarity index 82% rename from components/fxa-client/src/internal/scopes.rs rename to components/fxa-client/src/scopes.rs index 3503686679..a2a353393f 100644 --- a/components/fxa-client/src/internal/scopes.rs +++ b/components/fxa-client/src/scopes.rs @@ -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"; diff --git a/components/viaduct/src/headers.rs b/components/viaduct/src/headers.rs index 3fc2d37efd..510f28cac5 100644 --- a/components/viaduct/src/headers.rs +++ b/components/viaduct/src/headers.rs @@ -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
, } @@ -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 = as IntoIterator>::IntoIter; type Item = Header; diff --git a/components/viaduct/src/lib.rs b/components/viaduct/src/lib.rs index 39dfe21773..e2f0faadea 100644 --- a/components/viaduct/src/lib.rs +++ b/components/viaduct/src/lib.rs @@ -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, @@ -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, @@ -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 { diff --git a/examples/cli-support/src/fxa_creds.rs b/examples/cli-support/src/fxa_creds.rs index ea62eac4d2..d6c6759aeb 100644 --- a/examples/cli-support/src/fxa_creds.rs +++ b/examples/cli-support/src/fxa_creds.rs @@ -9,7 +9,7 @@ use std::{ io::{Read, Write}, }; -use anyhow::Result; +use anyhow::{bail, Result}; use url::Url; // This crate awkardly uses some internal implementation details of the fxa-client crate, @@ -35,14 +35,22 @@ fn load_fxa_creds(path: &str) -> Result { } fn load_or_create_fxa_creds(path: &str, cfg: FxaConfig, scopes: &[&str]) -> Result { - load_fxa_creds(path).or_else(|e| { - log::info!( - "Failed to load existing FxA credentials from {:?} (error: {}), launching OAuth flow", - path, - e - ); - create_fxa_creds(path, cfg, scopes) - }) + match load_fxa_creds(path) { + Ok(account) => { + if account.get_content_url()? != cfg.server.content_url() { + bail!("Stored credentials don't match configured server.\nDelete {path} to start over or specify a different server arg") + } + Ok(account) + } + Err(e) => { + log::info!( + "Failed to load existing FxA credentials from {:?} (error: {}), launching OAuth flow", + path, + e + ); + create_fxa_creds(path, cfg, scopes) + } + } } fn create_fxa_creds(path: &str, cfg: FxaConfig, scopes: &[&str]) -> Result { diff --git a/examples/fxa-client/src/main.rs b/examples/fxa-client/src/main.rs index ed9be39e55..aad87114fe 100644 --- a/examples/fxa-client/src/main.rs +++ b/examples/fxa-client/src/main.rs @@ -9,20 +9,24 @@ use std::fs; use clap::{Parser, Subcommand, ValueEnum}; use cli_support::{fxa_creds, init_logging_with}; -use fxa_client::{FirefoxAccount, FxaConfig, FxaServer}; +use fxa_client::{scopes, FirefoxAccount, FxaConfig, FxaServer}; static CREDENTIALS_FILENAME: &str = "credentials.json"; static CLIENT_ID: &str = "a2270f727f45f648"; static REDIRECT_URI: &str = "https://accounts.firefox.com/oauth/success/a2270f727f45f648"; -use anyhow::Result; +use anyhow::{bail, Result}; #[derive(Parser)] #[command(about, long_about = None)] struct Cli { /// The FxA server to use - #[arg(value_enum, default_value_t = Server::Release)] - server: Server, + #[clap(long)] + server: Option, + + /// Custom FxA server URL + #[clap(long)] + custom_url: Option, /// Request a session scope #[clap(long, short, action)] @@ -40,6 +44,10 @@ struct Cli { #[clap(long, short, action)] debug: bool, + /// Set the logging level to TRACE + #[clap(long, short, action)] + trace: bool, + #[command(subcommand)] command: Command, } @@ -56,12 +64,15 @@ enum Server { Stage, /// local dev sever LocalDev, + /// custom sever URL + Custom, } #[derive(Subcommand)] enum Command { Devices(devices::DeviceArgs), SendTab(send_tab::SendTabArgs), + RelayAccessToken, Disconnect, } @@ -70,7 +81,9 @@ fn main() -> Result<()> { nss::ensure_initialized(); viaduct_hyper::viaduct_init_backend_hyper()?; if cli.log { - if cli.debug { + if cli.trace { + init_logging_with("fxa_client=trace"); + } else if cli.debug { init_logging_with("fxa_client=debug"); } else if cli.info { init_logging_with("fxa_client=info"); @@ -90,6 +103,12 @@ fn main() -> Result<()> { match cli.command { Command::Devices(args) => devices::run(&account, args), Command::SendTab(args) => send_tab::run(&account, args), + Command::RelayAccessToken => { + println!("Requesting Relay access token..."); + account.get_access_token(scopes::RELAY, false)?; + println!("Success"); + Ok(()) + } Command::Disconnect => { account.disconnect(); Ok(()) @@ -99,15 +118,28 @@ fn main() -> Result<()> { Ok(()) } +impl Cli { + fn server(&self) -> Result { + Ok(match &self.server { + None => FxaServer::Release, + Some(Server::Release) => FxaServer::Release, + Some(Server::Stable) => FxaServer::Stable, + Some(Server::Stage) => FxaServer::Stage, + Some(Server::China) => FxaServer::China, + Some(Server::LocalDev) => FxaServer::LocalDev, + Some(Server::Custom) => FxaServer::Custom { + url: match &self.custom_url { + Some(url) => url.clone(), + None => bail!("--custom-url missing"), + }, + }, + }) + } +} + fn load_account(cli: &Cli, scopes: &[&str]) -> Result { let config = FxaConfig { - server: match cli.server { - Server::Release => FxaServer::Release, - Server::Stable => FxaServer::Stable, - Server::Stage => FxaServer::Stage, - Server::China => FxaServer::China, - Server::LocalDev => FxaServer::LocalDev, - }, + server: cli.server()?, redirect_uri: REDIRECT_URI.into(), client_id: CLIENT_ID.into(), token_server_url_override: None,