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