fune/third_party/rust/fxa-client/src/http_client.rs

803 lines
25 KiB
Rust

/* 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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
use crate::{config::Config, error::*};
use rc_crypto::hawk::{Credentials, Key, PayloadHasher, RequestBuilder, SHA256};
use rc_crypto::{digest, hkdf, hmac};
use serde_derive::*;
use serde_json::json;
use std::collections::HashMap;
use url::Url;
use viaduct::{header_names, status_codes, Method, Request, Response};
const HAWK_HKDF_SALT: [u8; 32] = [0b0; 32];
const HAWK_KEY_LENGTH: usize = 32;
#[cfg_attr(test, mockiato::mockable)]
pub trait FxAClient {
fn refresh_token_with_code(
&self,
config: &Config,
code: &str,
code_verifier: &str,
) -> Result<OAuthTokenResponse>;
fn refresh_token_with_session_token(
&self,
config: &Config,
session_token: &str,
scopes: &[&str],
) -> Result<OAuthTokenResponse>;
fn oauth_introspect_refresh_token(
&self,
config: &Config,
refresh_token: &str,
) -> Result<IntrospectResponse>;
fn access_token_with_refresh_token(
&self,
config: &Config,
refresh_token: &str,
ttl: Option<u64>,
scopes: &[&str],
) -> Result<OAuthTokenResponse>;
fn access_token_with_session_token(
&self,
config: &Config,
session_token: &str,
scopes: &[&str],
) -> Result<OAuthTokenResponse>;
fn authorization_code_using_session_token(
&self,
config: &Config,
client_id: &str,
session_token: &str,
scope: &str,
state: &str,
access_type: &str,
) -> Result<OAuthAuthResponse>;
fn duplicate_session(
&self,
config: &Config,
session_token: &str,
) -> Result<DuplicateTokenResponse>;
fn destroy_access_token(&self, config: &Config, token: &str) -> Result<()>;
fn destroy_refresh_token(&self, config: &Config, token: &str) -> Result<()>;
fn profile(
&self,
config: &Config,
profile_access_token: &str,
etag: Option<String>,
) -> Result<Option<ResponseAndETag<ProfileResponse>>>;
fn pending_commands(
&self,
config: &Config,
refresh_token: &str,
index: u64,
limit: Option<u64>,
) -> Result<PendingCommandsResponse>;
fn invoke_command(
&self,
config: &Config,
refresh_token: &str,
command: &str,
target: &str,
payload: &serde_json::Value,
) -> Result<()>;
fn devices(&self, config: &Config, refresh_token: &str) -> Result<Vec<GetDeviceResponse>>;
fn update_device(
&self,
config: &Config,
refresh_token: &str,
update: DeviceUpdateRequest<'_>,
) -> Result<UpdateDeviceResponse>;
fn destroy_device(&self, config: &Config, refresh_token: &str, id: &str) -> Result<()>;
fn attached_clients(
&self,
config: &Config,
session_token: &str,
) -> Result<Vec<GetAttachedClientResponse>>;
fn scoped_key_data(
&self,
config: &Config,
session_token: &str,
scope: &str,
) -> Result<HashMap<String, ScopedKeyDataResponse>>;
}
pub struct Client;
impl FxAClient for Client {
fn profile(
&self,
config: &Config,
access_token: &str,
etag: Option<String>,
) -> Result<Option<ResponseAndETag<ProfileResponse>>> {
let url = config.userinfo_endpoint()?;
let mut request =
Request::get(url).header(header_names::AUTHORIZATION, bearer_token(access_token))?;
if let Some(etag) = etag {
request = request.header(header_names::IF_NONE_MATCH, format!("\"{}\"", etag))?;
}
let resp = Self::make_request(request)?;
if resp.status == status_codes::NOT_MODIFIED {
return Ok(None);
}
let etag = resp
.headers
.get(header_names::ETAG)
.map(ToString::to_string);
Ok(Some(ResponseAndETag {
etag,
response: resp.json()?,
}))
}
// For the one-off generation of a `refresh_token` and associated meta from transient credentials.
fn refresh_token_with_code(
&self,
config: &Config,
code: &str,
code_verifier: &str,
) -> Result<OAuthTokenResponse> {
let req_body = OAauthTokenRequest::UsingCode {
code: code.to_string(),
client_id: config.client_id.to_string(),
code_verifier: code_verifier.to_string(),
ttl: None,
};
self.make_oauth_token_request(config, serde_json::to_value(req_body).unwrap())
}
fn refresh_token_with_session_token(
&self,
config: &Config,
session_token: &str,
scopes: &[&str],
) -> Result<OAuthTokenResponse> {
let url = config.token_endpoint()?;
let key = derive_auth_key_from_session_token(&session_token)?;
let body = json!({
"client_id": config.client_id,
"scope": scopes.join(" "),
"grant_type": "fxa-credentials",
"access_type": "offline",
});
let request = HawkRequestBuilder::new(Method::Post, url, &key)
.body(body)
.build()?;
Ok(Self::make_request(request)?.json()?)
}
// For the regular generation of an `access_token` from long-lived credentials.
fn access_token_with_refresh_token(
&self,
config: &Config,
refresh_token: &str,
ttl: Option<u64>,
scopes: &[&str],
) -> Result<OAuthTokenResponse> {
let req = OAauthTokenRequest::UsingRefreshToken {
client_id: config.client_id.clone(),
refresh_token: refresh_token.to_string(),
scope: Some(scopes.join(" ")),
ttl,
};
self.make_oauth_token_request(config, serde_json::to_value(req).unwrap())
}
fn access_token_with_session_token(
&self,
config: &Config,
session_token: &str,
scopes: &[&str],
) -> Result<OAuthTokenResponse> {
let parameters = json!({
"client_id": config.client_id,
"grant_type": "fxa-credentials",
"scope": scopes.join(" ")
});
let key = derive_auth_key_from_session_token(session_token)?;
let url = config.token_endpoint()?;
let request = HawkRequestBuilder::new(Method::Post, url, &key)
.body(parameters)
.build()?;
Self::make_request(request)?.json().map_err(Into::into)
}
fn authorization_code_using_session_token(
&self,
config: &Config,
client_id: &str,
session_token: &str,
scope: &str,
state: &str,
access_type: &str,
) -> Result<OAuthAuthResponse> {
let parameters = json!({
"client_id": client_id,
"scope": scope,
"response_type": "code",
"state": state,
"access_type": access_type,
});
let key = derive_auth_key_from_session_token(session_token)?;
let url = config.auth_url_path("v1/oauth/authorization")?;
let request = HawkRequestBuilder::new(Method::Post, url, &key)
.body(parameters)
.build()?;
Ok(Self::make_request(request)?.json()?)
}
fn oauth_introspect_refresh_token(
&self,
config: &Config,
refresh_token: &str,
) -> Result<IntrospectResponse> {
let body = json!({
"token_type_hint": "refresh_token",
"token": refresh_token,
});
let url = config.introspection_endpoint()?;
Ok(Self::make_request(Request::post(url).json(&body))?.json()?)
}
fn duplicate_session(
&self,
config: &Config,
session_token: &str,
) -> Result<DuplicateTokenResponse> {
let url = config.auth_url_path("v1/session/duplicate")?;
let key = derive_auth_key_from_session_token(&session_token)?;
let duplicate_body = json!({
"reason": "migration"
});
let request = HawkRequestBuilder::new(Method::Post, url, &key)
.body(duplicate_body)
.build()?;
Ok(Self::make_request(request)?.json()?)
}
fn destroy_access_token(&self, config: &Config, access_token: &str) -> Result<()> {
let body = json!({
"token": access_token,
});
self.destroy_token_helper(config, &body)
}
fn destroy_refresh_token(&self, config: &Config, refresh_token: &str) -> Result<()> {
let body = json!({
"refresh_token": refresh_token,
});
self.destroy_token_helper(config, &body)
}
fn pending_commands(
&self,
config: &Config,
refresh_token: &str,
index: u64,
limit: Option<u64>,
) -> Result<PendingCommandsResponse> {
let url = config.auth_url_path("v1/account/device/commands")?;
let mut request = Request::get(url)
.header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
.query(&[("index", &index.to_string())]);
if let Some(limit) = limit {
request = request.query(&[("limit", &limit.to_string())])
}
Ok(Self::make_request(request)?.json()?)
}
fn invoke_command(
&self,
config: &Config,
refresh_token: &str,
command: &str,
target: &str,
payload: &serde_json::Value,
) -> Result<()> {
let body = json!({
"command": command,
"target": target,
"payload": payload
});
let url = config.auth_url_path("v1/account/devices/invoke_command")?;
let request = Request::post(url)
.header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
.header(header_names::CONTENT_TYPE, "application/json")?
.body(body.to_string());
Self::make_request(request)?;
Ok(())
}
fn devices(&self, config: &Config, refresh_token: &str) -> Result<Vec<GetDeviceResponse>> {
let url = config.auth_url_path("v1/account/devices")?;
let request =
Request::get(url).header(header_names::AUTHORIZATION, bearer_token(refresh_token))?;
Ok(Self::make_request(request)?.json()?)
}
fn update_device(
&self,
config: &Config,
refresh_token: &str,
update: DeviceUpdateRequest<'_>,
) -> Result<UpdateDeviceResponse> {
let url = config.auth_url_path("v1/account/device")?;
let request = Request::post(url)
.header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
.header(header_names::CONTENT_TYPE, "application/json")?
.body(serde_json::to_string(&update)?);
Ok(Self::make_request(request)?.json()?)
}
fn destroy_device(&self, config: &Config, refresh_token: &str, id: &str) -> Result<()> {
let body = json!({
"id": id,
});
let url = config.auth_url_path("v1/account/device/destroy")?;
let request = Request::post(url)
.header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
.header(header_names::CONTENT_TYPE, "application/json")?
.body(body.to_string());
Self::make_request(request)?;
Ok(())
}
fn attached_clients(
&self,
config: &Config,
session_token: &str,
) -> Result<Vec<GetAttachedClientResponse>> {
let url = config.auth_url_path("v1/account/attached_clients")?;
let key = derive_auth_key_from_session_token(session_token)?;
let request = HawkRequestBuilder::new(Method::Get, url, &key).build()?;
Ok(Self::make_request(request)?.json()?)
}
fn scoped_key_data(
&self,
config: &Config,
session_token: &str,
scope: &str,
) -> Result<HashMap<String, ScopedKeyDataResponse>> {
let body = json!({
"client_id": config.client_id,
"scope": scope,
});
let url = config.auth_url_path("v1/account/scoped-key-data")?;
let key = derive_auth_key_from_session_token(session_token)?;
let request = HawkRequestBuilder::new(Method::Post, url, &key)
.body(body)
.build()?;
Self::make_request(request)?.json().map_err(|e| e.into())
}
}
impl Client {
pub fn new() -> Self {
Self {}
}
fn destroy_token_helper(&self, config: &Config, body: &serde_json::Value) -> Result<()> {
let url = config.oauth_url_path("v1/destroy")?;
Self::make_request(Request::post(url).json(body))?;
Ok(())
}
fn make_oauth_token_request(
&self,
config: &Config,
body: serde_json::Value,
) -> Result<OAuthTokenResponse> {
let url = config.token_endpoint()?;
Ok(Self::make_request(Request::post(url).json(&body))?.json()?)
}
fn make_request(request: Request) -> Result<Response> {
let resp = request.send()?;
if resp.is_success() || resp.status == status_codes::NOT_MODIFIED {
Ok(resp)
} else {
let json: std::result::Result<serde_json::Value, _> = resp.json();
match json {
Ok(json) => Err(ErrorKind::RemoteError {
code: json["code"].as_u64().unwrap_or(0),
errno: json["errno"].as_u64().unwrap_or(0),
error: json["error"].as_str().unwrap_or("").to_string(),
message: json["message"].as_str().unwrap_or("").to_string(),
info: json["info"].as_str().unwrap_or("").to_string(),
}
.into()),
Err(_) => Err(resp.require_success().unwrap_err().into()),
}
}
}
}
fn bearer_token(token: &str) -> String {
format!("Bearer {}", token)
}
fn kw(name: &str) -> Vec<u8> {
format!("identity.mozilla.com/picl/v1/{}", name)
.as_bytes()
.to_vec()
}
pub fn derive_auth_key_from_session_token(session_token: &str) -> Result<Vec<u8>> {
let session_token_bytes = hex::decode(session_token)?;
let context_info = kw("sessionToken");
let salt = hmac::SigningKey::new(&digest::SHA256, &HAWK_HKDF_SALT);
let mut out = vec![0u8; HAWK_KEY_LENGTH * 2];
hkdf::extract_and_expand(&salt, &session_token_bytes, &context_info, &mut out)?;
Ok(out)
}
struct HawkRequestBuilder<'a> {
url: Url,
method: Method,
body: Option<String>,
hkdf_sha256_key: &'a [u8],
}
impl<'a> HawkRequestBuilder<'a> {
pub fn new(method: Method, url: Url, hkdf_sha256_key: &'a [u8]) -> Self {
rc_crypto::ensure_initialized();
HawkRequestBuilder {
url,
method,
body: None,
hkdf_sha256_key,
}
}
// This class assumes that the content being sent it always of the type
// application/json.
pub fn body(mut self, body: serde_json::Value) -> Self {
self.body = Some(body.to_string());
self
}
fn make_hawk_header(&self) -> Result<String> {
// Make sure we de-allocate the hash after hawk_request_builder.
let hash;
let method = format!("{}", self.method);
let mut hawk_request_builder = RequestBuilder::from_url(method.as_str(), &self.url)?;
if let Some(ref body) = self.body {
hash = PayloadHasher::hash("application/json", SHA256, &body)?;
hawk_request_builder = hawk_request_builder.hash(&hash[..]);
}
let hawk_request = hawk_request_builder.request();
let token_id = hex::encode(&self.hkdf_sha256_key[0..HAWK_KEY_LENGTH]);
let hmac_key = &self.hkdf_sha256_key[HAWK_KEY_LENGTH..(2 * HAWK_KEY_LENGTH)];
let hawk_credentials = Credentials {
id: token_id,
key: Key::new(hmac_key, SHA256)?,
};
let header = hawk_request.make_header(&hawk_credentials)?;
Ok(format!("Hawk {}", header))
}
pub fn build(self) -> Result<Request> {
let hawk_header = self.make_hawk_header()?;
let mut request =
Request::new(self.method, self.url).header(header_names::AUTHORIZATION, hawk_header)?;
if let Some(body) = self.body {
request = request
.header(header_names::CONTENT_TYPE, "application/json")?
.body(body);
}
Ok(request)
}
}
#[derive(Clone)]
pub struct ResponseAndETag<T> {
pub response: T,
pub etag: Option<String>,
}
#[derive(Deserialize)]
pub struct PendingCommandsResponse {
pub index: u64,
pub last: Option<bool>,
pub messages: Vec<PendingCommand>,
}
#[derive(Deserialize)]
pub struct PendingCommand {
pub index: u64,
pub data: CommandData,
}
#[derive(Debug, Deserialize)]
pub struct CommandData {
pub command: String,
pub payload: serde_json::Value, // Need https://github.com/serde-rs/serde/issues/912 to make payload an enum instead.
pub sender: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PushSubscription {
#[serde(rename = "pushCallback")]
pub endpoint: String,
#[serde(rename = "pushPublicKey")]
pub public_key: String,
#[serde(rename = "pushAuthKey")]
pub auth_key: String,
}
/// We use the double Option pattern in this struct.
/// The outer option represents the existence of the field
/// and the inner option its value or null.
/// TL;DR:
/// `None`: the field will not be present in the JSON body.
/// `Some(None)`: the field will have a `null` value.
/// `Some(Some(T))`: the field will have the serialized value of T.
#[derive(Serialize)]
#[allow(clippy::option_option)]
pub struct DeviceUpdateRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "name")]
display_name: Option<Option<&'a str>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "type")]
device_type: Option<Option<&'a DeviceType>>,
#[serde(flatten)]
push_subscription: Option<&'a PushSubscription>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "availableCommands")]
available_commands: Option<Option<&'a HashMap<String, String>>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DeviceType {
#[serde(rename = "desktop")]
Desktop,
#[serde(rename = "mobile")]
Mobile,
#[serde(rename = "tablet")]
Tablet,
#[serde(rename = "vr")]
VR,
#[serde(rename = "tv")]
TV,
#[serde(other)]
#[serde(skip_serializing)] // Don't you dare trying.
Unknown,
}
#[allow(clippy::option_option)]
pub struct DeviceUpdateRequestBuilder<'a> {
device_type: Option<Option<&'a DeviceType>>,
display_name: Option<Option<&'a str>>,
push_subscription: Option<&'a PushSubscription>,
available_commands: Option<Option<&'a HashMap<String, String>>>,
}
impl<'a> DeviceUpdateRequestBuilder<'a> {
pub fn new() -> Self {
Self {
device_type: None,
display_name: None,
push_subscription: None,
available_commands: None,
}
}
pub fn push_subscription(mut self, push_subscription: &'a PushSubscription) -> Self {
self.push_subscription = Some(push_subscription);
self
}
pub fn available_commands(mut self, available_commands: &'a HashMap<String, String>) -> Self {
self.available_commands = Some(Some(available_commands));
self
}
pub fn clear_available_commands(mut self) -> Self {
self.available_commands = Some(None);
self
}
pub fn display_name(mut self, display_name: &'a str) -> Self {
self.display_name = Some(Some(display_name));
self
}
pub fn clear_display_name(mut self) -> Self {
self.display_name = Some(None);
self
}
#[allow(dead_code)]
pub fn device_type(mut self, device_type: &'a DeviceType) -> Self {
self.device_type = Some(Some(device_type));
self
}
pub fn build(self) -> DeviceUpdateRequest<'a> {
DeviceUpdateRequest {
display_name: self.display_name,
device_type: self.device_type,
push_subscription: self.push_subscription,
available_commands: self.available_commands,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceLocation {
pub city: Option<String>,
pub country: Option<String>,
pub state: Option<String>,
#[serde(rename = "stateCode")]
pub state_code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GetDeviceResponse {
#[serde(flatten)]
pub common: DeviceResponseCommon,
#[serde(rename = "isCurrentDevice")]
pub is_current_device: bool,
pub location: DeviceLocation,
#[serde(rename = "lastAccessTime")]
pub last_access_time: Option<u64>,
}
impl std::ops::Deref for GetDeviceResponse {
type Target = DeviceResponseCommon;
fn deref(&self) -> &DeviceResponseCommon {
&self.common
}
}
pub type UpdateDeviceResponse = DeviceResponseCommon;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceResponseCommon {
pub id: String,
#[serde(rename = "name")]
pub display_name: String,
#[serde(rename = "type")]
pub device_type: DeviceType,
#[serde(flatten)]
pub push_subscription: Option<PushSubscription>,
#[serde(rename = "availableCommands")]
pub available_commands: HashMap<String, String>,
#[serde(rename = "pushEndpointExpired")]
pub push_endpoint_expired: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetAttachedClientResponse {
pub client_id: Option<String>,
pub session_token_id: Option<String>,
pub refresh_token_id: Option<String>,
pub device_id: Option<String>,
pub device_type: Option<DeviceType>,
pub is_current_session: bool,
pub name: Option<String>,
pub created_time: Option<u64>,
pub last_access_time: Option<u64>,
pub scope: Option<Vec<String>>,
pub user_agent: String,
pub os: Option<String>,
}
// We model the OAuthTokenRequest according to the up to date
// definition on
// https://github.com/mozilla/fxa/blob/8ae0e6876a50c7f386a9ec5b6df9ebb54ccdf1b5/packages/fxa-auth-server/lib/oauth/routes/token.js#L70-L152
#[derive(Serialize)]
#[serde(tag = "grant_type")]
enum OAauthTokenRequest {
#[serde(rename = "refresh_token")]
UsingRefreshToken {
client_id: String,
refresh_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u64>,
},
#[serde(rename = "authorization_code")]
UsingCode {
client_id: String,
code: String,
code_verifier: String,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u64>,
},
}
#[derive(Deserialize)]
pub struct OAuthTokenResponse {
pub keys_jwe: Option<String>,
pub refresh_token: Option<String>,
pub session_token: Option<String>,
pub expires_in: u64,
pub scope: String,
pub access_token: String,
}
#[derive(Deserialize, Debug)]
pub struct OAuthAuthResponse {
pub redirect: String,
pub code: String,
pub state: String,
}
#[derive(Deserialize)]
pub struct IntrospectResponse {
pub active: bool,
// Technically the response has a lot of other fields,
// but in practice we only use `active`.
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProfileResponse {
pub uid: String,
pub email: String,
pub locale: String,
#[serde(rename = "displayName")]
pub display_name: Option<String>,
pub avatar: String,
#[serde(rename = "avatarDefault")]
pub avatar_default: bool,
#[serde(rename = "amrValues")]
pub amr_values: Vec<String>,
#[serde(rename = "twoFactorAuthentication")]
pub two_factor_authentication: bool,
}
#[derive(Deserialize)]
pub struct ScopedKeyDataResponse {
pub identifier: String,
#[serde(rename = "keyRotationSecret")]
pub key_rotation_secret: String,
#[serde(rename = "keyRotationTimestamp")]
pub key_rotation_timestamp: u64,
}
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct DuplicateTokenResponse {
pub uid: String,
#[serde(rename = "sessionToken")]
pub session_token: String,
pub verified: bool,
#[serde(rename = "authAt")]
pub auth_at: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(non_snake_case)]
fn check_OAauthTokenRequest_serialization() {
// Ensure OAauthTokenRequest serializes to what the server expects.
let using_code = OAauthTokenRequest::UsingCode {
code: "foo".to_owned(),
client_id: "bar".to_owned(),
code_verifier: "bobo".to_owned(),
ttl: None,
};
assert_eq!("{\"grant_type\":\"authorization_code\",\"client_id\":\"bar\",\"code\":\"foo\",\"code_verifier\":\"bobo\"}", serde_json::to_string(&using_code).unwrap());
let using_code = OAauthTokenRequest::UsingRefreshToken {
client_id: "bar".to_owned(),
refresh_token: "foo".to_owned(),
scope: Some("bobo".to_owned()),
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());
}
}