feat: Remove support for BrowserID (#1531)

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

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

View File

@ -192,7 +192,7 @@ jobs:
RUST_BACKTRACE: 1
# 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

File diff suppressed because it is too large Load Diff

View File

@ -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",
] }

View File

@ -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

View File

@ -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()));
};

View File

@ -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 =

View File

@ -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,

View File

@ -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,

View File

@ -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(),
},
}

View File

@ -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,

View File

@ -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)))

View File

@ -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",

View File

@ -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,

View File

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

View File

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

View File

@ -24,6 +24,7 @@ pub trait Crypto {
#[allow(dead_code)]
/// 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())?;

View File

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

View File

@ -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

View File

@ -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!(

View File

@ -38,19 +38,6 @@ pub struct Settings {
/// A secondary JWK to be used to verify OAuth tokens. This is intended to be used to enable
/// 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(),

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

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

View File

@ -24,7 +24,6 @@ from tokenserver.test_support import TestCase
# This is the client ID used for Firefox Desktop. The FxA team confirmed that
# this is the 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)

View File

@ -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, \

View File

@ -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()

View File

@ -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):