feat: Remove support for BrowserID (#1531)

* feat: Remove support for BrowserID
* mark test only functions as such. I think we can drop MapAndThenTrait for more recent implementations of rust.

Closes: SYNC-3684
This commit is contained in:
JR Conlin 2024-06-14 12:51:02 -07:00 committed by GitHub
parent 7004ad0121
commit dbbdd1dfc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 578 additions and 1966 deletions

View File

@ -192,7 +192,7 @@ jobs:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
# XXX: begin_test_transaction doesn't play nice over threaded tests # XXX: begin_test_transaction doesn't play nice over threaded tests
RUST_TEST_THREADS: 1 RUST_TEST_THREADS: 1
- image: circleci/mysql:5.7-ram - image: cimg/mysql:5.7
auth: auth:
username: $DOCKER_USER username: $DOCKER_USER
password: $DOCKER_PASS password: $DOCKER_PASS

712
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -30,12 +30,12 @@ license = "MPL-2.0"
[workspace.dependencies] [workspace.dependencies]
actix-web = "4" actix-web = "4"
base64 = "0.21" base64 = "0.22"
cadence = "0.29" cadence = "1.3"
backtrace = "0.3" backtrace = "0.3"
chrono = "0.4" chrono = "0.4"
docopt = "1.1" docopt = "1.1"
env_logger = "0.10" env_logger = "0.11"
futures = { version = "0.3", features = ["compat"] } futures = { version = "0.3", features = ["compat"] }
futures-util = { version = "0.3", features = [ futures-util = { version = "0.3", features = [
"async-await", "async-await",
@ -44,30 +44,31 @@ futures-util = { version = "0.3", features = [
"io", "io",
] } ] }
hex = "0.4" hex = "0.4"
hostname = "0.4"
hkdf = "0.12" hkdf = "0.12"
hmac = "0.12" hmac = "0.12"
http = "0.2" http = "1.1"
jsonwebtoken = { version = "9.2", default-features = false } jsonwebtoken = { version = "9.2", default-features = false }
lazy_static = "1.4" lazy_static = "1.4"
protobuf = "=2.25.2" # pin to 2.25.2 to prevent side updating protobuf = "=2.25.2" # pin to 2.25.2 to prevent side updating
rand = "0.8" rand = "0.8"
regex = "1.4" regex = "1.4"
reqwest = { version = "0.11", default-features = false, features = [ reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls", "rustls-tls",
] } ] }
sentry = { version = "0.31", default-features = false, features = [ sentry = { version = "0.32", default-features = false, features = [
"curl", "curl",
"backtrace", "backtrace",
"contexts", "contexts",
"debug-images", "debug-images",
] } ] }
sentry-backtrace = "0.31" sentry-backtrace = "0.32"
serde = "1.0" serde = "1.0"
serde_derive = "1.0" serde_derive = "1.0"
serde_json = { version = "1.0", features = ["arbitrary_precision"] } serde_json = { version = "1.0", features = ["arbitrary_precision"] }
sha2 = "0.10" sha2 = "0.10"
slog = { version = "2.5", features = [ slog = { version = "2.5", features = [
"max_level_info", "max_level_trace",
"release_max_level_info", "release_max_level_info",
"dynamic-keys", "dynamic-keys",
] } ] }

View File

@ -16,6 +16,8 @@ docopt.workspace = true
futures.workspace = true futures.workspace = true
futures-util.workspace = true futures-util.workspace = true
hex.workspace = true hex.workspace = true
hostname.workspace = true
http.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
rand.workspace = true rand.workspace = true
regex.workspace = true regex.workspace = true
@ -37,10 +39,9 @@ thiserror.workspace = true
actix-http = "3" actix-http = "3"
actix-rt = "2" actix-rt = "2"
actix-cors = "0.6" actix-cors = "0.7"
async-trait = "0.1.40" async-trait = "0.1.40"
dyn-clone = "1.0.4" dyn-clone = "1.0.4"
hostname = "0.3.1"
hawk = "5.0" hawk = "5.0"
mime = "0.3" mime = "0.3"
# pin to 0.19: https://github.com/getsentry/sentry-rust/issues/277 # pin to 0.19: https://github.com/getsentry/sentry-rust/issues/277

View File

@ -9,10 +9,10 @@ use std::convert::From;
use std::fmt; use std::fmt;
use actix_web::{ use actix_web::{
dev::ServiceResponse, error::ResponseError, http::StatusCode, middleware::ErrorHandlerResponse, dev::ServiceResponse, error::ResponseError, middleware::ErrorHandlerResponse, HttpResponse,
HttpResponse, HttpResponseBuilder, Result, HttpResponseBuilder, Result,
}; };
use http::StatusCode;
use serde::{ use serde::{
ser::{SerializeMap, SerializeSeq, Serializer}, ser::{SerializeMap, SerializeSeq, Serializer},
Serialize, Serialize,
@ -117,7 +117,7 @@ impl ApiError {
Ok(ErrorHandlerResponse::Response(res.map_into_left_body())) Ok(ErrorHandlerResponse::Response(res.map_into_left_body()))
} else { } else {
// Replace the outbound error message with our own for Sync requests. // Replace the outbound error message with our own for Sync requests.
let resp = HttpResponseBuilder::new(StatusCode::NOT_FOUND) let resp = HttpResponseBuilder::new(actix_web::http::StatusCode::NOT_FOUND)
.json(WeaveError::UnknownError as u32); .json(WeaveError::UnknownError as u32);
Ok(ErrorHandlerResponse::Response(ServiceResponse::new( Ok(ErrorHandlerResponse::Response(ServiceResponse::new(
res.request().clone(), res.request().clone(),
@ -194,7 +194,9 @@ impl ResponseError for ApiError {
// HttpResponse::build(self.status).json(self) // HttpResponse::build(self.status).json(self)
// //
// So instead we translate our error to a backwards compatible one // So instead we translate our error to a backwards compatible one
let mut resp = HttpResponse::build(self.status); let mut resp = HttpResponse::build(
actix_web::http::StatusCode::from_u16(self.status.as_u16()).unwrap(),
);
if self.is_conflict() { if self.is_conflict() {
resp.insert_header(("Retry-After", RETRY_AFTER.to_string())); resp.insert_header(("Retry-After", RETRY_AFTER.to_string()));
}; };

View File

@ -6,7 +6,6 @@ use actix_web::{
http::{ http::{
self, self,
header::{HeaderName, HeaderValue}, header::{HeaderName, HeaderValue},
StatusCode,
}, },
test, test,
web::Bytes, web::Bytes,
@ -15,6 +14,7 @@ use base64::{engine, Engine};
use chrono::offset::Utc; use chrono::offset::Utc;
use hawk::{self, Credentials, Key, RequestBuilder}; use hawk::{self, Credentials, Key, RequestBuilder};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use http::StatusCode;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@ -172,7 +172,7 @@ fn create_hawk_header(method: &str, port: u16, path: &str) -> String {
user_id: 42, user_id: 42,
fxa_uid: format!("xxx_test_uid_{}", *RAND_UID), fxa_uid: format!("xxx_test_uid_{}", *RAND_UID),
fxa_kid: format!("xxx_test_kid_{}", *RAND_UID), fxa_kid: format!("xxx_test_kid_{}", *RAND_UID),
device_id: "xxx_test".to_owned(), hashed_device_id: "xxx_test".to_owned(),
tokenserver_origin: Default::default(), tokenserver_origin: Default::default(),
}; };
let payload = let payload =

View File

@ -9,7 +9,6 @@ use std::sync::Arc;
use actix_web::{ use actix_web::{
dev::Payload, dev::Payload,
http::StatusCode,
web::{Data, Query}, web::{Data, Query},
FromRequest, HttpRequest, FromRequest, HttpRequest,
}; };
@ -17,6 +16,7 @@ use base64::{engine, Engine};
use futures::future::LocalBoxFuture; use futures::future::LocalBoxFuture;
use hex; use hex;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use http::StatusCode;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
@ -98,14 +98,12 @@ impl TokenserverRequest {
}); });
} }
// If the client previously reported a client state, every subsequent request must include // If the caller reports new client state, but the auth doesn't, flag
// one. Note that this is only relevant for BrowserID requests, since OAuth requests must // it as an error.
// always include a client state.
if !self.user.client_state.is_empty() && self.auth_data.client_state.is_empty() { if !self.user.client_state.is_empty() && self.auth_data.client_state.is_empty() {
let error_message = "Unacceptable client-state value empty string".to_owned(); let error_message = "Unacceptable client-state value empty string".to_owned();
return Err(TokenserverError::invalid_client_state(error_message, None)); return Err(TokenserverError::invalid_client_state(error_message, None));
} }
// The client state on the request must not have been used in the past. // The client state on the request must not have been used in the past.
if self if self
.user .user
@ -159,14 +157,11 @@ impl TokenserverRequest {
}); });
} }
// If there's no keys_changed_at on the request, there must be no value stored on the user // Oauth requests must always include a `keys_changed_at` header. The Python Tokenserver
// record. Note that this is only relevant for BrowserID requests, since OAuth requests // converts a NULL `keys_changed_at` to 0 in memory, which means that NULL `keys_changed_at`s
// must always include a keys_changed_at header. The Python Tokenserver converts a NULL // are treated equivalenty to 0 `keys_changed_at`s. This would allow users with a 0 `keys_changed_at`
// keys_changed_at on the user record to 0 in memory, which means that NULL // on their user record to hold off on sending a `keys_changed_at` in requests even though the
// keys_changed_ats are treated equivalently to 0 keys_changed_ats. This would allow users // value in the database is non-NULL. To be thorough, we handle this case here.
// with a 0 keys_changed_at on their user record to hold off on sending a keys_changed_at
// in requests even though the value in the database is non-NULL. To be thorough, we
// handle this case here.
if auth_keys_changed_at.is_none() if auth_keys_changed_at.is_none()
&& matches!(user_keys_changed_at, Some(inner) if inner != 0) && matches!(user_keys_changed_at, Some(inner) if inner != 0)
{ {
@ -178,7 +173,6 @@ impl TokenserverRequest {
..TokenserverError::invalid_keys_changed_at() ..TokenserverError::invalid_keys_changed_at()
}); });
} }
Ok(()) Ok(())
} }
} }
@ -209,12 +203,8 @@ impl FromRequest for TokenserverRequest {
log_items_mutator.insert("metrics_uid".to_owned(), hashed_fxa_uid.clone()); log_items_mutator.insert("metrics_uid".to_owned(), hashed_fxa_uid.clone());
// To preserve anonymity, compute a hash of the FxA device ID to be used for reporting // To preserve anonymity, compute a hash of the FxA device ID to be used for reporting
// metrics. Only requests using BrowserID will have a device ID, so use "none" as // metrics. Use "none" as a placeholder for "device" with OAuth requests.
// a placeholder for OAuth requests. let hashed_device_id = hash_device_id(&hashed_fxa_uid, fxa_metrics_hash_secret);
let hashed_device_id = {
let device_id = auth_data.device_id.as_deref().unwrap_or("none");
hash_device_id(&hashed_fxa_uid, device_id, fxa_metrics_hash_secret)
};
let DbWrapper(db) = DbWrapper::extract(&req).await?; let DbWrapper(db) = DbWrapper::extract(&req).await?;
let service_id = { let service_id = {
@ -352,10 +342,9 @@ impl FromRequest for DbPoolWrapper {
} }
} }
/// An authentication token as parsed from the `Authorization` header. Both BrowserID assertions /// An authentication token as parsed from the `Authorization` header.
/// and OAuth tokens are opaque to Tokenserver and must be verified via FxA. /// OAuth tokens are opaque to Tokenserver and must be verified via FxA.
pub enum Token { pub enum Token {
BrowserIdAssertion(String),
OAuthToken(String), OAuthToken(String),
} }
@ -393,10 +382,8 @@ impl FromRequest for Token {
if auth_type == "bearer" { if auth_type == "bearer" {
Ok(Token::OAuthToken(token.to_owned())) Ok(Token::OAuthToken(token.to_owned()))
} else if auth_type == "browserid" {
Ok(Token::BrowserIdAssertion(token.to_owned()))
} else { } else {
// The request must use a Bearer token or BrowserID token // The request must use a Bearer token
Err(TokenserverError { Err(TokenserverError {
description: "Unsupported".to_owned(), description: "Unsupported".to_owned(),
location: ErrorLocation::Body, location: ErrorLocation::Body,
@ -421,7 +408,6 @@ impl FromRequest for Token {
#[derive(Debug, Default, Eq, PartialEq)] #[derive(Debug, Default, Eq, PartialEq)]
pub struct AuthData { pub struct AuthData {
pub client_state: String, pub client_state: String,
pub device_id: Option<String>,
pub email: String, pub email: String,
pub fxa_uid: String, pub fxa_uid: String,
pub generation: Option<i64>, pub generation: Option<i64>,
@ -452,37 +438,6 @@ impl FromRequest for AuthData {
} }
match token { match token {
Token::BrowserIdAssertion(assertion) => {
// Add a tag to the request extensions
req.add_tag("token_type".to_owned(), "BrowserID".to_owned());
log_items_mutator.insert("token_type".to_owned(), "BrowserID".to_owned());
// Start a timer with the same tag
let mut tags = HashMap::default();
tags.insert("token_type".to_owned(), "BrowserID".to_owned());
metrics.start_timer("token_verification", Some(tags));
let verify_output =
state.browserid_verifier.verify(assertion, &metrics).await?;
// For requests using BrowserID, the client state is embedded in the
// X-Client-State header, and the generation and keys_changed_at are extracted
// from the assertion as part of the verification process.
let XClientStateHeader(client_state) =
XClientStateHeader::extract(&req).await?;
let (fxa_uid, _) = verify_output
.email
.split_once('@')
.unwrap_or((&verify_output.email, ""));
Ok(AuthData {
client_state: client_state.unwrap_or_else(|| "".to_owned()),
device_id: verify_output.device_id,
email: verify_output.email.clone(),
fxa_uid: fxa_uid.to_owned(),
generation: convert_zero_to_none(verify_output.generation),
keys_changed_at: convert_zero_to_none(verify_output.keys_changed_at),
})
}
Token::OAuthToken(token) => { Token::OAuthToken(token) => {
// Add a tag to the request extensions // Add a tag to the request extensions
req.add_tag("token_type".to_owned(), "OAuth".to_owned()); req.add_tag("token_type".to_owned(), "OAuth".to_owned());
@ -503,7 +458,6 @@ impl FromRequest for AuthData {
Ok(AuthData { Ok(AuthData {
client_state: key_id.client_state, client_state: key_id.client_state,
email, email,
device_id: None,
fxa_uid, fxa_uid,
generation: convert_zero_to_none(verify_output.generation), generation: convert_zero_to_none(verify_output.generation),
keys_changed_at: convert_zero_to_none(Some(key_id.keys_changed_at)), keys_changed_at: convert_zero_to_none(Some(key_id.keys_changed_at)),
@ -686,9 +640,14 @@ fn fxa_metrics_hash(fxa_uid: &str, hmac_key: &[u8]) -> String {
hex::encode(result) hex::encode(result)
} }
fn hash_device_id(fxa_uid: &str, device: &str, hmac_key: &[u8]) -> String { fn hash_device_id(fxa_uid: &str, hmac_key: &[u8]) -> String {
let mut to_hash = String::from(fxa_uid); let mut to_hash = String::from(fxa_uid);
to_hash.push_str(device); // TODO: This value originally was the deviceID from BrowserID.
// When support was dropped for BrowserID, the device string
// defaulted to "none". Append it here for now as a hard coded
// value until we can figure out if it's something we need to
// preserve for the UA or not.
to_hash.push_str("none");
let fxa_metrics_hash = fxa_metrics_hash(&to_hash, hmac_key); let fxa_metrics_hash = fxa_metrics_hash(&to_hash, hmac_key);
String::from(&fxa_metrics_hash[0..32]) String::from(&fxa_metrics_hash[0..32])
@ -709,7 +668,7 @@ mod tests {
use serde_json; use serde_json;
use syncserver_settings::Settings as GlobalSettings; use syncserver_settings::Settings as GlobalSettings;
use syncstorage_settings::ServerLimits; use syncstorage_settings::ServerLimits;
use tokenserver_auth::{browserid, oauth, MockVerifier}; use tokenserver_auth::{oauth, MockVerifier};
use tokenserver_db::mock::MockDbPool as MockTokenserverPool; use tokenserver_db::mock::MockDbPool as MockTokenserverPool;
use tokenserver_settings::Settings as TokenserverSettings; use tokenserver_settings::Settings as TokenserverSettings;
@ -740,7 +699,7 @@ mod tests {
verify_output, verify_output,
} }
}; };
let state = make_state(oauth_verifier, MockVerifier::default()); let state = make_state(oauth_verifier);
let req = TestRequest::default() let req = TestRequest::default()
.data(state) .data(state)
@ -761,7 +720,6 @@ mod tests {
let expected_tokenserver_request = TokenserverRequest { let expected_tokenserver_request = TokenserverRequest {
user: results::GetOrCreateUser::default(), user: results::GetOrCreateUser::default(),
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: fxa_uid.to_owned(), fxa_uid: fxa_uid.to_owned(),
email: "test123@test.com".to_owned(), email: "test123@test.com".to_owned(),
generation: Some(1234), generation: Some(1234),
@ -794,7 +752,7 @@ mod tests {
verify_output, verify_output,
} }
}; };
let state = make_state(oauth_verifier, MockVerifier::default()); let state = make_state(oauth_verifier);
let request = TestRequest::default() let request = TestRequest::default()
.data(state) .data(state)
@ -837,7 +795,7 @@ mod tests {
}; };
TestRequest::default() TestRequest::default()
.data(make_state(oauth_verifier, MockVerifier::default())) .data(make_state(oauth_verifier))
.data(Arc::clone(&SECRETS)) .data(Arc::clone(&SECRETS))
.insert_header(("authorization", "Bearer fake_token")) .insert_header(("authorization", "Bearer fake_token"))
.insert_header(("accept", "application/json,text/plain:q=0.5")) .insert_header(("accept", "application/json,text/plain:q=0.5"))
@ -942,7 +900,7 @@ mod tests {
}; };
TestRequest::default() TestRequest::default()
.data(make_state(oauth_verifier, MockVerifier::default())) .data(make_state(oauth_verifier))
.insert_header(("authorization", "Bearer fake_token")) .insert_header(("authorization", "Bearer fake_token"))
.insert_header(("accept", "application/json,text/plain:q=0.5")) .insert_header(("accept", "application/json,text/plain:q=0.5"))
.param("application", "sync") .param("application", "sync")
@ -1095,7 +1053,6 @@ mod tests {
old_client_states: vec![], old_client_states: vec![],
}, },
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: "test".to_owned(), fxa_uid: "test".to_owned(),
email: "test@test.com".to_owned(), email: "test@test.com".to_owned(),
generation: Some(1233), generation: Some(1233),
@ -1138,7 +1095,6 @@ mod tests {
old_client_states: vec![], old_client_states: vec![],
}, },
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: "test".to_owned(), fxa_uid: "test".to_owned(),
email: "test@test.com".to_owned(), email: "test@test.com".to_owned(),
generation: Some(1234), generation: Some(1234),
@ -1180,7 +1136,6 @@ mod tests {
old_client_states: vec![], old_client_states: vec![],
}, },
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: "test".to_owned(), fxa_uid: "test".to_owned(),
email: "test@test.com".to_owned(), email: "test@test.com".to_owned(),
generation: Some(1234), generation: Some(1234),
@ -1223,7 +1178,6 @@ mod tests {
old_client_states: vec!["bbbb".to_owned()], old_client_states: vec!["bbbb".to_owned()],
}, },
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: "test".to_owned(), fxa_uid: "test".to_owned(),
email: "test@test.com".to_owned(), email: "test@test.com".to_owned(),
generation: Some(1234), generation: Some(1234),
@ -1266,7 +1220,6 @@ mod tests {
old_client_states: vec![], old_client_states: vec![],
}, },
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: "test".to_owned(), fxa_uid: "test".to_owned(),
email: "test@test.com".to_owned(), email: "test@test.com".to_owned(),
generation: Some(1234), generation: Some(1234),
@ -1307,7 +1260,6 @@ mod tests {
old_client_states: vec![], old_client_states: vec![],
}, },
auth_data: AuthData { auth_data: AuthData {
device_id: None,
fxa_uid: "test".to_owned(), fxa_uid: "test".to_owned(),
email: "test@test.com".to_owned(), email: "test@test.com".to_owned(),
generation: Some(1235), generation: Some(1235),
@ -1335,17 +1287,13 @@ mod tests {
String::from_utf8(block_on(test::read_body(sresponse)).to_vec()).unwrap() String::from_utf8(block_on(test::read_body(sresponse)).to_vec()).unwrap()
} }
fn make_state( fn make_state(oauth_verifier: MockVerifier<oauth::VerifyOutput>) -> ServerState {
oauth_verifier: MockVerifier<oauth::VerifyOutput>,
browserid_verifier: MockVerifier<browserid::VerifyOutput>,
) -> ServerState {
let syncserver_settings = GlobalSettings::default(); let syncserver_settings = GlobalSettings::default();
let tokenserver_settings = TokenserverSettings::default(); let tokenserver_settings = TokenserverSettings::default();
ServerState { ServerState {
fxa_email_domain: "test.com".to_owned(), fxa_email_domain: "test.com".to_owned(),
fxa_metrics_hash_secret: "".to_owned(), fxa_metrics_hash_secret: "".to_owned(),
browserid_verifier: Box::new(browserid_verifier),
oauth_verifier: Box::new(oauth_verifier), oauth_verifier: Box::new(oauth_verifier),
db_pool: Box::new(MockTokenserverPool::new()), db_pool: Box::new(MockTokenserverPool::new()),
node_capacity_release_rate: None, node_capacity_release_rate: None,

View File

@ -11,7 +11,7 @@ use serde::{
use syncserver_common::{BlockingThreadpool, Metrics}; use syncserver_common::{BlockingThreadpool, Metrics};
#[cfg(not(feature = "py_verifier"))] #[cfg(not(feature = "py_verifier"))]
use tokenserver_auth::JWTVerifierImpl; use tokenserver_auth::JWTVerifierImpl;
use tokenserver_auth::{browserid, oauth, VerifyToken}; use tokenserver_auth::{oauth, VerifyToken};
use tokenserver_common::NodeType; use tokenserver_common::NodeType;
use tokenserver_db::{params, DbPool, TokenserverPool}; use tokenserver_db::{params, DbPool, TokenserverPool};
use tokenserver_settings::Settings; use tokenserver_settings::Settings;
@ -21,7 +21,7 @@ use crate::{
server::user_agent, server::user_agent,
}; };
use std::{collections::HashMap, convert::TryFrom, fmt, sync::Arc}; use std::{collections::HashMap, fmt, sync::Arc};
#[derive(Clone)] #[derive(Clone)]
pub struct ServerState { pub struct ServerState {
@ -29,7 +29,6 @@ pub struct ServerState {
pub fxa_email_domain: String, pub fxa_email_domain: String,
pub fxa_metrics_hash_secret: String, pub fxa_metrics_hash_secret: String,
pub oauth_verifier: Box<dyn VerifyToken<Output = oauth::VerifyOutput>>, pub oauth_verifier: Box<dyn VerifyToken<Output = oauth::VerifyOutput>>,
pub browserid_verifier: Box<dyn VerifyToken<Output = browserid::VerifyOutput>>,
pub node_capacity_release_rate: Option<f32>, pub node_capacity_release_rate: Option<f32>,
pub node_type: NodeType, pub node_type: NodeType,
pub metrics: Arc<StatsdClient>, pub metrics: Arc<StatsdClient>,
@ -72,10 +71,6 @@ impl ServerState {
oauth::Verifier::new(settings, blocking_threadpool.clone()) oauth::Verifier::new(settings, blocking_threadpool.clone())
.expect("failed to create Tokenserver OAuth verifier"), .expect("failed to create Tokenserver OAuth verifier"),
); );
let browserid_verifier = Box::new(
browserid::Verifier::try_from(settings)
.expect("failed to create Tokenserver BrowserID verifier"),
);
let use_test_transactions = false; let use_test_transactions = false;
TokenserverPool::new( TokenserverPool::new(
@ -102,7 +97,6 @@ impl ServerState {
fxa_email_domain: settings.fxa_email_domain.clone(), fxa_email_domain: settings.fxa_email_domain.clone(),
fxa_metrics_hash_secret: settings.fxa_metrics_hash_secret.clone(), fxa_metrics_hash_secret: settings.fxa_metrics_hash_secret.clone(),
oauth_verifier, oauth_verifier,
browserid_verifier,
db_pool: Box::new(db_pool), db_pool: Box::new(db_pool),
node_capacity_release_rate: settings.node_capacity_release_rate, node_capacity_release_rate: settings.node_capacity_release_rate,
node_type: settings.node_type, node_type: settings.node_type,

View File

@ -53,8 +53,8 @@ pub struct HawkPayload {
#[serde(default)] #[serde(default)]
pub fxa_kid: String, pub fxa_kid: String,
#[serde(default, rename = "hashed_device_id")] #[serde(default)]
pub device_id: String, pub hashed_device_id: String,
/// The Tokenserver that created this token. /// The Tokenserver that created this token.
#[serde(default)] #[serde(default)]
@ -156,7 +156,7 @@ impl HawkPayload {
user_id, user_id,
fxa_uid: "xxx_test".to_owned(), fxa_uid: "xxx_test".to_owned(),
fxa_kid: "xxx_test".to_owned(), fxa_kid: "xxx_test".to_owned(),
device_id: "xxx_test".to_owned(), hashed_device_id: "xxx_test".to_owned(),
tokenserver_origin: Default::default(), tokenserver_origin: Default::default(),
} }
} }
@ -508,7 +508,7 @@ mod tests {
user_id: 1, user_id: 1,
fxa_uid: "319b98f9961ff1dbdd07313cd6ba925a".to_owned(), fxa_uid: "319b98f9961ff1dbdd07313cd6ba925a".to_owned(),
fxa_kid: "de697ad66d845b2873c9d7e13b8971af".to_owned(), fxa_kid: "de697ad66d845b2873c9d7e13b8971af".to_owned(),
device_id: "2bcb92f4d4698c3d7b083a3c698a16ccd78bc2a8d20a96e4bb128ddceaf4e0b6".to_owned(), hashed_device_id: "2bcb92f4d4698c3d7b083a3c698a16ccd78bc2a8d20a96e4bb128ddceaf4e0b6".to_owned(),
tokenserver_origin: Default::default(), tokenserver_origin: Default::default(),
}, },
} }

View File

@ -2,12 +2,13 @@
#![allow(clippy::single_match)] #![allow(clippy::single_match)]
use std::fmt; use std::fmt;
use actix_web::http::{header::ToStrError, StatusCode}; use actix_web::http::header::ToStrError;
use actix_web::Error as ActixError; use actix_web::Error as ActixError;
use base64::DecodeError; use base64::DecodeError;
use hawk::Error as ParseError; use hawk::Error as ParseError;
use hmac::digest::{InvalidLength, MacError}; use hmac::digest::{InvalidLength, MacError};
use http::StatusCode;
use serde::{ use serde::{
ser::{SerializeSeq, Serializer}, ser::{SerializeSeq, Serializer},
Serialize, Serialize,

View File

@ -1,6 +1,7 @@
//! API Handlers //! API Handlers
use std::collections::HashMap; use std::collections::HashMap;
use std::convert::Into; use std::convert::Into;
use std::time::{Duration, Instant};
use actix_web::{http::StatusCode, web::Data, HttpRequest, HttpResponse, HttpResponseBuilder}; use actix_web::{http::StatusCode, web::Data, HttpRequest, HttpResponse, HttpResponseBuilder};
use serde::Serialize; use serde::Serialize;
@ -11,7 +12,6 @@ use syncstorage_db::{
results::{CreateBatch, Paginated}, results::{CreateBatch, Paginated},
Db, DbError, DbErrorIntrospect, Db, DbError, DbErrorIntrospect,
}; };
use time;
use crate::{ use crate::{
error::{ApiError, ApiErrorKind}, error::{ApiError, ApiErrorKind},
@ -585,7 +585,7 @@ pub async fn lbheartbeat(req: HttpRequest) -> Result<HttpResponse, ApiError> {
let deadarc = state.deadman.clone(); let deadarc = state.deadman.clone();
let mut deadman = *deadarc.read().await; let mut deadman = *deadarc.read().await;
if matches!(deadman.expiry, Some(expiry) if expiry <= time::Instant::now()) { if matches!(deadman.expiry, Some(expiry) if expiry <= Instant::now()) {
// We're set to report a failed health check after a certain time (to // We're set to report a failed health check after a certain time (to
// evict this instance and start a fresh one) // evict this instance and start a fresh one)
return Ok(HttpResponseBuilder::new(StatusCode::INTERNAL_SERVER_ERROR).json(resp)); return Ok(HttpResponseBuilder::new(StatusCode::INTERNAL_SERVER_ERROR).json(resp));
@ -625,7 +625,7 @@ pub async fn lbheartbeat(req: HttpRequest) -> Result<HttpResponse, ApiError> {
if active >= deadman.max_size && db_state.idle_connections == 0 { if active >= deadman.max_size && db_state.idle_connections == 0 {
if deadman.clock_start.is_none() { if deadman.clock_start.is_none() {
deadman.clock_start = Some(time::Instant::now()); deadman.clock_start = Some(Instant::now());
} }
status_code = StatusCode::INTERNAL_SERVER_ERROR; status_code = StatusCode::INTERNAL_SERVER_ERROR;
} else if deadman.clock_start.is_some() { } else if deadman.clock_start.is_some() {
@ -641,11 +641,8 @@ pub async fn lbheartbeat(req: HttpRequest) -> Result<HttpResponse, ApiError> {
Value::from(db_state.idle_connections), Value::from(db_state.idle_connections),
); );
if let Some(clock) = deadman.clock_start { if let Some(clock) = deadman.clock_start {
let duration: time::Duration = time::Instant::now() - clock; let duration: Duration = Instant::now() - clock;
resp.insert( resp.insert("duration_ms".to_string(), Value::from(duration.as_millis()));
"duration_ms".to_string(),
Value::from(duration.whole_milliseconds()),
);
}; };
Ok(HttpResponseBuilder::new(status_code).json(json!(resp))) Ok(HttpResponseBuilder::new(status_code).json(json!(resp)))

View File

@ -9,12 +9,12 @@ edition.workspace = true
cadence.workspace = true cadence.workspace = true
env_logger.workspace = true env_logger.workspace = true
futures.workspace = true futures.workspace = true
hostname.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
rand.workspace = true rand.workspace = true
slog-scope.workspace = true slog-scope.workspace = true
async-trait = "0.1.40" async-trait = "0.1.40"
hostname = "0.3.1"
log = { version = "0.4", features = [ log = { version = "0.4", features = [
"max_level_debug", "max_level_debug",
"release_max_level_info", "release_max_level_info",

View File

@ -1,6 +1,9 @@
//! Application settings objects and initialization //! Application settings objects and initialization
use std::cmp::min; use std::{
cmp::min,
time::{Duration, Instant},
};
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -40,8 +43,8 @@ pub struct Quota {
pub struct Deadman { pub struct Deadman {
pub max_size: u32, pub max_size: u32,
pub previous_count: usize, pub previous_count: usize,
pub clock_start: Option<time::Instant>, pub clock_start: Option<Instant>,
pub expiry: Option<time::Instant>, pub expiry: Option<Instant>,
} }
impl From<&Settings> for Deadman { impl From<&Settings> for Deadman {
@ -52,7 +55,7 @@ impl From<&Settings> for Deadman {
let ttl = lbheartbeat_ttl as f32; let ttl = lbheartbeat_ttl as f32;
let max_jitter = ttl * (settings.lbheartbeat_ttl_jitter as f32 * 0.01); let max_jitter = ttl * (settings.lbheartbeat_ttl_jitter as f32 * 0.01);
let ttl = thread_rng().gen_range(ttl..ttl + max_jitter); let ttl = thread_rng().gen_range(ttl..ttl + max_jitter);
time::Instant::now() + time::Duration::seconds(ttl as i64) Instant::now() + Duration::from_secs(ttl as u64)
}); });
Deadman { Deadman {
max_size: settings.database_pool_max_size, max_size: settings.database_pool_max_size,

View File

@ -20,4 +20,4 @@ batch_upload_enabled = true
force_consistent_sort_order = true force_consistent_sort_order = true
[hawkauth] [hawkauth]
secret = "TED KOPPEL IS A ROBOT" secret = "secret0"

View File

@ -1,643 +0,0 @@
use async_trait::async_trait;
use reqwest::{Client as ReqwestClient, StatusCode};
use serde::{de::Deserializer, Deserialize, Serialize};
use syncserver_common::Metrics;
use tokenserver_common::{ErrorLocation, TokenType, TokenserverError};
use tokenserver_settings::Settings;
use super::VerifyToken;
use std::{convert::TryFrom, time::Duration};
/// The information extracted from a valid BrowserID assertion.
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct VerifyOutput {
pub device_id: Option<String>,
pub email: String,
pub generation: Option<i64>,
pub keys_changed_at: Option<i64>,
}
/// The verifier used to verify BrowserID assertions.
#[derive(Clone)]
pub struct Verifier {
audience: String,
issuer: String,
fxa_verifier_url: String,
// reqwest's async client uses an `Arc` internally, so we don't need to use one here to take
// advantage of keep-alive connections across threads.
request_client: ReqwestClient,
}
impl TryFrom<&Settings> for Verifier {
type Error = &'static str;
fn try_from(settings: &Settings) -> Result<Self, Self::Error> {
Ok(Self {
audience: settings.fxa_browserid_audience.clone(),
issuer: settings.fxa_browserid_issuer.clone(),
request_client: ReqwestClient::builder()
.timeout(Duration::from_secs(settings.fxa_browserid_request_timeout))
.connect_timeout(Duration::from_secs(settings.fxa_browserid_connect_timeout))
.use_rustls_tls()
.build()
.map_err(|_| "failed to build BrowserID reqwest client")?,
fxa_verifier_url: settings.fxa_browserid_server_url.clone(),
})
}
}
#[async_trait]
impl VerifyToken for Verifier {
type Output = VerifyOutput;
/// Verifies a BrowserID assertion. Returns `VerifyOutput` for valid assertions and a
/// `TokenserverError` for invalid assertions.
async fn verify(
&self,
assertion: String,
_metrics: &Metrics,
) -> Result<VerifyOutput, TokenserverError> {
let response = self
.request_client
.post(&self.fxa_verifier_url)
.json(&VerifyRequest {
assertion,
audience: self.audience.clone(),
trusted_issuers: [self.issuer.clone()],
})
.send()
.await
.map_err(|e| {
if e.is_connect() {
// If we are unable to reach the FxA server or if FxA responds with an HTTP
// status other than 200, report a 503 to the client
TokenserverError {
context: format!(
"Request error occurred during BrowserID request to FxA: {}",
e
),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
}
} else {
// If any other error occurs during the request, report a 401 to the client
TokenserverError {
context: format!(
"Unknown error occurred during BrowserID request to FxA: {}",
e
),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
}
}
})?;
if response.status() != StatusCode::OK {
return Err(TokenserverError {
context: format!(
"FxA returned a status code other than 200 ({})",
response.status().as_u16()
),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
});
}
// If FxA responds with an invalid response body, report a 503 to the client
let response_body =
response
.json::<VerifyResponse>()
.await
.map_err(|e| TokenserverError {
context: format!(
"Invalid BrowserID verification response received from FxA: {}",
e
),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
})?;
match response_body {
VerifyResponse::Failure {
reason: Some(reason),
} if reason.contains("expired") || reason.contains("issued later than") => {
Err(TokenserverError {
status: "invalid-timestamp",
location: ErrorLocation::Body,
context: "Expired BrowserID assertion".to_owned(),
token_type: TokenType::BrowserId,
..Default::default()
})
}
VerifyResponse::Failure {
reason: Some(reason),
} => Err(TokenserverError {
context: format!("BrowserID verification error: {}", reason),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
}),
VerifyResponse::Failure { .. } => Err(TokenserverError {
context: "Unknown BrowserID verification error".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
}),
VerifyResponse::Okay { issuer, .. } if issuer != self.issuer => Err(TokenserverError {
context: "BrowserID issuer mismatch".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
}),
VerifyResponse::Okay {
idp_claims: Some(claims),
..
} if !claims.token_verified() => Err(TokenserverError {
context: "BrowserID assertion not verified".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
}),
VerifyResponse::Okay {
email,
idp_claims: Some(claims),
..
} => Ok(VerifyOutput {
device_id: claims.device_id.clone(),
email,
generation: claims.generation()?,
keys_changed_at: claims.keys_changed_at()?,
}),
VerifyResponse::Okay { email, .. } => Ok(VerifyOutput {
device_id: None,
email,
generation: None,
keys_changed_at: None,
}),
}
}
}
/// The request sent to the FxA BrowserID verifier for token verification.
#[derive(Serialize)]
struct VerifyRequest {
assertion: String,
audience: String,
#[serde(rename(serialize = "trustedIssuers"))]
trusted_issuers: [String; 1],
}
/// The response returned by the FxA BrowserID verifier for a token verification request.
#[derive(Deserialize, Serialize)]
#[serde(tag = "status", rename_all = "lowercase")]
enum VerifyResponse {
Okay {
email: String,
#[serde(rename = "idpClaims")]
idp_claims: Option<IdpClaims>,
issuer: String,
},
Failure {
reason: Option<String>,
},
}
/// The claims extracted from a valid BrowserID assertion.
#[derive(Deserialize, Serialize)]
struct IdpClaims {
#[serde(rename = "fxa-deviceId")]
pub device_id: Option<String>,
/// The nested `Option`s are necessary to distinguish between a `null` value and a missing key
/// altogether: `Some(None)` translates to a `null` value and `None` translates to a missing
/// key.
#[serde(
default,
rename = "fxa-generation",
deserialize_with = "strict_deserialize"
)]
generation: Option<Option<i64>>,
/// The nested `Option`s are necessary to distinguish between a `null` value and a missing key
/// altogether: `Some(None)` translates to a `null` value and `None` translates to a missing
/// key.
#[serde(
default,
rename = "fxa-keysChangedAt",
deserialize_with = "strict_deserialize"
)]
keys_changed_at: Option<Option<i64>>,
/// The nested `Option`s are necessary to distinguish between a `null` value and a missing key
/// altogether: `Some(None)` translates to a `null` value and `None` translates to a missing
/// key.
#[serde(
default,
rename = "fxa-tokenVerified",
deserialize_with = "strict_deserialize"
)]
token_verified: Option<Option<bool>>,
}
impl IdpClaims {
fn generation(&self) -> Result<Option<i64>, TokenserverError> {
match self.generation {
// If the fxa-generation claim is present, return its value. If it's missing, return None.
Some(Some(_)) | None => Ok(self.generation.flatten()),
// If the fxa-generation claim is null, return an error.
Some(None) => Err(TokenserverError {
context: "null fxa-generation claim in BrowserID assertion".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_generation()
}),
}
}
fn keys_changed_at(&self) -> Result<Option<i64>, TokenserverError> {
match self.keys_changed_at {
// If the fxa-keysChangedAt claim is present, return its value. If it's missing, return None.
Some(Some(_)) | None => Ok(self.keys_changed_at.flatten()),
// If the fxa-keysChangedAt claim is null, return an error.
Some(None) => Err(TokenserverError {
description: "invalid keysChangedAt".to_owned(),
status: "invalid-credentials",
location: ErrorLocation::Body,
context: "null fxa-keysChangedAt claim in BrowserID assertion".to_owned(),
token_type: TokenType::BrowserId,
..Default::default()
}),
}
}
fn token_verified(&self) -> bool {
match self.token_verified {
// If the fxa-tokenVerified claim is true or missing, return true.
Some(Some(true)) | None => true,
// If the fxa-tokenVerified claim is false or null, return false.
Some(Some(false)) | Some(None) => false,
}
}
}
// Approach inspired by: https://github.com/serde-rs/serde/issues/984#issuecomment-314143738
/// This function is used to deserialize JSON fields that may or may not be present. If the field
/// is present, its value is enclosed in `Some`. This results in types of the form
/// `Option<Option<T>>`. If the outer `Option` is `None`, the field wasn't present in the JSON, and
/// if the inner `Option` is `None`, the field was present with a `null` value.
fn strict_deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
Deserialize::deserialize(deserializer).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::{self, Mock};
use serde_json::json;
#[tokio::test]
async fn test_browserid_verifier_success() {
let body = json!({
"status": "okay",
"email": "test@example.com",
"audience": "https://test.com",
"issuer": "accounts.firefox.com",
"idpClaims": {
"fxa-deviceId": "test_device_id",
"fxa-generation": 1234,
"fxa-keysChangedAt": 5678
}
});
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body(body.to_string())
.create();
let verifier = Verifier::try_from(&Settings {
fxa_browserid_audience: "https://test.com".to_owned(),
fxa_browserid_issuer: "accounts.firefox.com".to_owned(),
fxa_browserid_server_url: format!("{}/v2", mockito::server_url()),
..Default::default()
})
.unwrap();
let result = verifier
.verify("test".to_owned(), &Default::default())
.await
.unwrap();
mock.assert();
let expected_result = VerifyOutput {
device_id: Some("test_device_id".to_owned()),
email: "test@example.com".to_owned(),
generation: Some(1234),
keys_changed_at: Some(5678),
};
assert_eq!(expected_result, result);
}
#[tokio::test]
async fn test_browserid_verifier_failure_cases() {
const AUDIENCE: &str = "https://test.com";
let verifier = Verifier::try_from(&Settings {
fxa_browserid_audience: AUDIENCE.to_owned(),
fxa_browserid_server_url: format!("{}/v2", mockito::server_url()),
..Default::default()
})
.unwrap();
let assertion = "test";
// Verifier returns 500
{
let mock = mockito::mock("POST", "/v2")
.with_status(500)
.with_header("content-type", "application/json")
.create();
let error = verifier
.verify(assertion.to_owned(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "FxA returned a status code other than 200 (500)".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
// "Server Error" in body
{
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body("<h1>Server Error</h1>")
.create();
let error = verifier
.verify(assertion.to_owned(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Invalid BrowserID verification response received from FxA: error decoding response body: expected value at line 1 column 1".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
// {"status": "error"}
{
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body("{\"status\": \"error\"}")
.create();
let error = verifier
.verify(assertion.to_owned(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Invalid BrowserID verification response received from FxA: error decoding response body: unknown variant `error`, expected `okay` or `failure` at line 1 column 18".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
// {"status": "potato"} in body
{
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body("{\"status\": \"potato\"}")
.create();
let error = verifier
.verify(assertion.to_owned(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Invalid BrowserID verification response received from FxA: error decoding response body: unknown variant `potato`, expected `okay` or `failure` at line 1 column 19".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
// {"status": "failure"} in body with random reason
{
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body("{\"status\": \"failure\", \"reason\": \"something broke\"}")
.create();
let error = verifier
.verify(assertion.to_owned(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "BrowserID verification error: something broke".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
};
assert_eq!(expected_error, error);
}
// {"status": "failure"} in body with no reason
{
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body("{\"status\": \"failure\"}")
.create();
let error = verifier
.verify(assertion.to_owned(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Unknown BrowserID verification error".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
};
assert_eq!(expected_error, error);
}
}
#[tokio::test]
async fn test_browserid_verifier_rejects_unissuers() {
const AUDIENCE: &str = "https://test.com";
const ISSUER: &str = "accounts.firefox.com";
fn mock(issuer: &'static str) -> Mock {
let body = json!({
"status": "okay",
"email": "test@example.com",
"audience": "https://testmytoken.com",
"issuer": issuer
});
mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body(body.to_string())
.create()
}
let expected_error = TokenserverError {
context: "BrowserID issuer mismatch".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::invalid_credentials("Unauthorized".to_owned())
};
let verifier = Verifier::try_from(&Settings {
fxa_browserid_audience: AUDIENCE.to_owned(),
fxa_browserid_issuer: ISSUER.to_owned(),
fxa_browserid_server_url: format!("{}/v2", mockito::server_url()),
..Default::default()
})
.unwrap();
let assertion = "test".to_owned();
{
let mock = mock("login.persona.org");
let error = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap_err();
mock.assert();
assert_eq!(expected_error, error);
}
{
let mock = mock(ISSUER);
let result = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap();
let expected_result = VerifyOutput {
device_id: None,
email: "test@example.com".to_owned(),
generation: None,
keys_changed_at: None,
};
mock.assert();
assert_eq!(expected_result, result);
}
{
let mock = mock("accounts.firefox.org");
let error = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap_err();
mock.assert();
assert_eq!(expected_error, error);
}
{
let mock = mock("http://accounts.firefox.com");
let error = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap_err();
mock.assert();
assert_eq!(expected_error, error);
}
{
let mock = mock("accounts.firefox.co");
let error = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap_err();
mock.assert();
assert_eq!(expected_error, error);
}
{
let body = json!({
"status": "okay",
"email": "test@example.com",
"audience": "https://testmytoken.com",
"issuer": 42,
});
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body(body.to_string())
.create();
let error = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Invalid BrowserID verification response received from FxA: error decoding response body: invalid type: integer `42`, expected a string".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
{
let body = json!({
"status": "okay",
"email": "test@example.com",
"audience": "https://testmytoken.com",
"issuer": None::<()>,
});
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body(body.to_string())
.create();
let error = verifier
.verify(assertion.clone(), &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Invalid BrowserID verification response received from FxA: error decoding response body: invalid type: null, expected a string".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
{
let body = json!({
"status": "okay",
"email": "test@example.com",
"audience": "https://testmytoken.com",
});
let mock = mockito::mock("POST", "/v2")
.with_header("content-type", "application/json")
.with_body(body.to_string())
.create();
let error = verifier
.verify(assertion, &Default::default())
.await
.unwrap_err();
mock.assert();
let expected_error = TokenserverError {
context: "Invalid BrowserID verification response received from FxA: error decoding response body: missing field `issuer`".to_owned(),
token_type: TokenType::BrowserId,
..TokenserverError::resource_unavailable()
};
assert_eq!(expected_error, error);
}
}
}

View File

@ -24,6 +24,7 @@ pub trait Crypto {
#[allow(dead_code)] #[allow(dead_code)]
/// Verify an HMAC signature on a payload given a shared key /// Verify an HMAC signature on a payload given a shared key
#[test]
fn hmac_verify(&self, key: &[u8], payload: &[u8], signature: &[u8]) -> Result<(), Self::Error>; fn hmac_verify(&self, key: &[u8], payload: &[u8], signature: &[u8]) -> Result<(), Self::Error>;
/// Generates random bytes using a cryptographic random number generator /// Generates random bytes using a cryptographic random number generator
@ -53,6 +54,7 @@ impl Crypto for CryptoImpl {
Ok(mac.finalize().into_bytes().to_vec()) Ok(mac.finalize().into_bytes().to_vec())
} }
#[test]
fn hmac_verify(&self, key: &[u8], payload: &[u8], signature: &[u8]) -> Result<(), Self::Error> { fn hmac_verify(&self, key: &[u8], payload: &[u8], signature: &[u8]) -> Result<(), Self::Error> {
let mut mac: Hmac<Sha256> = let mut mac: Hmac<Sha256> =
Hmac::new_from_slice(key).map_err(|_| TokenserverError::internal_error())?; Hmac::new_from_slice(key).map_err(|_| TokenserverError::internal_error())?;

View File

@ -1,5 +1,3 @@
pub mod browserid;
#[cfg(not(feature = "py"))] #[cfg(not(feature = "py"))]
mod crypto; mod crypto;

View File

@ -8,6 +8,7 @@ edition.workspace = true
[dependencies] [dependencies]
actix-web.workspace = true actix-web.workspace = true
backtrace.workspace = true backtrace.workspace = true
http.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
jsonwebtoken.workspace = true jsonwebtoken.workspace = true

View File

@ -1,7 +1,8 @@
use std::{cmp::PartialEq, error::Error, fmt}; use std::{cmp::PartialEq, error::Error, fmt};
use actix_web::{http::StatusCode, HttpResponse, ResponseError}; use actix_web::{HttpResponse, ResponseError};
use backtrace::Backtrace; use backtrace::Backtrace;
use http::StatusCode;
use serde::{ use serde::{
ser::{SerializeMap, Serializer}, ser::{SerializeMap, Serializer},
Serialize, Serialize,
@ -27,7 +28,6 @@ pub struct TokenserverError {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum TokenType { pub enum TokenType {
BrowserId,
Oauth, Oauth,
} }
@ -201,11 +201,11 @@ impl fmt::Display for ErrorLocation {
impl ResponseError for TokenserverError { impl ResponseError for TokenserverError {
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.http_status).json(ErrorResponse::from(self)) HttpResponse::build(self.status_code()).json(ErrorResponse::from(self))
} }
fn status_code(&self) -> StatusCode { fn status_code(&self) -> actix_web::http::StatusCode {
self.http_status actix_web::http::StatusCode::from_u16(self.http_status.as_u16()).unwrap()
} }
} }
@ -285,7 +285,6 @@ impl ReportableError for TokenserverError {
fn metric_label(&self) -> Option<String> { fn metric_label(&self) -> Option<String> {
if self.http_status.is_client_error() { if self.http_status.is_client_error() {
match self.token_type { match self.token_type {
TokenType::BrowserId => Some("request.error.browser_id".to_owned()),
TokenType::Oauth => Some("request.error.oauth".to_owned()), TokenType::Oauth => Some("request.error.oauth".to_owned()),
} }
} else if matches!( } else if matches!(

View File

@ -38,19 +38,6 @@ pub struct Settings {
/// A secondary JWK to be used to verify OAuth tokens. This is intended to be used to enable /// A secondary JWK to be used to verify OAuth tokens. This is intended to be used to enable
/// seamless key rotations on FxA. /// seamless key rotations on FxA.
pub fxa_oauth_secondary_jwk: Option<Jwk>, pub fxa_oauth_secondary_jwk: Option<Jwk>,
/// The issuer expected in the BrowserID verification response.
pub fxa_browserid_issuer: String,
/// The audience to be sent to the FxA BrowserID verification server.
pub fxa_browserid_audience: String,
/// The URL of the FxA server used for verifying BrowserID assertions.
pub fxa_browserid_server_url: String,
/// The timeout to be used when making requests to the FxA BrowserID verification server. This
/// timeout applies to the duration of the entire request lifecycle, from when the client
/// begins connecting to when the response body has been received.
pub fxa_browserid_request_timeout: u64,
/// The timeout to be used when connecting to the FxA BrowserID verification server. This
/// timeout applies only to the connect portion of the request lifecycle.
pub fxa_browserid_connect_timeout: u64,
/// The rate at which capacity should be released from nodes that are at capacity. /// The rate at which capacity should be released from nodes that are at capacity.
pub node_capacity_release_rate: Option<f32>, pub node_capacity_release_rate: Option<f32>,
/// The type of the storage nodes used by this instance of Tokenserver. /// The type of the storage nodes used by this instance of Tokenserver.
@ -88,11 +75,6 @@ impl Default for Settings {
fxa_oauth_request_timeout: 10, fxa_oauth_request_timeout: 10,
fxa_oauth_primary_jwk: None, fxa_oauth_primary_jwk: None,
fxa_oauth_secondary_jwk: None, fxa_oauth_secondary_jwk: None,
fxa_browserid_audience: "https://token.stage.mozaws.net".to_owned(),
fxa_browserid_issuer: "api-accounts.stage.mozaws.net".to_owned(),
fxa_browserid_server_url: "https://verifier.stage.mozaws.net/v2".to_owned(),
fxa_browserid_request_timeout: 10,
fxa_browserid_connect_timeout: 5,
node_capacity_release_rate: None, node_capacity_release_rate: None,
node_type: NodeType::Spanner, node_type: NodeType::Spanner,
statsd_label: "syncstorage.tokenserver".to_owned(), statsd_label: "syncstorage.tokenserver".to_owned(),

View File

@ -30,7 +30,7 @@ FXA_UID = "DEADBEEF00004be4ae957006c0ceb620"
FXA_KID = "DEADBEEF00004be4ae957006c0ceb620" FXA_KID = "DEADBEEF00004be4ae957006c0ceb620"
DEVICE_ID = "device1" DEVICE_ID = "device1"
NODE = "http://localhost:8000" NODE = "http://localhost:8000"
SECRET = "Ted_Koppel_is_a_robot" SECRET = os.envrion.get("SYNC_MASTER_SECRET", "Ted_Koppel_is_a_robot")
HMAC_KEY = b"foo" HMAC_KEY = b"foo"
# 10 years # 10 years

View File

@ -8,7 +8,7 @@ import sys
from test_storage import TestStorage from test_storage import TestStorage
from test_support import run_live_functional_tests from test_support import run_live_functional_tests
import time import time
from tokenserver.run import run_end_to_end_tests, run_local_tests from tokenserver.run import (run_end_to_end_tests, run_local_tests)
DEBUG_BUILD = "target/debug/syncserver" DEBUG_BUILD = "target/debug/syncserver"
RELEASE_BUILD = "/app/bin/syncserver" RELEASE_BUILD = "/app/bin/syncserver"
@ -52,21 +52,16 @@ if __name__ == "__main__":
os.environ.setdefault("SYNC_CORS_ALLOWED_ORIGIN", "*") os.environ.setdefault("SYNC_CORS_ALLOWED_ORIGIN", "*")
mock_fxa_server_url = os.environ["MOCK_FXA_SERVER_URL"] mock_fxa_server_url = os.environ["MOCK_FXA_SERVER_URL"]
url = "%s/v2" % mock_fxa_server_url url = "%s/v2" % mock_fxa_server_url
os.environ["SYNC_TOKENSERVER__FXA_BROWSERID_SERVER_URL"] = url
os.environ["SYNC_TOKENSERVER__FXA_OAUTH_SERVER_URL"] = mock_fxa_server_url os.environ["SYNC_TOKENSERVER__FXA_OAUTH_SERVER_URL"] = mock_fxa_server_url
the_server_subprocess = start_server() the_server_subprocess = start_server()
try: try:
res = 0 res = 0
res |= run_live_functional_tests(TestStorage, sys.argv) res |= run_live_functional_tests(TestStorage, sys.argv)
os.environ["TOKENSERVER_AUTH_METHOD"] = "oauth" os.environ["TOKENSERVER_AUTH_METHOD"] = "oauth"
res |= run_local_tests(include_browserid_specific_tests=False) res |= run_local_tests()
os.environ["TOKENSERVER_AUTH_METHOD"] = "browserid"
res |= run_local_tests(include_browserid_specific_tests=True)
finally: finally:
terminate_process(the_server_subprocess) terminate_process(the_server_subprocess)
os.environ["SYNC_TOKENSERVER__FXA_BROWSERID_SERVER_URL"] = \
"https://verifier.stage.mozaws.net/v2"
os.environ["SYNC_TOKENSERVER__FXA_OAUTH_SERVER_URL"] = \ os.environ["SYNC_TOKENSERVER__FXA_OAUTH_SERVER_URL"] = \
"https://oauth.stage.mozaws.net" "https://oauth.stage.mozaws.net"
the_server_subprocess = start_server() the_server_subprocess = start_server()
@ -86,7 +81,8 @@ if __name__ == "__main__":
the_server_subprocess = start_server() the_server_subprocess = start_server()
try: try:
res |= run_end_to_end_tests() verbosity = int(os.environ.get("VERBOSITY", "1"))
res |= run_end_to_end_tests(verbosity=verbosity)
finally: finally:
terminate_process(the_server_subprocess) terminate_process(the_server_subprocess)

View File

@ -794,10 +794,12 @@ class SyncStorageAuthenticationPolicy(TokenServerAuthenticationPolicy):
user["hashed_device_id"] = data["hashed_device_id"] user["hashed_device_id"] = data["hashed_device_id"]
if not VALID_FXA_ID_REGEX.match(user["hashed_device_id"]): if not VALID_FXA_ID_REGEX.match(user["hashed_device_id"]):
raise ValueError("invalid hashed_device_id in token data") raise ValueError("invalid hashed_device_id in token data")
"""
elif "device_id" in data: elif "device_id" in data:
user["hashed_device_id"] = data.get("device_id") user["hashed_device_id"] = data.get("device_id")
if not VALID_FXA_ID_REGEX.match(user["hashed_device_id"]): if not VALID_FXA_ID_REGEX.match(user["hashed_device_id"]):
raise ValueError("invalid device_id in token data") raise ValueError("invalid device_id in token data")
"""
return user return user

View File

@ -22,27 +22,6 @@ def _mock_oauth_jwk(request):
return {'keys': [{'fake': 'RSA key'}]} return {'keys': [{'fake': 'RSA key'}]}
@view_config(route_name='mock_verify', renderer='json')
def _mock_browserid_verify(request):
body = json.loads(request.json_body['assertion'])
return Response(json=body['body'], content_type='application/json',
status=body['status'])
# This endpoint is used by the legacy Tokenserver during startup. We mock it
# here so the unit tests can be run against the legacy Tokenserver.
@view_config(route_name='mock_config', renderer='json')
def _mock_config(request):
return {
"browserid": {
"issuer": "api-accounts.stage.mozaws.net",
"verificationUrl": "https://verifier.stage.mozaws.net/v2"
},
"contentUrl": "https://accounts.stage.mozaws.net"
}
def make_server(host, port): def make_server(host, port):
with Configurator() as config: with Configurator() as config:
config.add_route('mock_oauth_verify', '/v1/verify') config.add_route('mock_oauth_verify', '/v1/verify')
@ -52,15 +31,6 @@ def make_server(host, port):
config.add_route('mock_oauth_jwk', '/v1/jwks') config.add_route('mock_oauth_jwk', '/v1/jwks')
config.add_view(_mock_oauth_jwk, route_name='mock_oauth_jwk', config.add_view(_mock_oauth_jwk, route_name='mock_oauth_jwk',
renderer='json') renderer='json')
config.add_route('mock_browserid_verify', '/v2')
config.add_view(_mock_browserid_verify,
route_name='mock_browserid_verify',
renderer='json')
config.add_route('mock_config', '/config')
config.add_view(_mock_config, route_name='mock_config',
renderer='json')
app = config.make_wsgi_app() app = config.make_wsgi_app()
return _make_server(host, port, app) return _make_server(host, port, app)

View File

@ -4,32 +4,28 @@
import unittest import unittest
from tokenserver.test_authorization import TestAuthorization from tokenserver.test_authorization import TestAuthorization
from tokenserver.test_browserid import TestBrowserId
from tokenserver.test_e2e import TestE2e from tokenserver.test_e2e import TestE2e
from tokenserver.test_misc import TestMisc from tokenserver.test_misc import TestMisc
from tokenserver.test_node_assignment import TestNodeAssignment from tokenserver.test_node_assignment import TestNodeAssignment
def run_local_tests(include_browserid_specific_tests=True): def run_local_tests():
test_classes = [TestAuthorization, TestMisc, TestNodeAssignment] test_classes = [TestAuthorization, TestMisc, TestNodeAssignment]
if include_browserid_specific_tests:
test_classes.append(TestBrowserId)
return run_tests(test_classes) return run_tests(test_classes)
def run_end_to_end_tests(): def run_end_to_end_tests(verbosity=1):
return run_tests([TestE2e]) return run_tests([TestE2e], verbosity=verbosity)
def run_tests(test_cases): def run_tests(test_cases, verbosity=1):
loader = unittest.TestLoader() loader = unittest.TestLoader()
success = True success = True
for test_case in test_cases: for test_case in test_cases:
suite = loader.loadTestsFromTestCase(test_case) suite = loader.loadTestsFromTestCase(test_case)
runner = unittest.TextTestRunner() runner = unittest.TextTestRunner(verbosity=verbosity)
res = runner.run(suite) res = runner.run(suite)
success = success and res.wasSuccessful() success = success and res.wasSuccessful()

View File

@ -45,26 +45,26 @@ class TestAuthorization(TestCase, unittest.TestCase):
self.assertEqual(res.json, expected_error_response) self.assertEqual(res.json, expected_error_response)
def test_invalid_client_state_in_key_id(self): def test_invalid_client_state_in_key_id(self):
if self.auth_method == "oauth": additional_headers = {
additional_headers = { 'X-KeyID': "1234-state!"
'X-KeyID': "1234-state!" }
} headers = self._build_auth_headers(
headers = self._build_auth_headers(keys_changed_at=1234, keys_changed_at=1234,
client_state='aaaa', client_state='aaaa',
**additional_headers) **additional_headers)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401) res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
expected_error_response = { expected_error_response = {
'status': 'invalid-credentials', 'status': 'invalid-credentials',
'errors': [ 'errors': [
{ {
'location': 'body', 'location': 'body',
'name': '', 'name': '',
'description': 'Unauthorized' 'description': 'Unauthorized'
} }
] ]
} }
self.assertEqual(res.json, expected_error_response) self.assertEqual(res.json, expected_error_response)
def test_invalid_client_state_in_x_client_state(self): def test_invalid_client_state_in_x_client_state(self):
additional_headers = {'X-Client-State': 'state!'} additional_headers = {'X-Client-State': 'state!'}
@ -304,18 +304,6 @@ class TestAuthorization(TestCase, unittest.TestCase):
uid = self._add_user(generation=0, keys_changed_at=None, uid = self._add_user(generation=0, keys_changed_at=None,
client_state='aaaa') client_state='aaaa')
# Only BrowserID requests can omit keys_changed_at
if self.auth_method == 'browserid':
# Send a request without a generation that doesn't update
# keys_changed_at
headers = self._build_auth_headers(generation=None,
keys_changed_at=None,
client_state='aaaa')
self.app.get('/1.0/sync/1.5', headers=headers)
user = self._get_user(uid)
# This should not have set the user's generation
self.assertEqual(user['generation'], 0)
# Send a request without a generation that updates keys_changed_at # Send a request without a generation that updates keys_changed_at
headers = self._build_auth_headers(generation=None, headers = self._build_auth_headers(generation=None,
keys_changed_at=1234, keys_changed_at=1234,
@ -534,29 +522,29 @@ class TestAuthorization(TestCase, unittest.TestCase):
self.assertEqual(user['keys_changed_at'], 1234) self.assertEqual(user['keys_changed_at'], 1234)
def test_x_client_state_must_have_same_client_state_as_key_id(self): def test_x_client_state_must_have_same_client_state_as_key_id(self):
if self.auth_method == "oauth": self._add_user(client_state='aaaa')
self._add_user(client_state='aaaa') additional_headers = {'X-Client-State': 'bbbb'}
additional_headers = {'X-Client-State': 'bbbb'} headers = self._build_auth_headers(
headers = self._build_auth_headers(generation=1234, generation=1234,
keys_changed_at=1234, keys_changed_at=1234,
client_state='aaaa', client_state='aaaa',
**additional_headers) **additional_headers)
# If present, the X-Client-State header must have the same client # If present, the X-Client-State header must have the same client
# state as the X-KeyID header # state as the X-KeyID header
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401) res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
expected_error_response = { expected_error_response = {
'errors': [ 'errors': [
{ {
'description': 'Unauthorized', 'description': 'Unauthorized',
'location': 'body', 'location': 'body',
'name': '' 'name': ''
} }
], ],
'status': 'invalid-client-state' 'status': 'invalid-client-state'
} }
self.assertEqual(res.json, expected_error_response) self.assertEqual(res.json, expected_error_response)
headers['X-Client-State'] = 'aaaa' headers['X-Client-State'] = 'aaaa'
res = self.app.get('/1.0/sync/1.5', headers=headers) res = self.app.get('/1.0/sync/1.5', headers=headers)
def test_zero_generation_treated_as_null(self): def test_zero_generation_treated_as_null(self):
# Add a user that has a generation set # Add a user that has a generation set

View File

@ -1,562 +0,0 @@
# 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/.
import json
import unittest
from tokenserver.test_support import TestCase
class TestBrowserId(TestCase, unittest.TestCase):
def setUp(self):
super(TestBrowserId, self).setUp()
def tearDown(self):
super(TestBrowserId, self).tearDown()
def _build_browserid_fxa_error_response(self, reason, status=200):
body = {
'body': {
'status': 'failure'
},
'status': status
}
if reason:
body['body']['reason'] = reason
return {
'Authorization': 'BrowserID %s' % json.dumps(body),
'X-Client-State': 'aaaa'
}
def test_fxa_returns_status_not_ok(self):
expected_error_response = {
'status': 'error',
'errors': [
{
'location': 'body',
'description': 'Resource is not available',
'name': ''
}
]
}
# If FxA returns any status code other than 200, the client gets a 503
headers = self._build_browserid_headers(client_state='aaaa',
status=500)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=503)
self.assertEqual(res.json, expected_error_response)
headers = self._build_browserid_headers(client_state='aaaa',
status=404)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=503)
self.assertEqual(res.json, expected_error_response)
headers = self._build_browserid_headers(client_state='aaaa',
status=401)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=503)
self.assertEqual(res.json, expected_error_response)
headers = self._build_browserid_headers(client_state='aaaa',
status=201)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=503)
self.assertEqual(res.json, expected_error_response)
def test_fxa_returns_invalid_response(self):
# Craft a response that contains invalid JSON
token = json.dumps({
'body': {'test': True},
'status': 200,
}).replace('true', '')
headers = {
'Authorization': 'BrowserID %s' % token,
'X-Client-State': 'aaaa'
}
expected_error_response = {
'status': 'error',
'errors': [
{
'location': 'body',
'description': 'Resource is not available',
'name': ''
}
]
}
res = self.app.get('/1.0/sync/1.5', headers=headers, status=503)
self.assertEqual(res.json, expected_error_response)
def test_expired_token(self):
expected_error_response = {
'status': 'invalid-timestamp',
'errors': [
{
'location': 'body',
'description': 'Unauthorized',
'name': ''
}
]
}
# If the FxA response includes "expired" in the reason message,
# the client gets a 401 and a message indicating an invalid timestamp
headers = self._build_browserid_fxa_error_response('assertion expired')
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
# If the FxA response includes "issued later than" in the reason
# message, the client gets a 401 and a message indicating an invalid
# timestamp
headers = self._build_browserid_fxa_error_response('issued later than')
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
def test_other_reason_message(self):
expected_error_response = {
'status': 'invalid-credentials',
'errors': [
{
'location': 'body',
'description': 'Unauthorized',
'name': ''
}
]
}
# If the FxA response includes a reason that doesn't indicate an
# invalid timestamp, a generic error is returned
headers = self._build_browserid_fxa_error_response('invalid')
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
def test_missing_reason_message(self):
expected_error_response = {
'status': 'invalid-credentials',
'errors': [
{
'location': 'body',
'description': 'Unauthorized',
'name': ''
}
]
}
# If the FxA response includes no reason, a generic error is returned
headers = self._build_browserid_fxa_error_response(None)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
def test_issuer_mismatch(self):
expected_error_response = {
'status': 'invalid-credentials',
'errors': [
{
'location': 'body',
'description': 'Unauthorized',
'name': ''
}
]
}
# If the issuer in the response doesn't match the issuer on
# Tokenserver, a 401 is returned
invalid_issuer = 'invalid.com'
headers = self._build_browserid_headers(client_state='aaaa',
issuer=invalid_issuer)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
def test_fxa_error_response_not_ok(self):
expected_error_response = {
'status': 'error',
'errors': [
{
'location': 'body',
'description': 'Resource is not available',
'name': ''
}
]
}
# If an FxA error response returns a status other than 200, the client
# gets a 503 error
headers = self._build_browserid_fxa_error_response('bad token',
status=401)
res = self.app.get('/1.0/sync/1.5', headers=headers, status=503)
self.assertEqual(res.json, expected_error_response)
def test_no_idp_claims(self):
# A response from FxA that does not include idpClaims is still valid
headers = self._build_browserid_headers(client_state='aaaa')
self.app.get('/1.0/sync/1.5', headers=headers, status=200)
def test_partial_idp_claims(self):
# A response from FxA that includes a partially-filled idpClaims
# object is still valid
headers = self._build_browserid_headers(user='test1',
client_state='aaaa',
generation=1234)
self.app.get('/1.0/sync/1.5', headers=headers, status=200)
headers = self._build_browserid_headers(user='test2',
client_state='aaaa',
keys_changed_at=1234)
self.app.get('/1.0/sync/1.5', headers=headers, status=200)
headers = self._build_browserid_headers(user='test3',
client_state='aaaa',
device_id='id')
self.app.get('/1.0/sync/1.5', headers=headers, status=200)
def test_unverified_token(self):
headers = self._build_browserid_headers(client_state='aaaa',
token_verified=None)
# Assertion should not be rejected if fxa-tokenVerified is unset
self.app.get("/1.0/sync/1.5", headers=headers, status=200)
# Assertion should not be rejected if fxa-tokenVerified is true
headers = self._build_browserid_headers(client_state='aaaa',
token_verified=True)
self.app.get("/1.0/sync/1.5", headers=headers, status=200)
# Assertion should be rejected if fxa-tokenVerified is false
headers = self._build_browserid_headers(client_state='aaaa',
token_verified=False)
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-credentials'
}
self.assertEqual(res.json, expected_error_response)
# Assertion should be rejected if fxa-tokenVerified is null
headers['Authorization'] = headers['Authorization'].replace('false',
'null')
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
def test_credentials_from_oauth_and_browserid(self):
# Send initial credentials via oauth.
oauth_headers = self._build_oauth_headers(generation=1234,
keys_changed_at=1234,
client_state='aaaa')
res1 = self.app.get("/1.0/sync/1.5", headers=oauth_headers)
# Send the same credentials via BrowserID
browserid_headers = self._build_browserid_headers(generation=1234,
keys_changed_at=1234,
client_state='aaaa')
res2 = self.app.get("/1.0/sync/1.5", headers=browserid_headers)
# They should get the same node assignment.
self.assertEqual(res1.json["uid"], res2.json["uid"])
self.assertEqual(res1.json["api_endpoint"], res2.json["api_endpoint"])
# Earlier generation number via BrowserID -> invalid-generation
browserid_headers = self._build_browserid_headers(generation=1233,
keys_changed_at=1234,
client_state='aaaa')
res = self.app.get("/1.0/sync/1.5", headers=browserid_headers,
status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-generation'
}
self.assertEqual(res.json, expected_error_response)
# Earlier keys_changed_at via BrowserID is not accepted.
browserid_headers = self._build_browserid_headers(generation=1234,
keys_changed_at=1233,
client_state='aaaa')
res = self.app.get("/1.0/sync/1.5", headers=browserid_headers,
status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-keysChangedAt'
}
self.assertEqual(res.json, expected_error_response)
# Earlier generation number via OAuth -> invalid-generation
oauth_headers = self._build_oauth_headers(generation=1233,
keys_changed_at=1234,
client_state='aaaa')
res = self.app.get("/1.0/sync/1.5", headers=oauth_headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-generation'
}
self.assertEqual(res.json, expected_error_response)
# Earlier keys_changed_at via OAuth is not accepted.
oauth_headers = self._build_oauth_headers(generation=1234,
keys_changed_at=1233,
client_state='aaaa')
res = self.app.get("/1.0/sync/1.5", headers=oauth_headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-keysChangedAt'
}
self.assertEqual(res.json, expected_error_response)
# Change client-state via BrowserID.
browserid_headers = self._build_browserid_headers(generation=1235,
keys_changed_at=1235,
client_state='bbbb')
res1 = self.app.get("/1.0/sync/1.5", headers=browserid_headers)
# Previous OAuth creds are rejected due to keys_changed_at update.
oauth_headers = self._build_oauth_headers(generation=1235,
keys_changed_at=1234,
client_state='bbbb')
res = self.app.get("/1.0/sync/1.5", headers=oauth_headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-keysChangedAt'
}
self.assertEqual(res.json, expected_error_response)
# Previous OAuth creds are rejected due to generation update.
oauth_headers = self._build_oauth_headers(generation=1234,
keys_changed_at=1235,
client_state='bbbb')
res = self.app.get("/1.0/sync/1.5", headers=oauth_headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-generation'
}
self.assertEqual(res.json, expected_error_response)
# Updated OAuth creds are accepted.
oauth_headers = self._build_oauth_headers(generation=1235,
keys_changed_at=1235,
client_state='bbbb')
res2 = self.app.get("/1.0/sync/1.5", headers=oauth_headers)
# They should again get the same node assignment.
self.assertEqual(res1.json["uid"], res2.json["uid"])
self.assertEqual(res1.json["api_endpoint"],
res2.json["api_endpoint"])
def test_null_idp_claims(self):
headers = self._build_browserid_headers(generation=1234,
client_state='aaaa')
headers['Authorization'] = headers['Authorization'].replace('1234',
'null')
# A null fxa-generation claim results in a 401
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-generation'
}
self.assertEqual(res.json, expected_error_response)
# A null fxa-keysChangedAt claim results in a 401
headers = self._build_browserid_headers(keys_changed_at=1234,
client_state='aaaa')
headers['Authorization'] = headers['Authorization'].replace('1234',
'null')
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'invalid keysChangedAt',
'location': 'body',
'name': ''
}
],
'status': 'invalid-credentials'
}
self.assertEqual(res.json, expected_error_response)
# A null fxa-tokenVerified claim results in a 401
headers = self._build_browserid_headers(token_verified=True,
client_state='aaaa')
headers['Authorization'] = headers['Authorization'].replace('true',
'null')
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-credentials'
}
self.assertEqual(res.json, expected_error_response)
headers = self._build_browserid_headers(device_id="device id",
client_state='aaaa')
headers['Authorization'] = \
headers['Authorization'].replace('"device id"', 'null')
# A null fxa-deviceId claim is acceptable
self.app.get("/1.0/sync/1.5", headers=headers)
def test_uid_and_kid(self):
browserid_headers = self._build_browserid_headers(user='testuser',
generation=1234,
keys_changed_at=1233,
client_state='aaaa')
res = self.app.get("/1.0/sync/1.5", headers=browserid_headers)
token = self.unsafelyParseToken(res.json["id"])
self.assertEqual(token["uid"], res.json["uid"])
self.assertEqual(token["fxa_uid"], "testuser")
self.assertEqual(token["fxa_kid"], "0000000001233-qqo")
self.assertNotEqual(token["hashed_fxa_uid"], token["fxa_uid"])
self.assertEqual(token["hashed_fxa_uid"], res.json["hashed_fxa_uid"])
self.assertIn("hashed_device_id", token)
def test_generation_number_change(self):
headers = self._build_browserid_headers(client_state="aaaa")
# Start with no generation number.
res1 = self.app.get("/1.0/sync/1.5", headers=headers)
# Now send an explicit generation number.
# The node assignment should not change.
headers = self._build_browserid_headers(generation=1234,
client_state="aaaa")
res2 = self.app.get("/1.0/sync/1.5", headers=headers)
self.assertEqual(res1.json["uid"], res2.json["uid"])
self.assertEqual(res1.json["api_endpoint"], res2.json["api_endpoint"])
# Clients that don't report generation number are still allowed.
headers = self._build_browserid_headers(client_state="aaaa")
res2 = self.app.get("/1.0/sync/1.5", headers=headers)
self.assertEqual(res1.json["uid"], res2.json["uid"])
headers = self._build_browserid_headers(device_id="nonsense",
client_state="aaaa")
headers['Authorization'] = \
headers['Authorization'].replace("fxa-deviceId", "nonsense")
res2 = self.app.get("/1.0/sync/1.5", headers=headers)
self.assertEqual(res1.json["uid"], res2.json["uid"])
# But previous generation numbers get an invalid-generation response.
headers = self._build_browserid_headers(generation=1233,
client_state="aaaa")
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
self.assertEqual(res.json["status"], "invalid-generation")
# Equal generation numbers are accepted.
headers = self._build_browserid_headers(generation=1234,
client_state="aaaa")
res2 = self.app.get("/1.0/sync/1.5", headers=headers)
self.assertEqual(res1.json["uid"], res2.json["uid"])
self.assertEqual(res1.json["api_endpoint"], res2.json["api_endpoint"])
# Later generation numbers are accepted.
# Again, the node assignment should not change.
headers = self._build_browserid_headers(generation=1235,
client_state="aaaa")
res2 = self.app.get("/1.0/sync/1.5", headers=headers)
self.assertEqual(res1.json["uid"], res2.json["uid"])
self.assertEqual(res1.json["api_endpoint"], res2.json["api_endpoint"])
# And that should lock out the previous generation number
headers = self._build_browserid_headers(generation=1234,
client_state="aaaa")
res = self.app.get("/1.0/sync/1.5", headers=headers, status=401)
self.assertEqual(res.json["status"], "invalid-generation")
def test_reverting_to_no_keys_changed_at(self):
# Add a user that has no keys_changed_at set
uid = self._add_user(generation=0, keys_changed_at=None,
client_state='aaaa')
# Send a request with keys_changed_at
headers = self._build_browserid_headers(generation=None,
keys_changed_at=1234,
client_state='aaaa')
self.app.get('/1.0/sync/1.5', headers=headers)
user = self._get_user(uid)
# Confirm that keys_changed_at was set
self.assertEqual(user['keys_changed_at'], 1234)
# Send a request with no keys_changed_at
headers = self._build_browserid_headers(generation=None,
keys_changed_at=None,
client_state='aaaa')
# Once a keys_changed_at has been set, the server expects to receive
# it from that point onwards
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
expected_error_response = {
'status': 'invalid-keysChangedAt',
'errors': [
{
'location': 'body',
'name': '',
'description': 'Unauthorized',
}
]
}
self.assertEqual(res.json, expected_error_response)
def test_zero_keys_changed_at_treated_as_null(self):
# Add a user that has a zero keys_changed_at
uid = self._add_user(generation=0, keys_changed_at=0,
client_state='aaaa')
# Send a request with no keys_changed_at
headers = self._build_browserid_headers(generation=None,
keys_changed_at=None,
client_state='aaaa')
self.app.get('/1.0/sync/1.5', headers=headers)
# The request should succeed and the keys_changed_at should be
# unchanged
user = self._get_user(uid)
self.assertEqual(user['keys_changed_at'], 0)
def test_reverting_to_no_client_state(self):
# Add a user that has no client_state
uid = self._add_user(generation=0, keys_changed_at=None,
client_state="")
# Send a request with no client state
headers = self._build_browserid_headers(generation=None,
keys_changed_at=None,
client_state=None)
# The request should succeed
self.app.get('/1.0/sync/1.5', headers=headers)
# Send a request that updates the client state
headers = self._build_browserid_headers(generation=None,
keys_changed_at=None,
client_state='aaaa')
# The request should succeed
res = self.app.get('/1.0/sync/1.5', headers=headers)
user = self._get_user(res.json['uid'])
# A new user should have been created
self.assertNotEqual(uid, res.json['uid'])
# The client state should have been updated
self.assertEqual(user['client_state'], 'aaaa')
# Send another request with no client state
headers = self._build_browserid_headers(generation=None,
keys_changed_at=None,
client_state=None)
# The request should fail, since we are trying to revert to using no
# client state after setting one
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
expected_error_response = {
'status': 'invalid-client-state',
'errors': [
{
'location': 'header',
'name': 'X-Client-State',
'description': 'Unacceptable client-state value empty '
'string',
}
]
}
self.assertEqual(res.json, expected_error_response)

View File

@ -24,7 +24,6 @@ from tokenserver.test_support import TestCase
# This is the client ID used for Firefox Desktop. The FxA team confirmed that # This is the client ID used for Firefox Desktop. The FxA team confirmed that
# this is the proper client ID to be using for these integration tests. # this is the proper client ID to be using for these integration tests.
BROWSERID_AUDIENCE = "https://token.stage.mozaws.net"
CLIENT_ID = '5882386c6d801776' CLIENT_ID = '5882386c6d801776'
DEFAULT_TOKEN_DURATION = 3600 DEFAULT_TOKEN_DURATION = 3600
FXA_ACCOUNT_STAGE_HOST = 'https://api-accounts.stage.mozaws.net' FXA_ACCOUNT_STAGE_HOST = 'https://api-accounts.stage.mozaws.net'
@ -66,8 +65,6 @@ class TestE2e(TestCase, unittest.TestCase):
cls.session.verify_email_code(m['headers']['x-verify-code']) cls.session.verify_email_code(m['headers']['x-verify-code'])
# Create an OAuth token to be used for the end-to-end tests # Create an OAuth token to be used for the end-to-end tests
cls.oauth_token = cls.oauth_client.authorize_token(cls.session, SCOPE) cls.oauth_token = cls.oauth_client.authorize_token(cls.session, SCOPE)
cls.browserid_assertion = \
cls.session.get_identity_assertion(BROWSERID_AUDIENCE)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
@ -76,7 +73,10 @@ class TestE2e(TestCase, unittest.TestCase):
# of a race condition, where the record had already been removed. # of a race condition, where the record had already been removed.
# This causes `destroy_account` to return an error if it attempts # This causes `destroy_account` to return an error if it attempts
# to parse the invalid JSON response. # to parse the invalid JSON response.
# This traps for that event. # It's also possible that the `destroy_account` is rejected due to
# missing authentication. It is not known why the authentication
# is considered missing.
# This traps for those events.
try: try:
cls.client.destroy_account(cls.acct.email, cls.fxa_password) cls.client.destroy_account(cls.acct.email, cls.fxa_password)
except (ServerError, ClientError) as ex: except (ServerError, ClientError) as ex:
@ -92,10 +92,6 @@ class TestE2e(TestCase, unittest.TestCase):
bad_scope = 'bad_scope' bad_scope = 'bad_scope'
return self.oauth_client.authorize_token(self.session, bad_scope) return self.oauth_client.authorize_token(self.session, bad_scope)
def _get_browserid_assertion_with_bad_audience(self):
bad_audience = 'badaudience.com'
return self.session.get_identity_assertion(bad_audience)
def _get_bad_token(self): def _get_bad_token(self):
key = rsa.generate_private_key(backend=default_backend(), key = rsa.generate_private_key(backend=default_backend(),
public_exponent=65537, public_exponent=65537,
@ -179,34 +175,6 @@ class TestE2e(TestCase, unittest.TestCase):
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401) res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response) self.assertEqual(res.json, expected_error_response)
def test_unauthorized_browserid_error_status(self):
assertion = self._get_bad_token()
headers = {
'Authorization': 'BrowserID %s' % assertion,
'X-Client-State': 'aaaa',
}
# Bad assertion -> 'invalid-credentials'
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
expected_error_response = {
'errors': [
{
'description': 'Unauthorized',
'location': 'body',
'name': ''
}
],
'status': 'invalid-credentials'
}
self.assertEqual(res.json, expected_error_response)
# Bad audience -> 'invalid-credentials'
assertion = self._get_browserid_assertion_with_bad_audience()
headers = {
'Authorization': 'BrowserID %s' % assertion,
'X-Client-State': 'aaaa',
}
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
self.assertEqual(res.json, expected_error_response)
def test_valid_oauth_request(self): def test_valid_oauth_request(self):
oauth_token = self.oauth_token oauth_token = self.oauth_token
headers = { headers = {
@ -262,56 +230,3 @@ class TestE2e(TestCase, unittest.TestCase):
self.assertNotEqual(token["hashed_fxa_uid"], token["fxa_uid"]) self.assertNotEqual(token["hashed_fxa_uid"], token["fxa_uid"])
self.assertEqual(token["hashed_fxa_uid"], res.json["hashed_fxa_uid"]) self.assertEqual(token["hashed_fxa_uid"], res.json["hashed_fxa_uid"])
self.assertIn("hashed_device_id", token) self.assertIn("hashed_device_id", token)
def test_valid_browserid_request(self):
assertion = self.browserid_assertion
headers = {
'Authorization': 'BrowserID %s' % assertion,
'X-Client-State': 'aaaa'
}
# Send a valid request, allocating a new user
res = self.app.get('/1.0/sync/1.5', headers=headers)
fxa_uid = self.session.uid
# Retrieve the user from the database
user = self._get_user(res.json['uid'])
# First, let's verify that the token we received is valid. To do this,
# we can unpack the hawk header ID into the payload and its signature
# and then construct a tokenlib token to compute the signature
# ourselves. To obtain a matching signature, we use the same secret as
# is used by Tokenserver.
raw = urlsafe_b64decode(res.json['id'])
payload = raw[:-32]
signature = raw[-32:]
payload_str = payload.decode('utf-8')
signing_secret = self.TOKEN_SIGNING_SECRET
tm = tokenlib.TokenManager(secret=signing_secret)
expected_signature = tm._get_signature(payload_str.encode('utf8'))
# Using the #compare_digest method here is not strictly necessary, as
# this is not a security-sensitive situation, but it's good practice
self.assertTrue(hmac.compare_digest(expected_signature, signature))
# Check that the given key is a secret derived from the hawk ID
expected_secret = tokenlib.get_derived_secret(
res.json['id'], secret=signing_secret)
self.assertEqual(res.json['key'], expected_secret)
# Check to make sure the remainder of the fields are valid
self.assertEqual(res.json['uid'], user['uid'])
self.assertEqual(res.json['api_endpoint'],
'%s/1.5/%s' % (self.NODE_URL, user['uid']))
self.assertEqual(res.json['duration'], DEFAULT_TOKEN_DURATION)
self.assertEqual(res.json['hashalg'], 'sha256')
self.assertEqual(res.json['hashed_fxa_uid'],
self._fxa_metrics_hash(fxa_uid)[:32])
self.assertEqual(res.json['node_type'], 'spanner')
token = self.unsafelyParseToken(res.json['id'])
self.assertIn('hashed_device_id', token)
self.assertEqual(token["uid"], res.json["uid"])
self.assertEqual(token["fxa_uid"], fxa_uid)
assertion = self.browserid_assertion
keys_changed_at = \
self._extract_keys_changed_at_from_assertion(assertion)
self.assertEqual(token["fxa_kid"], "%s-qqo" % str(keys_changed_at))
self.assertNotEqual(token["hashed_fxa_uid"], token["fxa_uid"])
self.assertEqual(token["hashed_fxa_uid"], res.json["hashed_fxa_uid"])
self.assertIn("hashed_device_id", token)

View File

@ -17,22 +17,16 @@ DEFAULT_OAUTH_SCOPE = 'https://identity.mozilla.com/apps/oldsync'
class TestCase: class TestCase:
BROWSERID_ISSUER = os.environ['SYNC_TOKENSERVER__FXA_BROWSERID_ISSUER']
FXA_EMAIL_DOMAIN = 'api-accounts.stage.mozaws.net' FXA_EMAIL_DOMAIN = 'api-accounts.stage.mozaws.net'
FXA_METRICS_HASH_SECRET = 'secret0' FXA_METRICS_HASH_SECRET = os.environ.get("SYNC_MASTER_SECRET", 'secret0')
NODE_ID = 800 NODE_ID = 800
NODE_URL = 'https://example.com' NODE_URL = 'https://example.com'
TOKEN_SIGNING_SECRET = 'secret0' TOKEN_SIGNING_SECRET = os.environ.get("SYNC_MASTER_SECRET", 'secret0')
TOKENSERVER_HOST = os.environ['TOKENSERVER_HOST'] TOKENSERVER_HOST = os.environ['TOKENSERVER_HOST']
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.auth_method = os.environ['TOKENSERVER_AUTH_METHOD'] cls._build_auth_headers = cls._build_oauth_headers
if cls.auth_method == 'browserid':
cls._build_auth_headers = cls._build_browserid_headers
else:
cls._build_auth_headers = cls._build_oauth_headers
def setUp(self): def setUp(self):
engine = create_engine(os.environ['SYNC_TOKENSERVER__DATABASE_URL']) engine = create_engine(os.environ['SYNC_TOKENSERVER__DATABASE_URL'])
@ -101,51 +95,6 @@ class TestCase:
return headers return headers
def _build_browserid_headers(self, generation=None, user='test',
keys_changed_at=None, client_state=None,
issuer=BROWSERID_ISSUER, device_id=None,
token_verified=None, status=200,
**additional_headers):
claims = {
'status': 'okay',
'email': '%s@%s' % (user, self.FXA_EMAIL_DOMAIN),
'issuer': issuer
}
if device_id or generation is not None or \
keys_changed_at is not None or token_verified is not None:
idp_claims = {}
if device_id:
idp_claims['fxa-deviceId'] = device_id
if generation:
idp_claims['fxa-generation'] = generation
if keys_changed_at:
idp_claims['fxa-keysChangedAt'] = keys_changed_at
if token_verified is not None:
idp_claims['fxa-tokenVerified'] = token_verified
claims['idpClaims'] = idp_claims
body = {
'body': claims,
'status': status,
}
headers = {
'Authorization': 'BrowserID %s' % json.dumps(body),
}
if client_state:
headers['X-Client-State'] = client_state
headers.update(additional_headers)
return headers
def _add_node(self, capacity=100, available=100, node=NODE_URL, id=None, def _add_node(self, capacity=100, available=100, node=NODE_URL, id=None,
current_load=0, backoff=0, downed=0): current_load=0, backoff=0, downed=0):
query = 'INSERT INTO nodes (service, node, available, capacity, \ query = 'INSERT INTO nodes (service, node, available, capacity, \

View File

@ -2,16 +2,11 @@ from base64 import urlsafe_b64encode as b64encode
import binascii import binascii
import jwt import jwt
import os import os
import time
import browserid
import browserid.jwt
from browserid.tests.support import make_assertion
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from locust import HttpUser, task, between from locust import HttpUser, task, between
BROWSERID_AUDIENCE = os.environ['BROWSERID_AUDIENCE']
DEFAULT_OAUTH_SCOPE = 'https://identity.mozilla.com/apps/oldsync' DEFAULT_OAUTH_SCOPE = 'https://identity.mozilla.com/apps/oldsync'
# To create an invalid token, we sign the JWT with a private key that doesn't # To create an invalid token, we sign the JWT with a private key that doesn't
@ -25,23 +20,6 @@ INVALID_OAUTH_PRIVATE_KEY = rsa.generate_private_key(
# We use a custom mockmyid site to synthesize valid assertions. # We use a custom mockmyid site to synthesize valid assertions.
# It's hosted in a static S3 bucket so we don't swamp the live mockmyid server. # It's hosted in a static S3 bucket so we don't swamp the live mockmyid server.
MOCKMYID_DOMAIN = "mockmyid.s3-us-west-2.amazonaws.com" MOCKMYID_DOMAIN = "mockmyid.s3-us-west-2.amazonaws.com"
MOCKMYID_PRIVATE_KEY = browserid.jwt.DS128Key({
"algorithm": "DS",
"x": "385cb3509f086e110c5e24bdd395a84b335a09ae",
"y": "738ec929b559b604a232a9b55a5295afc368063bb9c20fac4e53a74970a4db795"
"6d48e4c7ed523405f629b4cc83062f13029c4d615bbacb8b97f5e56f0c7ac9bc1"
"d4e23809889fa061425c984061fca1826040c399715ce7ed385c4dd0d40225691"
"2451e03452d3c961614eb458f188e3e8d2782916c43dbe2e571251ce38262",
"p": "ff600483db6abfc5b45eab78594b3533d550d9f1bf2a992a7a8daa6dc34f8045a"
"d4e6e0c429d334eeeaaefd7e23d4810be00e4cc1492cba325ba81ff2d5a5b305a"
"8d17eb3bf4a06a349d392e00d329744a5179380344e82a18c47933438f891e22a"
"eef812d69c8f75e326cb70ea000c3f776dfdbd604638c2ef717fc26d02e17",
"q": "e21e04f911d1ed7991008ecaab3bf775984309c3",
"g": "c52a4a0ff3b7e61fdf1867ce84138369a6154f4afa92966e3c827e25cfa6cf508b"
"90e5de419e1337e07a2e9e2a3cd5dea704d175f8ebf6af397d69e110b96afb17c7"
"a03259329e4829b0d03bbc7896b15b4ade53e130858cc34d96269aa89041f40913"
"6c7242a38895c9d5bccad4f389af1d7a4bd1398bd072dffa896233397a",
})
ONE_YEAR = 60 * 60 * 24 * 365 ONE_YEAR = 60 * 60 * 24 * 365
TOKENSERVER_PATH = '/1.0/sync/1.5' TOKENSERVER_PATH = '/1.0/sync/1.5'
@ -115,46 +93,6 @@ class TokenserverTestUser(HttpUser):
self._do_token_exchange_via_oauth(token) self._do_token_exchange_via_oauth(token)
@task(100)
def test_browserid_success(self):
assertion = self._make_browserid_assertion(self.email)
self._do_token_exchange_via_browserid(assertion)
@task(3)
def test_expired_browserid_assertion(self):
assertion = self._make_browserid_assertion(
self.email,
exp=int(time.time() - ONE_YEAR) * 1000
)
self._do_token_exchange_via_browserid(assertion, status=401)
@task(3)
def test_browserid_email_issuer_mismatch(self):
email = "loadtest-%s@%s" % (self.fxa_uid, "hotmail.com")
assertion = self._make_browserid_assertion(email)
self._do_token_exchange_via_browserid(assertion, status=401)
@task(3)
def test_browserid_invalid_audience(self):
assertion = self._make_browserid_assertion(
self.email,
audience="http://123done.org"
)
self._do_token_exchange_via_browserid(assertion, status=401)
@task(3)
def test_browserid_invalid_issuer_priv_key(self):
assertion = self._make_browserid_assertion(
self.email,
issuer="api.accounts.firefox.com"
)
self._do_token_exchange_via_browserid(assertion, status=401)
def _make_oauth_token(self, email, key=VALID_OAUTH_PRIVATE_KEY, **fields): def _make_oauth_token(self, email, key=VALID_OAUTH_PRIVATE_KEY, **fields):
# For mock oauth tokens, we bundle the desired status code # For mock oauth tokens, we bundle the desired status code
# and response body into a JSON blob for the mock verifier # and response body into a JSON blob for the mock verifier
@ -187,21 +125,6 @@ class TokenserverTestUser(HttpUser):
return '%s-%s' % (keys_changed_at, client_state) return '%s-%s' % (keys_changed_at, client_state)
def _make_browserid_assertion(self, email, **kwds):
if "audience" not in kwds:
kwds["audience"] = BROWSERID_AUDIENCE
if "exp" not in kwds:
kwds["exp"] = int((time.time() + ONE_YEAR) * 1000)
if "issuer" not in kwds:
kwds["issuer"] = MOCKMYID_DOMAIN
if "issuer_keypair" not in kwds:
kwds["issuer_keypair"] = (None, MOCKMYID_PRIVATE_KEY)
kwds["idp_claims"] = {
'fxa-generation': self.generation_counter,
'fxa-keysChangedAt': self.generation_counter,
}
return make_assertion(email, **kwds)
def _do_token_exchange_via_oauth(self, token, status=200): def _do_token_exchange_via_oauth(self, token, status=200):
headers = { headers = {
'Authorization': 'Bearer %s' % token, 'Authorization': 'Bearer %s' % token,
@ -213,15 +136,3 @@ class TokenserverTestUser(HttpUser):
headers=headers) as res: headers=headers) as res:
if res.status_code == status: if res.status_code == status:
res.success() res.success()
def _do_token_exchange_via_browserid(self, assertion, status=200):
headers = {
'Authorization': 'BrowserID %s' % assertion,
'X-Client-State': self.client_state
}
with self.client.get(TOKENSERVER_PATH,
catch_response=True,
headers=headers) as res:
if res.status_code == status:
res.success()

View File

@ -10,13 +10,16 @@ Admin/managment scripts for TokenServer.
import sys import sys
import time import time
import logging import logging
import base64
import os import os
import json import json
from datetime import datetime from datetime import datetime
from datadog import initialize, statsd from datadog import initialize, statsd
from browserid.utils import encode_bytes as encode_bytes_b64
def encode_bytes_b64(value):
return base64.urlsafe_b64encode(value).rstrip(b'=').decode('ascii')
def run_script(main): def run_script(main):