diff --git a/Cargo.lock b/Cargo.lock
index 03afe6d86f..5e3c6f4fb2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -176,7 +176,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"time",
"tracing",
"url",
@@ -850,7 +850,7 @@ dependencies = [
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"windows-sys 0.59.0",
]
@@ -967,7 +967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -1026,7 +1026,7 @@ dependencies = [
"downcast-rs",
"itoa",
"pq-sys",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -1484,7 +1484,7 @@ dependencies = [
"chrono",
"serde",
"serde_json",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -1813,7 +1813,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -2101,9 +2101,11 @@ dependencies = [
"base64",
"getrandom 0.2.17",
"js-sys",
+ "pem",
"serde",
"serde_json",
"signature",
+ "simple_asn1",
]
[[package]]
@@ -2141,9 +2143,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.182"
+version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libloading"
@@ -2183,9 +2185,9 @@ dependencies = [
[[package]]
name = "libz-sys"
-version = "1.1.24"
+version = "1.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
+checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
dependencies = [
"cc",
"libc",
@@ -2407,7 +2409,7 @@ dependencies = [
"sha1",
"sha2 0.10.9",
"thiserror 2.0.18",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -3062,7 +3064,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -3100,7 +3102,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -3478,9 +3480,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
[[package]]
name = "schannel"
-version = "0.1.28"
+version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
@@ -3620,7 +3622,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -3771,6 +3773,18 @@ version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.18",
+ "time",
+]
+
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -3898,12 +3912,12 @@ dependencies = [
[[package]]
name = "socket2"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4151,7 +4165,7 @@ dependencies = [
"syncserver-db-common",
"syncstorage-db-common",
"syncstorage-settings",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -4185,7 +4199,7 @@ dependencies = [
"syncstorage-settings",
"thiserror 2.0.18",
"tokio",
- "uuid 1.21.0",
+ "uuid 1.22.0",
]
[[package]]
@@ -4495,7 +4509,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -4531,7 +4545,7 @@ dependencies = [
"postgres-protocol",
"postgres-types",
"rand 0.9.2",
- "socket2 0.6.2",
+ "socket2 0.6.3",
"tokio",
"tokio-util",
"whoami",
@@ -4863,9 +4877,9 @@ dependencies = [
[[package]]
name = "uuid"
-version = "1.21.0"
+version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
+checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -5457,9 +5471,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
-version = "0.7.14"
+version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
@@ -5604,18 +5618,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.40"
+version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
+checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.40"
+version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
+checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
dependencies = [
"proc-macro2",
"quote",
diff --git a/docs/src/config.md b/docs/src/config.md
index 90e68c5506..d322770e1c 100644
--- a/docs/src/config.md
+++ b/docs/src/config.md
@@ -105,6 +105,7 @@ The following configuration options are available.
| SYNC_TOKENSERVER__NODE_TYPE | spanner | Storage backend type reported in token response for telemetry. Valid values: "mysql", "postgres", "spanner" |
| SYNC_TOKENSERVER__STATSD_LABEL | syncstorage.tokenserver | StatsD metrics label prefix |
| SYNC_TOKENSERVER__TOKEN_DURATION | 3600 | Token TTL (1 hour) |
+| SYNC_TOKENSERVER__FXA_WEBHOOK_ENABLED | false | Enable the FxA webhook endpoint. When disabled, the route is not registered. |
### Tokenserver+FxA Integration
diff --git a/syncserver/Cargo.toml b/syncserver/Cargo.toml
index 113bf72b30..a442defe5c 100644
--- a/syncserver/Cargo.toml
+++ b/syncserver/Cargo.toml
@@ -61,6 +61,7 @@ woothee = "0.13"
[dev-dependencies]
temp-env.workspace = true
+tokenserver-auth = { path = "../tokenserver-auth", features = ["test-support"] }
[target.'cfg(target_os = "linux")'.dependencies]
slog-journald = "2.2.0"
diff --git a/syncserver/src/server/mod.rs b/syncserver/src/server/mod.rs
index 64152d7993..20baab8b1f 100644
--- a/syncserver/src/server/mod.rs
+++ b/syncserver/src/server/mod.rs
@@ -208,6 +208,18 @@ macro_rules! build_app {
web::resource("/1.0/{application}/{version}")
.route(web::get().to(tokenserver::handlers::get_tokenserver_result)),
)
+ .configure(|cfg| {
+ let fxa_webhook_enabled = $tokenserver_state
+ .as_ref()
+ .map(|s: &tokenserver::ServerState| s.fxa_webhook_enabled)
+ .unwrap_or(false);
+ if fxa_webhook_enabled {
+ cfg.service(
+ web::resource("/1.0/webhooks/fxa/events")
+ .route(web::post().to(tokenserver::handlers::handle_fxa_events)),
+ );
+ }
+ })
// Dockerflow
// Remember to update .::web::middleware::DOCKER_FLOW_ENDPOINTS
// when applying changes to endpoint names.
@@ -253,7 +265,8 @@ macro_rules! build_app {
#[macro_export]
macro_rules! build_app_without_syncstorage {
- ($state: expr, $secrets: expr, $cors: expr, $metrics: expr) => {
+ ($state: expr, $secrets: expr, $cors: expr, $metrics: expr) => {{
+ let fxa_webhook_enabled = $state.fxa_webhook_enabled;
App::new()
.app_data(Data::new($state))
.app_data(Data::new($secrets))
@@ -276,6 +289,14 @@ macro_rules! build_app_without_syncstorage {
web::resource("/1.0/{application}/{version}")
.route(web::get().to(tokenserver::handlers::get_tokenserver_result)),
)
+ .configure(move |cfg| {
+ if fxa_webhook_enabled {
+ cfg.service(
+ web::resource("/1.0/webhooks/fxa/events")
+ .route(web::post().to(tokenserver::handlers::handle_fxa_events)),
+ );
+ }
+ })
// Dockerflow
// Remember to update .::web::middleware::DOCKER_FLOW_ENDPOINTS
// when applying changes to endpoint names.
@@ -319,7 +340,7 @@ macro_rules! build_app_without_syncstorage {
SwaggerUi::new("/swagger-ui/{_:.*}")
.url("/api-doc/openapi.json", ApiDoc::openapi()),
)
- };
+ }};
}
impl Server {
diff --git a/syncserver/src/tokenserver/extractors.rs b/syncserver/src/tokenserver/extractors.rs
index 4ee1c8d591..b8ea6129ab 100644
--- a/syncserver/src/tokenserver/extractors.rs
+++ b/syncserver/src/tokenserver/extractors.rs
@@ -23,8 +23,9 @@ use serde::Deserialize;
use sha2::Sha256;
use syncserver_common::Taggable;
use syncserver_settings::Secrets;
+use tokenserver_auth::{FxaWebhookClaims, JWTVerifyError};
use tokenserver_common::{ErrorLocation, NodeType, TokenserverError};
-use tokenserver_db::{Db, DbPool, params, results};
+use tokenserver_db::{Db, DbPool, SYNC_SERVICE_NAME, params, results};
use super::{LogItemsMutator, ServerState, TokenserverMetrics};
use crate::server::MetricsWrapper;
@@ -33,8 +34,6 @@ lazy_static! {
static ref CLIENT_STATE_REGEX: Regex = Regex::new("^[a-zA-Z0-9._-]{1,32}$").unwrap();
}
-const SYNC_SERVICE_NAME: &str = "sync-1.5";
-
/// Information from the request needed to process a Tokenserver request.
#[derive(Debug, Default, Eq, PartialEq)]
pub struct TokenserverRequest {
@@ -345,9 +344,9 @@ impl FromRequest for DbPoolWrapper {
}
/// An authentication token as parsed from the `Authorization` header.
-/// OAuth tokens are opaque to Tokenserver and must be verified via FxA.
+/// Signed JWTs can be verified locally or via FxA.
pub enum Token {
- OAuthToken(String),
+ JWT(String),
}
impl FromRequest for Token {
@@ -383,7 +382,7 @@ impl FromRequest for Token {
let auth_type = auth_type.to_ascii_lowercase();
if auth_type == "bearer" {
- Ok(Token::OAuthToken(token.to_owned()))
+ Ok(Token::JWT(token.to_owned()))
} else {
// The request must use a Bearer token
Err(TokenserverError {
@@ -440,7 +439,7 @@ impl FromRequest for AuthData {
}
match token {
- Token::OAuthToken(token) => {
+ Token::JWT(token) => {
// Add a tag to the request extensions
req.add_tag("token_type".to_owned(), "OAuth".to_owned());
log_items_mutator.insert("token_type".to_owned(), "OAuth".to_owned());
@@ -612,6 +611,41 @@ impl FromRequest for TokenserverMetrics {
}
}
+#[derive(Debug)]
+pub struct FxaWebhookToken(pub FxaWebhookClaims);
+
+impl FromRequest for FxaWebhookToken {
+ type Error = TokenserverError;
+ type Future = LocalBoxFuture<'static, Result>;
+
+ fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
+ let req = req.clone();
+
+ Box::pin(async move {
+ let state = get_server_state(&req)?;
+ let Token::JWT(token) = Token::extract(&req).await?;
+
+ for verifier in &state.set_verifiers {
+ match verifier.verify::(&token) {
+ Ok(claims) => return Ok(FxaWebhookToken(claims)),
+ Err(JWTVerifyError::InvalidSignature) => continue,
+ Err(e) => {
+ return Err(TokenserverError {
+ context: format!("SET verification failed: {}", e),
+ ..TokenserverError::invalid_credentials("Unauthorized".to_owned())
+ });
+ }
+ }
+ }
+
+ Err(TokenserverError {
+ context: "SET signature invalid for all available keys".to_owned(),
+ ..TokenserverError::invalid_credentials("Unauthorized".to_owned())
+ })
+ })
+ }
+}
+
fn get_server_state(req: &HttpRequest) -> Result<&Data, TokenserverError> {
req.app_data::>()
.ok_or_else(|| TokenserverError {
@@ -663,13 +697,17 @@ mod tests {
dev::ServiceResponse,
http::{Method, StatusCode},
test::{self, TestRequest},
+ web::Data,
};
use futures::executor::block_on;
use lazy_static::lazy_static;
use serde_json;
use syncserver_settings::Settings as GlobalSettings;
use syncstorage_settings::ServerLimits;
- use tokenserver_auth::{MockVerifier, oauth};
+ use tokenserver_auth::{
+ MockVerifier, SETVerifierImpl, oauth,
+ test_utils::{OTHER_PRIVATE_KEY_PEM, TEST_PRIVATE_KEY_PEM, make_set, test_jwk},
+ };
use tokenserver_db::mock::MockDbPool as MockTokenserverPool;
use tokenserver_settings::Settings as TokenserverSettings;
@@ -1306,6 +1344,110 @@ mod tests {
)
.unwrap(),
token_duration: TOKEN_DURATION,
+ set_verifiers: Vec::new(),
+ fxa_webhook_enabled: false,
+ }
+ }
+
+ fn make_webhook_state(set_verifiers: Vec) -> 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(),
+ oauth_verifier: Box::new(MockVerifier::::default()),
+ db_pool: Box::new(MockTokenserverPool::new()),
+ node_capacity_release_rate: None,
+ node_type: NodeType::default(),
+ metrics: syncserver_common::metrics_from_opts(
+ &tokenserver_settings.statsd_label,
+ syncserver_settings.statsd_host.as_deref(),
+ syncserver_settings.statsd_port,
+ )
+ .unwrap(),
+ token_duration: TOKEN_DURATION,
+ set_verifiers,
+ fxa_webhook_enabled: true,
}
}
+
+ #[actix_rt::test]
+ async fn test_fxa_webhook_valid_token() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let state = make_webhook_state(vec![verifier]);
+ let token = make_set(
+ "quux",
+ "testo",
+ serde_json::json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::default()
+ .app_data(Data::new(state))
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_http_request();
+ let result = FxaWebhookToken::from_request(&req, &mut actix_web::dev::Payload::None).await;
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap().0.sub, "quux");
+ }
+
+ #[actix_rt::test]
+ async fn test_fxa_webhook_invalid_signature_fall_through() {
+ let v1 =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let v2 =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let state = make_webhook_state(vec![v1, v2]);
+ let token = make_set(
+ "quux",
+ "testo",
+ serde_json::json!({}),
+ 3600,
+ OTHER_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::default()
+ .app_data(Data::new(state))
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_http_request();
+ let result = FxaWebhookToken::from_request(&req, &mut actix_web::dev::Payload::None).await;
+
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .context
+ .contains("SET signature invalid for all available keys")
+ );
+ }
+
+ #[actix_rt::test]
+ async fn test_fxa_webhook_no_verifiers() {
+ let state = make_webhook_state(vec![]);
+ let token = make_set(
+ "quux",
+ "testo",
+ serde_json::json!({}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::default()
+ .app_data(Data::new(state))
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_http_request();
+ let result = FxaWebhookToken::from_request(&req, &mut actix_web::dev::Payload::None).await;
+ assert!(result.is_err());
+ }
+
+ #[actix_rt::test]
+ async fn test_fxa_webhook_tmissing_auth_header() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let state = make_webhook_state(vec![verifier]);
+ let req = TestRequest::default()
+ .app_data(Data::new(state))
+ .to_http_request();
+ let result = FxaWebhookToken::from_request(&req, &mut actix_web::dev::Payload::None).await;
+ assert!(result.is_err());
+ }
}
diff --git a/syncserver/src/tokenserver/handlers.rs b/syncserver/src/tokenserver/handlers.rs
index b42fd4844d..553b181090 100644
--- a/syncserver/src/tokenserver/handlers.rs
+++ b/syncserver/src/tokenserver/handlers.rs
@@ -3,22 +3,23 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
-use actix_web::{Error, HttpResponse, http::StatusCode};
+use actix_web::{Error, HttpResponse, http::StatusCode, web::Data};
use base64::{Engine, engine};
use serde::Serialize;
use serde_json::Value;
+use tokio::time::timeout;
+use utoipa::ToSchema;
+
use tokenserver_auth::{MakeTokenPlaintext, Tokenlib, TokenserverOrigin};
use tokenserver_common::{NodeType, TokenserverError};
use tokenserver_db::{
- Db,
- params::{GetNodeId, PostUser, PutUser, ReplaceUsers},
+ Db, SYNC_SERVICE_NAME,
+ params::{GetNodeId, PostUser, PutUser, ReplaceUsers, RetireUser, UpdateUserGeneration},
};
-use tokio::time::timeout;
-use utoipa::ToSchema;
use super::{
TokenserverMetrics,
- extractors::{DbWrapper, TokenserverRequest},
+ extractors::{DbWrapper, FxaWebhookToken, TokenserverRequest},
};
#[derive(Debug, Serialize, ToSchema)]
@@ -290,6 +291,51 @@ async fn update_user(
}
}
+pub async fn handle_fxa_events(
+ FxaWebhookToken(claims): FxaWebhookToken,
+ DbWrapper(mut db): DbWrapper,
+ state: Data,
+) -> Result {
+ let service_id = db
+ .get_service_id(tokenserver_db::params::GetServiceId {
+ service: SYNC_SERVICE_NAME.to_owned(),
+ })
+ .await?
+ .id;
+ let Some(events) = claims.events.as_object() else {
+ return Ok(HttpResponse::Ok().finish());
+ };
+
+ for event_type in events.keys() {
+ match event_type.as_str() {
+ "https://schemas.accounts.firefox.com/event/delete-user" => {
+ db.retire_user(RetireUser {
+ service_id,
+ email: format!("{}@{}", claims.sub, state.fxa_email_domain),
+ })
+ .await?;
+ }
+ "https://schemas.accounts.firefox.com/event/password-change" => {
+ if let Some(change_time_ms) = events[event_type]
+ .get("changeTime")
+ .and_then(|t| t.as_i64())
+ {
+ db.update_user_generation(UpdateUserGeneration {
+ service_id,
+ email: format!("{}@{}", claims.sub, state.fxa_email_domain),
+ generation: Some(change_time_ms / 1000 - 1),
+ keys_changed_at: None,
+ })
+ .await?;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(HttpResponse::Ok().finish())
+}
+
#[utoipa::path(
get,
path = "/__heartbeat__",
@@ -341,3 +387,270 @@ pub async fn test_error() -> Result {
..TokenserverError::internal_error()
})
}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ use actix_web::middleware::ErrorHandlers;
+ use actix_web::test::{self, TestRequest};
+ use actix_web::{
+ App, HttpRequest, HttpResponse, http::StatusCode, http::header::LOCATION, web, web::Data,
+ };
+ use serde_json::json;
+ use utoipa::OpenApi;
+ use utoipa_swagger_ui::SwaggerUi;
+
+ use syncserver_common::middleware::sentry::SentryWrapper;
+ use syncserver_settings::Settings;
+ use tokenserver_auth::test_utils::{
+ OTHER_PRIVATE_KEY_PEM, TEST_PRIVATE_KEY_PEM, make_set, test_jwk,
+ };
+ use tokenserver_auth::{MockVerifier, SETVerifierImpl, oauth};
+ use tokenserver_db::mock::MockDbPool;
+ use tokenserver_settings::Settings as TokenserverSettings;
+
+ use crate::build_app_without_syncstorage;
+ use crate::error::ApiError;
+ use crate::server::{ApiDoc, TOKENSERVER_DOCS_URL};
+ use crate::tokenserver::{self, ServerState};
+ use crate::web::middleware;
+
+ fn make_state(set_verifiers: Vec) -> ServerState {
+ make_state_with_db_pool(set_verifiers, Box::new(MockDbPool::new()))
+ }
+
+ async fn make_app(
+ set_verifiers: Vec,
+ ) -> impl actix_web::dev::Service<
+ actix_http::Request,
+ Response = actix_web::dev::ServiceResponse,
+ Error = actix_web::Error,
+ > {
+ let state = make_state(set_verifiers);
+ make_app_from_state(state).await
+ }
+
+ fn make_state_with_db_pool(
+ set_verifiers: Vec,
+ db_pool: Box,
+ ) -> ServerState {
+ let syncserver_settings = Settings::default();
+ let tokenserver_settings = TokenserverSettings::default();
+ ServerState {
+ fxa_email_domain: "api.accounts.firefox.com".to_owned(),
+ fxa_metrics_hash_secret: "topsecretz".to_owned(),
+ oauth_verifier: Box::new(MockVerifier::::default()),
+ db_pool,
+ node_capacity_release_rate: None,
+ node_type: Default::default(),
+ metrics: syncserver_common::metrics_from_opts(
+ &tokenserver_settings.statsd_label,
+ syncserver_settings.statsd_host.as_deref(),
+ syncserver_settings.statsd_port,
+ )
+ .unwrap(),
+ token_duration: 3600,
+ set_verifiers,
+ fxa_webhook_enabled: true,
+ }
+ }
+
+ async fn make_app_from_state(
+ state: ServerState,
+ ) -> impl actix_web::dev::Service<
+ actix_http::Request,
+ Response = actix_web::dev::ServiceResponse,
+ Error = actix_web::Error,
+ > {
+ let secrets = Arc::new(syncserver_settings::Secrets::new("secret").unwrap());
+ let metrics = state.metrics.clone();
+ test::init_service(build_app_without_syncstorage!(
+ state,
+ secrets,
+ actix_cors::Cors::default(),
+ metrics
+ ))
+ .await
+ }
+
+ #[actix_web::test]
+ async fn test_missing_auth_header() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let app = make_app(vec![verifier]).await;
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 401);
+ }
+
+ #[actix_web::test]
+ async fn test_wrong_signing_key() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let app = make_app(vec![verifier]).await;
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ 3600,
+ OTHER_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 401);
+ }
+
+ #[actix_web::test]
+ async fn test_expired_token() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let app = make_app(vec![verifier]).await;
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ -3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 401);
+ }
+
+ #[actix_web::test]
+ async fn test_wrong_audience() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let app = make_app(vec![verifier]).await;
+ let token = make_set(
+ "quux",
+ "some-other-RP",
+ json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 401);
+ }
+
+ #[actix_web::test]
+ async fn test_no_verifiers_configured() {
+ let app = make_app(vec![]).await;
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 401);
+ }
+
+ #[actix_web::test]
+ async fn test_delete_user_event() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let (pool, call_log) = MockDbPool::with_capture();
+ let app =
+ make_app_from_state(make_state_with_db_pool(vec![verifier], Box::new(pool))).await;
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 200);
+ let retire_user_calls = call_log.retire_user.lock().unwrap();
+ assert_eq!(retire_user_calls.len(), 1);
+ assert_eq!(retire_user_calls[0].email, "quux@api.accounts.firefox.com");
+ }
+
+ #[actix_web::test]
+ async fn test_password_change_event() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let (pool, call_log) = MockDbPool::with_capture();
+ let app =
+ make_app_from_state(make_state_with_db_pool(vec![verifier], Box::new(pool))).await;
+ let change_time_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_millis() as i64;
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/password-change": {"changeTime": change_time_ms}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 200);
+ let update_user_generation_calls = call_log.update_user_generation.lock().unwrap();
+ assert_eq!(update_user_generation_calls.len(), 1);
+ assert_eq!(
+ update_user_generation_calls[0].email,
+ "quux@api.accounts.firefox.com"
+ );
+ assert_eq!(
+ update_user_generation_calls[0].generation,
+ Some(change_time_ms / 1000 - 1)
+ );
+ assert_eq!(update_user_generation_calls[0].keys_changed_at, None);
+
+ assert_eq!(call_log.retire_user.lock().unwrap().len(), 0);
+ }
+
+ #[actix_web::test]
+ async fn test_unknown_event() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let (pool, call_log) = MockDbPool::with_capture();
+ let app =
+ make_app_from_state(make_state_with_db_pool(vec![verifier], Box::new(pool))).await;
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/unknown-event": {}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let req = TestRequest::post()
+ .uri("/1.0/webhooks/fxa/events")
+ .insert_header(("Authorization", format!("Bearer {token}")))
+ .to_request();
+ let resp = test::call_service(&app, req).await;
+ assert_eq!(resp.status(), 200);
+
+ assert_eq!(call_log.update_user_generation.lock().unwrap().len(), 0);
+ assert_eq!(call_log.retire_user.lock().unwrap().len(), 0);
+ }
+}
diff --git a/syncserver/src/tokenserver/mod.rs b/syncserver/src/tokenserver/mod.rs
index 2bf738f03d..8c9cf2a49e 100644
--- a/syncserver/src/tokenserver/mod.rs
+++ b/syncserver/src/tokenserver/mod.rs
@@ -13,7 +13,7 @@ use serde::{
use syncserver_common::{BlockingThreadpool, Metrics};
#[cfg(not(feature = "py_verifier"))]
use tokenserver_auth::JWTVerifierImpl;
-use tokenserver_auth::{VerifyToken, oauth};
+use tokenserver_auth::{SETVerifierImpl, VerifyToken, oauth};
use tokenserver_common::NodeType;
use tokenserver_db::{DbPool, pool_from_settings};
use tokenserver_settings::Settings;
@@ -32,6 +32,8 @@ pub struct ServerState {
pub node_type: NodeType,
pub metrics: Arc,
pub token_duration: u64,
+ pub set_verifiers: Vec,
+ pub fxa_webhook_enabled: bool,
}
impl ServerState {
@@ -70,6 +72,34 @@ impl ServerState {
oauth::Verifier::new(settings, blocking_threadpool.clone())
.expect("failed to create Tokenserver OAuth verifier"),
);
+
+ let set_verifiers = {
+ let mut verifiers = Vec::with_capacity(2);
+ if let Some(client_id) = &settings.fxa_client_id {
+ if let Some(primary_jwk) = &settings.fxa_oauth_primary_jwk {
+ verifiers.push(
+ SETVerifierImpl::new(
+ primary_jwk,
+ client_id,
+ &settings.fxa_oauth_server_url,
+ )
+ .expect("Invalid primary JWK for SET verification"),
+ );
+ }
+ if let Some(secondary_jwk) = &settings.fxa_oauth_secondary_jwk {
+ verifiers.push(
+ SETVerifierImpl::new(
+ secondary_jwk,
+ client_id,
+ &settings.fxa_oauth_server_url,
+ )
+ .expect("Invalid secondary JWK for SET verification"),
+ );
+ }
+ }
+ verifiers
+ };
+
let use_test_transactions = false;
let db_pool = pool_from_settings(settings, &Metrics::from(&metrics), use_test_transactions)
@@ -83,6 +113,8 @@ impl ServerState {
node_type: settings.node_type,
metrics,
token_duration: settings.token_duration,
+ set_verifiers,
+ fxa_webhook_enabled: settings.fxa_webhook_enabled,
})
}
diff --git a/tokenserver-auth/Cargo.toml b/tokenserver-auth/Cargo.toml
index 7deb08067c..9bf37e02f3 100644
--- a/tokenserver-auth/Cargo.toml
+++ b/tokenserver-auth/Cargo.toml
@@ -31,9 +31,10 @@ pyo3 = { version = "0.28", features = ["auto-initialize"], optional = true }
[dev-dependencies]
-# mockito = "0.30"
+jsonwebtoken = { workspace = true, features = ["use_pem"] }
mockito = "1.7.2"
tokio = { workspace = true, features = ["macros"] }
[features]
py = ["pyo3", "tokenserver-common/py"]
+test-support = ["jsonwebtoken/use_pem"]
diff --git a/tokenserver-auth/src/crypto.rs b/tokenserver-auth/src/crypto.rs
index 6ed5da8f39..cd7d3dfc8d 100644
--- a/tokenserver-auth/src/crypto.rs
+++ b/tokenserver-auth/src/crypto.rs
@@ -5,6 +5,7 @@ use ring::rand::{SecureRandom, SystemRandom};
use serde::de::DeserializeOwned;
use sha2::Sha256;
use tokenserver_common::TokenserverError;
+
pub const SHA256_OUTPUT_LEN: usize = 32;
/// A trait representing all the required cryptographic operations by the token server
pub trait Crypto {
@@ -72,9 +73,9 @@ impl Crypto for CryptoImpl {
}
}
-/// OAuthVerifyError captures the errors possible while verifing an OAuth JWT access token
+/// JWTVerifyError captures the errors possible while verifying a JWT
#[derive(Debug, thiserror::Error)]
-pub enum OAuthVerifyError {
+pub enum JWTVerifyError {
#[error("The signature has expired")]
ExpiredSignature,
#[error("Untrusted token")]
@@ -87,14 +88,14 @@ pub enum OAuthVerifyError {
InvalidSignature,
}
-impl OAuthVerifyError {
+impl JWTVerifyError {
pub fn metric_label(&self) -> &'static str {
match self {
- Self::ExpiredSignature => "oauth.error.expired_signature",
- Self::TrustError => "oauth.error.trust_error",
- Self::InvalidKey => "oauth.error.invalid_key",
- Self::InvalidSignature => "oauth.error.invalid_signature",
- Self::DecodingError => "oauth.error.decoding_error",
+ Self::ExpiredSignature => "jwt.error.expired_signature",
+ Self::TrustError => "jwt.error.trust_error",
+ Self::InvalidKey => "jwt.error.invalid_key",
+ Self::InvalidSignature => "jwt.error.invalid_signature",
+ Self::DecodingError => "jwt.error.decoding_error",
}
}
@@ -103,22 +104,22 @@ impl OAuthVerifyError {
}
}
-impl From for OAuthVerifyError {
+impl From for JWTVerifyError {
fn from(value: jsonwebtoken::errors::Error) -> Self {
match value.kind() {
- ErrorKind::InvalidKeyFormat => OAuthVerifyError::InvalidKey,
- ErrorKind::InvalidSignature => OAuthVerifyError::InvalidSignature,
- ErrorKind::ExpiredSignature => OAuthVerifyError::ExpiredSignature,
- _ => OAuthVerifyError::DecodingError,
+ ErrorKind::InvalidKeyFormat => JWTVerifyError::InvalidKey,
+ ErrorKind::InvalidSignature => JWTVerifyError::InvalidSignature,
+ ErrorKind::ExpiredSignature => JWTVerifyError::ExpiredSignature,
+ _ => JWTVerifyError::DecodingError,
}
}
}
/// A trait representing a JSON Web Token verifier
-pub trait JWTVerifier: TryFrom + Sync + Send + Clone {
+pub trait JWTVerifier: TryFrom + Sync + Send + Clone {
type Key: DeserializeOwned;
- fn verify(&self, token: &str) -> Result;
+ fn verify(&self, token: &str) -> Result;
}
/// An implementation of the JWT verifier using the jsonwebtoken crate
@@ -131,12 +132,12 @@ pub struct JWTVerifierImpl {
impl JWTVerifier for JWTVerifierImpl {
type Key = Jwk;
- fn verify(&self, token: &str) -> Result {
+ fn verify(&self, token: &str) -> Result {
let token_data = jsonwebtoken::decode::(token, &self.key, &self.validation)?;
token_data
.header
.typ
- .ok_or(OAuthVerifyError::TrustError)
+ .ok_or(JWTVerifyError::TrustError)
.and_then(|typ| {
// Ref https://tools.ietf.org/html/rfc7515#section-4.1.9 the `typ` header
// is lowercase and has an implicit default `application/` prefix.
@@ -146,7 +147,7 @@ impl JWTVerifier for JWTVerifierImpl {
typ
};
if typ.to_lowercase() != "application/at+jwt" {
- return Err(OAuthVerifyError::TrustError);
+ return Err(JWTVerifyError::TrustError);
}
Ok(typ)
})?;
@@ -155,10 +156,9 @@ impl JWTVerifier for JWTVerifierImpl {
}
impl TryFrom for JWTVerifierImpl {
- type Error = OAuthVerifyError;
+ type Error = JWTVerifyError;
fn try_from(value: Jwk) -> Result {
- let decoding_key =
- DecodingKey::from_jwk(&value).map_err(|_| OAuthVerifyError::InvalidKey)?;
+ let decoding_key = DecodingKey::from_jwk(&value).map_err(|_| JWTVerifyError::InvalidKey)?;
let mut validation = Validation::new(Algorithm::RS256);
// The FxA OAuth ecosystem currently doesn't make good use of aud, and
// instead relies on scope for restricting which services can accept
@@ -173,3 +173,79 @@ impl TryFrom for JWTVerifierImpl {
})
}
}
+
+/// Parsed claims from a FxA Security Event Token .
+#[derive(Debug, serde::Deserialize)]
+pub struct FxaWebhookClaims {
+ pub sub: String,
+ pub iss: String,
+ pub events: serde_json::Value,
+}
+
+/// An implementation of the JWT verifier for Security Event Tokens
+///
+#[derive(Clone)]
+pub struct SETVerifierImpl {
+ key: DecodingKey,
+ validation: Validation,
+}
+
+impl SETVerifierImpl {
+ pub fn new(jwk: &Jwk, client_id: &str, issuer_url: &str) -> Result {
+ let decoding_key = DecodingKey::from_jwk(jwk).map_err(|_| JWTVerifyError::InvalidKey)?;
+ let mut validation = Validation::new(Algorithm::RS256);
+ validation.set_audience(&[client_id]);
+ validation.set_issuer(&[issuer_url]);
+ validation.validate_exp = true;
+ Ok(Self {
+ key: decoding_key,
+ validation,
+ })
+ }
+
+ pub fn verify(&self, token: &str) -> Result {
+ let token_data = jsonwebtoken::decode::(token, &self.key, &self.validation)?;
+ Ok(token_data.claims)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test_utils::{OTHER_PRIVATE_KEY_PEM, TEST_PRIVATE_KEY_PEM, make_set, test_jwk};
+ use serde_json::json;
+
+ #[test]
+ fn test_verify_valid_set() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let token = make_set(
+ "quux",
+ "testo",
+ json!({"https://schemas.accounts.firefox.com/event/delete-user": {}}),
+ 3600,
+ TEST_PRIVATE_KEY_PEM,
+ );
+ let claims: FxaWebhookClaims = verifier.verify(&token).unwrap();
+ assert_eq!(claims.sub, "quux");
+ assert_eq!(claims.iss, "https://accounts.firefox.com/");
+ }
+
+ #[test]
+ fn test_verify_expired_set() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let token = make_set("quux", "testo", json!({}), -3600, TEST_PRIVATE_KEY_PEM);
+ let err = verifier.verify::(&token).unwrap_err();
+ assert!(matches!(err, JWTVerifyError::ExpiredSignature));
+ }
+
+ #[test]
+ fn test_verify_wrong_key_set() {
+ let verifier =
+ SETVerifierImpl::new(&test_jwk(), "testo", "https://accounts.firefox.com/").unwrap();
+ let token = make_set("quux", "testo", json!({}), 3600, OTHER_PRIVATE_KEY_PEM);
+ let err = verifier.verify::(&token).unwrap_err();
+ assert!(matches!(err, JWTVerifyError::InvalidSignature));
+ }
+}
diff --git a/tokenserver-auth/src/lib.rs b/tokenserver-auth/src/lib.rs
index 6291571f32..792abacaf6 100644
--- a/tokenserver-auth/src/lib.rs
+++ b/tokenserver-auth/src/lib.rs
@@ -1,8 +1,12 @@
-#[cfg(not(feature = "py"))]
+#[allow(dead_code)]
mod crypto;
+#[cfg(any(test, feature = "test-support"))]
+pub mod test_utils;
+pub use crypto::{FxaWebhookClaims, JWTVerifyError, SETVerifierImpl};
#[cfg(not(feature = "py"))]
pub use crypto::{JWTVerifier, JWTVerifierImpl};
+
#[allow(clippy::result_large_err)]
pub mod oauth;
#[allow(clippy::result_large_err)]
diff --git a/tokenserver-auth/src/oauth/native.rs b/tokenserver-auth/src/oauth/native.rs
index dc103763f0..3fb342b158 100644
--- a/tokenserver-auth/src/oauth/native.rs
+++ b/tokenserver-auth/src/oauth/native.rs
@@ -1,7 +1,7 @@
use super::VerifyOutput;
use crate::VerifyToken;
pub use crate::crypto::JWTVerifier;
-use crate::crypto::OAuthVerifyError;
+use crate::crypto::JWTVerifyError;
use async_trait::async_trait;
use reqwest::Url;
use serde::{Deserialize, Serialize};
@@ -146,9 +146,9 @@ where
&self,
verifiers: &[Cow<'_, J>],
token: &str,
- ) -> Result {
+ ) -> Result {
if verifiers.is_empty() {
- return Err(OAuthVerifyError::InvalidKey);
+ return Err(JWTVerifyError::InvalidKey);
}
verifiers
@@ -157,13 +157,13 @@ where
match verifier.verify::(token) {
// If it's an invalid signature, it means our key was well formatted,
// but the signature was incorrect. Lets try another key if we have any
- Err(OAuthVerifyError::InvalidSignature) => None,
+ Err(JWTVerifyError::InvalidSignature) => None,
res => Some(res),
}
})
// If there is nothing, it means all of our keys were well formatted, but none of them
// were able to verify the signature, lets erturn a TrustError
- .ok_or(OAuthVerifyError::TrustError)?
+ .ok_or(JWTVerifyError::TrustError)?
}
}
@@ -218,7 +218,7 @@ where
metrics.incr(e.metric_label())
}
match e {
- OAuthVerifyError::DecodingError | OAuthVerifyError::InvalidKey => {
+ JWTVerifyError::DecodingError | JWTVerifyError::InvalidKey => {
self.remote_verify_token(&token).await?
}
e => return Err(unauthorized_err_with_ctx(e)),
@@ -245,7 +245,7 @@ fn internal_err_with_ctx(err: E) -> TokenserverError {
#[cfg(test)]
mod tests {
- use crate::crypto::{JWTVerifierImpl, OAuthVerifyError};
+ use crate::crypto::{JWTVerifierImpl, JWTVerifyError};
use serde_json::json;
use super::*;
@@ -260,7 +260,7 @@ mod tests {
#[derive(Clone, Debug)]
struct MockJWTVerifier {}
impl TryFrom for MockJWTVerifier {
- type Error = OAuthVerifyError;
+ type Error = JWTVerifyError;
fn try_from(_value: MockJWK) -> Result {
Ok(Self {})
}
@@ -271,7 +271,7 @@ mod tests {
fn verify(
&self,
$token: &str,
- ) -> Result {
+ ) -> Result {
$im
}
}
@@ -314,7 +314,7 @@ mod tests {
async fn test_expired_signature_fails() -> Result<(), TokenserverError> {
let mut server = mockito::Server::new();
let mock = server.mock("POST", "/v1/verify").create();
- mock_jwk_verifier!(Err(OAuthVerifyError::InvalidSignature));
+ mock_jwk_verifier!(Err(JWTVerifyError::InvalidSignature));
let jwk_verifiers = vec![MockJWTVerifier {}];
let settings = Settings {
@@ -348,7 +348,7 @@ mod tests {
}
impl TryFrom for MockJWTVerifier {
- type Error = OAuthVerifyError;
+ type Error = JWTVerifyError;
fn try_from(_value: MockJWK) -> Result {
Ok(Self { id: 0 })
}
@@ -359,9 +359,9 @@ mod tests {
fn verify(
&self,
token: &str,
- ) -> Result {
+ ) -> Result {
if self.id == 0 {
- Err(OAuthVerifyError::InvalidSignature)
+ Err(JWTVerifyError::InvalidSignature)
} else {
Ok(serde_json::from_str(token).unwrap())
}
@@ -397,7 +397,7 @@ mod tests {
async fn test_verifier_all_signature_failures_fails() -> Result<(), TokenserverError> {
let mut server = mockito::Server::new();
let mock_verify = server.mock("POST", "/v1/verify").create();
- mock_jwk_verifier!(Err(OAuthVerifyError::InvalidSignature));
+ mock_jwk_verifier!(Err(JWTVerifyError::InvalidSignature));
let jwk_verifiers = vec![MockJWTVerifier {}, MockJWTVerifier {}];
let settings = Settings {
@@ -435,7 +435,7 @@ mod tests {
.with_body(body.to_string())
.create();
- mock_jwk_verifier!(Err(OAuthVerifyError::DecodingError));
+ mock_jwk_verifier!(Err(JWTVerifyError::DecodingError));
let jwk_verifiers = vec![MockJWTVerifier {}];
let settings = Settings {
@@ -506,7 +506,7 @@ mod tests {
..Settings::default()
};
- mock_jwk_verifier!(Err(OAuthVerifyError::DecodingError));
+ mock_jwk_verifier!(Err(JWTVerifyError::DecodingError));
let jwk_verifiers = vec![];
let verifier: Verifier = Verifier::new(&settings, jwk_verifiers).unwrap();
@@ -547,7 +547,7 @@ mod tests {
..Settings::default()
};
- mock_jwk_verifier!(Err(OAuthVerifyError::DecodingError));
+ mock_jwk_verifier!(Err(JWTVerifyError::DecodingError));
let jwk_verifiers = vec![];
let verifier: Verifier = Verifier::new(&settings, jwk_verifiers).unwrap();
diff --git a/tokenserver-auth/src/test_utils.rs b/tokenserver-auth/src/test_utils.rs
new file mode 100644
index 0000000000..e8bed69eaf
--- /dev/null
+++ b/tokenserver-auth/src/test_utils.rs
@@ -0,0 +1,100 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use jsonwebtoken::jwk::Jwk;
+use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
+use serde_json::json;
+
+/// RSA private key for TEST_JWK.
+pub const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAwDVLbkUbqt74yyEd1d5HQsmT/BVAMrausJCZq1FHyy9Ham+c
+6Uobln07+G2Wj/X0PaDUBfKq12M90hPKAsI1gvAIVs9TL6BOvFYQArodTGxya0JL
+v2GBSiJjgj8LQHL4DYbmvMsvtW6nHCGWtB9Fa9t9aN5R+/fcqj4mk0EzY5xL+cuz
+zxuMRhreMUL+WE74bzA+b4IT2SIJvYy4yYY0stLvC1DSqKNn9Z6jWMP4qIFz1OCk
+cJ74/wsUAIoicnVECe0jLEM+6xul6dIXwH2j7t105Kt+FSS+syOHqE+/oenfbrfO
+n0v0ILjiBPJFa6f3mN1jO4RwyvYj6vut4f9KfQIDAQABAoIBACFRq8mKA9WPUPgf
+fcVLArXmbTeT9HzGE8rKSkU7MhOcFshx4DTFsroX7Asw7hp3E7eSN2bvjeOIEdmm
+sgxf37haxUtNJdm586Qs1Bow6q7KltwWkjxzGd9Azliv9pKdy3fG1J1SKKtOKvxS
+q0X+rMFZe2gwL+yapz9Axl2c/hxMWRnU/4h2Z5yiAIMZ7fzWLvJcTU8Pd9yHeJDP
+N13sTl4o+O8cVGxfFkDWJQYVS11J3XW4jN7OALYBBQi0qhA0+wPZ3rlWoyvEc7nv
+TLtTVodNeu9pE8u1cPfaGt/NbWRw1HdgZx/KjHpfv2yd57ubLaeg3FftcWotXmdL
+0I9T+ekCgYEA55+/N19DZBybFZ1hGaY6cxVgwbQ1bKKdFDYxdL3n9PKWhQBsK5J0
+j6WkePzQV6fZgcGaioyatwghUe0uKC3F/18rB0XlJ0Mczq1USqG3XUF2Jc0nmn1S
+tEvJhL6ntBWz33K+33GPFsZbAhZ+tsmObCHs+v/6EygyLRBODNPl4bkCgYEA1G+i
+Q+DNO2Nea0ez7Cu68WYnQ3ccnPAJpCLgvvgmmBKWzl9A5MpspBHXv5P4DtdhXrmv
+ZjAA0ihbsYIdG6v730wXTTolOnTBs4dNg2u8o/yAmnPlxViqFCd6ZgSjr6K3lQu7
+Z+vW8P4tGAEIx2PHdbZwU3vZ1aF8ZqbM+g3vYOUCgYBUtI/6UQVVNDzm77IV7juL
+4LKMxDmRa/qj7JmzhsuwQZMYOqpUWO/1pG78rAAJPmIF2OaKapcd/oQo8OMjYHH7
+TTNwKnh+HkYHs02TKYbkPM9XTaqBDfnT469js1GjQxiPy+fP0Tix7IJVxiI6+IT0
+OIfw1vH+VYHcBw10FX4JSQKBgQCq980X4+xIR4jNvj9Ha0pgzV38JfiZNXYM6yUF
+jKFC8nL8VBzeBSu6P8HrJSMWjrCGk9pd23RNrr1c9uKGSrvC0nJObOVZTm42FkaD
+5klDkQvPQkdBtEHtRnhzcnhp+gLVqUOCN4QdH/MaxnpSPjNgwRtVlO+Txwtfcg61
+kFF/IQKBgQDe+BuvhET3OQtiiLUhZHkmtZTAc35+T1iArGVt/kFLARlhWRzGw4Ib
+7+TDE+0KxKloqo+oZGJFMD9qJWhxLEnChrgOFx0lN/2FRvntab97AKfJf0R1wHKj
+iXkKR8nsMwDtjLaoGsc8hbMT2nVRoPokfyIYJ5H+xzOfX1U05AZLjg==
+-----END RSA PRIVATE KEY-----";
+
+/// Private key for testing mismatching signatures.
+pub const OTHER_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAyd0B8fYfYJ8lKbkhii838MjGp3VTNnveK7C/sxWHysEcGioH
+TUvL9huz6tx41stE8Gi+uIGeXNzfcX3ZX2AFBnsT3z1+ndaPMw9dIhRWghrBp3u3
+KB8soG7M81b0fLtTyymBbUVCnA9NRgFjr9A2WJacuJp8CGHLzeNhKMXJLrskUJLa
+RktWeW79eyXTYuqiz0LlcDQAVjBeg30WPr80Wo6Scw55gC+c1dZl+P8FTX4/URxp
+dimwnyU9mfje21ZPlNiIYHjPFpg5Zim/QasjMHzUAVTOEBCvwWY2KRMWJRFPju0i
+goRkSV/MNq3doYKhQXM7cxKrhxsgNEhk9CTu7wIDAQABAoIBAFjx/eJsjWTYmtpo
+jYPCzIZXIVk2FCVkrP9ZUQ6KxRusvUI2FKOVa1iU2lD5NnGGfWjk7myECJBobjgm
+uLoSqAQ0BQyPnxPTL6PS+DmE9p07RusSUyDlo5dJWxs5zF6NeB2Du1i3dOMoxua6
+w/764odkTcf1ogNbfB7LOstpYv0ohf+OmLIndHDws1qqfh58KHZlsRf4nz1TK73s
+Pe5XbxpfjeDNp79czKJBr1HsMPKkCXw3VYhUNpoP7vC/UeSwRhuPrI7skO2+bXt7
+SXR6rA7SxCgPKR5f3+LOV6GiU4ixc2DRMU/n8BROb+CIGE52+qbcjhNJXN9XYWoY
+XDLlg/ECgYEA7yxlZez5hutCtnt/JvRaxaGywEHzcOazhUJNFMazs5INni8TKqZG
+MqNUH0aa/GdT8dOE/HFcilSgr2glcchiJb7cIg7O1rDobIP8iZkml40Xs6S20dEW
+83/+juX3VknPxxBGYkFo5ywSFKxD1zX9O9OB+u9DGHL+gWRgr4yZCwkCgYEA2BCk
+bE0wzyuwNx4qmSsEAdtxh4ZyEgv46pXS18DH0oEUd/jzbAbBgz2vYIrWsHqszBDD
+mnJr8YOhpqXMRmmyuPBuxvrNPwX+I9WE55NmVkY5NV1uDv26UgSWcuVMIBH2k9At
+72F7F03zfovytcAuW1VbfjNESIZkA41JBOe7EDcCgYEA2nhbRvdoFu3fSoEUbKjY
+IZ7KgQO9M2wIn7koX8oBbA4FknC9uT+Y77hxpv//on9gFo139IA4X8Nd49vmGEFK
+JeBphFKybTm7lSQbEjVrIxQmilnzBUVRCavpAu7dN1zFBri/EhFdmYyQF4Ijlfoj
+DvrsyCK1zyd7gwYFq1VqlsECgYAnK3UzcRb9J9VtWJmuZN74GzlMsXHylZsNpBWy
+KW/QWLhGO6qdlef1C/TEUscy/TpgUFW1pTKueQeQN5R922GcJ3JdvlABMevtwSKz
+/MPbtiVe6E4wh40Em3JO6ATR94+1IlOBhzGSev4+nc5lZq7Avgu1KEQjxcFR54Yq
+TnxaJwKBgQDPDdfixZlqxuKiWewJupuDI9T2HBKp0BnRBzKZjSn+IuvCHUQC8xtv
+yyVcq33mCxMSP9YqFrvZwjnoVMWCbVTPU7f6xzP4IGxXpySkVf8QwGp7Fc9q/XG4
+8DA43GmyC52UynXzmBzTZfURio3qSQRFPtyZApJDcicR1nUw22KolA==
+-----END RSA PRIVATE KEY-----";
+
+/// Public JWK of TEST_PRIVATE_KEY_PEM.
+pub const TEST_JWK: &str = r#"{
+ "kty": "RSA",
+ "alg": "RS256",
+ "n": "wDVLbkUbqt74yyEd1d5HQsmT_BVAMrausJCZq1FHyy9Ham-c6Uobln07-G2Wj_X0PaDUBfKq12M90hPKAsI1gvAIVs9TL6BOvFYQArodTGxya0JLv2GBSiJjgj8LQHL4DYbmvMsvtW6nHCGWtB9Fa9t9aN5R-_fcqj4mk0EzY5xL-cuzzxuMRhreMUL-WE74bzA-b4IT2SIJvYy4yYY0stLvC1DSqKNn9Z6jWMP4qIFz1OCkcJ74_wsUAIoicnVECe0jLEM-6xul6dIXwH2j7t105Kt-FSS-syOHqE-_oenfbrfOn0v0ILjiBPJFa6f3mN1jO4RwyvYj6vut4f9KfQ",
+ "e": "AQAB"
+}"#;
+
+pub fn test_jwk() -> Jwk {
+ serde_json::from_str(TEST_JWK).unwrap()
+}
+
+/// Sign a SET with the given PEM.
+pub fn make_set(
+ sub: &str,
+ aud: &str,
+ events: serde_json::Value,
+ exp_offset_secs: i64,
+ private_key_pem: &[u8],
+) -> String {
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs() as i64;
+ let claims = json!({
+ "iss": "https://accounts.firefox.com/",
+ "sub": sub,
+ "aud": aud,
+ "iat": now,
+ "exp": now + exp_offset_secs,
+ "jti": "wibble",
+ "events": events,
+ });
+ let key = EncodingKey::from_rsa_pem(private_key_pem).expect("invalid PEM key");
+ encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap()
+}
diff --git a/tokenserver-db-common/src/lib.rs b/tokenserver-db-common/src/lib.rs
index 879868eea6..e10c55c12d 100644
--- a/tokenserver-db-common/src/lib.rs
+++ b/tokenserver-db-common/src/lib.rs
@@ -22,6 +22,8 @@ pub type DbResult = Result;
/// "retired" from the db.
pub const MAX_GENERATION: i64 = i64::MAX;
+pub const SYNC_SERVICE_NAME: &str = "sync-1.5";
+
#[async_trait(?Send)]
pub trait DbPool: Sync + Send + GetPoolState {
async fn init(&mut self) -> DbResult<()>;
@@ -66,6 +68,17 @@ pub trait Db {
/// Based on service_id, email, generation, and changed keys timestamp, update user.
async fn put_user(&mut self, params: params::PutUser) -> DbResult;
+ /// Update `generation` and/or `keys_changed_at` in place using COALESCE. `None` leaves the
+ /// existing value unchanged.
+ async fn update_user_generation(
+ &mut self,
+ params: params::UpdateUserGeneration,
+ ) -> DbResult;
+
+ /// Mark all records for the user as replaced, and set a large generation number to block
+ /// future logins.
+ async fn retire_user(&mut self, params: params::RetireUser) -> DbResult;
+
/// Show database uptime status and health as boolean.
async fn check(&mut self) -> DbResult;
diff --git a/tokenserver-db-common/src/params.rs b/tokenserver-db-common/src/params.rs
index 2b96fb54d8..6151b0b056 100644
--- a/tokenserver-db-common/src/params.rs
+++ b/tokenserver-db-common/src/params.rs
@@ -74,6 +74,18 @@ pub struct ReplaceUser {
pub replaced_at: i64,
}
+pub struct RetireUser {
+ pub service_id: i32,
+ pub email: String,
+}
+
+pub struct UpdateUserGeneration {
+ pub service_id: i32,
+ pub email: String,
+ pub generation: Option,
+ pub keys_changed_at: Option,
+}
+
#[derive(Debug, Default)]
pub struct GetNodeId {
pub service_id: i32,
diff --git a/tokenserver-db-common/src/results.rs b/tokenserver-db-common/src/results.rs
index ecbef8db0f..b7bff6cdaa 100644
--- a/tokenserver-db-common/src/results.rs
+++ b/tokenserver-db-common/src/results.rs
@@ -57,6 +57,8 @@ pub struct PostUser {
pub type ReplaceUsers = ();
pub type ReplaceUser = ();
+pub type RetireUser = ();
+pub type UpdateUserGeneration = ();
pub type PutUser = ();
#[derive(Default, QueryableByName)]
diff --git a/tokenserver-db/src/lib.rs b/tokenserver-db/src/lib.rs
index b3499586d6..b1fbe64637 100644
--- a/tokenserver-db/src/lib.rs
+++ b/tokenserver-db/src/lib.rs
@@ -5,7 +5,9 @@ mod tests;
use url::Url;
use syncserver_common::Metrics;
-pub use tokenserver_db_common::{Db, DbError, DbPool, params, results};
+pub use tokenserver_db_common::{
+ Db, DbError, DbPool, MAX_GENERATION, SYNC_SERVICE_NAME, params, results,
+};
use tokenserver_settings::Settings;
pub fn pool_from_settings(
diff --git a/tokenserver-db/src/mock.rs b/tokenserver-db/src/mock.rs
index 73f19dd7ba..914e9cc103 100644
--- a/tokenserver-db/src/mock.rs
+++ b/tokenserver-db/src/mock.rs
@@ -1,18 +1,32 @@
#![allow(clippy::new_without_default)]
-use std::sync::LazyLock;
+use std::sync::{Arc, LazyLock, Mutex};
use async_trait::async_trait;
use syncserver_common::Metrics;
use syncserver_db_common::{GetPoolState, PoolState};
use tokenserver_db_common::{Db, DbError, DbPool, params, results};
-#[derive(Clone, Debug)]
-pub struct MockDbPool;
+#[derive(Clone, Default)]
+pub struct CallLog {
+ pub retire_user: Arc>>,
+ pub update_user_generation: Arc>>,
+}
+
+#[derive(Clone, Default)]
+pub struct MockDbPool {
+ call_log: CallLog,
+}
impl MockDbPool {
pub fn new() -> Self {
- MockDbPool
+ MockDbPool::default()
+ }
+
+ pub fn with_capture() -> (Self, CallLog) {
+ let pool = MockDbPool::default();
+ let call_log = pool.call_log.clone();
+ (pool, call_log)
}
}
@@ -23,7 +37,9 @@ impl DbPool for MockDbPool {
}
async fn get(&self) -> Result, DbError> {
- Ok(Box::new(MockDb::new()))
+ Ok(Box::new(MockDb {
+ call_log: self.call_log.clone(),
+ }))
}
fn box_clone(&self) -> Box {
@@ -37,12 +53,14 @@ impl GetPoolState for MockDbPool {
}
}
-#[derive(Clone, Debug)]
-pub struct MockDb;
+#[derive(Clone, Default)]
+pub struct MockDb {
+ call_log: CallLog,
+}
impl MockDb {
pub fn new() -> Self {
- MockDb
+ MockDb::default()
}
}
@@ -70,6 +88,26 @@ impl Db for MockDb {
Ok(())
}
+ async fn update_user_generation(
+ &mut self,
+ params: params::UpdateUserGeneration,
+ ) -> Result {
+ self.call_log
+ .update_user_generation
+ .lock()
+ .unwrap()
+ .push(params);
+ Ok(())
+ }
+
+ async fn retire_user(
+ &mut self,
+ params: params::RetireUser,
+ ) -> Result {
+ self.call_log.retire_user.lock().unwrap().push(params);
+ Ok(())
+ }
+
async fn check(&mut self) -> Result {
Ok(true)
}
diff --git a/tokenserver-mysql/src/db/db_impl.rs b/tokenserver-mysql/src/db/db_impl.rs
index 3db0e51f80..6a0ed3f2b9 100644
--- a/tokenserver-mysql/src/db/db_impl.rs
+++ b/tokenserver-mysql/src/db/db_impl.rs
@@ -1,6 +1,6 @@
-use std::time::Duration;
#[cfg(debug_assertions)]
-use std::time::{SystemTime, UNIX_EPOCH};
+use std::time::UNIX_EPOCH;
+use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use diesel::{
@@ -87,6 +87,29 @@ impl Db for TokenserverDb {
Ok(())
}
+ /// Mark a user as retired by email.
+ async fn retire_user(&mut self, params: params::RetireUser) -> DbResult {
+ const QUERY: &str = r#"
+ UPDATE users
+ SET generation = ?,
+ replaced_at = ?
+ WHERE service = ?
+ AND email = ?
+ AND replaced_at IS NULL
+ "#;
+
+ let now = SystemTime::UNIX_EPOCH.elapsed().unwrap().as_millis() as i64;
+
+ diesel::sql_query(QUERY)
+ .bind::(tokenserver_db_common::MAX_GENERATION)
+ .bind::(now)
+ .bind::(params.service_id)
+ .bind::(params.email)
+ .execute(&mut self.conn)
+ .await?;
+ Ok(())
+ }
+
/// Update the user with the given email and service ID with the given `generation` and
/// `keys_changed_at`.
async fn put_user(&mut self, params: params::PutUser) -> DbResult {
@@ -121,6 +144,33 @@ impl Db for TokenserverDb {
Ok(())
}
+ async fn update_user_generation(
+ &mut self,
+ params: params::UpdateUserGeneration,
+ ) -> DbResult {
+ const QUERY: &str = r#"
+ UPDATE users
+ SET generation = COALESCE(?, generation),
+ keys_changed_at = COALESCE(?, keys_changed_at)
+ WHERE service = ?
+ AND email = ?
+ AND generation <= COALESCE(?, generation)
+ AND COALESCE(keys_changed_at, 0) <= COALESCE(?, keys_changed_at, 0)
+ AND replaced_at IS NULL
+ "#;
+
+ diesel::sql_query(QUERY)
+ .bind::, _>(params.generation)
+ .bind::, _>(params.keys_changed_at)
+ .bind::(params.service_id)
+ .bind::(¶ms.email)
+ .bind::, _>(params.generation)
+ .bind::, _>(params.keys_changed_at)
+ .execute(&mut self.conn)
+ .await?;
+ Ok(())
+ }
+
/// Create a new user.
async fn post_user(&mut self, user: params::PostUser) -> DbResult {
const QUERY: &str = r#"
diff --git a/tokenserver-postgres/src/db/db_impl.rs b/tokenserver-postgres/src/db/db_impl.rs
index 6cf75520bd..71abfafe1f 100644
--- a/tokenserver-postgres/src/db/db_impl.rs
+++ b/tokenserver-postgres/src/db/db_impl.rs
@@ -1,9 +1,9 @@
+#[cfg(debug_assertions)]
+use std::time::UNIX_EPOCH;
/// Note the addition of `#[cfg(debug_assertions)]` flags methods and
/// imports only to be added during debug builds.
/// cargo build --release will not include this code in the binary.
-use std::time::Duration;
-#[cfg(debug_assertions)]
-use std::time::{SystemTime, UNIX_EPOCH};
+use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use diesel::{
@@ -397,6 +397,33 @@ impl Db for TokenserverPgDb {
Ok(())
}
+ async fn update_user_generation(
+ &mut self,
+ params: params::UpdateUserGeneration,
+ ) -> DbResult {
+ const QUERY: &str = r#"
+ UPDATE users
+ SET generation = COALESCE($1, generation),
+ keys_changed_at = COALESCE($2, keys_changed_at)
+ WHERE service = $3
+ AND email = $4
+ AND generation <= COALESCE($5, generation)
+ AND COALESCE(keys_changed_at, 0) <= COALESCE($6, keys_changed_at, 0)
+ AND replaced_at IS NULL
+ "#;
+
+ diesel::sql_query(QUERY)
+ .bind::, _>(params.generation)
+ .bind::, _>(params.keys_changed_at)
+ .bind::(params.service_id)
+ .bind::(params.email)
+ .bind::, _>(params.generation)
+ .bind::, _>(params.keys_changed_at)
+ .execute(&mut self.conn)
+ .await?;
+ Ok(())
+ }
+
/// Update the user record with the given uid and service id
/// marking it as 'replaced'. This is through updating the `replaced_at` field.
async fn replace_user(
@@ -448,6 +475,29 @@ impl Db for TokenserverPgDb {
Ok(())
}
+ /// Mark a user as retired by email.
+ async fn retire_user(&mut self, params: params::RetireUser) -> DbResult {
+ const QUERY: &str = r#"
+ UPDATE users
+ SET generation = $1,
+ replaced_at = $2
+ WHERE service = $3
+ AND email = $4
+ AND replaced_at IS NULL
+ "#;
+
+ let now = SystemTime::UNIX_EPOCH.elapsed().unwrap().as_millis() as i64;
+
+ diesel::sql_query(QUERY)
+ .bind::(tokenserver_db_common::MAX_GENERATION)
+ .bind::(now)
+ .bind::(params.service_id)
+ .bind::(params.email)
+ .execute(&mut self.conn)
+ .await?;
+ Ok(())
+ }
+
/// Given ONLY a particular `node_id`, update the users table to indicate an unassigned
/// node by updating the `replaced_at` field with the current time since Unix Epoch.
#[cfg(debug_assertions)]
diff --git a/tokenserver-settings/src/lib.rs b/tokenserver-settings/src/lib.rs
index dac0886f9c..bc2a693cff 100644
--- a/tokenserver-settings/src/lib.rs
+++ b/tokenserver-settings/src/lib.rs
@@ -35,6 +35,8 @@ 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,
+ /// Sync's client id assigned by FxA. It is used to validate the `aud` of JWKs.
+ pub fxa_client_id: Option,
/// The rate at which capacity should be released from nodes that are at capacity.
pub node_capacity_release_rate: Option,
/// The type of the storage nodes used by this instance of Tokenserver.
@@ -61,6 +63,9 @@ pub struct Settings {
/// The capacity value for the node record created for `init_node_url`. Only used if
/// `init_node_url` is set.
pub init_node_capacity: i32,
+ /// Whether to enable the FxA webhook endpoint.
+ /// Defaults to false.
+ pub fxa_webhook_enabled: bool,
}
impl Default for Settings {
@@ -77,6 +82,7 @@ impl Default for Settings {
fxa_oauth_request_timeout: 10,
fxa_oauth_primary_jwk: None,
fxa_oauth_secondary_jwk: None,
+ fxa_client_id: None,
node_capacity_release_rate: None,
node_type: NodeType::Spanner,
statsd_label: "syncstorage.tokenserver".to_owned(),
@@ -86,6 +92,7 @@ impl Default for Settings {
token_duration: 3600,
init_node_url: None,
init_node_capacity: 100000,
+ fxa_webhook_enabled: false,
}
}
}