mirror of
https://github.com/mozilla-services/syncstorage-rs.git
synced 2025-08-06 03:46:57 +02:00
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:
parent
7004ad0121
commit
dbbdd1dfc3
@ -192,7 +192,7 @@ jobs:
|
||||
RUST_BACKTRACE: 1
|
||||
# XXX: begin_test_transaction doesn't play nice over threaded tests
|
||||
RUST_TEST_THREADS: 1
|
||||
- image: circleci/mysql:5.7-ram
|
||||
- image: cimg/mysql:5.7
|
||||
auth:
|
||||
username: $DOCKER_USER
|
||||
password: $DOCKER_PASS
|
||||
|
712
Cargo.lock
generated
712
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@ -30,12 +30,12 @@ license = "MPL-2.0"
|
||||
[workspace.dependencies]
|
||||
actix-web = "4"
|
||||
|
||||
base64 = "0.21"
|
||||
cadence = "0.29"
|
||||
base64 = "0.22"
|
||||
cadence = "1.3"
|
||||
backtrace = "0.3"
|
||||
chrono = "0.4"
|
||||
docopt = "1.1"
|
||||
env_logger = "0.10"
|
||||
env_logger = "0.11"
|
||||
futures = { version = "0.3", features = ["compat"] }
|
||||
futures-util = { version = "0.3", features = [
|
||||
"async-await",
|
||||
@ -44,30 +44,31 @@ futures-util = { version = "0.3", features = [
|
||||
"io",
|
||||
] }
|
||||
hex = "0.4"
|
||||
hostname = "0.4"
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
http = "0.2"
|
||||
http = "1.1"
|
||||
jsonwebtoken = { version = "9.2", default-features = false }
|
||||
lazy_static = "1.4"
|
||||
protobuf = "=2.25.2" # pin to 2.25.2 to prevent side updating
|
||||
rand = "0.8"
|
||||
regex = "1.4"
|
||||
reqwest = { version = "0.11", default-features = false, features = [
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
] }
|
||||
sentry = { version = "0.31", default-features = false, features = [
|
||||
sentry = { version = "0.32", default-features = false, features = [
|
||||
"curl",
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
] }
|
||||
sentry-backtrace = "0.31"
|
||||
sentry-backtrace = "0.32"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = { version = "1.0", features = ["arbitrary_precision"] }
|
||||
sha2 = "0.10"
|
||||
slog = { version = "2.5", features = [
|
||||
"max_level_info",
|
||||
"max_level_trace",
|
||||
"release_max_level_info",
|
||||
"dynamic-keys",
|
||||
] }
|
||||
|
@ -16,6 +16,8 @@ docopt.workspace = true
|
||||
futures.workspace = true
|
||||
futures-util.workspace = true
|
||||
hex.workspace = true
|
||||
hostname.workspace = true
|
||||
http.workspace = true
|
||||
lazy_static.workspace = true
|
||||
rand.workspace = true
|
||||
regex.workspace = true
|
||||
@ -37,10 +39,9 @@ thiserror.workspace = true
|
||||
|
||||
actix-http = "3"
|
||||
actix-rt = "2"
|
||||
actix-cors = "0.6"
|
||||
actix-cors = "0.7"
|
||||
async-trait = "0.1.40"
|
||||
dyn-clone = "1.0.4"
|
||||
hostname = "0.3.1"
|
||||
hawk = "5.0"
|
||||
mime = "0.3"
|
||||
# pin to 0.19: https://github.com/getsentry/sentry-rust/issues/277
|
||||
|
@ -9,10 +9,10 @@ use std::convert::From;
|
||||
use std::fmt;
|
||||
|
||||
use actix_web::{
|
||||
dev::ServiceResponse, error::ResponseError, http::StatusCode, middleware::ErrorHandlerResponse,
|
||||
HttpResponse, HttpResponseBuilder, Result,
|
||||
dev::ServiceResponse, error::ResponseError, middleware::ErrorHandlerResponse, HttpResponse,
|
||||
HttpResponseBuilder, Result,
|
||||
};
|
||||
|
||||
use http::StatusCode;
|
||||
use serde::{
|
||||
ser::{SerializeMap, SerializeSeq, Serializer},
|
||||
Serialize,
|
||||
@ -117,7 +117,7 @@ impl ApiError {
|
||||
Ok(ErrorHandlerResponse::Response(res.map_into_left_body()))
|
||||
} else {
|
||||
// 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);
|
||||
Ok(ErrorHandlerResponse::Response(ServiceResponse::new(
|
||||
res.request().clone(),
|
||||
@ -194,7 +194,9 @@ impl ResponseError for ApiError {
|
||||
// HttpResponse::build(self.status).json(self)
|
||||
//
|
||||
// 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() {
|
||||
resp.insert_header(("Retry-After", RETRY_AFTER.to_string()));
|
||||
};
|
||||
|
@ -6,7 +6,6 @@ use actix_web::{
|
||||
http::{
|
||||
self,
|
||||
header::{HeaderName, HeaderValue},
|
||||
StatusCode,
|
||||
},
|
||||
test,
|
||||
web::Bytes,
|
||||
@ -15,6 +14,7 @@ use base64::{engine, Engine};
|
||||
use chrono::offset::Utc;
|
||||
use hawk::{self, Credentials, Key, RequestBuilder};
|
||||
use hmac::{Hmac, Mac};
|
||||
use http::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use rand::{thread_rng, Rng};
|
||||
use serde::de::DeserializeOwned;
|
||||
@ -172,7 +172,7 @@ fn create_hawk_header(method: &str, port: u16, path: &str) -> String {
|
||||
user_id: 42,
|
||||
fxa_uid: format!("xxx_test_uid_{}", *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(),
|
||||
};
|
||||
let payload =
|
||||
|
@ -9,7 +9,6 @@ use std::sync::Arc;
|
||||
|
||||
use actix_web::{
|
||||
dev::Payload,
|
||||
http::StatusCode,
|
||||
web::{Data, Query},
|
||||
FromRequest, HttpRequest,
|
||||
};
|
||||
@ -17,6 +16,7 @@ use base64::{engine, Engine};
|
||||
use futures::future::LocalBoxFuture;
|
||||
use hex;
|
||||
use hmac::{Hmac, Mac};
|
||||
use http::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
@ -98,14 +98,12 @@ impl TokenserverRequest {
|
||||
});
|
||||
}
|
||||
|
||||
// If the client previously reported a client state, every subsequent request must include
|
||||
// one. Note that this is only relevant for BrowserID requests, since OAuth requests must
|
||||
// always include a client state.
|
||||
// If the caller reports new client state, but the auth doesn't, flag
|
||||
// it as an error.
|
||||
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();
|
||||
return Err(TokenserverError::invalid_client_state(error_message, None));
|
||||
}
|
||||
|
||||
// The client state on the request must not have been used in the past.
|
||||
if self
|
||||
.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
|
||||
// record. Note that this is only relevant for BrowserID requests, since OAuth requests
|
||||
// must always include a keys_changed_at header. The Python Tokenserver converts a NULL
|
||||
// keys_changed_at on the user record to 0 in memory, which means that NULL
|
||||
// keys_changed_ats are treated equivalently to 0 keys_changed_ats. This would allow users
|
||||
// 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.
|
||||
// Oauth requests must always include a `keys_changed_at` header. The Python Tokenserver
|
||||
// converts a NULL `keys_changed_at` to 0 in memory, which means that NULL `keys_changed_at`s
|
||||
// are treated equivalenty to 0 `keys_changed_at`s. This would allow users 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()
|
||||
&& matches!(user_keys_changed_at, Some(inner) if inner != 0)
|
||||
{
|
||||
@ -178,7 +173,6 @@ impl TokenserverRequest {
|
||||
..TokenserverError::invalid_keys_changed_at()
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -209,12 +203,8 @@ impl FromRequest for TokenserverRequest {
|
||||
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
|
||||
// metrics. Only requests using BrowserID will have a device ID, so use "none" as
|
||||
// a placeholder for OAuth requests.
|
||||
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)
|
||||
};
|
||||
// metrics. Use "none" as a placeholder for "device" with OAuth requests.
|
||||
let hashed_device_id = hash_device_id(&hashed_fxa_uid, fxa_metrics_hash_secret);
|
||||
|
||||
let DbWrapper(db) = DbWrapper::extract(&req).await?;
|
||||
let service_id = {
|
||||
@ -352,10 +342,9 @@ impl FromRequest for DbPoolWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/// An authentication token as parsed from the `Authorization` header. Both BrowserID assertions
|
||||
/// and OAuth tokens are opaque to Tokenserver and must be verified via FxA.
|
||||
/// An authentication token as parsed from the `Authorization` header.
|
||||
/// OAuth tokens are opaque to Tokenserver and must be verified via FxA.
|
||||
pub enum Token {
|
||||
BrowserIdAssertion(String),
|
||||
OAuthToken(String),
|
||||
}
|
||||
|
||||
@ -393,10 +382,8 @@ impl FromRequest for Token {
|
||||
|
||||
if auth_type == "bearer" {
|
||||
Ok(Token::OAuthToken(token.to_owned()))
|
||||
} else if auth_type == "browserid" {
|
||||
Ok(Token::BrowserIdAssertion(token.to_owned()))
|
||||
} else {
|
||||
// The request must use a Bearer token or BrowserID token
|
||||
// The request must use a Bearer token
|
||||
Err(TokenserverError {
|
||||
description: "Unsupported".to_owned(),
|
||||
location: ErrorLocation::Body,
|
||||
@ -421,7 +408,6 @@ impl FromRequest for Token {
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
pub struct AuthData {
|
||||
pub client_state: String,
|
||||
pub device_id: Option<String>,
|
||||
pub email: String,
|
||||
pub fxa_uid: String,
|
||||
pub generation: Option<i64>,
|
||||
@ -452,37 +438,6 @@ impl FromRequest for AuthData {
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Add a tag to the request extensions
|
||||
req.add_tag("token_type".to_owned(), "OAuth".to_owned());
|
||||
@ -503,7 +458,6 @@ impl FromRequest for AuthData {
|
||||
Ok(AuthData {
|
||||
client_state: key_id.client_state,
|
||||
email,
|
||||
device_id: None,
|
||||
fxa_uid,
|
||||
generation: convert_zero_to_none(verify_output.generation),
|
||||
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)
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
String::from(&fxa_metrics_hash[0..32])
|
||||
@ -709,7 +668,7 @@ mod tests {
|
||||
use serde_json;
|
||||
use syncserver_settings::Settings as GlobalSettings;
|
||||
use syncstorage_settings::ServerLimits;
|
||||
use tokenserver_auth::{browserid, oauth, MockVerifier};
|
||||
use tokenserver_auth::{oauth, MockVerifier};
|
||||
use tokenserver_db::mock::MockDbPool as MockTokenserverPool;
|
||||
use tokenserver_settings::Settings as TokenserverSettings;
|
||||
|
||||
@ -740,7 +699,7 @@ mod tests {
|
||||
verify_output,
|
||||
}
|
||||
};
|
||||
let state = make_state(oauth_verifier, MockVerifier::default());
|
||||
let state = make_state(oauth_verifier);
|
||||
|
||||
let req = TestRequest::default()
|
||||
.data(state)
|
||||
@ -761,7 +720,6 @@ mod tests {
|
||||
let expected_tokenserver_request = TokenserverRequest {
|
||||
user: results::GetOrCreateUser::default(),
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: fxa_uid.to_owned(),
|
||||
email: "test123@test.com".to_owned(),
|
||||
generation: Some(1234),
|
||||
@ -794,7 +752,7 @@ mod tests {
|
||||
verify_output,
|
||||
}
|
||||
};
|
||||
let state = make_state(oauth_verifier, MockVerifier::default());
|
||||
let state = make_state(oauth_verifier);
|
||||
|
||||
let request = TestRequest::default()
|
||||
.data(state)
|
||||
@ -837,7 +795,7 @@ mod tests {
|
||||
};
|
||||
|
||||
TestRequest::default()
|
||||
.data(make_state(oauth_verifier, MockVerifier::default()))
|
||||
.data(make_state(oauth_verifier))
|
||||
.data(Arc::clone(&SECRETS))
|
||||
.insert_header(("authorization", "Bearer fake_token"))
|
||||
.insert_header(("accept", "application/json,text/plain:q=0.5"))
|
||||
@ -942,7 +900,7 @@ mod tests {
|
||||
};
|
||||
|
||||
TestRequest::default()
|
||||
.data(make_state(oauth_verifier, MockVerifier::default()))
|
||||
.data(make_state(oauth_verifier))
|
||||
.insert_header(("authorization", "Bearer fake_token"))
|
||||
.insert_header(("accept", "application/json,text/plain:q=0.5"))
|
||||
.param("application", "sync")
|
||||
@ -1095,7 +1053,6 @@ mod tests {
|
||||
old_client_states: vec![],
|
||||
},
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: "test".to_owned(),
|
||||
email: "test@test.com".to_owned(),
|
||||
generation: Some(1233),
|
||||
@ -1138,7 +1095,6 @@ mod tests {
|
||||
old_client_states: vec![],
|
||||
},
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: "test".to_owned(),
|
||||
email: "test@test.com".to_owned(),
|
||||
generation: Some(1234),
|
||||
@ -1180,7 +1136,6 @@ mod tests {
|
||||
old_client_states: vec![],
|
||||
},
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: "test".to_owned(),
|
||||
email: "test@test.com".to_owned(),
|
||||
generation: Some(1234),
|
||||
@ -1223,7 +1178,6 @@ mod tests {
|
||||
old_client_states: vec!["bbbb".to_owned()],
|
||||
},
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: "test".to_owned(),
|
||||
email: "test@test.com".to_owned(),
|
||||
generation: Some(1234),
|
||||
@ -1266,7 +1220,6 @@ mod tests {
|
||||
old_client_states: vec![],
|
||||
},
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: "test".to_owned(),
|
||||
email: "test@test.com".to_owned(),
|
||||
generation: Some(1234),
|
||||
@ -1307,7 +1260,6 @@ mod tests {
|
||||
old_client_states: vec![],
|
||||
},
|
||||
auth_data: AuthData {
|
||||
device_id: None,
|
||||
fxa_uid: "test".to_owned(),
|
||||
email: "test@test.com".to_owned(),
|
||||
generation: Some(1235),
|
||||
@ -1335,17 +1287,13 @@ mod tests {
|
||||
String::from_utf8(block_on(test::read_body(sresponse)).to_vec()).unwrap()
|
||||
}
|
||||
|
||||
fn make_state(
|
||||
oauth_verifier: MockVerifier<oauth::VerifyOutput>,
|
||||
browserid_verifier: MockVerifier<browserid::VerifyOutput>,
|
||||
) -> ServerState {
|
||||
fn make_state(oauth_verifier: MockVerifier<oauth::VerifyOutput>) -> ServerState {
|
||||
let syncserver_settings = GlobalSettings::default();
|
||||
let tokenserver_settings = TokenserverSettings::default();
|
||||
|
||||
ServerState {
|
||||
fxa_email_domain: "test.com".to_owned(),
|
||||
fxa_metrics_hash_secret: "".to_owned(),
|
||||
browserid_verifier: Box::new(browserid_verifier),
|
||||
oauth_verifier: Box::new(oauth_verifier),
|
||||
db_pool: Box::new(MockTokenserverPool::new()),
|
||||
node_capacity_release_rate: None,
|
||||
|
@ -11,7 +11,7 @@ use serde::{
|
||||
use syncserver_common::{BlockingThreadpool, Metrics};
|
||||
#[cfg(not(feature = "py_verifier"))]
|
||||
use tokenserver_auth::JWTVerifierImpl;
|
||||
use tokenserver_auth::{browserid, oauth, VerifyToken};
|
||||
use tokenserver_auth::{oauth, VerifyToken};
|
||||
use tokenserver_common::NodeType;
|
||||
use tokenserver_db::{params, DbPool, TokenserverPool};
|
||||
use tokenserver_settings::Settings;
|
||||
@ -21,7 +21,7 @@ use crate::{
|
||||
server::user_agent,
|
||||
};
|
||||
|
||||
use std::{collections::HashMap, convert::TryFrom, fmt, sync::Arc};
|
||||
use std::{collections::HashMap, fmt, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState {
|
||||
@ -29,7 +29,6 @@ pub struct ServerState {
|
||||
pub fxa_email_domain: String,
|
||||
pub fxa_metrics_hash_secret: String,
|
||||
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_type: NodeType,
|
||||
pub metrics: Arc<StatsdClient>,
|
||||
@ -72,10 +71,6 @@ impl ServerState {
|
||||
oauth::Verifier::new(settings, blocking_threadpool.clone())
|
||||
.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;
|
||||
|
||||
TokenserverPool::new(
|
||||
@ -102,7 +97,6 @@ impl ServerState {
|
||||
fxa_email_domain: settings.fxa_email_domain.clone(),
|
||||
fxa_metrics_hash_secret: settings.fxa_metrics_hash_secret.clone(),
|
||||
oauth_verifier,
|
||||
browserid_verifier,
|
||||
db_pool: Box::new(db_pool),
|
||||
node_capacity_release_rate: settings.node_capacity_release_rate,
|
||||
node_type: settings.node_type,
|
||||
|
@ -53,8 +53,8 @@ pub struct HawkPayload {
|
||||
#[serde(default)]
|
||||
pub fxa_kid: String,
|
||||
|
||||
#[serde(default, rename = "hashed_device_id")]
|
||||
pub device_id: String,
|
||||
#[serde(default)]
|
||||
pub hashed_device_id: String,
|
||||
|
||||
/// The Tokenserver that created this token.
|
||||
#[serde(default)]
|
||||
@ -156,7 +156,7 @@ impl HawkPayload {
|
||||
user_id,
|
||||
fxa_uid: "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(),
|
||||
}
|
||||
}
|
||||
@ -508,7 +508,7 @@ mod tests {
|
||||
user_id: 1,
|
||||
fxa_uid: "319b98f9961ff1dbdd07313cd6ba925a".to_owned(),
|
||||
fxa_kid: "de697ad66d845b2873c9d7e13b8971af".to_owned(),
|
||||
device_id: "2bcb92f4d4698c3d7b083a3c698a16ccd78bc2a8d20a96e4bb128ddceaf4e0b6".to_owned(),
|
||||
hashed_device_id: "2bcb92f4d4698c3d7b083a3c698a16ccd78bc2a8d20a96e4bb128ddceaf4e0b6".to_owned(),
|
||||
tokenserver_origin: Default::default(),
|
||||
},
|
||||
}
|
||||
|
@ -2,12 +2,13 @@
|
||||
#![allow(clippy::single_match)]
|
||||
use std::fmt;
|
||||
|
||||
use actix_web::http::{header::ToStrError, StatusCode};
|
||||
use actix_web::http::header::ToStrError;
|
||||
use actix_web::Error as ActixError;
|
||||
use base64::DecodeError;
|
||||
|
||||
use hawk::Error as ParseError;
|
||||
use hmac::digest::{InvalidLength, MacError};
|
||||
use http::StatusCode;
|
||||
use serde::{
|
||||
ser::{SerializeSeq, Serializer},
|
||||
Serialize,
|
||||
|
@ -1,6 +1,7 @@
|
||||
//! API Handlers
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Into;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use actix_web::{http::StatusCode, web::Data, HttpRequest, HttpResponse, HttpResponseBuilder};
|
||||
use serde::Serialize;
|
||||
@ -11,7 +12,6 @@ use syncstorage_db::{
|
||||
results::{CreateBatch, Paginated},
|
||||
Db, DbError, DbErrorIntrospect,
|
||||
};
|
||||
use time;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiErrorKind},
|
||||
@ -585,7 +585,7 @@ pub async fn lbheartbeat(req: HttpRequest) -> Result<HttpResponse, ApiError> {
|
||||
|
||||
let deadarc = state.deadman.clone();
|
||||
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
|
||||
// evict this instance and start a fresh one)
|
||||
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 deadman.clock_start.is_none() {
|
||||
deadman.clock_start = Some(time::Instant::now());
|
||||
deadman.clock_start = Some(Instant::now());
|
||||
}
|
||||
status_code = StatusCode::INTERNAL_SERVER_ERROR;
|
||||
} 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),
|
||||
);
|
||||
if let Some(clock) = deadman.clock_start {
|
||||
let duration: time::Duration = time::Instant::now() - clock;
|
||||
resp.insert(
|
||||
"duration_ms".to_string(),
|
||||
Value::from(duration.whole_milliseconds()),
|
||||
);
|
||||
let duration: Duration = Instant::now() - clock;
|
||||
resp.insert("duration_ms".to_string(), Value::from(duration.as_millis()));
|
||||
};
|
||||
|
||||
Ok(HttpResponseBuilder::new(status_code).json(json!(resp)))
|
||||
|
@ -9,12 +9,12 @@ edition.workspace = true
|
||||
cadence.workspace = true
|
||||
env_logger.workspace = true
|
||||
futures.workspace = true
|
||||
hostname.workspace = true
|
||||
lazy_static.workspace = true
|
||||
rand.workspace = true
|
||||
slog-scope.workspace = true
|
||||
|
||||
async-trait = "0.1.40"
|
||||
hostname = "0.3.1"
|
||||
log = { version = "0.4", features = [
|
||||
"max_level_debug",
|
||||
"release_max_level_info",
|
||||
|
@ -1,6 +1,9 @@
|
||||
//! Application settings objects and initialization
|
||||
|
||||
use std::cmp::min;
|
||||
use std::{
|
||||
cmp::min,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use rand::{thread_rng, Rng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -40,8 +43,8 @@ pub struct Quota {
|
||||
pub struct Deadman {
|
||||
pub max_size: u32,
|
||||
pub previous_count: usize,
|
||||
pub clock_start: Option<time::Instant>,
|
||||
pub expiry: Option<time::Instant>,
|
||||
pub clock_start: Option<Instant>,
|
||||
pub expiry: Option<Instant>,
|
||||
}
|
||||
|
||||
impl From<&Settings> for Deadman {
|
||||
@ -52,7 +55,7 @@ impl From<&Settings> for Deadman {
|
||||
let ttl = lbheartbeat_ttl as f32;
|
||||
let max_jitter = ttl * (settings.lbheartbeat_ttl_jitter as f32 * 0.01);
|
||||
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 {
|
||||
max_size: settings.database_pool_max_size,
|
||||
|
@ -20,4 +20,4 @@ batch_upload_enabled = true
|
||||
force_consistent_sort_order = true
|
||||
|
||||
[hawkauth]
|
||||
secret = "TED KOPPEL IS A ROBOT"
|
||||
secret = "secret0"
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ pub trait Crypto {
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// 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>;
|
||||
|
||||
/// Generates random bytes using a cryptographic random number generator
|
||||
@ -53,6 +54,7 @@ impl Crypto for CryptoImpl {
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_verify(&self, key: &[u8], payload: &[u8], signature: &[u8]) -> Result<(), Self::Error> {
|
||||
let mut mac: Hmac<Sha256> =
|
||||
Hmac::new_from_slice(key).map_err(|_| TokenserverError::internal_error())?;
|
||||
|
@ -1,5 +1,3 @@
|
||||
pub mod browserid;
|
||||
|
||||
#[cfg(not(feature = "py"))]
|
||||
mod crypto;
|
||||
|
||||
|
@ -8,6 +8,7 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
actix-web.workspace = true
|
||||
backtrace.workspace = true
|
||||
http.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
|
@ -1,7 +1,8 @@
|
||||
use std::{cmp::PartialEq, error::Error, fmt};
|
||||
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use backtrace::Backtrace;
|
||||
use http::StatusCode;
|
||||
use serde::{
|
||||
ser::{SerializeMap, Serializer},
|
||||
Serialize,
|
||||
@ -27,7 +28,6 @@ pub struct TokenserverError {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TokenType {
|
||||
BrowserId,
|
||||
Oauth,
|
||||
}
|
||||
|
||||
@ -201,11 +201,11 @@ impl fmt::Display for ErrorLocation {
|
||||
|
||||
impl ResponseError for TokenserverError {
|
||||
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 {
|
||||
self.http_status
|
||||
fn status_code(&self) -> actix_web::http::StatusCode {
|
||||
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> {
|
||||
if self.http_status.is_client_error() {
|
||||
match self.token_type {
|
||||
TokenType::BrowserId => Some("request.error.browser_id".to_owned()),
|
||||
TokenType::Oauth => Some("request.error.oauth".to_owned()),
|
||||
}
|
||||
} else if matches!(
|
||||
|
@ -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
|
||||
/// seamless key rotations on FxA.
|
||||
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.
|
||||
pub node_capacity_release_rate: Option<f32>,
|
||||
/// 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_primary_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_type: NodeType::Spanner,
|
||||
statsd_label: "syncstorage.tokenserver".to_owned(),
|
||||
|
@ -30,7 +30,7 @@ FXA_UID = "DEADBEEF00004be4ae957006c0ceb620"
|
||||
FXA_KID = "DEADBEEF00004be4ae957006c0ceb620"
|
||||
DEVICE_ID = "device1"
|
||||
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"
|
||||
|
||||
# 10 years
|
||||
|
@ -8,7 +8,7 @@ import sys
|
||||
from test_storage import TestStorage
|
||||
from test_support import run_live_functional_tests
|
||||
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"
|
||||
RELEASE_BUILD = "/app/bin/syncserver"
|
||||
@ -52,21 +52,16 @@ if __name__ == "__main__":
|
||||
os.environ.setdefault("SYNC_CORS_ALLOWED_ORIGIN", "*")
|
||||
mock_fxa_server_url = os.environ["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
|
||||
the_server_subprocess = start_server()
|
||||
try:
|
||||
res = 0
|
||||
res |= run_live_functional_tests(TestStorage, sys.argv)
|
||||
os.environ["TOKENSERVER_AUTH_METHOD"] = "oauth"
|
||||
res |= run_local_tests(include_browserid_specific_tests=False)
|
||||
os.environ["TOKENSERVER_AUTH_METHOD"] = "browserid"
|
||||
res |= run_local_tests(include_browserid_specific_tests=True)
|
||||
res |= run_local_tests()
|
||||
finally:
|
||||
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"] = \
|
||||
"https://oauth.stage.mozaws.net"
|
||||
the_server_subprocess = start_server()
|
||||
@ -86,7 +81,8 @@ if __name__ == "__main__":
|
||||
|
||||
the_server_subprocess = start_server()
|
||||
try:
|
||||
res |= run_end_to_end_tests()
|
||||
verbosity = int(os.environ.get("VERBOSITY", "1"))
|
||||
res |= run_end_to_end_tests(verbosity=verbosity)
|
||||
finally:
|
||||
terminate_process(the_server_subprocess)
|
||||
|
||||
|
@ -794,10 +794,12 @@ class SyncStorageAuthenticationPolicy(TokenServerAuthenticationPolicy):
|
||||
user["hashed_device_id"] = data["hashed_device_id"]
|
||||
if not VALID_FXA_ID_REGEX.match(user["hashed_device_id"]):
|
||||
raise ValueError("invalid hashed_device_id in token data")
|
||||
"""
|
||||
elif "device_id" in data:
|
||||
user["hashed_device_id"] = data.get("device_id")
|
||||
if not VALID_FXA_ID_REGEX.match(user["hashed_device_id"]):
|
||||
raise ValueError("invalid device_id in token data")
|
||||
"""
|
||||
return user
|
||||
|
||||
|
||||
|
@ -22,27 +22,6 @@ def _mock_oauth_jwk(request):
|
||||
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):
|
||||
with Configurator() as config:
|
||||
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_view(_mock_oauth_jwk, route_name='mock_oauth_jwk',
|
||||
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()
|
||||
|
||||
return _make_server(host, port, app)
|
||||
|
@ -4,32 +4,28 @@
|
||||
import unittest
|
||||
|
||||
from tokenserver.test_authorization import TestAuthorization
|
||||
from tokenserver.test_browserid import TestBrowserId
|
||||
from tokenserver.test_e2e import TestE2e
|
||||
from tokenserver.test_misc import TestMisc
|
||||
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]
|
||||
|
||||
if include_browserid_specific_tests:
|
||||
test_classes.append(TestBrowserId)
|
||||
|
||||
return run_tests(test_classes)
|
||||
|
||||
|
||||
def run_end_to_end_tests():
|
||||
return run_tests([TestE2e])
|
||||
def run_end_to_end_tests(verbosity=1):
|
||||
return run_tests([TestE2e], verbosity=verbosity)
|
||||
|
||||
|
||||
def run_tests(test_cases):
|
||||
def run_tests(test_cases, verbosity=1):
|
||||
loader = unittest.TestLoader()
|
||||
success = True
|
||||
|
||||
for test_case in test_cases:
|
||||
suite = loader.loadTestsFromTestCase(test_case)
|
||||
runner = unittest.TextTestRunner()
|
||||
runner = unittest.TextTestRunner(verbosity=verbosity)
|
||||
res = runner.run(suite)
|
||||
success = success and res.wasSuccessful()
|
||||
|
||||
|
@ -45,26 +45,26 @@ class TestAuthorization(TestCase, unittest.TestCase):
|
||||
self.assertEqual(res.json, expected_error_response)
|
||||
|
||||
def test_invalid_client_state_in_key_id(self):
|
||||
if self.auth_method == "oauth":
|
||||
additional_headers = {
|
||||
'X-KeyID': "1234-state!"
|
||||
}
|
||||
headers = self._build_auth_headers(keys_changed_at=1234,
|
||||
client_state='aaaa',
|
||||
**additional_headers)
|
||||
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
|
||||
additional_headers = {
|
||||
'X-KeyID': "1234-state!"
|
||||
}
|
||||
headers = self._build_auth_headers(
|
||||
keys_changed_at=1234,
|
||||
client_state='aaaa',
|
||||
**additional_headers)
|
||||
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
|
||||
|
||||
expected_error_response = {
|
||||
'status': 'invalid-credentials',
|
||||
'errors': [
|
||||
{
|
||||
'location': 'body',
|
||||
'name': '',
|
||||
'description': 'Unauthorized'
|
||||
}
|
||||
]
|
||||
}
|
||||
self.assertEqual(res.json, expected_error_response)
|
||||
expected_error_response = {
|
||||
'status': 'invalid-credentials',
|
||||
'errors': [
|
||||
{
|
||||
'location': 'body',
|
||||
'name': '',
|
||||
'description': 'Unauthorized'
|
||||
}
|
||||
]
|
||||
}
|
||||
self.assertEqual(res.json, expected_error_response)
|
||||
|
||||
def test_invalid_client_state_in_x_client_state(self):
|
||||
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,
|
||||
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
|
||||
headers = self._build_auth_headers(generation=None,
|
||||
keys_changed_at=1234,
|
||||
@ -534,29 +522,29 @@ class TestAuthorization(TestCase, unittest.TestCase):
|
||||
self.assertEqual(user['keys_changed_at'], 1234)
|
||||
|
||||
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')
|
||||
additional_headers = {'X-Client-State': 'bbbb'}
|
||||
headers = self._build_auth_headers(generation=1234,
|
||||
keys_changed_at=1234,
|
||||
client_state='aaaa',
|
||||
**additional_headers)
|
||||
# If present, the X-Client-State header must have the same client
|
||||
# state as the X-KeyID header
|
||||
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
|
||||
expected_error_response = {
|
||||
'errors': [
|
||||
{
|
||||
'description': 'Unauthorized',
|
||||
'location': 'body',
|
||||
'name': ''
|
||||
}
|
||||
],
|
||||
'status': 'invalid-client-state'
|
||||
}
|
||||
self.assertEqual(res.json, expected_error_response)
|
||||
headers['X-Client-State'] = 'aaaa'
|
||||
res = self.app.get('/1.0/sync/1.5', headers=headers)
|
||||
self._add_user(client_state='aaaa')
|
||||
additional_headers = {'X-Client-State': 'bbbb'}
|
||||
headers = self._build_auth_headers(
|
||||
generation=1234,
|
||||
keys_changed_at=1234,
|
||||
client_state='aaaa',
|
||||
**additional_headers)
|
||||
# If present, the X-Client-State header must have the same client
|
||||
# state as the X-KeyID header
|
||||
res = self.app.get('/1.0/sync/1.5', headers=headers, status=401)
|
||||
expected_error_response = {
|
||||
'errors': [
|
||||
{
|
||||
'description': 'Unauthorized',
|
||||
'location': 'body',
|
||||
'name': ''
|
||||
}
|
||||
],
|
||||
'status': 'invalid-client-state'
|
||||
}
|
||||
self.assertEqual(res.json, expected_error_response)
|
||||
headers['X-Client-State'] = 'aaaa'
|
||||
res = self.app.get('/1.0/sync/1.5', headers=headers)
|
||||
|
||||
def test_zero_generation_treated_as_null(self):
|
||||
# Add a user that has a generation set
|
||||
|
@ -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)
|
@ -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 proper client ID to be using for these integration tests.
|
||||
BROWSERID_AUDIENCE = "https://token.stage.mozaws.net"
|
||||
CLIENT_ID = '5882386c6d801776'
|
||||
DEFAULT_TOKEN_DURATION = 3600
|
||||
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'])
|
||||
# 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.browserid_assertion = \
|
||||
cls.session.get_identity_assertion(BROWSERID_AUDIENCE)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
@ -76,7 +73,10 @@ class TestE2e(TestCase, unittest.TestCase):
|
||||
# of a race condition, where the record had already been removed.
|
||||
# This causes `destroy_account` to return an error if it attempts
|
||||
# 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:
|
||||
cls.client.destroy_account(cls.acct.email, cls.fxa_password)
|
||||
except (ServerError, ClientError) as ex:
|
||||
@ -92,10 +92,6 @@ class TestE2e(TestCase, unittest.TestCase):
|
||||
bad_scope = '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):
|
||||
key = rsa.generate_private_key(backend=default_backend(),
|
||||
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)
|
||||
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):
|
||||
oauth_token = self.oauth_token
|
||||
headers = {
|
||||
@ -262,56 +230,3 @@ class TestE2e(TestCase, unittest.TestCase):
|
||||
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_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)
|
||||
|
@ -17,22 +17,16 @@ DEFAULT_OAUTH_SCOPE = 'https://identity.mozilla.com/apps/oldsync'
|
||||
|
||||
|
||||
class TestCase:
|
||||
BROWSERID_ISSUER = os.environ['SYNC_TOKENSERVER__FXA_BROWSERID_ISSUER']
|
||||
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_URL = 'https://example.com'
|
||||
TOKEN_SIGNING_SECRET = 'secret0'
|
||||
TOKEN_SIGNING_SECRET = os.environ.get("SYNC_MASTER_SECRET", 'secret0')
|
||||
TOKENSERVER_HOST = os.environ['TOKENSERVER_HOST']
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.auth_method = os.environ['TOKENSERVER_AUTH_METHOD']
|
||||
|
||||
if cls.auth_method == 'browserid':
|
||||
cls._build_auth_headers = cls._build_browserid_headers
|
||||
else:
|
||||
cls._build_auth_headers = cls._build_oauth_headers
|
||||
cls._build_auth_headers = cls._build_oauth_headers
|
||||
|
||||
def setUp(self):
|
||||
engine = create_engine(os.environ['SYNC_TOKENSERVER__DATABASE_URL'])
|
||||
@ -101,51 +95,6 @@ class TestCase:
|
||||
|
||||
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,
|
||||
current_load=0, backoff=0, downed=0):
|
||||
query = 'INSERT INTO nodes (service, node, available, capacity, \
|
||||
|
@ -2,16 +2,11 @@ from base64 import urlsafe_b64encode as b64encode
|
||||
import binascii
|
||||
import jwt
|
||||
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.asymmetric import rsa
|
||||
from locust import HttpUser, task, between
|
||||
|
||||
BROWSERID_AUDIENCE = os.environ['BROWSERID_AUDIENCE']
|
||||
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
|
||||
@ -25,23 +20,6 @@ INVALID_OAUTH_PRIVATE_KEY = rsa.generate_private_key(
|
||||
# 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.
|
||||
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
|
||||
TOKENSERVER_PATH = '/1.0/sync/1.5'
|
||||
|
||||
@ -115,46 +93,6 @@ class TokenserverTestUser(HttpUser):
|
||||
|
||||
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):
|
||||
# For mock oauth tokens, we bundle the desired status code
|
||||
# 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)
|
||||
|
||||
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):
|
||||
headers = {
|
||||
'Authorization': 'Bearer %s' % token,
|
||||
@ -213,15 +136,3 @@ class TokenserverTestUser(HttpUser):
|
||||
headers=headers) as res:
|
||||
if res.status_code == status:
|
||||
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()
|
||||
|
@ -10,13 +10,16 @@ Admin/managment scripts for TokenServer.
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import base64
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
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):
|
||||
|
Loading…
Reference in New Issue
Block a user