mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2800 lines
94 KiB
Rust
2800 lines
94 KiB
Rust
//! User auth/security service tests
|
|
//!
|
|
//! Tests for `UserService::refresh_token`, login status checks, `delete_user`,
|
|
//! `change_password/set_password`, and `create_or_load_by_oauth2`.
|
|
//!
|
|
//! S1/S2 tests use `InMemoryTokenBlacklistStore` + `InMemoryBruteForceProtection` + real `JwtService`.
|
|
//! S3/S7/S13 tests use testcontainers PG.
|
|
//!
|
|
use std::sync::Arc;
|
|
|
|
use opaque_ke::argon2::Argon2 as OpaqueArgon2Ksf;
|
|
use opaque_ke::ciphersuite::CipherSuite;
|
|
use opaque_ke::rand::rngs::OsRng;
|
|
use opaque_ke::{
|
|
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
|
|
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
|
|
};
|
|
use sqlx::PgPool;
|
|
use std::collections::BTreeMap;
|
|
use synctv_common::ssrf::SsrfGuard;
|
|
use synctv_core::{
|
|
cache::{CacheL2Backend, KeyBuilder, UsernameCache},
|
|
models::{OAuth2Provider, SignupMethod, User, UserId},
|
|
repository::{
|
|
PasswordCredentialMaterial, SettingsRepository, UserEmailRepository,
|
|
UserOAuthProviderRepository, UserPasswordRepository, UserRepository,
|
|
},
|
|
service::{
|
|
local_oauth_state_store, AccountRegistrationOutcome, AuthFactorMethod, AuthenticatedLogin,
|
|
BruteForceProtection, InMemoryTokenBlacklistStore, JwtService, OAuth2GithubProviderConfig,
|
|
OAuth2GoogleProviderConfig, OAuth2LinkResult, OAuth2ProviderConfig, OAuth2ProviderConfigs,
|
|
OAuth2ProviderPrivateConfig, OAuth2Service, OAuth2ServiceRuntime, OpaquePasswordService,
|
|
RateLimiter, RuntimeSettingsStore, SettingsService, TokenBlacklistStore,
|
|
TokenCredentialBinding, UserService,
|
|
},
|
|
Error,
|
|
};
|
|
use synctv_core_testing::{
|
|
create_test_pool, opaque_login_user, opaque_register_user, TestOptionExt, TestResultExt,
|
|
};
|
|
|
|
struct TestOpaqueCipherSuite;
|
|
|
|
impl CipherSuite for TestOpaqueCipherSuite {
|
|
type OprfCs = opaque_ke::Ristretto255;
|
|
type KeyExchange = opaque_ke::TripleDh<opaque_ke::Ristretto255, sha2_010::Sha512>;
|
|
type Ksf = OpaqueArgon2Ksf<'static>;
|
|
}
|
|
|
|
async fn create_user_with_password_fixture(
|
|
pool: &PgPool,
|
|
username: String,
|
|
email: Option<String>,
|
|
password: &str,
|
|
) -> synctv_core::Result<User> {
|
|
let normalized_username = username.trim().to_lowercase();
|
|
let opaque_record = OpaquePasswordService::new_ephemeral_for_process().register_password(
|
|
format!("synctv:user:{normalized_username}").as_bytes(),
|
|
password,
|
|
)?;
|
|
let user = User::new(normalized_username, SignupMethod::Email);
|
|
let mut tx = pool.begin().await?;
|
|
let created = UserRepository::new(pool.clone())
|
|
.create_with_executor(&user, &mut *tx)
|
|
.await?;
|
|
UserEmailRepository::new(pool.clone())
|
|
.create_for_user_with_executor(&created, email.as_deref(), &mut *tx)
|
|
.await?;
|
|
UserPasswordRepository::new(pool.clone())
|
|
.create_for_user_with_executor(
|
|
&created,
|
|
PasswordCredentialMaterial::opaque_only(&opaque_record),
|
|
&mut *tx,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(created)
|
|
}
|
|
|
|
async fn insert_test_passkey(pool: &PgPool, user_id: &UserId, credential_id: &[u8]) {
|
|
sqlx::query!(
|
|
r"
|
|
INSERT INTO auth_webauthn_credentials (
|
|
user_id, credential_id, passkey, name
|
|
)
|
|
VALUES ($1, $2, '{}'::jsonb, 'test passkey')
|
|
",
|
|
user_id.as_i64(),
|
|
credential_id
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.checked("test passkey should be inserted");
|
|
}
|
|
|
|
const JWT_SECRET: &str = "test-secret-key-for-user-auth-service-tests-long-enough-1234567890";
|
|
|
|
fn create_jwt_service() -> JwtService {
|
|
JwtService::with_durations(JWT_SECRET, 1, 30, 4, 60).checked("test operation should succeed")
|
|
}
|
|
|
|
fn create_user_service_with_components(
|
|
pool: &PgPool,
|
|
username_cache: UsernameCache,
|
|
token_blacklist: Arc<dyn TokenBlacklistStore>,
|
|
runtime: synctv_core::service::UserServiceRuntimeOptions,
|
|
) -> UserService {
|
|
let jwt = create_jwt_service();
|
|
let key_builder = KeyBuilder::new("test");
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
UserService::new_with_runtime(
|
|
pool,
|
|
jwt,
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
runtime,
|
|
)
|
|
}
|
|
|
|
fn create_user_service_with_blacklist(
|
|
pool: &PgPool,
|
|
token_blacklist: Arc<dyn TokenBlacklistStore>,
|
|
) -> UserService {
|
|
let username_cache = UsernameCache::local_only("test:username:".to_string(), 1000, 0);
|
|
create_user_service_with_components(
|
|
pool,
|
|
username_cache,
|
|
token_blacklist,
|
|
default_test_user_runtime_options(),
|
|
)
|
|
}
|
|
|
|
fn create_user_service(pool: &PgPool) -> UserService {
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
create_user_service_with_blacklist(pool, token_blacklist)
|
|
}
|
|
|
|
fn create_user_service_with_in_memory_blacklist(pool: &PgPool) -> UserService {
|
|
create_user_service_with_blacklist(
|
|
pool,
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400)),
|
|
)
|
|
}
|
|
|
|
async fn register_password_user_refresh_token(service: &UserService, label: &str) -> String {
|
|
let suffix = synctv_common::snanoid!(6);
|
|
let username = format!("{label}_{suffix}");
|
|
let email = Some(format!("{label}_{suffix}@test.com"));
|
|
let (_user, Some(_access_token), Some(refresh_token)) =
|
|
opaque_register_user(service, username.clone(), email, "StrongPass1")
|
|
.await
|
|
.checked("password registration should succeed")
|
|
else {
|
|
std::panic::panic_any("password registration should issue tokens");
|
|
};
|
|
|
|
refresh_token
|
|
}
|
|
|
|
async fn register_password_user_with_username(
|
|
service: &UserService,
|
|
label: &str,
|
|
) -> (User, String) {
|
|
let suffix = synctv_common::snanoid!(6);
|
|
let username = format!("{label}_{suffix}");
|
|
let email = Some(format!("{label}_{suffix}@test.com"));
|
|
let (user, _, _) = opaque_register_user(service, username.clone(), email, "StrongPass1")
|
|
.await
|
|
.checked("password registration should succeed");
|
|
(user, username)
|
|
}
|
|
|
|
async fn run_concurrent_refresh_attempts(
|
|
service: UserService,
|
|
refresh_token: String,
|
|
attempts: usize,
|
|
) -> usize {
|
|
use tokio::sync::Barrier;
|
|
|
|
let barrier = Arc::new(Barrier::new(attempts));
|
|
let mut handles = Vec::with_capacity(attempts);
|
|
|
|
for _ in 0..attempts {
|
|
let service = service.clone();
|
|
let token = refresh_token.clone();
|
|
let barrier = barrier.clone();
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
barrier.wait().await;
|
|
service.refresh_token(token).await.is_ok()
|
|
}));
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
for handle in handles {
|
|
if handle.await.checked("refresh task should complete") {
|
|
success_count += 1;
|
|
}
|
|
}
|
|
|
|
success_count
|
|
}
|
|
|
|
fn default_test_user_runtime_options() -> synctv_core::service::UserServiceRuntimeOptions {
|
|
synctv_core::service::UserServiceRuntimeOptions {
|
|
password_registration_policy_override: Some(
|
|
synctv_core::service::RegistrationPolicy::Immediate,
|
|
),
|
|
..synctv_core::service::UserServiceRuntimeOptions::test_defaults()
|
|
}
|
|
}
|
|
|
|
fn oauth2_user_info(
|
|
provider: OAuth2Provider,
|
|
provider_instance_name: &str,
|
|
provider_user_id: String,
|
|
username: impl Into<String>,
|
|
) -> synctv_core::service::OAuth2UserInfo {
|
|
synctv_core::service::OAuth2UserInfo {
|
|
provider,
|
|
provider_instance_name: provider_instance_name.to_string(),
|
|
provider_issuer: None,
|
|
provider_user_id,
|
|
username: username.into(),
|
|
avatar: None,
|
|
}
|
|
}
|
|
|
|
async fn oauth2_service_with_google_signup(pool: &PgPool) -> OAuth2Service {
|
|
oauth2_service_with_provider_signup(pool, "google", false).await
|
|
}
|
|
|
|
async fn oauth2_service_with_github_review(pool: &PgPool) -> OAuth2Service {
|
|
oauth2_service_with_provider_signup(pool, "github", true).await
|
|
}
|
|
|
|
async fn oauth2_service_with_provider_signup(
|
|
pool: &PgPool,
|
|
provider_name: &str,
|
|
signup_need_review: bool,
|
|
) -> OAuth2Service {
|
|
let settings_service = Arc::new(SettingsService::new(
|
|
SettingsRepository::new(pool.clone()),
|
|
pool.clone(),
|
|
));
|
|
let runtime_settings_store = Arc::new(RuntimeSettingsStore::new(settings_service));
|
|
let provider_config = OAuth2ProviderConfig {
|
|
enable_signup: true,
|
|
signup_need_review,
|
|
config: match provider_name {
|
|
"github" => OAuth2ProviderPrivateConfig::GitHub(OAuth2GithubProviderConfig {
|
|
client_id: format!("{provider_name}-client-id"),
|
|
client_secret: format!("{provider_name}-client-secret"),
|
|
}),
|
|
"google" => OAuth2ProviderPrivateConfig::Google(OAuth2GoogleProviderConfig {
|
|
client_id: format!("{provider_name}-client-id"),
|
|
client_secret: format!("{provider_name}-client-secret"),
|
|
}),
|
|
other => panic!("unsupported test OAuth2 provider: {other}"),
|
|
},
|
|
};
|
|
let oauth2_configs = OAuth2ProviderConfigs(BTreeMap::from([(
|
|
provider_name.to_string(),
|
|
provider_config,
|
|
)]));
|
|
let mut runtime_settings = runtime_settings_store
|
|
.runtime_settings()
|
|
.checked("runtime settings should load");
|
|
runtime_settings.oauth2.providers = oauth2_configs;
|
|
runtime_settings_store
|
|
.persist_runtime_settings(&runtime_settings)
|
|
.await
|
|
.checked("OAuth2 runtime settings should persist");
|
|
|
|
OAuth2Service::new_with_runtime(
|
|
UserOAuthProviderRepository::new(pool.clone()),
|
|
local_oauth_state_store(),
|
|
synctv_core::oauth2::providers::provider_registry(SsrfGuard::strict_policy()),
|
|
SsrfGuard::strict_policy(),
|
|
false,
|
|
OAuth2ServiceRuntime {
|
|
runtime_settings_store: Some(runtime_settings_store),
|
|
user_service: Some(Arc::new(create_user_service(pool))),
|
|
},
|
|
)
|
|
.checked("OAuth2 service should initialize")
|
|
}
|
|
|
|
struct FamilyRevocationFailingStore {
|
|
inner: InMemoryTokenBlacklistStore,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl TokenBlacklistStore for FamilyRevocationFailingStore {
|
|
async fn is_blacklisted_checked(&self, key: &str) -> synctv_core::Result<bool> {
|
|
self.inner.is_blacklisted_checked(key).await
|
|
}
|
|
|
|
async fn blacklist(&self, key: &str, ttl_secs: u64) -> synctv_core::Result<()> {
|
|
self.inner.blacklist(key, ttl_secs).await
|
|
}
|
|
|
|
async fn blacklist_if_not_exists(&self, key: &str, ttl_secs: u64) -> synctv_core::Result<bool> {
|
|
self.inner.blacklist_if_not_exists(key, ttl_secs).await
|
|
}
|
|
|
|
async fn get_family_revoked_at_checked(&self, key: &str) -> synctv_core::Result<Option<i64>> {
|
|
self.inner.get_family_revoked_at_checked(key).await
|
|
}
|
|
|
|
async fn set_family_revoked(
|
|
&self,
|
|
_key: &str,
|
|
_timestamp: i64,
|
|
_ttl_secs: u64,
|
|
) -> synctv_core::Result<()> {
|
|
Err(Error::Internal(
|
|
"simulated family revocation persistence failure".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
struct FamilyRevocationReadFailingStore {
|
|
inner: InMemoryTokenBlacklistStore,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl TokenBlacklistStore for FamilyRevocationReadFailingStore {
|
|
async fn is_blacklisted_checked(&self, key: &str) -> synctv_core::Result<bool> {
|
|
self.inner.is_blacklisted_checked(key).await
|
|
}
|
|
|
|
async fn blacklist(&self, key: &str, ttl_secs: u64) -> synctv_core::Result<()> {
|
|
self.inner.blacklist(key, ttl_secs).await
|
|
}
|
|
|
|
async fn blacklist_if_not_exists(&self, key: &str, ttl_secs: u64) -> synctv_core::Result<bool> {
|
|
self.inner.blacklist_if_not_exists(key, ttl_secs).await
|
|
}
|
|
|
|
async fn get_family_revoked_at_checked(&self, _key: &str) -> synctv_core::Result<Option<i64>> {
|
|
Err(Error::Internal(
|
|
"simulated family revocation read failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn set_family_revoked(
|
|
&self,
|
|
key: &str,
|
|
timestamp: i64,
|
|
ttl_secs: u64,
|
|
) -> synctv_core::Result<()> {
|
|
self.inner
|
|
.set_family_revoked(key, timestamp, ttl_secs)
|
|
.await
|
|
}
|
|
}
|
|
|
|
struct FailingCacheL2;
|
|
|
|
#[async_trait::async_trait]
|
|
impl CacheL2Backend for FailingCacheL2 {
|
|
async fn get(&self, _key: &str) -> synctv_core::Result<Option<String>> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn set(&self, _key: &str, _json: &str, _ttl_secs: u64) -> synctv_core::Result<()> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn delete(&self, _key: &str) -> synctv_core::Result<()> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn get_batch(&self, _keys: &[String]) -> synctv_core::Result<Vec<Option<String>>> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn set_if_newer(
|
|
&self,
|
|
_key: &str,
|
|
_json: &str,
|
|
_ttl_secs: u64,
|
|
_new_ts_millis: i64,
|
|
) -> synctv_core::Result<bool> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn set_if_version_at_least(
|
|
&self,
|
|
_key: &str,
|
|
_json: &str,
|
|
_ttl_secs: u64,
|
|
_version: i64,
|
|
) -> synctv_core::Result<bool> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn delete_by_prefix(&self, _prefix: &str) -> synctv_core::Result<()> {
|
|
Err(Error::Internal(
|
|
"simulated username cache backend failure".to_string(),
|
|
))
|
|
}
|
|
|
|
fn is_active(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
fn create_user_service_with_failing_username_cache(pool: &PgPool) -> UserService {
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let username_cache = UsernameCache::new(
|
|
Arc::new(FailingCacheL2),
|
|
"test:username:".to_string(),
|
|
1000,
|
|
60,
|
|
);
|
|
create_user_service_with_components(
|
|
pool,
|
|
username_cache,
|
|
token_blacklist,
|
|
default_test_user_runtime_options(),
|
|
)
|
|
}
|
|
|
|
fn opaque_client_registration_start(
|
|
rng: &mut OsRng,
|
|
password: &[u8],
|
|
context: &str,
|
|
) -> opaque_ke::ClientRegistrationStartResult<TestOpaqueCipherSuite> {
|
|
match ClientRegistration::<TestOpaqueCipherSuite>::start(rng, password) {
|
|
Ok(start) => start,
|
|
Err(error) => std::panic::panic_any(format!("{context}: {error:?}")),
|
|
}
|
|
}
|
|
|
|
fn opaque_client_login_start(
|
|
rng: &mut OsRng,
|
|
password: &[u8],
|
|
context: &str,
|
|
) -> opaque_ke::ClientLoginStartResult<TestOpaqueCipherSuite> {
|
|
match ClientLogin::<TestOpaqueCipherSuite>::start(rng, password) {
|
|
Ok(start) => start,
|
|
Err(error) => std::panic::panic_any(format!("{context}: {error:?}")),
|
|
}
|
|
}
|
|
|
|
fn checked_opaque_registration_finish(
|
|
result: Result<
|
|
opaque_ke::ClientRegistrationFinishResult<TestOpaqueCipherSuite>,
|
|
opaque_ke::errors::ProtocolError,
|
|
>,
|
|
context: &str,
|
|
) -> opaque_ke::ClientRegistrationFinishResult<TestOpaqueCipherSuite> {
|
|
match result {
|
|
Ok(finish) => finish,
|
|
Err(error) => std::panic::panic_any(format!("{context}: {error:?}")),
|
|
}
|
|
}
|
|
|
|
fn checked_opaque_login_finish(
|
|
result: Result<
|
|
opaque_ke::ClientLoginFinishResult<TestOpaqueCipherSuite>,
|
|
opaque_ke::errors::ProtocolError,
|
|
>,
|
|
context: &str,
|
|
) -> opaque_ke::ClientLoginFinishResult<TestOpaqueCipherSuite> {
|
|
match result {
|
|
Ok(finish) => finish,
|
|
Err(error) => std::panic::panic_any(format!("{context}: {error:?}")),
|
|
}
|
|
}
|
|
|
|
async fn opaque_register(
|
|
service: &UserService,
|
|
username: String,
|
|
email: Option<String>,
|
|
password: &str,
|
|
) -> synctv_core::Result<(synctv_core::models::User, Option<String>, Option<String>)> {
|
|
let mut rng = OsRng;
|
|
let client_start = opaque_client_registration_start(
|
|
&mut rng,
|
|
password.as_bytes(),
|
|
"client OPAQUE registration start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_registration_with_control(
|
|
username,
|
|
email,
|
|
client_start.message.serialize().to_vec().into(),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
let registration_response = RegistrationResponse::<TestOpaqueCipherSuite>::deserialize(
|
|
&challenge.registration_response,
|
|
)
|
|
.checked("server registration response should deserialize");
|
|
let client_finish = checked_opaque_registration_finish(
|
|
client_start.state.finish(
|
|
&mut rng,
|
|
password.as_bytes(),
|
|
registration_response,
|
|
ClientRegistrationFinishParameters::default(),
|
|
),
|
|
"client OPAQUE registration finish should succeed",
|
|
);
|
|
|
|
match service
|
|
.finish_opaque_registration_with_control(
|
|
&challenge.session_id,
|
|
client_finish.message.serialize().to_vec().into(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(AccountRegistrationOutcome::Registered {
|
|
user,
|
|
access_token,
|
|
refresh_token,
|
|
..
|
|
}) => Ok((user, Some(access_token), Some(refresh_token))),
|
|
Ok(AccountRegistrationOutcome::PendingReview(_)) => Err(Error::Internal(
|
|
"opaque_register helper received pending review outcome".to_string(),
|
|
)),
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
async fn opaque_update_password(
|
|
service: &UserService,
|
|
user_id: &UserId,
|
|
current_password: &str,
|
|
new_password: &str,
|
|
) -> synctv_core::Result<synctv_core::models::User> {
|
|
let mut rng = OsRng;
|
|
let login_start = opaque_client_login_start(
|
|
&mut rng,
|
|
current_password.as_bytes(),
|
|
"client OPAQUE login start should succeed",
|
|
);
|
|
let registration_start = opaque_client_registration_start(
|
|
&mut rng,
|
|
new_password.as_bytes(),
|
|
"client OPAQUE registration start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_password_update(
|
|
user_id,
|
|
login_start.message.serialize().to_vec().into(),
|
|
registration_start.message.serialize().to_vec().into(),
|
|
)
|
|
.await?;
|
|
let credential_response =
|
|
CredentialResponse::<TestOpaqueCipherSuite>::deserialize(&challenge.credential_response)
|
|
.checked("server credential response should deserialize");
|
|
let login_finish = checked_opaque_login_finish(
|
|
login_start.state.finish(
|
|
&mut rng,
|
|
current_password.as_bytes(),
|
|
credential_response,
|
|
ClientLoginFinishParameters::default(),
|
|
),
|
|
"client OPAQUE login finish should succeed",
|
|
);
|
|
let registration_response = RegistrationResponse::<TestOpaqueCipherSuite>::deserialize(
|
|
&challenge.registration_response,
|
|
)
|
|
.checked("server registration response should deserialize");
|
|
let registration_finish = checked_opaque_registration_finish(
|
|
registration_start.state.finish(
|
|
&mut rng,
|
|
new_password.as_bytes(),
|
|
registration_response,
|
|
ClientRegistrationFinishParameters::default(),
|
|
),
|
|
"client OPAQUE registration finish should succeed",
|
|
);
|
|
|
|
service
|
|
.finish_opaque_password_update(
|
|
user_id,
|
|
&challenge.session_id,
|
|
login_finish.message.serialize().to_vec().into(),
|
|
registration_finish.message.serialize().to_vec().into(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn opaque_reset_password_after_external_verification(
|
|
service: &UserService,
|
|
user_id: &UserId,
|
|
new_password: &str,
|
|
) -> synctv_core::Result<synctv_core::models::User> {
|
|
let mut rng = OsRng;
|
|
let registration_start = opaque_client_registration_start(
|
|
&mut rng,
|
|
new_password.as_bytes(),
|
|
"client OPAQUE registration start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_password_reset_after_external_verification(
|
|
user_id,
|
|
registration_start.message.serialize().to_vec().into(),
|
|
)
|
|
.await?;
|
|
let registration_response = RegistrationResponse::<TestOpaqueCipherSuite>::deserialize(
|
|
&challenge.registration_response,
|
|
)
|
|
.checked("server registration response should deserialize");
|
|
let registration_finish = checked_opaque_registration_finish(
|
|
registration_start.state.finish(
|
|
&mut rng,
|
|
new_password.as_bytes(),
|
|
registration_response,
|
|
ClientRegistrationFinishParameters::default(),
|
|
),
|
|
"client OPAQUE registration finish should succeed",
|
|
);
|
|
|
|
service
|
|
.finish_opaque_password_reset_after_external_verification(
|
|
&challenge.session_id,
|
|
registration_finish.message.serialize().to_vec().into(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn pending_passkey_opaque_update_upload(
|
|
service: &UserService,
|
|
user_id: &UserId,
|
|
new_password: &str,
|
|
) -> synctv_core::Result<(String, Vec<u8>)> {
|
|
let mut rng = OsRng;
|
|
let registration_start = opaque_client_registration_start(
|
|
&mut rng,
|
|
new_password.as_bytes(),
|
|
"client OPAQUE registration start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_password_update_pending_passkey_verification(
|
|
user_id,
|
|
registration_start.message.serialize().to_vec().into(),
|
|
)
|
|
.await?;
|
|
let registration_response = RegistrationResponse::<TestOpaqueCipherSuite>::deserialize(
|
|
&challenge.registration_response,
|
|
)
|
|
.checked("server registration response should deserialize");
|
|
let registration_finish = checked_opaque_registration_finish(
|
|
registration_start.state.finish(
|
|
&mut rng,
|
|
new_password.as_bytes(),
|
|
registration_response,
|
|
ClientRegistrationFinishParameters::default(),
|
|
),
|
|
"client OPAQUE registration finish should succeed",
|
|
);
|
|
|
|
Ok((
|
|
challenge.session_id,
|
|
registration_finish.message.serialize().to_vec(),
|
|
))
|
|
}
|
|
|
|
async fn opaque_login(
|
|
service: &UserService,
|
|
identifier: String,
|
|
password: &str,
|
|
) -> synctv_core::Result<(synctv_core::models::User, String, String)> {
|
|
let login = opaque_login_outcome(service, identifier, password).await?;
|
|
match login {
|
|
AuthenticatedLogin::Complete {
|
|
user,
|
|
email: _,
|
|
access_token,
|
|
refresh_token,
|
|
} => Ok((user, access_token, refresh_token)),
|
|
AuthenticatedLogin::MfaRequired { .. } => Err(Error::Authentication(
|
|
"Unexpected MFA challenge in opaque_login test helper".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn opaque_login_outcome(
|
|
service: &UserService,
|
|
identifier: String,
|
|
password: &str,
|
|
) -> synctv_core::Result<AuthenticatedLogin> {
|
|
let login_session = service
|
|
.start_login_with_control(identifier, true, true, None, None)
|
|
.await?;
|
|
let mut rng = OsRng;
|
|
let client_start = opaque_client_login_start(
|
|
&mut rng,
|
|
password.as_bytes(),
|
|
"client OPAQUE login start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_login_with_control(
|
|
&login_session.session_id,
|
|
client_start.message.serialize().to_vec().into(),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
let credential_response =
|
|
CredentialResponse::<TestOpaqueCipherSuite>::deserialize(&challenge.credential_response)
|
|
.checked("server credential response should deserialize");
|
|
let client_finish = client_start
|
|
.state
|
|
.finish(
|
|
&mut rng,
|
|
password.as_bytes(),
|
|
credential_response,
|
|
ClientLoginFinishParameters::default(),
|
|
)
|
|
.map_err(|_| Error::Authentication("Authentication failed".to_string()))?;
|
|
|
|
let login = service
|
|
.finish_opaque_login_with_control(
|
|
&challenge.session_id,
|
|
client_finish.message.serialize().to_vec().into(),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
Ok(login)
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn test_start_login_reports_account_primary_methods() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let suffix = synctv_common::snanoid!(8);
|
|
let username = format!("login_methods_{suffix}");
|
|
let email = format!("login_methods_{suffix}@test.com");
|
|
let user = create_user_with_password_fixture(
|
|
&pool,
|
|
username.clone(),
|
|
Some(email.clone()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("password user should be created");
|
|
insert_test_passkey(&pool, &user.id, format!("credential-{suffix}").as_bytes()).await;
|
|
|
|
let challenge = service
|
|
.start_login_with_control(username, true, true, None, None)
|
|
.await
|
|
.checked("login session should start");
|
|
assert_eq!(
|
|
challenge.available_methods,
|
|
vec![
|
|
AuthFactorMethod::WebAuthn,
|
|
AuthFactorMethod::Password,
|
|
AuthFactorMethod::Email,
|
|
]
|
|
);
|
|
let session = service
|
|
.get_login_session_for_method(&challenge.session_id, AuthFactorMethod::Email)
|
|
.await
|
|
.checked("email method should be available");
|
|
assert_eq!(session.user_id(), Some(user.id));
|
|
assert_eq!(session.email(), Some(email.as_str()));
|
|
|
|
let password_only = service
|
|
.start_login_with_control(email, false, false, None, None)
|
|
.await
|
|
.checked("server capability filters should be applied");
|
|
assert_eq!(
|
|
password_only.available_methods,
|
|
vec![AuthFactorMethod::Password]
|
|
);
|
|
|
|
let passkey_only_username = format!("passkey_only_{suffix}");
|
|
let passkey_only_user = UserRepository::new(pool.clone())
|
|
.create(&User::new(
|
|
passkey_only_username.clone(),
|
|
SignupMethod::WebAuthn,
|
|
))
|
|
.await
|
|
.checked("passkey-only user should be created");
|
|
insert_test_passkey(
|
|
&pool,
|
|
&passkey_only_user.id,
|
|
format!("passkey-only-credential-{suffix}").as_bytes(),
|
|
)
|
|
.await;
|
|
let passkey_only = service
|
|
.start_login_with_control(passkey_only_username, true, true, None, None)
|
|
.await
|
|
.checked("passkey-only login session should start");
|
|
assert_eq!(
|
|
passkey_only.available_methods,
|
|
vec![AuthFactorMethod::WebAuthn]
|
|
);
|
|
|
|
let email_only_username = format!("email_only_{suffix}");
|
|
let email_only_address = format!("email_only_{suffix}@test.com");
|
|
let email_only_user = UserRepository::new(pool.clone())
|
|
.create(&User::new(email_only_username.clone(), SignupMethod::Email))
|
|
.await
|
|
.checked("email-only user should be created");
|
|
UserEmailRepository::new(pool.clone())
|
|
.create_for_user_with_executor(&email_only_user, Some(&email_only_address), &pool)
|
|
.await
|
|
.checked("email-only identity should be created");
|
|
let email_only = service
|
|
.start_login_with_control(email_only_username, true, true, None, None)
|
|
.await
|
|
.checked("email-only login session should start");
|
|
assert_eq!(email_only.available_methods, vec![AuthFactorMethod::Email]);
|
|
|
|
let unknown_identifier = format!("missing_{suffix}@test.com");
|
|
let unknown = service
|
|
.start_login_with_control(unknown_identifier.clone(), true, true, None, None)
|
|
.await
|
|
.checked("unknown identifiers should receive a decoy login session");
|
|
let repeated_unknown = service
|
|
.start_login_with_control(unknown_identifier, true, true, None, None)
|
|
.await
|
|
.checked("unknown identifier profile should be repeatable");
|
|
assert_eq!(
|
|
unknown.available_methods,
|
|
repeated_unknown.available_methods
|
|
);
|
|
assert!(!unknown.available_methods.is_empty());
|
|
assert!(unknown.available_methods.iter().all(|method| matches!(
|
|
method,
|
|
AuthFactorMethod::Password | AuthFactorMethod::WebAuthn | AuthFactorMethod::Email
|
|
)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn test_login_session_has_one_atomic_confirmation_claim() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = Arc::new(create_user_service(&pool));
|
|
let suffix = synctv_common::snanoid!(8);
|
|
let username = format!("email_claim_{suffix}");
|
|
let email = format!("email_claim_{suffix}@test.com");
|
|
let user = UserRepository::new(pool.clone())
|
|
.create(&User::new(username.clone(), SignupMethod::Email))
|
|
.await
|
|
.checked("email login user should be created");
|
|
UserEmailRepository::new(pool.clone())
|
|
.create_for_user_with_executor(&user, Some(&email), &pool)
|
|
.await
|
|
.checked("email identity should be created");
|
|
let login = service
|
|
.start_login_with_control(username, true, true, None, None)
|
|
.await
|
|
.checked("login session should start");
|
|
|
|
let first_service = Arc::clone(&service);
|
|
let first_session_id = login.session_id.clone();
|
|
let second_service = Arc::clone(&service);
|
|
let second_session_id = login.session_id;
|
|
let (first, second) = tokio::join!(
|
|
first_service.consume_login_session_for_method(&first_session_id, AuthFactorMethod::Email,),
|
|
second_service
|
|
.consume_login_session_for_method(&second_session_id, AuthFactorMethod::Email,),
|
|
);
|
|
|
|
assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker-backed PostgreSQL"]
|
|
async fn test_opaque_challenge_keeps_identified_login_session_reusable() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let suffix = synctv_common::snanoid!(8);
|
|
let username = format!("opaque_retry_{suffix}");
|
|
create_user_with_password_fixture(&pool, username.clone(), None, "StrongPass1")
|
|
.await
|
|
.checked("password user should be created");
|
|
|
|
let login = service
|
|
.start_login_with_control(username, true, true, None, None)
|
|
.await
|
|
.checked("login session should start");
|
|
let mut rng = OsRng;
|
|
let client_start = opaque_client_login_start(
|
|
&mut rng,
|
|
b"WrongPass1",
|
|
"client OPAQUE login start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_login_with_control(
|
|
&login.session_id,
|
|
client_start.message.serialize().to_vec().into(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("OPAQUE challenge should start");
|
|
|
|
assert_ne!(challenge.session_id, login.session_id);
|
|
service
|
|
.get_login_session_for_method(&login.session_id, AuthFactorMethod::Password)
|
|
.await
|
|
.checked("identified session should remain available for another attempt");
|
|
}
|
|
|
|
fn expect_complete_login(login: AuthenticatedLogin) -> (synctv_core::models::User, String, String) {
|
|
match login {
|
|
AuthenticatedLogin::Complete {
|
|
user,
|
|
email: _,
|
|
access_token,
|
|
refresh_token,
|
|
} => (user, access_token, refresh_token),
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("expected complete login, got MFA challenge")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PasswordCredentialRow {
|
|
opaque_record: Option<Vec<u8>>,
|
|
opaque_credential_identifier: Option<Vec<u8>>,
|
|
version: i32,
|
|
}
|
|
|
|
async fn load_password_credential_row(pool: &PgPool, user_id: UserId) -> PasswordCredentialRow {
|
|
let row = sqlx::query!(
|
|
r#"
|
|
SELECT opaque_record, opaque_credential_identifier, opaque_ciphersuite,
|
|
opaque_server_setup_version, version AS "version!"
|
|
FROM auth_password_credentials
|
|
WHERE user_id = $1
|
|
"#,
|
|
user_id.as_i64()
|
|
)
|
|
.fetch_one(pool)
|
|
.await
|
|
.checked("password credential row should exist");
|
|
PasswordCredentialRow {
|
|
opaque_record: row.opaque_record,
|
|
opaque_credential_identifier: row.opaque_credential_identifier,
|
|
version: row.version,
|
|
}
|
|
}
|
|
|
|
// S1: UserService::refresh_token (Refresh Token Rotation)
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_happy_path() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, Some(access_token), Some(refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("refresh_user_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("refresh_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens from registration");
|
|
};
|
|
|
|
let (new_access, new_refresh) = service
|
|
.refresh_token(refresh_token.clone())
|
|
.await
|
|
.checked("Refresh should succeed");
|
|
|
|
let jwt = create_jwt_service();
|
|
let access_claims = jwt
|
|
.verify_access_token(&new_access)
|
|
.checked("New access token valid");
|
|
let refresh_claims = jwt
|
|
.verify_refresh_token(&new_refresh)
|
|
.checked("New refresh token valid");
|
|
|
|
assert_eq!(access_claims.user_id(), user.id);
|
|
assert_eq!(refresh_claims.user_id(), user.id);
|
|
|
|
assert_ne!(new_access, access_token);
|
|
assert_ne!(new_refresh, refresh_token);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_old_jti_blacklisted_before_new_issued() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist.clone());
|
|
|
|
let (_user, _access, Some(refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("blacklist_user_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("blacklist_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
let jwt = create_jwt_service();
|
|
let old_claims = jwt
|
|
.verify_refresh_token(&refresh_token)
|
|
.checked("Old refresh token valid");
|
|
let old_jti = old_claims.token_id().to_string();
|
|
|
|
let _new_tokens = service
|
|
.refresh_token(refresh_token.clone())
|
|
.await
|
|
.checked("Refresh should succeed");
|
|
|
|
let key_builder = KeyBuilder::new("test");
|
|
let blacklist_key = key_builder.refresh_token_blacklist(&old_jti);
|
|
assert!(
|
|
token_blacklist
|
|
.is_blacklisted_checked(&blacklist_key)
|
|
.await
|
|
.checked("test operation should succeed"),
|
|
"Old JTI should be blacklisted after refresh"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_replay_same_jti_triggers_family_revocation() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist.clone());
|
|
|
|
let (_user, _access, Some(refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("replay_user_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("replay_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
let (_new_access, new_refresh) = service
|
|
.refresh_token(refresh_token.clone())
|
|
.await
|
|
.checked("First refresh should succeed");
|
|
|
|
let replay_result = service.refresh_token(refresh_token.clone()).await;
|
|
assert!(
|
|
replay_result.is_err(),
|
|
"Replayed refresh token should be rejected"
|
|
);
|
|
assert!(matches!(
|
|
replay_result.failed("operation should fail"),
|
|
Error::Authentication(_)
|
|
));
|
|
|
|
let second_refresh = service.refresh_token(new_refresh).await;
|
|
assert!(
|
|
second_refresh.is_err(),
|
|
"New refresh token should also be rejected after family revocation"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_logout_session_revocation_blocks_only_current_refresh_session() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist);
|
|
|
|
let username = format!("logout_session_{}", synctv_common::snanoid!(6));
|
|
let email = Some(format!(
|
|
"logout_session_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
));
|
|
let (user, Some(first_access), Some(first_refresh)) =
|
|
opaque_register_user(&service, username.clone(), email, "StrongPass1")
|
|
.await
|
|
.checked("Registration should succeed")
|
|
else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
let AuthenticatedLogin::Complete {
|
|
refresh_token: second_refresh,
|
|
..
|
|
} = opaque_login_user(&service, username, "StrongPass1")
|
|
.await
|
|
.checked("Second login should succeed")
|
|
else {
|
|
std::panic::panic_any("expected complete login");
|
|
};
|
|
|
|
let jwt = create_jwt_service();
|
|
let access_claims = jwt
|
|
.verify_access_token(&first_access)
|
|
.checked("Access token should be valid");
|
|
let revoked_at = access_claims.iat.saturating_add(1);
|
|
|
|
service
|
|
.revoke_refresh_token_session(
|
|
&user.id,
|
|
access_claims
|
|
.session_id()
|
|
.checked("access token should carry sid"),
|
|
revoked_at,
|
|
)
|
|
.await
|
|
.checked("Logout session revocation should persist");
|
|
|
|
let first_result = service.refresh_token(first_refresh).await;
|
|
assert!(
|
|
first_result.is_err(),
|
|
"Refresh token from logged-out session should be rejected"
|
|
);
|
|
|
|
let second_result = service.refresh_token(second_refresh).await;
|
|
assert!(
|
|
second_result.is_ok(),
|
|
"Another login session should not be revoked by current-session logout"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_rejects_invalid_user_state_and_password_version() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let repo = UserRepository::new(pool.clone());
|
|
|
|
let (_user, _access, Some(refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("pv_user_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("pv_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
let jwt = create_jwt_service();
|
|
let claims = jwt
|
|
.verify_refresh_token(&refresh_token)
|
|
.checked("Token valid");
|
|
let user_id = claims.user_id();
|
|
opaque_update_password(&service, &user_id, "StrongPass1", "NewStrongPass1")
|
|
.await
|
|
.checked("Password change should succeed");
|
|
|
|
let result = service.refresh_token(refresh_token).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Refresh with old password version should be rejected"
|
|
);
|
|
let error = result.failed("operation should fail");
|
|
assert!(
|
|
matches!(error, Error::Authentication(_)),
|
|
"expected Authentication, got {error:?}"
|
|
);
|
|
|
|
let (strict_user, _access, Some(_refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("pv_strict_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("pv_strict_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
let jwt = create_jwt_service();
|
|
let mismatched_refresh = jwt
|
|
.sign_refresh_token_with_session(
|
|
&strict_user.id,
|
|
99,
|
|
None,
|
|
"strict-password-version-session",
|
|
&TokenCredentialBinding::Password { version: 99 },
|
|
)
|
|
.checked("mismatched refresh token should be signed");
|
|
|
|
let result = service.refresh_token(mismatched_refresh).await;
|
|
assert!(
|
|
matches!(result, Err(Error::Authentication(_))),
|
|
"Refresh token with any mismatched password version should be rejected"
|
|
);
|
|
|
|
let (banned_user, _access, Some(banned_refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("banned_refresh_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"banned_refresh_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
repo.ban(&banned_user.id, None, Some("test ban".to_string()))
|
|
.await
|
|
.checked("Failed to ban user");
|
|
|
|
let result = service.refresh_token(banned_refresh_token).await;
|
|
assert!(result.is_err(), "Banned user should not be able to refresh");
|
|
let error = result.failed("operation should fail");
|
|
assert!(
|
|
matches!(error, Error::Authentication(_)),
|
|
"expected Authentication, got {error:?}"
|
|
);
|
|
|
|
let (deleted_user, _access, Some(deleted_refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("deleted_refresh_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"deleted_refresh_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
sqlx::query!(
|
|
"UPDATE users SET deleted_at = NOW() WHERE id = $1",
|
|
deleted_user.id.as_i64()
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("Failed to soft-delete");
|
|
|
|
let result = service.refresh_token(deleted_refresh_token).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Deleted user should not be able to refresh"
|
|
);
|
|
let error = result.failed("operation should fail");
|
|
assert!(
|
|
matches!(error, Error::Authentication(_)),
|
|
"expected Authentication, got {error:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_family_revocation_timestamp_blocks_older_tokens() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist.clone());
|
|
|
|
let (_user, _access, Some(refresh_token_1)) = opaque_register_user(
|
|
&service,
|
|
format!("family_rev_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"family_rev_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected tokens");
|
|
};
|
|
|
|
let (_access_2, refresh_token_2) = service
|
|
.refresh_token(refresh_token_1.clone())
|
|
.await
|
|
.checked("First refresh should succeed");
|
|
|
|
let (_access_3, refresh_token_3) = service
|
|
.refresh_token(refresh_token_2.clone())
|
|
.await
|
|
.checked("Second refresh should succeed");
|
|
|
|
let replay_result = service.refresh_token(refresh_token_1).await;
|
|
assert!(replay_result.is_err(), "Replayed old token should fail");
|
|
|
|
let result = service.refresh_token(refresh_token_3).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Token issued before family revocation should be blocked"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_fails_closed_when_family_revocation_lookup_errors() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(FamilyRevocationReadFailingStore {
|
|
inner: InMemoryTokenBlacklistStore::new(10_000, 3600, 86400),
|
|
});
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist);
|
|
|
|
let (_user, _access, Some(refresh_token)) = opaque_register_user(
|
|
&service,
|
|
format!("family_lookup_fail_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"family_lookup_fail_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed") else {
|
|
std::panic::panic_any("expected refresh token");
|
|
};
|
|
|
|
let result = service.refresh_token(refresh_token).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Refresh should fail closed when family revocation lookup cannot be verified"
|
|
);
|
|
assert!(matches!(
|
|
result.failed("operation should fail"),
|
|
Error::Internal(_)
|
|
));
|
|
}
|
|
|
|
// S2: UserService::login status checks
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_registration_persists_login_credential() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let username = format!("plain_password_default_{}", synctv_common::snanoid!(6));
|
|
opaque_register_user(
|
|
&service,
|
|
username.clone(),
|
|
Some(format!(
|
|
"plain_password_default_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("OPAQUE registration should create a user");
|
|
|
|
let created = service
|
|
.get_user_by_username(&username)
|
|
.await
|
|
.checked("registered user should be fetchable");
|
|
let row = load_password_credential_row(&pool, created.id).await;
|
|
assert!(
|
|
row.opaque_record.is_some() && row.opaque_credential_identifier.is_some(),
|
|
"OPAQUE registration must persist OPAQUE credential material"
|
|
);
|
|
|
|
let opaque_result = opaque_login_user(&service, username, "StrongPass1").await;
|
|
assert!(
|
|
opaque_result.is_ok(),
|
|
"OPAQUE login must use the persisted password credential"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_login_rejects_inactive_accounts() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let repo = UserRepository::new(pool.clone());
|
|
|
|
let username = format!("banned_login_{}", synctv_common::snanoid!(6));
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
username.clone(),
|
|
Some(format!(
|
|
"banned_login_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
repo.ban(&user.id, None, Some("test ban".to_string()))
|
|
.await
|
|
.checked("Failed to ban user");
|
|
|
|
let result = opaque_login_user(&service, username, "StrongPass1").await;
|
|
assert!(result.is_err(), "Banned user should not be able to login");
|
|
assert!(matches!(
|
|
result.failed("operation should fail"),
|
|
Error::Authentication(_)
|
|
));
|
|
|
|
let rejected_username = format!("rejected_login_{}", synctv_common::snanoid!(6));
|
|
let (rejected_user, _, _) = opaque_register_user(
|
|
&service,
|
|
rejected_username.clone(),
|
|
Some(format!(
|
|
"rejected_login_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
sqlx::query!(
|
|
r"
|
|
INSERT INTO user_registration_requests (
|
|
id, username, email, opaque_record,
|
|
opaque_credential_identifier, opaque_ciphersuite,
|
|
opaque_server_setup_version, signup_method, status,
|
|
requested_at, reviewed_at, rejection_reason
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, $10)
|
|
",
|
|
synctv_core::models::generate_id(),
|
|
&format!("rejected_request_{}", synctv_common::snanoid!(6)),
|
|
Option::<String>::None,
|
|
b"not-used-opaque-record".as_slice(),
|
|
b"not-used-opaque-id".as_slice(),
|
|
"opaque-ristretto255-sha512-argon2id",
|
|
1_i32,
|
|
i16::from(rejected_user.signup_method),
|
|
i16::from(synctv_core::models::ReviewStatus::Rejected),
|
|
"rejected by test"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("Failed to create rejected registration request");
|
|
|
|
repo.ban(
|
|
&rejected_user.id,
|
|
None,
|
|
Some("rejected account cannot login".to_string()),
|
|
)
|
|
.await
|
|
.checked("Failed to disable rejected test user");
|
|
|
|
let result = opaque_login_user(&service, rejected_username, "StrongPass1").await;
|
|
assert!(result.is_err(), "Rejected user should not be able to login");
|
|
assert!(matches!(
|
|
result.failed("operation should fail"),
|
|
Error::Authentication(_)
|
|
));
|
|
|
|
let deleted_username = format!("deleted_login_{}", synctv_common::snanoid!(6));
|
|
let (deleted_user, _, _) = opaque_register_user(
|
|
&service,
|
|
deleted_username.clone(),
|
|
Some(format!(
|
|
"deleted_login_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
sqlx::query!(
|
|
"UPDATE users SET deleted_at = NOW() WHERE id = $1",
|
|
deleted_user.id.as_i64()
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("Failed to soft-delete");
|
|
|
|
let result = opaque_login_user(&service, deleted_username, "StrongPass1").await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Soft-deleted user should not be able to login"
|
|
);
|
|
let error = result.failed("operation should fail");
|
|
assert!(
|
|
matches!(error, Error::Authentication(_)),
|
|
"expected Authentication, got {error:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_login_email_and_oauth2_user_types_allowed() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let email_user = format!("email_allowed_{}", synctv_common::snanoid!(6));
|
|
let (_user_with_email, _, _) = opaque_register_user(
|
|
&service,
|
|
email_user.clone(),
|
|
Some(format!("{email_user}@test.com")),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
let provider = synctv_core::models::oauth2_client::OAuth2Provider::Google;
|
|
let oauth_user = service
|
|
.create_or_load_by_oauth2(&provider, "oauth_allowed", "oauth_allowed")
|
|
.await
|
|
.checked("OAuth2 user creation should succeed");
|
|
|
|
opaque_reset_password_after_external_verification(&service, &oauth_user.id, "StrongPass1")
|
|
.await
|
|
.checked("Setting password through OPAQUE reset should succeed");
|
|
|
|
let email_result = opaque_login_user(&service, email_user, "StrongPass1").await;
|
|
|
|
let oauth_result = opaque_login(&service, oauth_user.username.clone(), "StrongPass1").await;
|
|
|
|
assert!(
|
|
email_result.is_ok(),
|
|
"Email user should be allowed when verification not required: {:?}",
|
|
email_result.err()
|
|
);
|
|
assert!(
|
|
oauth_result.is_ok(),
|
|
"OAuth2 user should be allowed when verification not required: {:?}",
|
|
oauth_result.err()
|
|
);
|
|
}
|
|
|
|
// S3: UserService::delete_user
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_delete_user_already_deleted_guard() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
format!("del_guard_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("del_guard_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
service
|
|
.delete_user(&user.id)
|
|
.await
|
|
.checked("First delete should succeed");
|
|
|
|
let result = service.delete_user(&user.id).await;
|
|
assert!(result.is_err(), "Double delete should fail");
|
|
let err = result.failed("operation should fail");
|
|
match &err {
|
|
Error::InvalidInput(msg) => assert!(
|
|
msg.contains("already deleted"),
|
|
"Expected 'already deleted' message, got: {msg}"
|
|
),
|
|
Error::NotFound(_) => {}
|
|
_ => std::panic::panic_any(format!("expected InvalidInput or NotFound, got: {err}")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_delete_user_transaction_atomicity_with_oauth2() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
format!("del_oauth_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("del_oauth_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
service
|
|
.delete_user(&user.id)
|
|
.await
|
|
.checked("Delete with OAuth2 cleanup should succeed");
|
|
|
|
let deleted_user: Option<i64> = sqlx::query_scalar!(
|
|
r#"SELECT id AS "id!" FROM users WHERE id = $1 AND deleted_at IS NOT NULL"#,
|
|
user.id.as_i64()
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.checked("Query should succeed");
|
|
assert!(
|
|
deleted_user.is_some(),
|
|
"User should be soft-deleted in the database"
|
|
);
|
|
}
|
|
|
|
// S7: forced password reset
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_force_password_reset_revokes_password_credential_and_bumps_version() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
format!("setpw_{}", synctv_common::snanoid!(6)),
|
|
Some(format!("setpw_{}@test.com", synctv_common::snanoid!(6))),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
let old_version = load_password_credential_row(&pool, user.id).await.version;
|
|
|
|
let _updated_user = service
|
|
.force_password_reset(&user.id)
|
|
.await
|
|
.checked("forced password reset should succeed");
|
|
let after_version = load_password_credential_row(&pool, user.id).await.version;
|
|
|
|
assert_eq!(
|
|
after_version,
|
|
old_version + 1,
|
|
"Password version should be incremented by forced reset"
|
|
);
|
|
|
|
let row = load_password_credential_row(&pool, user.id).await;
|
|
assert!(
|
|
row.opaque_record.is_none(),
|
|
"forced password reset must revoke OPAQUE credential material"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_registration_creates_opaque_only_password_credential() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let username = format!("opaque_reg_{}", synctv_common::snanoid!(6));
|
|
let (user, access_token, refresh_token) = opaque_register(
|
|
&service,
|
|
username.clone(),
|
|
Some(format!(
|
|
"opaque_reg_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("OPAQUE registration should succeed");
|
|
|
|
assert!(
|
|
access_token.is_some() && refresh_token.is_some(),
|
|
"OPAQUE registration should issue tokens"
|
|
);
|
|
|
|
let row = load_password_credential_row(&pool, user.id).await;
|
|
assert!(
|
|
row.opaque_record.is_some() && row.opaque_credential_identifier.is_some(),
|
|
"OPAQUE-specific registration must persist OPAQUE credential material"
|
|
);
|
|
assert!(
|
|
service
|
|
.has_usable_password_authentication(&user)
|
|
.await
|
|
.checked("password auth capability check should succeed"),
|
|
"OPAQUE-only registration must count as usable password authentication"
|
|
);
|
|
|
|
let password_login = opaque_login_user(&service, username, "StrongPass1").await;
|
|
assert!(
|
|
password_login.is_ok(),
|
|
"OPAQUE login should work for OPAQUE-only registrations"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_only_password_counts_as_first_factor_without_plaintext_mfa() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let username = format!("opaque_mfa_{}", synctv_common::snanoid!(6));
|
|
let email = format!("opaque_mfa_{}@test.com", synctv_common::snanoid!(6));
|
|
let (user, _, _) = opaque_register_user(&service, username.clone(), Some(email), "StrongPass1")
|
|
.await
|
|
.checked("user creation should succeed");
|
|
|
|
let (_preferences, factors) = service
|
|
.get_user_preferences(&user.id)
|
|
.await
|
|
.checked("preferences should load");
|
|
assert!(factors.password);
|
|
assert!(factors.email);
|
|
assert!(factors.supports_two_factor());
|
|
|
|
let result = service.set_two_factor_enabled(&user.id, true).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"OPAQUE password plus email should allow enabling 2FA"
|
|
);
|
|
|
|
let login = opaque_login_outcome(&service, username, "StrongPass1")
|
|
.await
|
|
.checked("OPAQUE password first factor should start MFA");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = login else {
|
|
std::panic::panic_any("2FA-enabled OPAQUE password login should require a second factor");
|
|
};
|
|
assert!(
|
|
challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Email),
|
|
"email should be available after an OPAQUE password first factor"
|
|
);
|
|
assert!(
|
|
!challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Password),
|
|
"password must not be offered as a plaintext MFA completion method"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_password_update_replaces_opaque_password_credential() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, username) = register_password_user_with_username(&service, "opaque_update").await;
|
|
|
|
let before = load_password_credential_row(&pool, user.id).await;
|
|
assert!(
|
|
before.opaque_record.is_some(),
|
|
"password registration should store OPAQUE credential material"
|
|
);
|
|
let before_version = before.version;
|
|
|
|
let updated_user = opaque_update_password(&service, &user.id, "StrongPass1", "NewStrongPass1")
|
|
.await
|
|
.checked("OPAQUE password update should succeed");
|
|
let after = load_password_credential_row(&pool, user.id).await;
|
|
let after_version = after.version;
|
|
assert_eq!(
|
|
after_version,
|
|
before_version + 1,
|
|
"OPAQUE password update must invalidate existing tokens by bumping version"
|
|
);
|
|
|
|
assert!(
|
|
after.opaque_record.is_some(),
|
|
"OPAQUE password update must persist the new OPAQUE credential"
|
|
);
|
|
assert!(
|
|
service
|
|
.has_usable_password_authentication(&updated_user)
|
|
.await
|
|
.checked("password auth capability check should succeed"),
|
|
"OPAQUE-only password update must count as usable password authentication"
|
|
);
|
|
|
|
let old_password_login = opaque_login_user(&service, username.clone(), "StrongPass1").await;
|
|
let new_password_login = opaque_login_user(&service, username.clone(), "NewStrongPass1").await;
|
|
assert!(
|
|
old_password_login.is_err() && new_password_login.is_ok(),
|
|
"OPAQUE login should use the updated password credential"
|
|
);
|
|
|
|
let opaque_login_result = opaque_login_user(&service, username, "NewStrongPass1").await;
|
|
assert!(
|
|
opaque_login_result.is_ok(),
|
|
"OPAQUE login must work with the updated OPAQUE-only password credential"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_password_reset_replaces_opaque_password_credential() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, username) = register_password_user_with_username(&service, "opaque_reset").await;
|
|
|
|
let before = load_password_credential_row(&pool, user.id).await;
|
|
assert!(
|
|
before.opaque_record.is_some(),
|
|
"password registration should store OPAQUE credential material"
|
|
);
|
|
let before_version = before.version;
|
|
|
|
let _updated_user =
|
|
opaque_reset_password_after_external_verification(&service, &user.id, "NewStrongPass1")
|
|
.await
|
|
.checked("OPAQUE password reset should succeed");
|
|
let after = load_password_credential_row(&pool, user.id).await;
|
|
let after_version = after.version;
|
|
assert_eq!(
|
|
after_version,
|
|
before_version + 1,
|
|
"OPAQUE password reset must invalidate existing tokens by bumping version"
|
|
);
|
|
|
|
assert!(
|
|
after.opaque_record.is_some(),
|
|
"OPAQUE password reset must persist the new OPAQUE credential"
|
|
);
|
|
|
|
let old_password_login = opaque_login_user(&service, username.clone(), "StrongPass1").await;
|
|
let new_password_login = opaque_login_user(&service, username.clone(), "NewStrongPass1").await;
|
|
assert!(
|
|
old_password_login.is_err() && new_password_login.is_ok(),
|
|
"OPAQUE login should use the reset password credential"
|
|
);
|
|
|
|
let opaque_login_result = opaque_login_user(&service, username, "NewStrongPass1").await;
|
|
assert!(
|
|
opaque_login_result.is_ok(),
|
|
"OPAQUE login must work with the reset OPAQUE-only password credential"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_password_update_requires_current_credential_proof() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, username) =
|
|
register_password_user_with_username(&service, "opaque_update_proof").await;
|
|
|
|
let mut rng = OsRng;
|
|
let login_start = opaque_client_login_start(
|
|
&mut rng,
|
|
b"WrongStrongPass1",
|
|
"client OPAQUE login start should succeed",
|
|
);
|
|
let registration_start = opaque_client_registration_start(
|
|
&mut rng,
|
|
b"NewStrongPass1",
|
|
"client OPAQUE registration start should succeed",
|
|
);
|
|
let challenge = service
|
|
.start_opaque_password_update(
|
|
&user.id,
|
|
login_start.message.serialize().to_vec().into(),
|
|
registration_start.message.serialize().to_vec().into(),
|
|
)
|
|
.await
|
|
.checked("starting an OPAQUE password update should not prove the password yet");
|
|
let registration_response = RegistrationResponse::<TestOpaqueCipherSuite>::deserialize(
|
|
&challenge.registration_response,
|
|
)
|
|
.checked("server registration response should deserialize");
|
|
let registration_finish = checked_opaque_registration_finish(
|
|
registration_start.state.finish(
|
|
&mut rng,
|
|
b"NewStrongPass1",
|
|
registration_response,
|
|
ClientRegistrationFinishParameters::default(),
|
|
),
|
|
"client OPAQUE registration finish should succeed",
|
|
);
|
|
|
|
let result = service
|
|
.finish_opaque_password_update(
|
|
&user.id,
|
|
&challenge.session_id,
|
|
b"invalid-current-credential-proof".to_vec().into(),
|
|
registration_finish.message.serialize().to_vec().into(),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"OPAQUE password update must reject requests that cannot prove the current credential"
|
|
);
|
|
|
|
let old_password_login = opaque_login_user(&service, username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
old_password_login.is_ok(),
|
|
"failed OPAQUE password update must not replace the existing password credential"
|
|
);
|
|
|
|
let new_opaque_login = opaque_login(&service, username, "NewStrongPass1").await;
|
|
assert!(
|
|
new_opaque_login.is_err(),
|
|
"failed OPAQUE password update must not install the requested new credential"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_opaque_password_update_requires_passkey_finish_for_pending_passkey_session() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, username) =
|
|
register_password_user_with_username(&service, "opaque_passkey_update").await;
|
|
|
|
let (session_id, registration_upload) =
|
|
pending_passkey_opaque_update_upload(&service, &user.id, "NewStrongPass1")
|
|
.await
|
|
.checked("pending passkey OPAQUE update should start");
|
|
|
|
let bypass_result = service
|
|
.finish_opaque_password_update_after_external_verification(
|
|
&user.id,
|
|
&session_id,
|
|
registration_upload.into(),
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(bypass_result, Err(Error::Authentication(_))),
|
|
"pending passkey sessions must not be finishable through generic external verification"
|
|
);
|
|
|
|
let password_login = opaque_login_user(&service, username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
password_login.is_ok(),
|
|
"failed passkey-bypass attempt must leave the original password intact"
|
|
);
|
|
|
|
let (session_id, registration_upload) =
|
|
pending_passkey_opaque_update_upload(&service, &user.id, "NewStrongPass1")
|
|
.await
|
|
.checked("second pending passkey OPAQUE update should start");
|
|
let updated_user = service
|
|
.finish_opaque_password_update_after_passkey_verification(
|
|
&user.id,
|
|
&session_id,
|
|
registration_upload.into(),
|
|
)
|
|
.await
|
|
.checked("passkey-verified finish should accept pending passkey sessions");
|
|
|
|
assert!(
|
|
service
|
|
.has_usable_password_authentication(&updated_user)
|
|
.await
|
|
.checked("password auth capability check should succeed"),
|
|
"passkey-verified OPAQUE update must leave usable password authentication"
|
|
);
|
|
|
|
let old_password_login = opaque_login_user(&service, username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
old_password_login.is_err(),
|
|
"old password must stop working after passkey-verified OPAQUE update"
|
|
);
|
|
|
|
let opaque_login_result = opaque_login_user(&service, username, "NewStrongPass1").await;
|
|
assert!(
|
|
opaque_login_result.is_ok(),
|
|
"new OPAQUE credential must work after passkey-verified password update"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_set_password_succeeds_even_when_family_revocation_store_fails() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> = Arc::new(FamilyRevocationFailingStore {
|
|
inner: InMemoryTokenBlacklistStore::new(10_000, 3600, 86400),
|
|
});
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist);
|
|
|
|
let (user, username) = register_password_user_with_username(&service, "setpw_fail").await;
|
|
|
|
let result = service.force_password_reset(&user.id).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Password reset should rely on version, not fail on best-effort family revocation persistence"
|
|
);
|
|
|
|
let login_old = opaque_login_user(&service, username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
login_old.is_err(),
|
|
"Old password must stop working after version is updated"
|
|
);
|
|
|
|
let login_new = opaque_login_user(&service, username.clone(), "AdminNewPass1").await;
|
|
assert!(
|
|
login_new.is_err(),
|
|
"Forced reset should wait for the user-owned OPAQUE reset flow"
|
|
);
|
|
|
|
opaque_reset_password_after_external_verification(&service, &user.id, "AdminNewPass1")
|
|
.await
|
|
.checked("external verification should install replacement password");
|
|
let opaque_login_new = opaque_login_user(&service, username, "AdminNewPass1").await;
|
|
assert!(
|
|
opaque_login_new.is_ok(),
|
|
"New OPAQUE password must become active through reset flow"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_username_registration_and_login_are_case_insensitive() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let suffix = synctv_common::snanoid!(6);
|
|
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
format!("CaseUser_{suffix}"),
|
|
Some(format!("case_user_{suffix}@test.com")),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
assert_eq!(
|
|
user.username,
|
|
format!("caseuser_{}", suffix.to_lowercase()),
|
|
"Stored username should use the canonical lowercase form"
|
|
);
|
|
|
|
let duplicate = opaque_register_user(
|
|
&service,
|
|
format!("CASEUSER_{suffix}"),
|
|
Some(format!("case_user_dup_{suffix}@test.com")),
|
|
"StrongPass1",
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(duplicate, Err(Error::AlreadyExists(_))),
|
|
"Case variants of the same username must collide"
|
|
);
|
|
|
|
let (logged_in_user, _, _) = expect_complete_login(
|
|
opaque_login_user(&service, format!("cAsEuSeR_{suffix}"), "StrongPass1")
|
|
.await
|
|
.checked("Login should accept case variants of the canonical username"),
|
|
);
|
|
assert_eq!(logged_in_user.id, user.id);
|
|
|
|
let fetched = service
|
|
.get_user_by_username(&format!("CASEUSER_{suffix}"))
|
|
.await
|
|
.checked("Case-insensitive username lookup should find the user");
|
|
assert_eq!(fetched.id, user.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_update_profile_updates_username_only() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let new_username = format!("profile_atomic_new_{}", synctv_common::snanoid!(6));
|
|
let (user, old_username) =
|
|
register_password_user_with_username(&service, "profile_atomic").await;
|
|
|
|
let before_version = load_password_credential_row(&pool, user.id).await.version;
|
|
let updated_user = service
|
|
.update_profile(&user.id, Some(new_username.to_uppercase()))
|
|
.await
|
|
.checked("Profile username update should succeed");
|
|
let after_version = load_password_credential_row(&pool, user.id).await.version;
|
|
|
|
assert_eq!(updated_user.username, new_username.to_lowercase());
|
|
assert_eq!(
|
|
after_version, before_version,
|
|
"Username-only profile update must not increment version"
|
|
);
|
|
|
|
let login_old = opaque_login_user(&service, old_username, "StrongPass1").await;
|
|
assert!(
|
|
login_old.is_err(),
|
|
"Old username must stop working after a successful profile update"
|
|
);
|
|
|
|
let login_new = opaque_login_user(&service, new_username.to_uppercase(), "StrongPass1").await;
|
|
assert!(
|
|
login_new.is_ok(),
|
|
"Existing password must remain active with the new username after profile update"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_update_profile_rejects_empty_update() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
|
|
let (user, old_username) =
|
|
register_password_user_with_username(&service, "profile_rollback").await;
|
|
|
|
let result = service.update_profile(&user.id, None).await;
|
|
|
|
assert!(
|
|
matches!(result, Err(Error::InvalidInput(_))),
|
|
"Empty profile update must be rejected"
|
|
);
|
|
|
|
let persisted = service
|
|
.get_user(&user.id)
|
|
.await
|
|
.checked("User should still exist after rejected update");
|
|
assert_eq!(
|
|
persisted.username,
|
|
old_username.to_lowercase(),
|
|
"Username must not change when profile update is rejected"
|
|
);
|
|
|
|
let login_old = opaque_login_user(&service, old_username, "StrongPass1").await;
|
|
assert!(
|
|
login_old.is_ok(),
|
|
"Original credentials must remain valid after a rejected combined profile update"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_update_profile_commits_when_family_revocation_store_fails() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> = Arc::new(FamilyRevocationFailingStore {
|
|
inner: InMemoryTokenBlacklistStore::new(10_000, 3600, 86400),
|
|
});
|
|
let service = create_user_service_with_blacklist(&pool, token_blacklist);
|
|
|
|
let new_username = format!("profile_revoke_new_{}", synctv_common::snanoid!(6));
|
|
let (user, old_username) =
|
|
register_password_user_with_username(&service, "profile_revoke").await;
|
|
|
|
let updated = service
|
|
.update_profile(&user.id, Some(new_username.clone()))
|
|
.await
|
|
.checked("Profile update should commit");
|
|
|
|
let persisted = service
|
|
.get_user(&user.id)
|
|
.await
|
|
.checked("User should still exist after successful combined update");
|
|
assert_eq!(
|
|
persisted.username,
|
|
new_username.to_lowercase(),
|
|
"Username change must commit even when best-effort family revocation persistence fails"
|
|
);
|
|
assert_eq!(updated.username, persisted.username);
|
|
|
|
let login_old = opaque_login_user(&service, old_username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
login_old.is_err(),
|
|
"Original credentials must stop working after a successful combined profile update"
|
|
);
|
|
|
|
let login_new = opaque_login_user(&service, new_username, "StrongPass1").await;
|
|
assert!(
|
|
login_new.is_ok(),
|
|
"Existing password must remain active after a successful profile update"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_register_succeeds_when_username_cache_write_fails() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service_with_failing_username_cache(&pool);
|
|
|
|
let username = format!("cache_fail_register_{}", synctv_common::snanoid!(6));
|
|
let (user, access_token, refresh_token) = opaque_register_user(
|
|
&service,
|
|
username.clone(),
|
|
Some(format!(
|
|
"cache_fail_register_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration must succeed even when username cache write fails");
|
|
|
|
assert_eq!(user.username, username.to_lowercase());
|
|
assert!(access_token.is_some());
|
|
assert!(refresh_token.is_some());
|
|
|
|
let persisted = service
|
|
.get_user(&user.id)
|
|
.await
|
|
.checked("Registered user must be durable in the database");
|
|
assert_eq!(persisted.username, user.username);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_finalize_registration_succeeds_when_username_cache_write_fails() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service_with_failing_username_cache(&pool);
|
|
|
|
let user = create_user_with_password_fixture(
|
|
&pool,
|
|
format!("cache_fail_finalize_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"cache_fail_finalize_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("User creation should succeed");
|
|
|
|
let (access_token, refresh_token) = service
|
|
.finalize_registration(&user)
|
|
.await
|
|
.checked("Finalization must succeed even when username cache write fails");
|
|
|
|
let jwt = create_jwt_service();
|
|
assert!(jwt.verify_access_token(&access_token).is_ok());
|
|
assert!(jwt.verify_refresh_token(&refresh_token).is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_create_user_with_role_succeeds_when_username_cache_write_fails() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service_with_failing_username_cache(&pool);
|
|
|
|
let created = service
|
|
.create_user_with_optional_direct_password(
|
|
format!("cache_fail_admin_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"cache_fail_admin_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
None,
|
|
Some(synctv_core::models::UserRole::Admin),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("Admin user creation must succeed even when username cache write fails");
|
|
|
|
let persisted = service
|
|
.get_user(&created.id)
|
|
.await
|
|
.checked("Created admin user must be durable in the database");
|
|
assert_eq!(persisted.id, created.id);
|
|
assert_eq!(persisted.role, synctv_core::models::UserRole::Admin);
|
|
assert_eq!(
|
|
persisted.signup_method,
|
|
synctv_core::models::SignupMethod::AdminCreated
|
|
);
|
|
|
|
let password_repository = UserPasswordRepository::new(pool.clone());
|
|
let password_state = password_repository
|
|
.get_state(&created.id)
|
|
.await
|
|
.checked("password state lookup should succeed");
|
|
let has_opaque_credential = password_repository
|
|
.has_opaque_credential(&created.id)
|
|
.await
|
|
.checked("password credential lookup should succeed");
|
|
assert_eq!(password_state.version, 0);
|
|
assert!(
|
|
!has_opaque_credential,
|
|
"admin-created users must wait for OPAQUE reset before credential material exists"
|
|
);
|
|
|
|
let password_login = opaque_login_user(&service, created.username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
password_login.is_err(),
|
|
"admin-created users should need OPAQUE reset before password login"
|
|
);
|
|
|
|
opaque_reset_password_after_external_verification(&service, &created.id, "StrongPass1")
|
|
.await
|
|
.checked("external verification should initialize admin-created password");
|
|
let opaque_login_result =
|
|
opaque_login_user(&service, created.username.clone(), "StrongPass1").await;
|
|
assert!(
|
|
opaque_login_result.is_ok(),
|
|
"admin-created users must be able to use OPAQUE login"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_create_user_with_initial_banned_status_persists_ban_record() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let reviewer = service
|
|
.create_user_with_optional_direct_password(
|
|
format!("initial_banned_reviewer_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"initial_banned_reviewer_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
None,
|
|
Some(synctv_core::models::UserRole::Admin),
|
|
Some(synctv_core::models::UserStatus::Active),
|
|
None,
|
|
)
|
|
.await
|
|
.checked("reviewer should be created");
|
|
|
|
let created = service
|
|
.create_user_with_optional_direct_password(
|
|
format!("initial_banned_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"initial_banned_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
None,
|
|
Some(synctv_core::models::UserRole::User),
|
|
Some(synctv_core::models::UserStatus::Banned),
|
|
Some(&reviewer.id),
|
|
)
|
|
.await
|
|
.checked("admin-created banned user should be created");
|
|
|
|
assert_eq!(created.status, synctv_core::models::UserStatus::Banned);
|
|
assert!(created.is_banned);
|
|
|
|
let persisted = service
|
|
.get_user(&created.id)
|
|
.await
|
|
.checked("created user should be durable");
|
|
assert_eq!(persisted.status, synctv_core::models::UserStatus::Banned);
|
|
assert_eq!(persisted.banned_by.as_ref(), Some(&reviewer.id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_get_username_falls_back_to_database_when_cache_read_fails() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service_with_failing_username_cache(&pool);
|
|
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
format!("cache_fail_lookup_{}", synctv_common::snanoid!(6)),
|
|
Some(format!(
|
|
"cache_fail_lookup_{}@test.com",
|
|
synctv_common::snanoid!(6)
|
|
)),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration must succeed");
|
|
|
|
let username = service
|
|
.get_username(&user.id)
|
|
.await
|
|
.checked("Username lookup should fall back to database on cache read failure");
|
|
|
|
assert_eq!(username.as_deref(), Some(user.username.as_str()));
|
|
}
|
|
|
|
// S13: create_or_load_by_oauth2
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_create_or_load_by_oauth2_normalizes_username_and_falls_back_to_provider_id() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let provider = OAuth2Provider::Google;
|
|
|
|
let sanitized_user = service
|
|
.create_or_load_by_oauth2(&provider, "provider_user_123", "user@special!chars.test")
|
|
.await
|
|
.checked("Should create user with sanitized username");
|
|
|
|
assert!(
|
|
sanitized_user
|
|
.username
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '_' || c == '-'),
|
|
"Username should be sanitized: {}",
|
|
sanitized_user.username
|
|
);
|
|
assert!(
|
|
!sanitized_user.username.contains('@'),
|
|
"@ should be stripped from username"
|
|
);
|
|
assert!(
|
|
!sanitized_user.username.contains('!'),
|
|
"! should be stripped from username"
|
|
);
|
|
assert_eq!(
|
|
sanitized_user.status,
|
|
synctv_core::models::UserStatus::Active,
|
|
"OAuth2-created users should start active so first login succeeds"
|
|
);
|
|
|
|
let fallback_user = service
|
|
.create_or_load_by_oauth2(&provider, "fallback_provider_id", "@@@!!!")
|
|
.await
|
|
.checked("Should create user with fallback username");
|
|
|
|
assert!(
|
|
fallback_user.username.starts_with("user_"),
|
|
"Empty sanitized username should fall back to 'user_<provider_id>': {}",
|
|
fallback_user.username
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_create_or_load_by_oauth2_collision_retry() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let provider = OAuth2Provider::Google;
|
|
|
|
let user1 = service
|
|
.create_or_load_by_oauth2(&provider, "provider1", "oauth_user")
|
|
.await
|
|
.checked("First user creation should succeed");
|
|
|
|
assert_eq!(user1.username, "oauth_user");
|
|
|
|
let user2 = service
|
|
.create_or_load_by_oauth2(&provider, "provider2", "oauth_user")
|
|
.await
|
|
.checked("Second user creation should succeed with suffixed username");
|
|
|
|
assert_ne!(
|
|
user2.username, "oauth_user",
|
|
"Second user should have a different (suffixed) username"
|
|
);
|
|
assert!(
|
|
user2.username.starts_with("oauth_user_"),
|
|
"Suffixed username should start with 'oauth_user_': {}",
|
|
user2.username
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_find_or_create_and_link_concurrent_requests_do_not_commit_orphan_oauth2_users() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let oauth_service = oauth2_service_with_google_signup(&pool).await;
|
|
|
|
let provider = OAuth2Provider::Google;
|
|
let user_info = synctv_core::service::OAuth2UserInfo {
|
|
provider: provider.clone(),
|
|
provider_instance_name: "google".to_string(),
|
|
provider_issuer: Some("https://accounts.google.com".to_string()),
|
|
provider_user_id: format!("oauth_concurrent_{}", synctv_common::snanoid!(8)),
|
|
username: format!("oauth_concurrent_user_{}", synctv_common::snanoid!(6)),
|
|
avatar: None,
|
|
};
|
|
|
|
let first = oauth_service.find_or_create_and_link("google", &user_info);
|
|
let second = oauth_service.find_or_create_and_link("google", &user_info);
|
|
let (first_result, second_result) = tokio::join!(first, second);
|
|
|
|
let OAuth2LinkResult::Linked {
|
|
user_id: first_user_id,
|
|
..
|
|
} = first_result.checked("first concurrent login must succeed")
|
|
else {
|
|
std::panic::panic_any("first concurrent login should not require review");
|
|
};
|
|
let OAuth2LinkResult::Linked {
|
|
user_id: second_user_id,
|
|
..
|
|
} = second_result.checked("second concurrent login must succeed")
|
|
else {
|
|
std::panic::panic_any("second concurrent login should not require review");
|
|
};
|
|
assert_eq!(
|
|
first_user_id, second_user_id,
|
|
"Concurrent logins for the same provider identity must converge to one user"
|
|
);
|
|
|
|
let oauth_repo = UserOAuthProviderRepository::new(pool.clone());
|
|
let mapping = oauth_repo
|
|
.find_by_provider_instance("google", &user_info.provider_user_id)
|
|
.await
|
|
.checked("mapping lookup must succeed")
|
|
.checked("mapping must exist");
|
|
assert_eq!(mapping.user_id, first_user_id);
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let oauth2_user_count: i64 = sqlx::query_scalar!(
|
|
r#"
|
|
SELECT COUNT(*) AS "count!"
|
|
FROM users u
|
|
JOIN auth_oauth2_identities oc ON oc.user_id = u.id
|
|
WHERE oc.provider_instance_name = $1
|
|
AND oc.provider_user_id = $2
|
|
AND u.deleted_at IS NULL
|
|
"#,
|
|
"google",
|
|
&user_info.provider_user_id
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("user count query must succeed");
|
|
assert_eq!(
|
|
oauth2_user_count, 1,
|
|
"Concurrent OAuth2 signups must not commit an extra orphan user row"
|
|
);
|
|
|
|
let persisted_user = user_repo
|
|
.get_by_id(&first_user_id)
|
|
.await
|
|
.checked("user lookup must succeed")
|
|
.checked("winning user must exist");
|
|
let persisted_email = synctv_core::repository::UserEmailRepository::new(pool.clone())
|
|
.get_email(&first_user_id)
|
|
.await
|
|
.checked("email identity lookup must succeed");
|
|
assert!(persisted_email.is_none());
|
|
assert_eq!(
|
|
persisted_user.status,
|
|
synctv_core::models::UserStatus::Active,
|
|
"OAuth2-created users must be active immediately"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_find_or_create_and_link_repeated_review_signup_returns_existing_pending_request() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let oauth_service = oauth2_service_with_github_review(&pool).await;
|
|
|
|
let provider = OAuth2Provider::GitHub;
|
|
let user_info = oauth2_user_info(
|
|
provider.clone(),
|
|
"github",
|
|
format!("oauth_pending_{}", synctv_common::snanoid!(8)),
|
|
format!("oauth_pending_user_{}", synctv_common::snanoid!(6)),
|
|
);
|
|
|
|
let first = oauth_service
|
|
.find_or_create_and_link("github", &user_info)
|
|
.await
|
|
.checked("first OAuth2 review signup should create a pending request");
|
|
let second = oauth_service
|
|
.find_or_create_and_link("github", &user_info)
|
|
.await
|
|
.checked("repeated OAuth2 review signup should return existing pending request");
|
|
|
|
let OAuth2LinkResult::PendingReview(first_pending) = first else {
|
|
std::panic::panic_any("first OAuth2 review signup should require review");
|
|
};
|
|
let OAuth2LinkResult::PendingReview(second_pending) = second else {
|
|
std::panic::panic_any("repeated OAuth2 review signup should require review");
|
|
};
|
|
|
|
assert_eq!(
|
|
first_pending.request_id, second_pending.request_id,
|
|
"repeated pending OAuth2 signup should converge to the original review request"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_find_or_create_and_link_review_signup_skips_existing_usernames() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_service = create_user_service(&pool);
|
|
let oauth_service = oauth2_service_with_github_review(&pool).await;
|
|
|
|
opaque_register_user(
|
|
&user_service,
|
|
"oauth_review_collision_user",
|
|
Some("oauth_review_collision_local@test.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("seed local user should be created");
|
|
|
|
let provider = OAuth2Provider::GitHub;
|
|
let user_info = oauth2_user_info(
|
|
provider.clone(),
|
|
"github",
|
|
format!("oauth_review_collision_{}", synctv_common::snanoid!(8)),
|
|
"oauth_review_collision_user",
|
|
);
|
|
|
|
let OAuth2LinkResult::PendingReview(pending) = oauth_service
|
|
.find_or_create_and_link("github", &user_info)
|
|
.await
|
|
.checked("OAuth2 review signup should create a pending request with a suffixed username")
|
|
else {
|
|
std::panic::panic_any("OAuth2 signup should require review in this test");
|
|
};
|
|
|
|
let pending_username: String = sqlx::query_scalar!(
|
|
r#"
|
|
SELECT username AS "username!"
|
|
FROM user_registration_requests
|
|
WHERE id = $1
|
|
"#,
|
|
pending.request_id.as_i64()
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("pending registration request should exist");
|
|
|
|
assert_ne!(pending_username, "oauth_review_collision_user");
|
|
assert!(
|
|
pending_username.starts_with("oauth_review_collision_user_"),
|
|
"expected suffixed pending username, got {pending_username}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_find_or_create_and_link_retries_with_suffixed_username_on_collision() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_service = create_user_service(&pool);
|
|
let oauth_service = oauth2_service_with_google_signup(&pool).await;
|
|
|
|
opaque_register_user(
|
|
&user_service,
|
|
"oauth_collision_user",
|
|
Some("local_collision@test.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("seed local user should be created");
|
|
|
|
let provider = OAuth2Provider::Google;
|
|
let user_info = oauth2_user_info(
|
|
provider.clone(),
|
|
"google",
|
|
format!("oauth_collision_{}", synctv_common::snanoid!(8)),
|
|
"oauth_collision_user",
|
|
);
|
|
|
|
let OAuth2LinkResult::Linked {
|
|
user_id: created_user_id,
|
|
is_new,
|
|
} = oauth_service
|
|
.find_or_create_and_link("google", &user_info)
|
|
.await
|
|
.checked("OAuth2 signup should succeed by choosing a suffixed username")
|
|
else {
|
|
std::panic::panic_any("OAuth2 signup should not require review in this test");
|
|
};
|
|
|
|
assert!(is_new, "first OAuth2 login should create a new user");
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let created_user = user_repo
|
|
.get_by_id(&created_user_id)
|
|
.await
|
|
.checked("user lookup should succeed")
|
|
.checked("created OAuth2 user should exist");
|
|
|
|
assert_ne!(created_user.username, "oauth_collision_user");
|
|
assert!(
|
|
created_user.username.starts_with("oauth_collision_user_"),
|
|
"expected suffixed username, got {}",
|
|
created_user.username
|
|
);
|
|
assert_eq!(
|
|
created_user.signup_method,
|
|
synctv_core::models::SignupMethod::OAuth2
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_rate_limiting_per_user() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service(&pool);
|
|
let refresh_token = register_password_user_refresh_token(&service, "rate_limit_refresh").await;
|
|
|
|
let mut success_count = 0;
|
|
let mut rate_limited = false;
|
|
let mut current_token = refresh_token;
|
|
|
|
for _ in 0..20 {
|
|
match service.refresh_token(current_token.clone()).await {
|
|
Ok((_new_access, new_refresh)) => {
|
|
success_count += 1;
|
|
current_token = new_refresh;
|
|
}
|
|
Err(Error::RateLimited(_)) => {
|
|
rate_limited = true;
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
std::panic::panic_any(format!("unexpected error during refresh: {e:?}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
rate_limited,
|
|
"Refresh token endpoint should be rate limited after {success_count} requests"
|
|
);
|
|
|
|
assert!(
|
|
success_count > 0,
|
|
"Should allow at least some refresh requests before rate limiting"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_concurrent_refresh_race_condition() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service_with_in_memory_blacklist(&pool);
|
|
let refresh_token = register_password_user_refresh_token(&service, "concurrent_race").await;
|
|
|
|
let successes = run_concurrent_refresh_attempts(service.clone(), refresh_token, 10).await;
|
|
let failures = 10 - successes;
|
|
|
|
assert_eq!(
|
|
successes, 1,
|
|
"Exactly ONE concurrent refresh should succeed, got {successes} successes and {failures} failures. \
|
|
Multiple successful refreshes would mean refresh-token replay detection failed."
|
|
);
|
|
assert_eq!(
|
|
failures, 9,
|
|
"Nine requests should fail due to JTI already blacklisted"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_concurrent_refresh_family_revocation() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let service = create_user_service_with_in_memory_blacklist(&pool);
|
|
let refresh_token = register_password_user_refresh_token(&service, "family_rev_race").await;
|
|
|
|
let (_access1, refresh_token1) = service
|
|
.refresh_token(refresh_token.clone())
|
|
.await
|
|
.checked("First refresh should succeed");
|
|
|
|
let _successes = run_concurrent_refresh_attempts(service.clone(), refresh_token, 5).await;
|
|
|
|
let result = service.refresh_token(refresh_token1).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"New token should be blocked after family revocation from concurrent replay detection"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_refresh_token_rate_limit_recovers() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let token_blacklist: Arc<dyn TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let username_cache = UsernameCache::local_only("test:username:".to_string(), 1000, 0);
|
|
let mut runtime = default_test_user_runtime_options();
|
|
runtime.refresh_rate_limiter = Arc::new(RateLimiter::local_only(
|
|
"test-refresh-recover-short-window:".to_string(),
|
|
));
|
|
runtime.refresh_rate_limit_config = synctv_core::service::RefreshRateLimitConfig {
|
|
requests: 1,
|
|
window_secs: 1,
|
|
};
|
|
let service =
|
|
create_user_service_with_components(&pool, username_cache, token_blacklist, runtime);
|
|
let refresh_token = register_password_user_refresh_token(&service, "rate_limit_recover").await;
|
|
|
|
// Using 1 request / 1 second preserves the recovery semantics while
|
|
// avoiding a real 7 second sleep in the test.
|
|
let mut current_token = refresh_token;
|
|
for _ in 0..2 {
|
|
match service.refresh_token(current_token.clone()).await {
|
|
Ok((_access, new_refresh)) => {
|
|
current_token = new_refresh;
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
|
|
let result = service.refresh_token(current_token.clone()).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Refresh should be rate limited before the short window resets"
|
|
);
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
|
|
|
let result = service.refresh_token(current_token).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should be able to refresh again after rate limit window resets: {:?}",
|
|
result.err()
|
|
);
|
|
}
|
|
|
|
// S2.6: Password timing attack prevention tests
|