Skip to content
Merged
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: 7 additions & 3 deletions components/fxa-client/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@
//! The methods in this section provide URLs at which the user can perform various
//! account-management activities.

use crate::{ApiResult, Error, FirefoxAccount};
use crate::{ApiResult, Error, FirefoxAccount, FxaServer};
use error_support::handle_error;

impl FirefoxAccount {
/// Check if an account was created from a config
#[handle_error(Error)]
pub fn matches_server(&self, server: &FxaServer) -> ApiResult<bool> {
self.internal.lock().matches_server(server)
}

/// 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_token_server_endpoint_url(&self) -> ApiResult<String> {
self.internal.lock().get_token_server_endpoint_url()
Expand Down
45 changes: 44 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 additional confirmation.
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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

whoa, that's awesome!

#[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 @@ -989,6 +1020,7 @@ struct InvokeCommandRequest<'a> {
mod tests {
use super::*;
use mockito::mock;

#[test]
#[allow(non_snake_case)]
fn check_OAauthTokenRequest_serialization() {
Expand All @@ -1007,6 +1039,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: "https://identity.mozilla.com/apps/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
14 changes: 13 additions & 1 deletion components/fxa-client/src/internal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use self::{
state_persistence::PersistedState,
telemetry::FxaTelemetry,
};
use crate::{DeviceConfig, Error, FxaConfig, FxaRustAuthState, FxaState, Result};
use crate::{DeviceConfig, Error, FxaConfig, FxaRustAuthState, FxaServer, FxaState, Result};
use serde_derive::*;
use std::{
collections::{HashMap, HashSet},
Expand Down Expand Up @@ -124,6 +124,18 @@ impl FirefoxAccount {
self.devices_cache = None;
}

/// Check if this account was created from a config
pub fn matches_server(&self, server: &FxaServer) -> Result<bool> {
// This check depends on the fact that we get the `content_url` when constructing our
// internal config and never change it. It's slightly brittle, but that's okay since it's
// only used by the CLI.
let content_url = self.state.config().content_url()?.to_string();
let server_url = server.content_url();
// Ignore trailing slashes when comparing the URLs.
Ok(content_url.strip_suffix("/").unwrap_or(&content_url)
== server_url.strip_suffix("/").unwrap_or(server_url))
}

/// 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
36 changes: 31 additions & 5 deletions components/fxa-client/src/internal/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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
Copy Markdown
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, agree with both those points. It does seem like it is "just" misnamed though and doesn't need a new variant - I can't see why we'd want to expose an error regarding our cache strategy.

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 +439,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 +550,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
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
2 changes: 1 addition & 1 deletion components/fxa-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,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
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
26 changes: 17 additions & 9 deletions examples/cli-support/src/fxa_creds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,14 +35,22 @@ fn load_fxa_creds(path: &str) -> Result<FirefoxAccount> {
}

fn load_or_create_fxa_creds(path: &str, cfg: FxaConfig, scopes: &[&str]) -> Result<FirefoxAccount> {
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.matches_server(&cfg.server)? {
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<FirefoxAccount> {
Expand Down
Loading