1mod backup_codes;
2mod challenge;
3mod codec;
4mod config;
5mod cookies;
6mod enrollment;
7mod lockout;
8mod otp_flow;
9mod totp;
10
11use backup_codes::generate_backup_codes;
12use backup_codes::generate_backup_codes_core;
13use backup_codes::generate_numeric_string;
14use backup_codes::verify_backup_code_core;
15use backup_codes::view_backup_codes_core;
16use challenge::begin_factor_attempt;
17pub(crate) use challenge::begin_sign_in_challenge;
18use challenge::finalize_pending_two_factor;
19pub(crate) use challenge::inspect_trusted_device;
20use challenge::load_two_factor_record;
21use challenge::rearm_factor_attempt;
22use challenge::resolve_two_factor_state;
23use challenge::verification_error_response;
24use challenge::verify_existing_session_factor;
25use codec::decrypt_value;
26use codec::encrypt_value;
27pub use config::AccountLockoutConfig;
28pub use config::SendTwoFactorOtp;
29pub use config::TwoFactorConfig;
30use cookies::clear_cookie_header;
31use cookies::cookie_expiry;
32use cookies::create_session_cookie_for_dont_remember;
33use cookies::create_signed_cookie_header;
34use cookies::create_trust_device_cookie_header;
35use cookies::get_factor_cookie;
36use cookies::read_signed_cookie;
37use cookies::sign_value;
38use cookies::two_factor_cookie_max_age;
39use cookies::verify_factor_cookie_value;
40use enrollment::disable_core;
41use enrollment::enable_core;
42use enrollment::mark_factor_verified;
43use enrollment::parse_password_body;
44use enrollment::verify_user_password;
45use lockout::assert_account_not_locked;
46use lockout::record_account_failure;
47use lockout::reset_account_failures;
48use otp_flow::send_otp_core;
49use otp_flow::verify_otp_core;
50use totp::generate_secret;
51use totp::generate_totp_at;
52use totp::get_totp_uri_core;
53use totp::totp_counter;
54use totp::totp_uri;
55use totp::verify_totp_core;
56mod backup_storage;
57mod endpoint;
58
59mod otp_storage;
60
61mod otp;
62
63use super::StatusResponse;
64use crate::helpers::{
65 SessionIssueError, get_credential_password_hash, issue_user_session_record,
66 issue_user_session_with_overrides_record,
67};
68use aes_gcm::aead::Aead;
69use aes_gcm::{Aes256Gcm, Key, Nonce};
70use alibi_core::entity::{AuthSession, AuthTwoFactor, AuthUser};
71use alibi_core::utils::cookie_utils::{
72 create_clear_cookie, create_session_cookie, create_session_cookie_with_max_age,
73 related_cookie_name,
74};
75use alibi_core::wire::UserView;
76use alibi_core::{
77 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateTwoFactor,
78 CreateVerification, RequestMeta, TwoFactor, UpdateTwoFactor, UpdateUser,
79};
80use async_trait::async_trait;
81pub use backup_storage::{TwoFactorBackupCipher, TwoFactorBackupStorage};
82use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
83use chrono::{Duration, Utc};
84pub use endpoint::{BackupCodesOutput, TotpOutput};
85use hkdf::Hkdf;
86use hmac::{Hmac, KeyInit, Mac};
87pub use otp_storage::{TwoFactorOtpCipher, TwoFactorOtpHasher, TwoFactorOtpStorage};
88use rand::RngExt;
89use rand::distr::Alphanumeric;
90use serde::{Deserialize, Serialize};
91use sha2::Sha256;
92use std::sync::Arc;
93use validator::Validate;
94
95const TWO_FACTOR_COOKIE_SUFFIX: &str = "two_factor";
96
97const TRUST_DEVICE_COOKIE_SUFFIX: &str = "trust_device";
98
99const DONT_REMEMBER_COOKIE_SUFFIX: &str = "dont_remember";
100
101const METADATA_ENABLED: &str = "two_factor.enabled";
102
103const METADATA_OTP_ENABLED: &str = "two_factor.otp_enabled";
104
105const METADATA_TWO_FACTOR_COOKIE_MAX_AGE: &str = "two_factor.two_factor_cookie_max_age";
106
107const METADATA_TRUST_DEVICE_MAX_AGE: &str = "two_factor.trust_device_max_age";
108
109const METADATA_TOTP_DISABLED: &str = "two_factor.totp_disabled";
110
111const DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS: f64 = 600.0;
112
113const DEFAULT_TRUST_DEVICE_MAX_AGE_SECS: f64 = 2_592_000.0;
114
115#[derive(Clone, Copy)]
116struct TwoFactorCookiePolicy {
117 challenge_max_age: f64,
118 trust_max_age: f64,
119}
120
121const DEFAULT_TOTP_PERIOD_SECS: f64 = 30.0;
122
123const DEFAULT_TOTP_DIGITS: f64 = 6.0;
124
125const ENCRYPTION_INFO: &[u8] = b"better-auth-two-factor-encryption";
126
127#[derive(Clone)]
129pub struct TwoFactorPlugin {
130 config: TwoFactorConfig,
131}
132
133#[derive(Debug, Deserialize, Validate)]
134pub(crate) struct EnableRequest {
135 password: Option<String>,
136 #[serde(default)]
137 method: EnableMethod,
138 issuer: Option<String>,
139}
140
141#[derive(Debug, Default, Deserialize, PartialEq)]
142#[serde(rename_all = "lowercase")]
143enum EnableMethod {
144 Otp,
145 #[default]
146 Totp,
147}
148
149#[derive(Debug, Deserialize, Validate)]
150pub(crate) struct DisableRequest {
151 password: Option<String>,
152}
153
154#[derive(Debug, Deserialize, Validate)]
155pub(crate) struct GetTotpUriRequest {
156 password: Option<String>,
157}
158
159#[derive(Debug, Deserialize, Validate)]
160pub(crate) struct VerifyTotpRequest {
161 code: String,
162 #[serde(rename = "trustDevice")]
163 trust_device: Option<bool>,
164}
165
166#[derive(Deserialize)]
167struct SendOtpRequest {
168 #[serde(rename = "trustDevice")]
169 _trust_device: Option<bool>,
170}
171
172impl crate::authentication_helpers::RequestBody for SendOtpRequest {
173 const FIELDS: &'static [crate::authentication_helpers::JsonField] =
174 &[crate::authentication_helpers::JsonField {
175 name: "trustDevice",
176 kind: crate::authentication_helpers::JsonFieldKind::Boolean,
177 required: false,
178 }];
179}
180
181#[derive(Debug, Deserialize, Validate)]
182pub(crate) struct VerifyOtpRequest {
183 code: String,
184 #[serde(rename = "trustDevice")]
185 trust_device: Option<bool>,
186}
187
188#[derive(Debug, Deserialize, Validate)]
189pub(crate) struct GenerateBackupCodesRequest {
190 password: Option<String>,
191}
192
193#[derive(Debug, Deserialize, Validate)]
194pub(crate) struct VerifyBackupCodeRequest {
195 code: String,
196 #[serde(rename = "disableSession")]
197 disable_session: Option<bool>,
198 #[serde(rename = "trustDevice")]
199 trust_device: Option<bool>,
200}
201
202#[derive(Debug, Serialize)]
203#[serde(tag = "method", rename_all = "lowercase")]
204pub(crate) enum EnableResponse {
205 Otp,
206 Totp {
207 #[serde(rename = "totpURI")]
208 totp_uri: String,
209 #[serde(rename = "backupCodes")]
210 backup_codes: Vec<String>,
211 },
212}
213
214#[derive(Debug, Serialize)]
215pub(crate) struct TotpUriResponse {
216 #[serde(rename = "totpURI")]
217 totp_uri: String,
218}
219
220#[derive(Debug, Serialize)]
221pub(crate) struct SessionTokenResponse<U> {
222 token: String,
223 user: U,
224}
225
226#[derive(Debug, Serialize)]
227struct BackupVerificationResponse {
228 #[serde(skip_serializing_if = "Option::is_none")]
229 token: Option<String>,
230 user: UserView,
231}
232
233impl From<SessionTokenResponse<UserView>> for BackupVerificationResponse {
234 fn from(response: SessionTokenResponse<UserView>) -> Self {
235 Self {
236 token: Some(response.token),
237 user: response.user,
238 }
239 }
240}
241
242#[derive(Debug, Serialize)]
243pub(crate) struct BackupCodesResponse {
244 status: bool,
245 #[serde(rename = "backupCodes")]
246 backup_codes: Vec<String>,
247}
248
249#[derive(Debug, Serialize)]
250pub(crate) struct TwoFactorRedirectResponse {
251 #[serde(rename = "twoFactorRedirect")]
252 two_factor_redirect: bool,
253 #[serde(rename = "twoFactorMethods")]
256 two_factor_methods: Vec<&'static str>,
257}
258
259struct PendingTwoFactorState<S: alibi_core::AuthSchema> {
260 user: alibi_core::AdapterRecord<S::User>,
261 verification: alibi_core::verification::VerificationSnapshot,
262 key: String,
263 dont_remember: bool,
264}
265
266enum ResolvedTwoFactorState<S: alibi_core::AuthSchema> {
267 Session {
268 user: alibi_core::AuthenticatedUser<S>,
269 session: Box<alibi_core::wire::SessionView>,
270 key: String,
271 },
272 Pending(PendingTwoFactorState<S>),
273}
274
275pub(crate) struct SignInTwoFactorRedirect {
276 pub response: TwoFactorRedirectResponse,
277 pub set_cookie_headers: Vec<String>,
278}
279
280pub(crate) struct TrustedDeviceCheck {
281 pub trusted: bool,
282 pub set_cookie_headers: Vec<String>,
283}
284
285impl TwoFactorPlugin {
286 #[must_use]
288 pub fn custom_send_otp(mut self, sender: Arc<dyn SendTwoFactorOtp>) -> Self {
289 self.config.send_otp = Some(sender);
290 self
291 }
292
293 pub fn generate_totp(&self, secret: &str) -> AuthResult<String> {
301 require_totp_enabled(&self.config)?;
302 generate_totp_at(&self.config, secret, totp_counter(&self.config), 0.0)
303 }
304
305 pub async fn view_backup_codes<S: alibi_core::AuthSchema>(
319 &self,
320 user_id: &str,
321 ctx: &AuthContext<S>,
322 ) -> AuthResult<serde_json::Value> {
323 view_backup_codes_core(user_id, &self.config, ctx).await
324 }
325}
326
327alibi_core::impl_auth_plugin! {
328 TwoFactorPlugin, "two-factor";
329 routes {
330 post "/two-factor/enable" => handle_enable, "enable_two_factor";
331 post "/two-factor/disable" => handle_disable, "disable_two_factor";
332 post "/two-factor/get-totp-uri" => handle_get_totp_uri, "get_totp_uri";
333 post "/two-factor/verify-totp" => handle_verify_totp, "verify_totp";
334 post "/two-factor/send-otp" => handle_send_otp, "send_otp";
335 post "/two-factor/verify-otp" => handle_verify_otp, "verify_otp";
336 post "/two-factor/generate-backup-codes" => handle_generate_backup_codes, "generate_backup_codes";
337 post "/two-factor/verify-backup-code" => handle_verify_backup_code, "verify_backup_code";
338 }
339 extra {
340 route_openapi_metadata!(S);
341
342 fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
343 vec![alibi_core::PluginRateLimit { matches: |path| path.starts_with("/two-factor/"), limit: alibi_core::EndpointRateLimit { window_seconds: 10.0, max_requests: 3.0 } }]
344 }
345 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
346
347 fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
348
349 async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
350
351 async fn on_init(
352 &self,
353 ctx: &mut alibi_core::AuthInitContext<S>,
354 ) -> AuthResult<()> {
355 let default = |mut input: alibi_core::CreateUser| {
356 _ = input.two_factor_enabled.get_or_insert(false);
357 Ok(input)
358 };
359 if ctx.config.user_validation.is_some() {ctx.register_user_creation_adapter_default(default);}
360 else {ctx.register_user_create_transform(default);}
361 ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
362 ctx.set_metadata(METADATA_TOTP_DISABLED, serde_json::Value::Bool(self.config.totp_disabled));
363 ctx.set_metadata(
364 METADATA_OTP_ENABLED,
365 serde_json::Value::Bool(self.config.send_otp.is_some()),
366 );
367 ctx.set_metadata(
368 METADATA_TWO_FACTOR_COOKIE_MAX_AGE,
369 serde_json::Number::from_f64(self.config.two_factor_cookie_max_age)
370 .map_or(serde_json::Value::Null, serde_json::Value::Number),
371 );
372 ctx.set_metadata(
373 METADATA_TRUST_DEVICE_MAX_AGE,
374 serde_json::Number::from_f64(self.config.trust_device_max_age)
375 .map_or(serde_json::Value::Null, serde_json::Value::Number),
376 );
377 ctx.extensions.insert(TwoFactorCookiePolicy {
378 challenge_max_age: self.config.two_factor_cookie_max_age,
379 trust_max_age: self.config.trust_device_max_age,
380 });
381 Ok(())
382 }
383 }
384}
385
386impl TwoFactorPlugin {
387 async fn handle_enable(
388 &self,
389 req: &AuthRequest,
390 ctx: &AuthContext<impl alibi_core::AuthSchema>,
391 ) -> AuthResult<AuthResponse> {
392 let body: EnableRequest =
393 match parse_password_body(req, self.config.allow_passwordless, true) {
394 Ok(body) => body,
395 Err(response) => return Ok(response),
396 };
397 let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
398
399 let (response, set_cookie_headers) =
400 match enable_core(&body, &user, &session, &self.config, ctx).await {
401 Ok(result) => result,
402 Err(
403 BackupOperationError::Auth(AuthError::SessionCreationCancelled)
404 | BackupOperationError::InvalidGeneration,
405 ) => {
406 return Ok(AuthResponse::new(500));
407 }
408 Err(BackupOperationError::Auth(error)) => return Err(error),
409 };
410 let mut auth_response = AuthResponse::json(200, &response)?;
411 for cookie in set_cookie_headers {
412 auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
413 }
414 Ok(auth_response)
415 }
416
417 async fn handle_disable(
418 &self,
419 req: &AuthRequest,
420 ctx: &AuthContext<impl alibi_core::AuthSchema>,
421 ) -> AuthResult<AuthResponse> {
422 let body: DisableRequest =
423 match parse_password_body(req, self.config.allow_passwordless, false) {
424 Ok(v) => v,
425 Err(resp) => return Ok(resp),
426 };
427 let (user, session) = ctx
428 .require_authoritative_session(req)
429 .await
430 .map_err(|error| match error {
431 AuthError::Unauthenticated | AuthError::SessionNotFound => AuthError::Upstream {
432 status: 401,
433 code: "UNAUTHORIZED",
434 message: "Unauthorized",
435 },
436 other @ (AuthError::Api { .. }
437 | AuthError::Upstream { .. }
438 | AuthError::BadRequest(_)
439 | AuthError::InvalidRequest(_)
440 | AuthError::Validation(_)
441 | AuthError::InvalidCredentials
442 | AuthError::AuthenticationFailed(_)
443 | AuthError::Forbidden(_)
444 | AuthError::UserCreationCancelled
445 | AuthError::SessionCreationCancelled
446 | AuthError::BannedUser(_)
447 | AuthError::Unauthorized
448 | AuthError::UserNotFound
449 | AuthError::NotFound(_)
450 | AuthError::Conflict(_)
451 | AuthError::MethodNotAllowed(_)
452 | AuthError::PayloadTooLarge(_)
453 | AuthError::UnprocessableEntity(_)
454 | AuthError::RateLimited { .. }
455 | AuthError::NotImplemented(_)
456 | AuthError::Config(_)
457 | AuthError::Database(_)
458 | AuthError::Serialization(_)
459 | AuthError::Plugin { .. }
460 | AuthError::CallbackFailure(_)
461 | AuthError::Internal(_)
462 | AuthError::Encryption(_)
463 | AuthError::PasswordHash(_)
464 | AuthError::Jwt(_)) => other,
465 })?;
466
467 let (response, set_cookie_headers) =
468 disable_core(&body, &user, &session, req, &self.config, ctx).await?;
469 let mut auth_response = AuthResponse::json(200, &response)?;
470 for cookie in set_cookie_headers {
471 auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
472 }
473 Ok(auth_response)
474 }
475
476 async fn handle_get_totp_uri(
477 &self,
478 req: &AuthRequest,
479 ctx: &AuthContext<impl alibi_core::AuthSchema>,
480 ) -> AuthResult<AuthResponse> {
481 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
482 let allow_passwordless = self
483 .config
484 .totp_allow_passwordless
485 .unwrap_or(self.config.allow_passwordless);
486 let body: GetTotpUriRequest = match parse_password_body(req, allow_passwordless, false) {
487 Ok(v) => v,
488 Err(resp) => return Ok(resp),
489 };
490
491 let response = get_totp_uri_core(&body, &user, &self.config, ctx).await?;
492 AuthResponse::json(200, &response).map_err(AuthError::from)
493 }
494
495 async fn handle_verify_totp(
496 &self,
497 req: &AuthRequest,
498 ctx: &AuthContext<impl alibi_core::AuthSchema>,
499 ) -> AuthResult<AuthResponse> {
500 let body: VerifyTotpRequest = match alibi_core::validate_request_body(req) {
501 Ok(v) => v,
502 Err(resp) => return Ok(resp),
503 };
504
505 let (response, set_cookie_headers) =
506 match verify_totp_core(req, &body, &self.config, ctx).await {
507 Ok(result) => result,
508 Err(
509 TotpVerificationError::InvalidGeneration
510 | TotpVerificationError::SessionCreationCancelled,
511 ) => return Ok(AuthResponse::new(500)),
512 Err(TotpVerificationError::Auth(error)) => {
513 return verification_error_response(error, ctx);
514 }
515 };
516 let mut auth_response = AuthResponse::json(200, &response)?;
517 for cookie in set_cookie_headers {
518 auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
519 }
520 Ok(auth_response)
521 }
522
523 async fn handle_send_otp(
524 &self,
525 req: &AuthRequest,
526 ctx: &AuthContext<impl alibi_core::AuthSchema>,
527 ) -> AuthResult<AuthResponse> {
528 if let Err(response) = crate::authentication_helpers::parse_body::<SendOtpRequest>(req) {
529 return Ok(response);
530 }
531 let response = match send_otp_core(req, &self.config, ctx).await {
532 Ok(response) => response,
533 Err(SendOtpError::InvalidGeneration) => return Ok(AuthResponse::new(500)),
534 Err(SendOtpError::Auth(error)) => return Err(error),
535 };
536 AuthResponse::json(200, &response).map_err(AuthError::from)
537 }
538
539 async fn handle_verify_otp(
540 &self,
541 req: &AuthRequest,
542 ctx: &AuthContext<impl alibi_core::AuthSchema>,
543 ) -> AuthResult<AuthResponse> {
544 let body: VerifyOtpRequest = match alibi_core::validate_request_body(req) {
545 Ok(v) => v,
546 Err(resp) => return Ok(resp),
547 };
548
549 let (response, set_cookie_headers) =
550 match verify_otp_core(req, &body, &self.config, ctx).await {
551 Ok(result) => result,
552 Err(ExistingSessionFactorError::SessionCreationCancelled) => {
553 return Ok(AuthResponse::new(500));
554 }
555 Err(ExistingSessionFactorError::Auth(
557 error @ AuthError::Upstream {
558 code: "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE",
559 ..
560 },
561 )) => return Err(error),
562 Err(ExistingSessionFactorError::Auth(error)) => {
563 return verification_error_response(error, ctx);
564 }
565 };
566 let mut auth_response = AuthResponse::json(200, &response)?;
567 for cookie in set_cookie_headers {
568 auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
569 }
570 Ok(auth_response)
571 }
572
573 async fn handle_generate_backup_codes(
574 &self,
575 req: &AuthRequest,
576 ctx: &AuthContext<impl alibi_core::AuthSchema>,
577 ) -> AuthResult<AuthResponse> {
578 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
579 let allow_passwordless = self
580 .config
581 .backup_allow_passwordless
582 .unwrap_or(self.config.allow_passwordless);
583 let body: GenerateBackupCodesRequest =
584 match parse_password_body(req, allow_passwordless, false) {
585 Ok(v) => v,
586 Err(resp) => return Ok(resp),
587 };
588
589 let response = match generate_backup_codes_core(&body, &user, &self.config, ctx).await {
590 Ok(response) => response,
591 Err(BackupOperationError::InvalidGeneration) => return Ok(AuthResponse::new(500)),
592 Err(BackupOperationError::Auth(error)) => return Err(error),
593 };
594 AuthResponse::json(200, &response).map_err(AuthError::from)
595 }
596
597 async fn handle_verify_backup_code(
598 &self,
599 req: &AuthRequest,
600 ctx: &AuthContext<impl alibi_core::AuthSchema>,
601 ) -> AuthResult<AuthResponse> {
602 let body: VerifyBackupCodeRequest = match alibi_core::validate_request_body(req) {
603 Ok(v) => v,
604 Err(resp) => return Ok(resp),
605 };
606
607 let (response, set_cookie_headers) =
608 match verify_backup_code_core(req, &body, &self.config, ctx).await {
609 Ok(result) => result,
610 Err(error) => return verification_error_response(error, ctx),
611 };
612 let mut auth_response = AuthResponse::json(200, &response)?;
613 for cookie in set_cookie_headers {
614 auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
615 }
616 Ok(auth_response)
617 }
618}
619
620enum TotpVerificationError {
621 Auth(AuthError),
622 InvalidGeneration,
623 SessionCreationCancelled,
624}
625impl From<AuthError> for TotpVerificationError {
626 fn from(error: AuthError) -> Self {
627 Self::Auth(error)
628 }
629}
630
631enum SendOtpError {
632 Auth(AuthError),
633 InvalidGeneration,
635}
636
637impl From<AuthError> for SendOtpError {
638 fn from(error: AuthError) -> Self {
639 Self::Auth(error)
640 }
641}
642
643struct FactorAttempt {
644 identifier: String,
645 count: f64,
646 expires_at: chrono::DateTime<Utc>,
647}
648
649enum ExistingSessionFactorError {
652 Auth(AuthError),
653 SessionCreationCancelled,
654}
655
656impl From<AuthError> for ExistingSessionFactorError {
657 fn from(error: AuthError) -> Self {
658 Self::Auth(error)
659 }
660}
661
662impl ExistingSessionFactorError {
663 fn into_auth_error(self) -> AuthError {
664 match self {
665 Self::Auth(error) => error,
666 Self::SessionCreationCancelled => AuthError::SessionCreationCancelled,
667 }
668 }
669}
670
671enum BackupOperationError {
672 Auth(AuthError),
673 InvalidGeneration,
674}
675
676impl From<AuthError> for BackupOperationError {
677 fn from(error: AuthError) -> Self {
678 Self::Auth(error)
679 }
680}
681
682impl<S: alibi_core::AuthSchema> ResolvedTwoFactorState<S> {
683 fn user(&self) -> alibi_core::AuthenticatedUser<S> {
684 match self {
685 Self::Session { user, .. } => user.clone(),
686 Self::Pending(pending) => alibi_core::AuthenticatedUser::Stored(pending.user.clone()),
687 }
688 }
689
690 fn key(&self) -> &str {
691 match self {
692 Self::Session { key, .. } => key,
693 Self::Pending(pending) => &pending.key,
694 }
695 }
696}
697
698impl std::fmt::Debug for TwoFactorPlugin {
699 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700 f.debug_struct("TwoFactorPlugin").finish_non_exhaustive()
701 }
702}
703
704pub(crate) fn is_enabled(ctx: &AuthContext<impl alibi_core::AuthSchema>) -> bool {
705 ctx.get_metadata(METADATA_ENABLED)
706 .and_then(serde_json::Value::as_bool)
707 .unwrap_or(false)
708}
709
710const fn require_totp_enabled(config: &TwoFactorConfig) -> AuthResult<()> {
711 if config.totp_disabled {
712 return Err(AuthError::Upstream {
713 status: 400,
714 code: "TOTP_NOT_CONFIGURED",
715 message: "totp isn't configured",
716 });
717 }
718 Ok(())
719}
720
721#[cfg(test)]
723mod tests {
724 use super::cookies::sign_cookie_value;
725 use super::*;
726 use crate::test_helpers;
727 use alibi_core::wire::{SessionView, UserView};
728 use alibi_core::{AuthPlugin, AuthVerification};
729 use alibi_core::{CreateAccount, CreateUser, HttpMethod};
730 use chrono::Duration;
731 use cookie::Cookie;
732
733 type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
734
735 #[tokio::test]
736 #[expect(
737 clippy::too_many_lines,
738 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
739 )]
740 async fn skip_enrollment_hooks_retain_factor_generation_and_current_token_on_rejection() {
741 use alibi_core::{AuthConfig, CreateSession};
742 use alibi_seaorm::{
743 Database, DatabaseHooks, HookControl, SeaOrmBackend, SeaOrmHookContext, SeaOrmStore,
744 };
745 struct PolicyHook {
746 cancel_session: bool,
747 session_forbidden: bool,
748 observed: Arc<std::sync::Mutex<Vec<(String, String)>>>,
749 }
750 #[async_trait]
751 impl DatabaseHooks<TestSchema, SeaOrmBackend> for PolicyHook {
752 async fn before_update_user(
753 &self,
754 id: &str,
755 update: &mut UpdateUser,
756 context: &SeaOrmHookContext<'_>,
757 ) -> AuthResult<HookControl> {
758 if context
759 .request
760 .as_ref()
761 .is_some_and(|request| request.path.ends_with("/two-factor/enable"))
762 {
763 self.observed
764 .lock()
765 .unwrap()
766 .push(("user".into(), id.into()));
767 assert_eq!(update.two_factor_enabled, Some(true));
768 if !self.cancel_session {
769 return Err(AuthError::Upstream {
770 status: 400,
771 code: "USER_UPDATE_DENIED",
772 message: "Configured user update denied",
773 });
774 }
775 }
776 Ok(HookControl::Continue)
777 }
778 async fn before_create_session(
779 &self,
780 session: &mut CreateSession,
781 context: &SeaOrmHookContext<'_>,
782 ) -> AuthResult<HookControl> {
783 if context
784 .request
785 .as_ref()
786 .is_some_and(|request| request.path.ends_with("/two-factor/enable"))
787 {
788 self.observed
789 .lock()
790 .unwrap()
791 .push(("session".into(), session.user_id.clone()));
792 assert_eq!(
793 session.active_organization_id.as_deref(),
794 Some("retained-org")
795 );
796 assert_eq!(session.active_team_id.as_deref(), Some("retained-team"));
797 assert_eq!(session.impersonated_by.as_deref(), Some("retained-admin"));
798 assert_eq!(session.ip_address.as_deref(), Some("127.0.0.9"));
799 assert_eq!(session.user_agent.as_deref(), Some("retained-agent"));
800 return if self.session_forbidden {
801 Err(AuthError::forbidden(
802 "session creation cancelled by database hook",
803 ))
804 } else {
805 Ok(HookControl::Cancel)
806 };
807 }
808 Ok(HookControl::Continue)
809 }
810 }
811 let password_hash = alibi_core::hash_password(None, "password123")
812 .await
813 .unwrap();
814 for (cancel_session, session_forbidden) in [(false, false), (true, false), (true, true)] {
815 for existing in [false, true] {
816 let db = Database::connect("sqlite::memory:").await.unwrap();
817 alibi_seaorm::store::__private_test_support::migrator::run_migrations(&db)
818 .await
819 .unwrap();
820 let config = Arc::new(AuthConfig::new("skip-hook-secret-at-least-32-characters"));
821 let mut ctx = AuthContext::new(
822 Arc::clone(&config),
823 Arc::new(SeaOrmStore::<TestSchema>::new(
824 Arc::clone(&config),
825 db.clone(),
826 )),
827 );
828 let user = ctx
829 .database
830 .create_user(
831 CreateUser::new()
832 .with_name("Hook Owner")
833 .with_email("hook-owner@fixture.test"),
834 )
835 .await
836 .unwrap();
837 _ = ctx
838 .database
839 .create_account(CreateAccount {
840 additional_fields: alibi_core::field_policy::FieldValues::default(),
841 user_id: user.id.clone(),
842 account_id: user.id.clone(),
843 provider_id: "credential".into(),
844 password: Some(password_hash.clone()),
845 access_token: None,
846 refresh_token: None,
847 id_token: None,
848 access_token_expires_at: None,
849 refresh_token_expires_at: None,
850 scope: None,
851 })
852 .await
853 .unwrap();
854 let session = ctx
855 .database
856 .create_session(CreateSession {
857 token: None,
858 user_id: user.id.clone(),
859 expires_at: Utc::now() + ctx.config.session.expires_in,
860 active_organization_id: Some("retained-org".into()),
861 active_team_id: Some("retained-team".into()),
862 impersonated_by: Some("retained-admin".into()),
863 ip_address: Some("127.0.0.9".into()),
864 user_agent: Some("retained-agent".into()),
865 additional_fields: alibi_core::field_policy::FieldValues::default(),
866 })
867 .await
868 .unwrap();
869 let factor = if existing {
870 Some(
871 ctx.database
872 .create_two_factor(CreateTwoFactor {
873 user_id: user.id.clone(),
874 secret: encrypt_value(&ctx.config, "historical-secret").unwrap(),
875 backup_codes: encrypt_value(&ctx.config, "[\"historical-backup\"]")
876 .unwrap(),
877 verified: Some(false),
878 failed_verification_count: Some(0.5),
879 locked_until: Some(Utc::now() + Duration::minutes(1)),
880 })
881 .await
882 .unwrap(),
883 )
884 } else {
885 None
886 };
887 let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
888 ctx.database = Arc::new(
889 SeaOrmStore::<TestSchema>::new(config, db.clone()).with_hooks(vec![Arc::new(
890 PolicyHook {
891 cancel_session,
892 session_forbidden,
893 observed: Arc::clone(&observed),
894 },
895 )]),
896 );
897 let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
898 skip_verification_on_enable: true,
899 ..Default::default()
900 });
901 let mut init = alibi_core::AuthInitContext::new(
902 Arc::clone(&ctx.config),
903 Arc::clone(&ctx.database),
904 );
905 plugin.on_init(&mut init).await.unwrap();
906 crate::OrganizationPlugin::with_config(crate::organization::OrganizationConfig {
907 teams: crate::organization::TeamsConfig {
908 enabled: true,
909 ..Default::default()
910 },
911 ..Default::default()
912 })
913 .on_init(&mut init)
914 .await
915 .unwrap();
916 crate::AdminPlugin::new().on_init(&mut init).await.unwrap();
917 ctx.database = init.database_with_registered_transforms();
918 let parts = init.into_parts();
919 ctx.metadata = parts.metadata;
920 ctx.extensions = parts.extensions;
921 let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
922 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
923 _ = request
924 .headers
925 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
926 request.body = Some(br#"{"password":"password123"}"#.to_vec());
927 let result = alibi_core::with_request_hook_context(
928 &request,
929 plugin.on_request(&request, &ctx),
930 )
931 .await;
932 if cancel_session && !session_forbidden {
933 let response = result.unwrap().unwrap();
934 assert_eq!(response.status, 500);
935 assert_eq!(response.body.len(), 0);
936 } else {
937 let error = result.unwrap_err();
938 assert_eq!(error.status_code(), if cancel_session { 403 } else { 400 });
939 if session_forbidden {
940 assert!(matches!(error, AuthError::Forbidden(_)));
941 }
942 }
943 let expected = if cancel_session {
944 vec![
945 ("user".into(), user.id.clone()),
946 ("session".into(), user.id.clone()),
947 ]
948 } else {
949 vec![("user".into(), user.id.clone())]
950 };
951 assert_eq!(*observed.lock().unwrap(), expected);
952 assert_eq!(
953 serde_json::to_value(
954 ctx.database
955 .get_two_factor_by_user_id(&user.id)
956 .await
957 .unwrap()
958 )
959 .unwrap(),
960 serde_json::to_value(factor).unwrap()
961 );
962 let stored_user = ctx
963 .database
964 .get_user_by_id(&user.id)
965 .await
966 .unwrap()
967 .unwrap();
968 assert_eq!(stored_user.two_factor_enabled(), cancel_session);
969 let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
970 assert_eq!(sessions.len(), 1);
971 assert_eq!(
972 serde_json::to_value(
973 (sessions)
974 .first()
975 .expect("fixture contains the requested index")
976 )
977 .unwrap(),
978 serde_json::to_value(&session).unwrap()
979 );
980 let (current_user, current_session) = ctx.require_session(&request).await.unwrap();
981 assert_eq!(current_user.id, user.id);
982 assert_eq!(current_session.token, session.token);
983 db.close().await.unwrap();
984 }
985 }
986 }
987
988 #[tokio::test]
989 #[expect(
990 clippy::too_many_lines,
991 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
992 )]
993 async fn two_factor_password_checks_use_configured_native_hasher_and_utf16_maximum() {
994 use alibi_core::{PasswordHasher, ScryptHasher, UpdateAccount};
995 struct PrefixedHasher {
996 received: std::sync::Mutex<Vec<(String, String)>>,
997 }
998 #[async_trait]
999 impl PasswordHasher for PrefixedHasher {
1000 async fn hash(&self, password: &str) -> AuthResult<String> {
1001 ScryptHasher
1002 .hash(&format!("provider-prefix:{password}"))
1003 .await
1004 }
1005 async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
1006 self.received
1007 .lock()
1008 .unwrap()
1009 .push((hash.into(), password.into()));
1010 ScryptHasher
1011 .verify(hash, &format!("provider-prefix:{password}"))
1012 .await
1013 }
1014 }
1015 let (mut ctx, user, session) =
1016 create_test_context_with_credential_user("native-provider@fixture.test", false).await;
1017 let provider = Arc::new(PrefixedHasher {
1018 received: std::sync::Mutex::new(Vec::new()),
1019 });
1020 let hash = provider.hash("password123").await.unwrap();
1021 let account = ctx
1022 .database
1023 .get_user_accounts(&user.id)
1024 .await
1025 .unwrap()
1026 .into_iter()
1027 .find(|account| account.provider_id == "credential")
1028 .unwrap();
1029 _ = ctx
1030 .database
1031 .update_account(
1032 &account.id,
1033 UpdateAccount {
1034 password: Some(hash.clone()),
1035 ..Default::default()
1036 },
1037 )
1038 .await
1039 .unwrap();
1040 let plugin = TwoFactorPlugin::new();
1041 let mut init =
1042 alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1043 crate::EmailPasswordPlugin::new()
1044 .password_max_length(13)
1045 .password_hasher(Arc::<PrefixedHasher>::clone(&provider))
1046 .on_init(&mut init)
1047 .await
1048 .unwrap();
1049 plugin.on_init(&mut init).await.unwrap();
1050 let parts = init.into_parts();
1051 ctx.extensions = parts.extensions;
1052 ctx.metadata = parts.metadata;
1053 let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
1054 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
1055 _ = request
1056 .headers
1057 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
1058 request.body = Some(br#"{"password":"password123"}"#.to_vec());
1059 let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
1060 assert_eq!(response.status, 200);
1061 let factor = ctx
1062 .database
1063 .get_two_factor_by_user_id(&user.id)
1064 .await
1065 .unwrap()
1066 .unwrap();
1067 request.path = "/two-factor/get-totp-uri".into();
1068 request.body = Some(br#"{"password":"wrong-password"}"#.to_vec());
1069 let wrong = plugin.on_request(&request, &ctx).await.unwrap_err();
1070 assert_eq!(
1071 wrong.error_payload().1.as_deref(),
1072 Some("PASSWORD_TOO_LONG")
1073 );
1074 request.body = Some(br#"{"password":"wrong-pass"}"#.to_vec());
1076 let wrong_2 = plugin.on_request(&request, &ctx).await.unwrap_err();
1077 assert_eq!(
1078 wrong_2.error_payload().1.as_deref(),
1079 Some("INVALID_PASSWORD")
1080 );
1081 request.body =
1082 Some(serde_json::to_vec(&serde_json::json!({"password":"🍵".repeat(7)})).unwrap());
1083 let long = plugin.on_request(&request, &ctx).await.unwrap_err();
1084 assert_eq!(long.error_payload().1.as_deref(), Some("PASSWORD_TOO_LONG"));
1085 assert_eq!(
1086 *provider.received.lock().unwrap(),
1087 vec![
1088 (hash.clone(), "password123".into()),
1089 (hash, "wrong-pass".into())
1090 ]
1091 );
1092 assert_eq!(
1093 serde_json::to_value(
1094 ctx.database
1095 .get_two_factor_by_user_id(&user.id)
1096 .await
1097 .unwrap()
1098 .unwrap()
1099 )
1100 .unwrap(),
1101 serde_json::to_value(factor).unwrap()
1102 );
1103 assert!(
1104 ctx.database
1105 .get_session(&session.token)
1106 .await
1107 .unwrap()
1108 .is_some()
1109 );
1110 }
1111
1112 #[tokio::test]
1113 async fn signed_empty_factor_challenge_cannot_read_a_seeded_empty_identifier() {
1114 let (ctx, user, _) =
1115 create_test_context_with_credential_user("empty-challenge@fixture.test", true).await;
1116 let seeded = ctx
1117 .database
1118 .create_verification(CreateVerification {
1119 identifier: String::new(),
1120 value: user.id.clone(),
1121 expires_at: Utc::now() + Duration::minutes(5),
1122 })
1123 .await
1124 .unwrap();
1125 let signed = alibi_core::utils::cookie_utils::sign_cookie_value("", &ctx.config.secret);
1126 assert_eq!(
1127 alibi_core::utils::cookie_utils::verify_cookie_value(&signed, &ctx.config.secret),
1128 Some(String::new())
1129 );
1130 let mut req = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-otp");
1131 _ = req.headers.insert(
1132 "cookie".into(),
1133 format!(
1134 "{}={signed}",
1135 related_cookie_name(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX)
1136 ),
1137 );
1138 let error = resolve_two_factor_state(&req, &ctx)
1139 .await
1140 .err()
1141 .expect("An empty signed challenge must be rejected");
1142 assert_eq!(error.status_code(), 401);
1143 let untouched = ctx
1144 .database
1145 .get_verification_by_identifier("")
1146 .await
1147 .unwrap()
1148 .unwrap();
1149 assert_eq!(untouched.id(), seeded.id());
1150 assert_eq!(untouched.value(), seeded.value());
1151 assert_eq!(untouched.expires_at(), seeded.expires_at());
1152 assert_eq!(
1153 ctx.database
1154 .get_user_sessions(&user.id)
1155 .await
1156 .unwrap()
1157 .len(),
1158 1
1159 );
1160 }
1161
1162 #[tokio::test]
1163 async fn pending_factor_preferences_authenticate_the_first_cookie_and_preserve_issued_expiry() {
1164 let (ctx, user, _) =
1165 create_test_context_with_credential_user("preferences@fixture.test", true).await;
1166 let preference_name = related_cookie_name(&ctx.config, DONT_REMEMBER_COOKIE_SUFFIX);
1167 let empty = alibi_core::utils::cookie_utils::sign_cookie_value("", &ctx.config.secret);
1168 let signed = alibi_core::utils::cookie_utils::sign_cookie_value("true", &ctx.config.secret);
1169 let foreign = alibi_core::utils::cookie_utils::sign_cookie_value("true", "foreign-secret");
1170 for (preference, temporary) in [
1171 (empty.clone(), false),
1172 (signed.clone(), true),
1173 (foreign, false),
1174 ("true".to_owned(), false),
1175 (format!("{empty}; {preference_name}={signed}"), false),
1176 (format!("{signed}; {preference_name}={empty}"), true),
1177 ] {
1178 let challenge = begin_sign_in_challenge(&user, None, &ctx).await.unwrap();
1179 let challenge_cookie = challenge
1180 .set_cookie_headers
1181 .iter()
1182 .find(|header| {
1183 header.starts_with(&format!(
1184 "{}=",
1185 related_cookie_name(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX)
1186 ))
1187 })
1188 .unwrap()
1189 .split(';')
1190 .next()
1191 .unwrap();
1192 let mut req = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-otp");
1193 _ = req.headers.insert(
1194 "cookie".into(),
1195 format!("{challenge_cookie}; {preference_name}={preference}"),
1196 );
1197 let ResolvedTwoFactorState::Pending(pending) =
1198 resolve_two_factor_state(&req, &ctx).await.unwrap()
1199 else {
1200 panic!("A signed pending challenge must resolve without a session cookie");
1201 };
1202 assert_eq!(pending.dont_remember, temporary);
1203 let (completed, headers) =
1204 finalize_pending_two_factor(pending, &req, false, true, &ctx)
1205 .await
1206 .unwrap();
1207 let before = ctx
1208 .database
1209 .get_session(&completed.token)
1210 .await
1211 .unwrap()
1212 .unwrap();
1213 let lifetime = before.expires_at() - before.created_at();
1214 assert!(
1215 (lifetime - Duration::days(if temporary { 1 } else { 7 }))
1216 .num_milliseconds()
1217 .abs()
1218 < 1000
1219 );
1220 let mut read = AuthRequest::new(HttpMethod::Get, "/get-session");
1221 let cookies = headers
1222 .iter()
1223 .filter(|header| !header.contains("Max-Age=0"))
1224 .map(|header| header.split(';').next().unwrap())
1225 .collect::<Vec<_>>()
1226 .join("; ");
1227 _ = read.headers.insert("cookie".into(), cookies);
1228 let (authenticated_user, authenticated_session) =
1229 ctx.require_session(&read).await.unwrap();
1230 assert_eq!(authenticated_user.id(), user.id);
1231 assert_eq!(authenticated_session.token, completed.token);
1232 assert_eq!(authenticated_session.expires_at, before.expires_at());
1233 let after = ctx
1234 .database
1235 .get_session(&completed.token)
1236 .await
1237 .unwrap()
1238 .unwrap();
1239 assert_eq!(after.expires_at(), before.expires_at());
1240 assert_eq!(after.updated_at(), before.updated_at());
1241 }
1242 }
1243
1244 async fn create_test_context_with_credential_user(
1245 email: &str,
1246 two_factor_enabled: bool,
1247 ) -> (AuthContext<TestSchema>, UserView, SessionView) {
1248 let mut ctx = test_helpers::create_test_context().await;
1249 let mut init =
1250 alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1251 TwoFactorPlugin::new().on_init(&mut init).await.unwrap();
1252 ctx.database = init.database_with_registered_transforms();
1253 let parts = init.into_parts();
1254 ctx.metadata = parts.metadata;
1255 ctx.extensions = parts.extensions;
1256 let user = test_helpers::create_user(
1257 &ctx,
1258 CreateUser::new()
1259 .with_email(email)
1260 .with_name("Two Factor Tester"),
1261 )
1262 .await;
1263
1264 let password_hash = alibi_core::hash_password(None, "password123")
1265 .await
1266 .unwrap();
1267 _ = ctx
1268 .database
1269 .create_account(CreateAccount {
1270 additional_fields: alibi_core::field_policy::FieldValues::default(),
1271 user_id: user.id.clone(),
1272 account_id: user.id.clone(),
1273 provider_id: "credential".to_owned(),
1274 access_token: None,
1275 refresh_token: None,
1276 id_token: None,
1277 access_token_expires_at: None,
1278 refresh_token_expires_at: None,
1279 scope: None,
1280 password: Some(password_hash),
1281 })
1282 .await
1283 .unwrap();
1284
1285 let user = if two_factor_enabled {
1286 UserView::from(
1287 &ctx.database
1288 .update_user(
1289 &user.id,
1290 UpdateUser {
1291 two_factor_enabled: Some(true),
1292 ..Default::default()
1293 },
1294 )
1295 .await
1296 .unwrap(),
1297 )
1298 } else {
1299 user
1300 };
1301
1302 let session = test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(1)).await;
1303 (ctx, user, session)
1304 }
1305
1306 fn cookie_value(header: &str) -> String {
1307 Cookie::parse(header)
1308 .expect("Set-Cookie header should parse")
1309 .value()
1310 .to_owned()
1311 }
1312
1313 #[test]
1314 fn test_signed_cookie_round_trip_and_tamper_rejection() {
1315 let signed = sign_cookie_value("secret-value", "payload-value");
1316 let verified = verify_factor_cookie_value("secret-value", &signed);
1317 assert_eq!(verified.as_deref(), Some("payload-value"));
1318
1319 let tampered = signed.replacen("payload-value", "other-value", 1);
1320 let tampered_verified = verify_factor_cookie_value("secret-value", &tampered);
1321 assert!(tampered_verified.is_none());
1322 }
1323
1324 #[tokio::test]
1325 async fn test_begin_sign_in_challenge_sets_pending_cookie_and_remember_choice() {
1326 let (ctx, user, _session) =
1327 create_test_context_with_credential_user("challenge@example.com", true).await;
1328
1329 let challenge = begin_sign_in_challenge(&user, Some(false), &ctx)
1330 .await
1331 .unwrap();
1332 assert!(challenge.response.two_factor_redirect);
1333
1334 let two_factor_cookie = challenge
1335 .set_cookie_headers
1336 .iter()
1337 .find(|header| header.starts_with("better-auth.two_factor="))
1338 .cloned()
1339 .expect("challenge should set the two-factor cookie");
1340 let dont_remember_cookie = challenge
1341 .set_cookie_headers
1342 .iter()
1343 .find(|header| header.starts_with("better-auth.dont_remember="))
1344 .cloned()
1345 .expect("challenge should set the remember-choice cookie");
1346
1347 let two_factor_req = test_helpers::create_auth_request_no_query(
1348 HttpMethod::Post,
1349 "/two-factor/verify-otp",
1350 None,
1351 None,
1352 );
1353 let mut req = two_factor_req;
1354 req.headers.insert(
1355 "cookie".to_owned(),
1356 format!(
1357 "better-auth.two_factor={}; better-auth.dont_remember={}",
1358 cookie_value(&two_factor_cookie),
1359 cookie_value(&dont_remember_cookie)
1360 ),
1361 );
1362
1363 let identifier = read_signed_cookie(&req, TWO_FACTOR_COOKIE_SUFFIX, &ctx)
1364 .expect("signed cookie should verify");
1365 let verification = ctx
1366 .database
1367 .get_verification_by_identifier(&identifier)
1368 .await
1369 .unwrap()
1370 .expect("challenge should persist a pending verification");
1371 assert_eq!(verification.value(), user.id);
1372 }
1373
1374 #[tokio::test]
1375 async fn test_inspect_trusted_device_rotates_server_state() {
1376 let (ctx, user, _session) =
1377 create_test_context_with_credential_user("trusted@example.com", true).await;
1378
1379 let trust_cookie = create_trust_device_cookie_header(&user, &ctx)
1380 .await
1381 .unwrap();
1382 let mut req = test_helpers::create_auth_request_no_query(
1383 HttpMethod::Post,
1384 "/sign-in/email",
1385 None,
1386 None,
1387 );
1388 req.headers.insert(
1389 "cookie".to_owned(),
1390 format!("better-auth.trust_device={}", cookie_value(&trust_cookie)),
1391 );
1392
1393 let original_cookie = read_signed_cookie(&req, TRUST_DEVICE_COOKIE_SUFFIX, &ctx)
1394 .expect("trust cookie should verify");
1395 let original_identifier = original_cookie
1396 .split_once('!')
1397 .expect("trust cookie should include the identifier")
1398 .1
1399 .to_owned();
1400
1401 let result = inspect_trusted_device(&req, &user, &ctx).await.unwrap();
1402 assert!(result.trusted);
1403 assert_eq!(result.set_cookie_headers.len(), 1);
1404
1405 let rotated_cookie = (*(result.set_cookie_headers)
1406 .first()
1407 .expect("fixture contains the requested index"))
1408 .clone();
1409 let mut rotated_req = test_helpers::create_auth_request_no_query(
1410 HttpMethod::Post,
1411 "/sign-in/email",
1412 None,
1413 None,
1414 );
1415 rotated_req.headers.insert(
1416 "cookie".to_owned(),
1417 format!("better-auth.trust_device={}", cookie_value(&rotated_cookie)),
1418 );
1419 let rotated_value = read_signed_cookie(&rotated_req, TRUST_DEVICE_COOKIE_SUFFIX, &ctx)
1420 .expect("rotated trust cookie should verify");
1421 let rotated_identifier = rotated_value
1422 .split_once('!')
1423 .expect("rotated cookie should include the identifier")
1424 .1
1425 .to_owned();
1426
1427 assert_ne!(original_identifier, rotated_identifier);
1428 assert!(
1429 ctx.database
1430 .get_verification_by_identifier(&original_identifier)
1431 .await
1432 .unwrap()
1433 .is_none(),
1434 "the previous trust-device record should be deleted during rotation",
1435 );
1436 assert!(
1437 ctx.database
1438 .get_verification_by_identifier(&rotated_identifier)
1439 .await
1440 .unwrap()
1441 .is_some(),
1442 "the rotated trust-device record should be persisted",
1443 );
1444 }
1445
1446 #[tokio::test]
1447 #[expect(
1448 clippy::too_many_lines,
1449 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1450 )]
1451 async fn totp_enrollment_and_real_verification_apply_user_hooks_and_preserve_rotation_owner() {
1452 let (mut ctx, user, session) =
1453 create_test_context_with_credential_user("reissue@example.com", false).await;
1454 ctx.database.delete_session(&session.token).await.unwrap();
1455 let session = ctx
1456 .database
1457 .create_session(alibi_core::CreateSession {
1458 token: None,
1459 user_id: user.id.clone(),
1460 expires_at: session.expires_at,
1461 ip_address: Some("127.0.0.7".into()),
1462 user_agent: Some("configured-agent".into()),
1463 active_organization_id: Some("configured-organization".into()),
1464 active_team_id: Some("configured-team".into()),
1465 impersonated_by: Some("configured-admin".into()),
1466 additional_fields: alibi_core::field_policy::FieldValues::default(),
1467 })
1468 .await
1469 .unwrap();
1470 let plugin = TwoFactorPlugin::new();
1471 let mut configured =
1472 alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1473 plugin.on_init(&mut configured).await.unwrap();
1474 crate::OrganizationPlugin::with_config(crate::organization::OrganizationConfig {
1475 teams: crate::organization::TeamsConfig {
1476 enabled: true,
1477 ..Default::default()
1478 },
1479 ..Default::default()
1480 })
1481 .on_init(&mut configured)
1482 .await
1483 .unwrap();
1484 crate::AdminPlugin::new()
1485 .on_init(&mut configured)
1486 .await
1487 .unwrap();
1488 let configured = configured.into_parts();
1489 ctx.metadata = configured.metadata;
1490 ctx.extensions = configured.extensions;
1491 let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
1492 let mut enrollment = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
1493 _ = enrollment
1494 .headers
1495 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
1496 enrollment.body = Some(br#"{"password":"password123"}"#.to_vec());
1497 let enabled = plugin.on_request(&enrollment, &ctx).await.unwrap().unwrap();
1498 assert_eq!(enabled.status, 200);
1499 let factor = ctx
1500 .database
1501 .get_two_factor_by_user_id(&user.id)
1502 .await
1503 .unwrap()
1504 .unwrap();
1505 assert_eq!(factor.verified, Some(false));
1506
1507 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1508 let observed = Arc::clone(&captured);
1509 let mut init =
1510 alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1511 init.metadata = ctx.metadata.clone();
1514 init.extensions = ctx.extensions.clone();
1515 init.register_user_update_transform(move |id, mut update| {
1516 observed
1517 .lock()
1518 .unwrap()
1519 .push((id.to_owned(), update.two_factor_enabled));
1520 if update.two_factor_enabled == Some(true) {
1521 update.name = Some("Hook Updated Owner".into());
1522 }
1523 Ok(update)
1524 });
1525 ctx.database = init.database_with_registered_transforms();
1526 let plaintext = decrypt_value(&ctx.config, &factor.secret).unwrap();
1527 let code = plugin.generate_totp(&plaintext).unwrap();
1528 let mut verification = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-totp");
1529 verification.headers = enrollment.headers;
1530 verification.body = Some(serde_json::to_vec(&serde_json::json!({"code":code})).unwrap());
1531 let response = plugin
1532 .on_request(&verification, &ctx)
1533 .await
1534 .unwrap()
1535 .unwrap();
1536 assert_eq!(response.status, 200);
1537 let payload: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1538 assert_eq!(
1539 (*(*(payload).get("user").unwrap_or(&serde_json::Value::Null))
1540 .get("twoFactorEnabled")
1541 .unwrap_or(&serde_json::Value::Null)),
1542 false
1543 );
1544 assert_eq!(
1546 (*(payload).get("token").unwrap_or(&serde_json::Value::Null)),
1547 session.token
1548 );
1549 let set_cookie_headers = response.headers.get_all("Set-Cookie").collect::<Vec<_>>();
1550 let rotated = alibi_core::utils::cookie_utils::verify_cookie_value(
1551 &cookie_value(
1552 (set_cookie_headers)
1553 .first()
1554 .expect("fixture contains the requested index"),
1555 ),
1556 &ctx.config.secret,
1557 )
1558 .expect("the session cookie must authenticate its token");
1559 assert_ne!(rotated, session.token);
1560 assert_eq!(set_cookie_headers.len(), 1);
1561 assert!(
1562 ctx.database
1563 .get_session(&session.token)
1564 .await
1565 .unwrap()
1566 .is_none(),
1567 "the original session should be deleted after re-issuing",
1568 );
1569 assert!(
1570 ctx.database.get_session(&rotated).await.unwrap().is_some(),
1571 "the new session token should be persisted",
1572 );
1573 assert_eq!(
1574 *captured.lock().unwrap(),
1575 vec![(user.id.clone(), Some(true))]
1576 );
1577 let stored_user = ctx
1578 .database
1579 .get_user_by_id(&user.id)
1580 .await
1581 .unwrap()
1582 .unwrap();
1583 assert_eq!(stored_user.two_factor_enabled, Some(true));
1584 assert_eq!(stored_user.name.as_deref(), Some("Hook Updated Owner"));
1585 let verified_factor = ctx
1586 .database
1587 .get_two_factor_by_user_id(&user.id)
1588 .await
1589 .unwrap()
1590 .unwrap();
1591 assert_eq!(verified_factor.id, factor.id);
1592 assert_eq!(verified_factor.secret, factor.secret);
1593 assert_eq!(verified_factor.backup_codes, factor.backup_codes);
1594 assert_eq!(verified_factor.verified, Some(true));
1595 let mut browser = AuthRequest::new(HttpMethod::Get, "/get-session");
1596 _ = browser.headers.insert(
1597 "cookie".into(),
1598 (*(set_cookie_headers)
1599 .first()
1600 .expect("fixture contains the requested index"))
1601 .split(';')
1602 .next()
1603 .unwrap()
1604 .into(),
1605 );
1606 let (current_user, current_session) = ctx.require_session(&browser).await.unwrap();
1607 assert_eq!(current_user.id, user.id);
1608 assert_eq!(current_user.name.as_deref(), Some("Hook Updated Owner"));
1609 assert_eq!(current_session.token, rotated);
1610 assert_eq!(current_session.ip_address, session.ip_address);
1611 assert_eq!(current_session.user_agent, session.user_agent);
1612 assert_eq!(
1613 current_session.active_organization_id,
1614 session.active_organization_id
1615 );
1616 assert_eq!(current_session.active_team_id, session.active_team_id);
1617 assert_eq!(current_session.impersonated_by, session.impersonated_by);
1618 }
1619
1620 #[tokio::test]
1621 async fn test_view_backup_codes_returns_decrypted_codes() {
1622 let plugin = TwoFactorPlugin::new();
1623 let (ctx, user, _session) =
1624 create_test_context_with_credential_user("view-codes@example.com", true).await;
1625
1626 let expected_codes = vec!["ABCDE-12345".to_owned(), "FGHIJ-67890".to_owned()];
1627 let encrypted = encrypt_value(
1628 &ctx.config,
1629 &serde_json::to_string(&expected_codes).unwrap(),
1630 )
1631 .unwrap();
1632 _ = ctx
1633 .database
1634 .create_two_factor(CreateTwoFactor {
1635 user_id: user.id.clone(),
1636 secret: encrypt_value(&ctx.config, "totp-secret").unwrap(),
1637 backup_codes: encrypted,
1638 ..Default::default()
1639 })
1640 .await
1641 .unwrap();
1642
1643 let backup_codes = plugin.view_backup_codes(&user.id, &ctx).await.unwrap();
1644 assert_eq!(backup_codes, serde_json::json!(expected_codes));
1645 }
1646
1647 #[tokio::test]
1648 async fn test_view_backup_codes_rejects_invalid_stored_json() {
1649 let plugin = TwoFactorPlugin::new();
1650 let (ctx, user, _session) =
1651 create_test_context_with_credential_user("invalid-view-codes@example.com", true).await;
1652
1653 _ = ctx
1654 .database
1655 .create_two_factor(CreateTwoFactor {
1656 user_id: user.id.clone(),
1657 secret: encrypt_value(&ctx.config, "totp-secret").unwrap(),
1658 backup_codes: encrypt_value(&ctx.config, "[").unwrap(),
1659 ..Default::default()
1660 })
1661 .await
1662 .unwrap();
1663
1664 let err = plugin.view_backup_codes(&user.id, &ctx).await.unwrap_err();
1665 assert_eq!(err.to_string(), "Invalid backup code");
1666 }
1667
1668 #[test]
1669 fn test_routes_do_not_expose_view_backup_codes() {
1670 let plugin = TwoFactorPlugin::new();
1671 assert!(
1672 <TwoFactorPlugin as AuthPlugin<TestSchema>>::routes(&plugin)
1673 .iter()
1674 .all(|route| route.path != "/two-factor/view-backup-codes"),
1675 "view-backup-codes must stay server-only",
1676 );
1677 }
1678
1679 #[tokio::test]
1680 #[expect(
1681 clippy::too_many_lines,
1682 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1683 )]
1684 async fn disable_preserves_persisted_extensions_and_removes_all_matching_trust_records() {
1685 let (mut ctx, user, first_session) =
1686 create_test_context_with_credential_user("disable-extensions@fixture.test", true).await;
1687 let mut init =
1688 alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1689 crate::admin::AdminPlugin::new()
1690 .on_init(&mut init)
1691 .await
1692 .unwrap();
1693 crate::organization::OrganizationPlugin::with_config(
1694 crate::organization::OrganizationConfig {
1695 teams: crate::organization::TeamsConfig {
1696 enabled: true,
1697 ..Default::default()
1698 },
1699 ..Default::default()
1700 },
1701 )
1702 .on_init(&mut init)
1703 .await
1704 .unwrap();
1705 ctx.metadata.extend(init.into_parts().metadata);
1706 ctx.database
1707 .delete_session(&first_session.token)
1708 .await
1709 .unwrap();
1710 let current = ctx
1711 .database
1712 .create_session(alibi_core::CreateSession {
1713 token: None,
1714 user_id: user.id.clone(),
1715 expires_at: Utc::now() + Duration::hours(1),
1716 ip_address: Some("192.0.2.45".to_owned()),
1717 user_agent: Some("fixture-agent".to_owned()),
1718 impersonated_by: Some("trusted-impersonator".to_owned()),
1719 active_organization_id: Some("trusted-organization".to_owned()),
1720 active_team_id: Some("trusted-team".to_owned()),
1721 additional_fields: alibi_core::field_policy::FieldValues::default(),
1722 })
1723 .await
1724 .unwrap();
1725 ctx.database
1726 .create_two_factor(CreateTwoFactor {
1727 user_id: user.id.clone(),
1728 secret: "stored-secret".to_owned(),
1729 backup_codes: "stored-codes".to_owned(),
1730 ..Default::default()
1731 })
1732 .await
1733 .unwrap();
1734 for _ in 0..2 {
1735 ctx.database
1736 .create_verification(CreateVerification {
1737 identifier: "trusted-device-record".to_owned(),
1738 value: user.id.clone(),
1739 expires_at: Utc::now() + Duration::days(30),
1740 })
1741 .await
1742 .unwrap();
1743 }
1744 let session_cookie =
1745 alibi_core::utils::cookie_utils::sign_cookie_value(current.token(), &ctx.config.secret);
1746 let trust_cookie = alibi_core::utils::cookie_utils::sign_cookie_value(
1747 "trust-token!trusted-device-record",
1748 &ctx.config.secret,
1749 );
1750 let mut req = AuthRequest::new(HttpMethod::Post, "/two-factor/disable");
1751 req.body =
1752 Some(serde_json::to_vec(&serde_json::json!({"password":"password123"})).unwrap());
1753 _ = req.headers.insert(
1754 "cookie".to_owned(),
1755 format!(
1756 "{}={session_cookie}; {}={trust_cookie}",
1757 ctx.config.session.cookie_name,
1758 related_cookie_name(&ctx.config, TRUST_DEVICE_COOKIE_SUFFIX)
1759 ),
1760 );
1761 let response = TwoFactorPlugin::new()
1762 .on_request(&req, &ctx)
1763 .await
1764 .unwrap()
1765 .unwrap();
1766 assert_eq!(response.status, 200);
1767 let stored = ctx.database.get_user_sessions(&user.id).await.unwrap();
1768 assert_eq!(stored.len(), 1);
1769 let replacement = (stored)
1770 .first()
1771 .expect("fixture contains the requested index");
1772 assert_ne!(replacement.token(), current.token());
1773 assert_eq!(
1774 replacement.active_organization_id(),
1775 Some("trusted-organization")
1776 );
1777 assert_eq!(replacement.active_team_id(), Some("trusted-team"));
1778 assert_eq!(replacement.impersonated_by(), Some("trusted-impersonator"));
1779 assert_eq!(replacement.ip_address(), current.ip_address());
1780 assert_eq!(replacement.user_agent(), current.user_agent());
1781 assert!(
1782 ctx.database
1783 .get_session(current.token())
1784 .await
1785 .unwrap()
1786 .is_none()
1787 );
1788 assert!(
1789 !ctx.database
1790 .get_user_by_id(&user.id)
1791 .await
1792 .unwrap()
1793 .unwrap()
1794 .two_factor_enabled()
1795 );
1796 assert!(
1797 ctx.database
1798 .get_two_factor_by_user_id(&user.id)
1799 .await
1800 .unwrap()
1801 .is_none()
1802 );
1803 assert!(
1804 ctx.database
1805 .get_verification_by_identifier("trusted-device-record")
1806 .await
1807 .unwrap()
1808 .is_none()
1809 );
1810 }
1811
1812 #[tokio::test]
1813 #[expect(
1814 clippy::too_many_lines,
1815 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1816 )]
1817 async fn otp_async_codec_failures_consume_only_their_stage_and_delivery_rejection_retains_rotation_state()
1818 {
1819 struct Callback {
1820 fail_store: bool,
1821 fail_compare: bool,
1822 observations: Arc<std::sync::Mutex<Vec<String>>>,
1823 delivered: Arc<std::sync::Mutex<Option<String>>>,
1824 }
1825 #[async_trait]
1826 impl TwoFactorOtpHasher for Callback {
1827 async fn hash(&self, otp: &str) -> AuthResult<String> {
1828 let mut observations = self.observations.lock().unwrap();
1829 observations.push(format!("hash:{otp}"));
1830 if self.fail_store || (self.fail_compare && observations.len() > 2) {
1831 return Err(AuthError::Upstream {
1832 status: 400,
1833 code: "CODEC_REJECTED",
1834 message: "Configured codec rejected",
1835 });
1836 }
1837 drop(observations);
1838
1839 Ok(format!("stored-{otp}"))
1840 }
1841 }
1842 #[async_trait]
1843 impl SendTwoFactorOtp for Callback {
1844 async fn send(&self, _user: &UserView, otp: &str) -> AuthResult<()> {
1845 self.observations
1846 .lock()
1847 .unwrap()
1848 .push(format!("send:{otp}"));
1849 *self.delivered.lock().unwrap() = Some(otp.to_owned());
1850 Err(AuthError::bad_request("Configured async delivery rejected"))
1851 }
1852 }
1853 for (fail_store, fail_compare) in [(true, false), (false, true), (false, false)] {
1854 let (mut ctx, user, original) =
1855 create_test_context_with_credential_user("codec@fixture.test", false).await;
1856 ctx.database.delete_session(&original.token).await.unwrap();
1857 let session = ctx
1858 .database
1859 .create_session(alibi_core::CreateSession {
1860 additional_fields: alibi_core::field_policy::FieldValues::default(),
1861 token: None,
1862 user_id: user.id.clone(),
1863 expires_at: original.expires_at,
1864 ip_address: Some("127.0.0.8".into()),
1865 user_agent: Some("otp-agent".into()),
1866 active_organization_id: Some("otp-org".into()),
1867 active_team_id: Some("otp-team".into()),
1868 impersonated_by: Some("otp-admin".into()),
1869 })
1870 .await
1871 .unwrap();
1872 let observations = Arc::new(std::sync::Mutex::new(Vec::new()));
1873 let delivered = Arc::new(std::sync::Mutex::new(None));
1874 let callback = Arc::new(Callback {
1875 fail_store,
1876 fail_compare,
1877 observations: Arc::clone(&observations),
1878 delivered: Arc::clone(&delivered),
1879 });
1880 let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
1881 send_otp: Some(Arc::<Callback>::clone(&callback)),
1882 otp_storage: TwoFactorOtpStorage::CustomHash(callback),
1883 ..Default::default()
1884 });
1885 let mut init = alibi_core::AuthInitContext::new(
1886 Arc::clone(&ctx.config),
1887 Arc::clone(&ctx.database),
1888 );
1889 plugin.on_init(&mut init).await.unwrap();
1890 crate::OrganizationPlugin::with_config(crate::organization::OrganizationConfig {
1891 teams: crate::organization::TeamsConfig {
1892 enabled: true,
1893 ..Default::default()
1894 },
1895 ..Default::default()
1896 })
1897 .on_init(&mut init)
1898 .await
1899 .unwrap();
1900 crate::AdminPlugin::new().on_init(&mut init).await.unwrap();
1901
1902 init.register_user_update_transform(|_, mut update| {
1903 if update.two_factor_enabled == Some(true) {
1904 update.name = Some("OTP Hook Owner".into());
1905 }
1906 Ok(update)
1907 });
1908 ctx.database = init.database_with_registered_transforms();
1909 let parts = init.into_parts();
1910 ctx.metadata = parts.metadata;
1911 ctx.extensions = parts.extensions;
1912
1913 let identifier = format!("2fa-otp-{}!{}", user.id, session.id);
1914 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/send-otp");
1915 request.headers.insert(
1916 "cookie".into(),
1917 create_session_cookie(&session.token, &ctx.config)
1918 .unwrap()
1919 .split(';')
1920 .next()
1921 .unwrap()
1922 .into(),
1923 );
1924 request.body = Some(b"{}".to_vec());
1925 let result = plugin.on_request(&request, &ctx).await;
1926 if fail_store {
1927 assert!(matches!(
1928 result,
1929 Err(AuthError::Upstream {
1930 code: "CODEC_REJECTED",
1931 ..
1932 })
1933 ));
1934 assert!(delivered.lock().unwrap().is_none());
1935 assert!(
1936 ctx.database
1937 .get_latest_verification_by_identifier(&identifier)
1938 .await
1939 .unwrap()
1940 .is_none()
1941 );
1942 } else {
1943 assert_eq!(
1944 result.unwrap().unwrap().status,
1945 200,
1946 "async delivery failure must retain issued state"
1947 );
1948 let otp = delivered
1949 .lock()
1950 .unwrap()
1951 .clone()
1952 .expect("actual callback delivery");
1953 let stored = ctx
1954 .database
1955 .get_latest_verification_by_identifier(&identifier)
1956 .await
1957 .unwrap()
1958 .unwrap();
1959 assert_eq!(stored.value, format!("stored-{otp}:0"));
1960 request.path = "/two-factor/verify-otp".into();
1961 request.body = Some(serde_json::to_vec(&serde_json::json!({"code":otp})).unwrap());
1962 let result_2 = plugin.on_request(&request, &ctx).await;
1963 assert!(
1964 ctx.database
1965 .get_latest_verification_by_identifier(&identifier)
1966 .await
1967 .unwrap()
1968 .is_none()
1969 );
1970 if fail_compare {
1971 assert!(matches!(
1972 result_2,
1973 Err(AuthError::Upstream {
1974 code: "CODEC_REJECTED",
1975 ..
1976 })
1977 ));
1978 assert!(
1979 !ctx.database
1980 .get_user_by_id(&user.id)
1981 .await
1982 .unwrap()
1983 .unwrap()
1984 .two_factor_enabled()
1985 );
1986 assert!(
1987 ctx.database
1988 .get_session(&session.token)
1989 .await
1990 .unwrap()
1991 .is_some()
1992 );
1993 } else {
1994 let response = result_2.unwrap().unwrap();
1995 assert_eq!(response.status, 200);
1996 let payload: serde_json::Value =
1997 serde_json::from_slice(&response.body).unwrap();
1998 assert_eq!(
1999 (*(*(payload).get("user").unwrap_or(&serde_json::Value::Null))
2000 .get("name")
2001 .unwrap_or(&serde_json::Value::Null)),
2002 "OTP Hook Owner"
2003 );
2004 assert_eq!(
2005 (*(*(payload).get("user").unwrap_or(&serde_json::Value::Null))
2006 .get("twoFactorEnabled")
2007 .unwrap_or(&serde_json::Value::Null)),
2008 true
2009 );
2010 let token = (*(payload).get("token").unwrap_or(&serde_json::Value::Null))
2011 .as_str()
2012 .unwrap();
2013 assert_ne!(token, session.token);
2014 let new = ctx.database.get_session(token).await.unwrap().unwrap();
2015 assert_eq!(new.user_id, session.user_id);
2016 assert_eq!(new.ip_address, session.ip_address);
2017 assert_eq!(new.user_agent, session.user_agent);
2018 assert_eq!(new.active_organization_id, session.active_organization_id);
2019 assert_eq!(new.active_team_id, session.active_team_id);
2020 assert_eq!(new.impersonated_by, session.impersonated_by);
2021 assert!(
2022 ctx.database
2023 .get_session(&session.token)
2024 .await
2025 .unwrap()
2026 .is_none()
2027 );
2028 let header = response.headers.get_all("Set-Cookie").next().unwrap();
2029 assert_eq!(
2030 alibi_core::utils::cookie_utils::verify_cookie_value(
2031 &cookie_value(header),
2032 &ctx.config.secret
2033 )
2034 .as_deref(),
2035 Some(token)
2036 );
2037 }
2038 assert_eq!(
2039 observations.lock().unwrap().as_slice(),
2040 &[
2041 format!("hash:{otp}"),
2042 format!("send:{otp}"),
2043 format!("hash:{otp}")
2044 ]
2045 );
2046 }
2047 assert!(
2048 ctx.database
2049 .get_two_factor_by_user_id(&user.id)
2050 .await
2051 .unwrap()
2052 .is_none()
2053 );
2054 }
2055 }
2056
2057 #[tokio::test]
2058 async fn otp_enable_without_delivery_checks_password_then_rejects_without_mutating_the_owner() {
2059 let (ctx, user, session) =
2060 create_test_context_with_credential_user("disabled-otp@fixture.test", false).await;
2061 let plugin = TwoFactorPlugin::new();
2062 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
2063 request.headers.insert(
2064 "cookie".into(),
2065 create_session_cookie(&session.token, &ctx.config)
2066 .unwrap()
2067 .split(';')
2068 .next()
2069 .unwrap()
2070 .into(),
2071 );
2072 request.body = Some(br#"{"password":"wrong","method":"otp"}"#.to_vec());
2073 let wrong = plugin.on_request(&request, &ctx).await.unwrap_err();
2074 assert_eq!(
2075 serde_json::from_slice::<serde_json::Value>(&wrong.to_auth_response().body).unwrap(),
2076 serde_json::json!({"message":"Invalid password","code":"INVALID_PASSWORD"})
2077 );
2078 request.body = Some(br#"{"password":"password123","method":"otp"}"#.to_vec());
2079 assert!(matches!(
2080 plugin.on_request(&request, &ctx).await,
2081 Err(AuthError::Upstream {
2082 status: 400,
2083 code: "OTP_NOT_CONFIGURED",
2084 message: "OTP is not available"
2085 })
2086 ));
2087 assert!(
2088 !ctx.database
2089 .get_user_by_id(&user.id)
2090 .await
2091 .unwrap()
2092 .unwrap()
2093 .two_factor_enabled()
2094 );
2095 assert!(
2096 ctx.database
2097 .get_two_factor_by_user_id(&user.id)
2098 .await
2099 .unwrap()
2100 .is_none()
2101 );
2102 let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
2103 assert_eq!(sessions.len(), 1);
2104 assert_eq!(
2105 (sessions)
2106 .first()
2107 .expect("fixture contains the requested index")
2108 .token,
2109 session.token
2110 );
2111 }
2112
2113 #[tokio::test]
2114 #[expect(
2115 clippy::too_many_lines,
2116 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2117 )]
2118 async fn installed_legacy_factor_reads_authenticates_and_consumes_backups_without_rewriting_secret()
2119 {
2120 let legacy_secret =
2123 "AAECAwQFBgcICQoL9NYPdhHpe_5gn4m4X_opqjMmxni2EyB3YCXHFEgQIyla3Fd0gf3_j1wgttJLPrPp";
2124 let legacy_codes =
2125 "DA0ODxAREhMUFRYXMc-xHuks-lR5NpeRiTHkuDgsOlsOZwvsxoEufAsjDonqpW0x_ZMYlL0UMdsH";
2126 let plaintext = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef";
2127 let plugin = TwoFactorPlugin::new();
2128 let (ctx, user, session) =
2129 create_test_context_with_credential_user("legacy-factor@fixture.test", true).await;
2130 let installed = ctx
2131 .database
2132 .create_two_factor(CreateTwoFactor {
2133 user_id: user.id.clone(),
2134 secret: legacy_secret.into(),
2135 backup_codes: legacy_codes.into(),
2136 ..Default::default()
2137 })
2138 .await
2139 .unwrap();
2140 assert_eq!(
2141 plugin.view_backup_codes(&user.id, &ctx).await.unwrap(),
2142 serde_json::json!(["ABCDE-12345", "FGHIJ-67890"])
2143 );
2144 let request = |path: &str, body: serde_json::Value| {
2145 let mut request = AuthRequest::new(HttpMethod::Post, path);
2146 let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
2147 request
2148 .headers
2149 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
2150 request.body = Some(serde_json::to_vec(&body).unwrap());
2151 request
2152 };
2153 let uri = plugin
2154 .on_request(
2155 &request(
2156 "/two-factor/get-totp-uri",
2157 serde_json::json!({"password":"password123"}),
2158 ),
2159 &ctx,
2160 )
2161 .await
2162 .unwrap()
2163 .unwrap();
2164 assert_eq!(uri.status, 200);
2165 let verified = plugin
2166 .on_request(
2167 &request(
2168 "/two-factor/verify-totp",
2169 serde_json::json!({"code": plugin.generate_totp(plaintext).unwrap()}),
2170 ),
2171 &ctx,
2172 )
2173 .await
2174 .unwrap()
2175 .unwrap();
2176 assert_eq!(verified.status, 200);
2177 let consumed = plugin
2178 .on_request(
2179 &request(
2180 "/two-factor/verify-backup-code",
2181 serde_json::json!({"code":"ABCDE-12345"}),
2182 ),
2183 &ctx,
2184 )
2185 .await
2186 .unwrap()
2187 .unwrap();
2188 assert_eq!(consumed.status, 200);
2189 assert_eq!(
2190 plugin.view_backup_codes(&user.id, &ctx).await.unwrap(),
2191 serde_json::json!(["FGHIJ-67890"])
2192 );
2193 let after = ctx
2194 .database
2195 .get_two_factor_by_user_id(&user.id)
2196 .await
2197 .unwrap()
2198 .unwrap();
2199 assert_eq!(after.id, installed.id);
2200 assert_eq!(after.secret, legacy_secret);
2201 assert_ne!(after.backup_codes, legacy_codes);
2202 assert!(after.backup_codes.len().is_multiple_of(2));
2203 assert!(
2204 after
2205 .backup_codes
2206 .bytes()
2207 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2208 );
2209 let stored = after.backup_codes.clone();
2210 let replay = plugin
2211 .on_request(
2212 &request(
2213 "/two-factor/verify-backup-code",
2214 serde_json::json!({"code":"ABCDE-12345"}),
2215 ),
2216 &ctx,
2217 )
2218 .await
2219 .unwrap_err();
2220 assert_eq!(replay.to_string(), "Invalid backup code");
2221 assert_eq!(
2222 ctx.database
2223 .get_two_factor_by_user_id(&user.id)
2224 .await
2225 .unwrap()
2226 .unwrap()
2227 .backup_codes,
2228 stored
2229 );
2230 assert_eq!(
2231 ctx.database
2232 .get_session(&session.token)
2233 .await
2234 .unwrap()
2235 .unwrap()
2236 .user_id,
2237 user.id
2238 );
2239 let tampered = format!(
2240 "{}A",
2241 (legacy_secret)
2242 .get(..legacy_secret.len() - 1)
2243 .expect("fixture range is on a UTF-8 boundary")
2244 );
2245 ctx.database
2246 .update_two_factor(
2247 &installed.id,
2248 UpdateTwoFactor {
2249 secret: Some(tampered.clone()),
2250 ..Default::default()
2251 },
2252 )
2253 .await
2254 .unwrap();
2255 let rejected = plugin
2256 .on_request(
2257 &request(
2258 "/two-factor/get-totp-uri",
2259 serde_json::json!({"password":"password123"}),
2260 ),
2261 &ctx,
2262 )
2263 .await
2264 .unwrap_err();
2265 assert!(matches!(rejected, AuthError::Internal(_)));
2266 let rejected_row = ctx
2267 .database
2268 .get_two_factor_by_user_id(&user.id)
2269 .await
2270 .unwrap()
2271 .unwrap();
2272 assert_eq!(rejected_row.secret, tampered);
2273 assert_eq!(rejected_row.backup_codes, stored);
2274 assert!(
2275 ctx.database
2276 .get_session(&session.token)
2277 .await
2278 .unwrap()
2279 .is_some()
2280 );
2281 }
2282
2283 #[tokio::test]
2284 #[expect(
2285 clippy::too_many_lines,
2286 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2287 )]
2288 async fn configured_backup_callback_errors_preserve_factor_user_and_current_session() {
2289 type Observed = Arc<std::sync::Mutex<Vec<(String, String)>>>;
2293 fn denied(status: u16) -> AuthError {
2294 AuthError::Upstream {
2295 status,
2296 code: "BACKUP_CALLBACK_DENIED",
2297 message: "session creation cancelled by database hook",
2298 }
2299 }
2300 struct RejectingCipher {
2301 status: u16,
2302 observed: Observed,
2303 }
2304 #[async_trait]
2305 impl TwoFactorBackupCipher for RejectingCipher {
2306 async fn encrypt(&self, json: &str) -> AuthResult<String> {
2307 self.observed
2308 .lock()
2309 .unwrap()
2310 .push(("encrypt".into(), json.into()));
2311 Err(denied(self.status))
2312 }
2313 async fn decrypt(&self, _stored: &str) -> AuthResult<String> {
2314 panic!("enrollment and regeneration must not decode an existing factor")
2315 }
2316 }
2317 for phase in ["generate", "encrypt"] {
2318 for status in [400, 403] {
2319 let (ctx, user, session) =
2320 create_test_context_with_credential_user("backup-callback@fixture.test", false)
2321 .await;
2322 let observed: Observed = Arc::default();
2323 let generator_observed = Arc::clone(&observed);
2324 let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
2325 skip_verification_on_enable: true,
2326 custom_backup_codes_generate: Some(Arc::new(move || {
2327 generator_observed
2328 .lock()
2329 .unwrap()
2330 .push(("generate".into(), String::new()));
2331 if phase == "generate" {
2332 Err(denied(status))
2333 } else {
2334 Ok(vec!["callback-code".into(), "callback-code".into()])
2335 }
2336 })),
2337 backup_storage: TwoFactorBackupStorage::CustomCipher(Arc::new(
2338 RejectingCipher {
2339 status,
2340 observed: Arc::clone(&observed),
2341 },
2342 )),
2343 ..Default::default()
2344 });
2345 let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
2346 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
2347 _ = request
2348 .headers
2349 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
2350 request.body = Some(br#"{"password":"password123"}"#.to_vec());
2351 _ = ctx.require_session(&request).await.unwrap();
2354 let before_sessions =
2355 serde_json::to_value(ctx.database.get_user_sessions(&user.id).await.unwrap())
2356 .unwrap();
2357 let error = plugin.on_request(&request, &ctx).await.unwrap_err();
2358 assert!(
2359 matches!(error, AuthError::Upstream { status: actual, code: "BACKUP_CALLBACK_DENIED", .. } if actual == status)
2360 );
2361 assert!(
2362 ctx.database
2363 .get_two_factor_by_user_id(&user.id)
2364 .await
2365 .unwrap()
2366 .is_none()
2367 );
2368 assert!(
2369 !ctx.database
2370 .get_user_by_id(&user.id)
2371 .await
2372 .unwrap()
2373 .unwrap()
2374 .two_factor_enabled()
2375 );
2376 assert_eq!(
2377 serde_json::to_value(ctx.database.get_user_sessions(&user.id).await.unwrap())
2378 .unwrap(),
2379 before_sessions
2380 );
2381 let expected = if phase == "generate" {
2382 vec![("generate".into(), String::new())]
2383 } else {
2384 vec![
2385 ("generate".into(), String::new()),
2386 (
2387 "encrypt".into(),
2388 "[\"callback-code\",\"callback-code\"]".into(),
2389 ),
2390 ]
2391 };
2392 assert_eq!(*observed.lock().unwrap(), expected);
2393
2394 _ = ctx
2395 .database
2396 .update_user(
2397 &user.id,
2398 UpdateUser {
2399 two_factor_enabled: Some(true),
2400 ..Default::default()
2401 },
2402 )
2403 .await
2404 .unwrap();
2405 let factor = ctx
2406 .database
2407 .create_two_factor(CreateTwoFactor {
2408 user_id: user.id.clone(),
2409 secret: "installed-factor-secret".into(),
2410 backup_codes: "installed-factor-codes".into(),
2411 verified: Some(true),
2412 failed_verification_count: Some(2.5),
2413 locked_until: Some(Utc::now() + Duration::minutes(1)),
2414 })
2415 .await
2416 .unwrap();
2417 let before_user =
2418 serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap())
2419 .unwrap();
2420 observed.lock().unwrap().clear();
2421 let mut regeneration =
2422 AuthRequest::new(HttpMethod::Post, "/two-factor/generate-backup-codes");
2423 regeneration.headers.clone_from(&request.headers);
2424 regeneration.body.clone_from(&request.body);
2425 let error_2 = plugin.on_request(®eneration, &ctx).await.unwrap_err();
2426 assert!(
2427 matches!(error_2, AuthError::Upstream { status: actual, code: "BACKUP_CALLBACK_DENIED", .. } if actual == status)
2428 );
2429 assert_eq!(*observed.lock().unwrap(), expected);
2430 assert_eq!(
2431 serde_json::to_value(
2432 ctx.database
2433 .get_two_factor_by_user_id(&user.id)
2434 .await
2435 .unwrap()
2436 )
2437 .unwrap(),
2438 serde_json::json!(factor)
2439 );
2440 assert_eq!(
2441 serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap())
2442 .unwrap(),
2443 before_user
2444 );
2445 assert_eq!(
2446 serde_json::to_value(ctx.database.get_user_sessions(&user.id).await.unwrap())
2447 .unwrap(),
2448 before_sessions
2449 );
2450 assert_eq!(
2451 ctx.require_session(®eneration).await.unwrap().1.token,
2452 session.token
2453 );
2454 }
2455 }
2456 }
2457
2458 #[tokio::test]
2459 #[expect(
2460 clippy::too_many_lines,
2461 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2462 )]
2463 async fn pending_backup_cipher_errors_restore_only_decode_stage_attempts() {
2464 type Observed = Arc<std::sync::Mutex<Vec<(String, String)>>>;
2465 struct Cipher {
2466 phase: &'static str,
2467 rejected: Arc<std::sync::atomic::AtomicBool>,
2468 observed: Observed,
2469 }
2470 #[async_trait]
2471 impl TwoFactorBackupCipher for Cipher {
2472 async fn encrypt(&self, json: &str) -> AuthResult<String> {
2473 self.observed
2474 .lock()
2475 .unwrap()
2476 .push(("encrypt".into(), json.into()));
2477 if self.phase == "encrypt"
2478 && self.rejected.load(std::sync::atomic::Ordering::SeqCst)
2479 {
2480 return Err(AuthError::Upstream {
2481 status: 400,
2482 code: "BACKUP_CALLBACK_DENIED",
2483 message: "Callback denied",
2484 });
2485 }
2486 Ok(format!("backup-{json}"))
2487 }
2488 async fn decrypt(&self, stored: &str) -> AuthResult<String> {
2489 self.observed
2490 .lock()
2491 .unwrap()
2492 .push(("decrypt".into(), stored.into()));
2493 if self.phase == "decrypt"
2494 && self.rejected.load(std::sync::atomic::Ordering::SeqCst)
2495 {
2496 return Err(AuthError::Upstream {
2497 status: 400,
2498 code: "BACKUP_CALLBACK_DENIED",
2499 message: "Callback denied",
2500 });
2501 }
2502 Ok(stored.strip_prefix("backup-").unwrap().into())
2503 }
2504 }
2505 for phase in ["decrypt", "encrypt"] {
2506 let (ctx, user, session) = create_test_context_with_credential_user(
2507 "backup-pending-callback@fixture.test",
2508 true,
2509 )
2510 .await;
2511 ctx.database.delete_session(&session.token).await.unwrap();
2512 let factor = ctx
2513 .database
2514 .create_two_factor(CreateTwoFactor {
2515 user_id: user.id.clone(),
2516 secret: "installed-factor-secret".into(),
2517 backup_codes: "backup-[\"same\",\"same\",\"remaining\"]".into(),
2518 verified: Some(true),
2519 failed_verification_count: Some(2.5),
2520 locked_until: None,
2521 })
2522 .await
2523 .unwrap();
2524 let challenge = begin_sign_in_challenge(&user, None, &ctx).await.unwrap();
2525 let cookie_name = related_cookie_name(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX);
2526 let cookie = challenge
2527 .set_cookie_headers
2528 .iter()
2529 .find(|header| header.starts_with(&format!("{cookie_name}=")))
2530 .unwrap();
2531 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-backup-code");
2532 request
2533 .headers
2534 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
2535 request.body =
2536 Some(br#"{"code":"same","disableSession":true,"trustDevice":true}"#.to_vec());
2537 let key = read_signed_cookie(&request, TWO_FACTOR_COOKIE_SUFFIX, &ctx).unwrap();
2538 let attempt_id = format!("2fa-attempts-{key}");
2539 let before_challenge = serde_json::to_value(
2540 ctx.database
2541 .get_verification_by_identifier(&key)
2542 .await
2543 .unwrap(),
2544 )
2545 .unwrap();
2546 let before_user =
2547 serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap()).unwrap();
2548 let rejected = Arc::new(std::sync::atomic::AtomicBool::new(true));
2549 let observed: Observed = Arc::default();
2550 let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
2551 backup_storage: TwoFactorBackupStorage::CustomCipher(Arc::new(Cipher {
2552 phase,
2553 rejected: Arc::clone(&rejected),
2554 observed: Arc::clone(&observed),
2555 })),
2556 ..Default::default()
2557 });
2558 let error = plugin.on_request(&request, &ctx).await.unwrap_err();
2559 assert!(matches!(
2560 error,
2561 AuthError::Upstream {
2562 status: 400,
2563 code: "BACKUP_CALLBACK_DENIED",
2564 ..
2565 }
2566 ));
2567 assert_eq!(
2568 serde_json::to_value(
2569 ctx.database
2570 .get_two_factor_by_user_id(&user.id)
2571 .await
2572 .unwrap()
2573 )
2574 .unwrap(),
2575 serde_json::json!(factor)
2576 );
2577 assert_eq!(
2578 serde_json::to_value(
2579 ctx.database
2580 .get_verification_by_identifier(&key)
2581 .await
2582 .unwrap()
2583 )
2584 .unwrap(),
2585 before_challenge
2586 );
2587 assert_eq!(
2588 serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap()).unwrap(),
2589 before_user
2590 );
2591 assert_eq!(
2592 ctx.database
2593 .get_user_sessions(&user.id)
2594 .await
2595 .unwrap()
2596 .len(),
2597 0
2598 );
2599 let attempts = ctx
2600 .database
2601 .get_verification_by_identifier(&attempt_id)
2602 .await
2603 .unwrap();
2604 if phase == "decrypt" {
2605 assert_eq!(attempts.unwrap().value(), "0");
2606 assert_eq!(
2607 *observed.lock().unwrap(),
2608 vec![("decrypt".into(), factor.backup_codes.clone())]
2609 );
2610 } else {
2611 assert!(attempts.is_none());
2612 assert_eq!(
2613 *observed.lock().unwrap(),
2614 vec![
2615 ("decrypt".into(), factor.backup_codes.clone()),
2616 ("encrypt".into(), "[\"remaining\"]".into())
2617 ]
2618 );
2619 }
2620 rejected.store(false, std::sync::atomic::Ordering::SeqCst);
2621 let retry = plugin.on_request(&request, &ctx).await;
2622 if phase == "decrypt" {
2623 let response = retry.unwrap().unwrap();
2624 assert_eq!(response.status, 200);
2625 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
2626 assert!(body.get("token").is_none());
2627 assert_eq!(
2628 (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
2629 .get("id")
2630 .unwrap_or(&serde_json::Value::Null)),
2631 user.id
2632 );
2633 assert!(response.headers.get("set-cookie").is_none());
2634 let updated = ctx
2635 .database
2636 .get_two_factor_by_user_id(&user.id)
2637 .await
2638 .unwrap()
2639 .unwrap();
2640 assert_eq!(updated.id, factor.id);
2641 assert_eq!(updated.secret, factor.secret);
2642 assert_eq!(updated.backup_codes, "backup-[\"remaining\"]");
2643 assert_eq!(updated.failed_verification_count, Some(0.0));
2644 } else {
2645 let error_2 = retry.unwrap_err();
2646 assert_eq!(error_2.status_code(), 401);
2647 assert_eq!(error_2.to_string(), "Invalid two factor cookie");
2648 assert_eq!(
2649 serde_json::to_value(
2650 ctx.database
2651 .get_two_factor_by_user_id(&user.id)
2652 .await
2653 .unwrap()
2654 )
2655 .unwrap(),
2656 serde_json::json!(factor)
2657 );
2658 }
2659 assert!(
2660 ctx.database
2661 .get_verification_by_identifier(&attempt_id)
2662 .await
2663 .unwrap()
2664 .is_none()
2665 );
2666 assert_eq!(
2667 serde_json::to_value(
2668 ctx.database
2669 .get_verification_by_identifier(&key)
2670 .await
2671 .unwrap()
2672 )
2673 .unwrap(),
2674 before_challenge
2675 );
2676 assert_eq!(
2677 ctx.database
2678 .get_user_sessions(&user.id)
2679 .await
2680 .unwrap()
2681 .len(),
2682 0
2683 );
2684 }
2685 }
2686
2687 #[tokio::test]
2688 #[expect(
2689 clippy::too_many_lines,
2690 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2691 )]
2692 async fn authenticated_otp_maps_only_session_creation_cancellation_and_preserves_hook_inputs() {
2693 use alibi_core::{AuthConfig, CreateSession};
2694 use alibi_seaorm::{
2695 Database, DatabaseHooks, HookControl, SeaOrmBackend, SeaOrmHookContext, SeaOrmStore,
2696 };
2697
2698 struct Hook {
2699 mode: u8,
2700 observed: Arc<std::sync::Mutex<Vec<String>>>,
2701 }
2702 #[async_trait]
2703 impl DatabaseHooks<TestSchema, SeaOrmBackend> for Hook {
2704 async fn before_update_user(
2705 &self,
2706 _id: &str,
2707 update: &mut UpdateUser,
2708 _context: &SeaOrmHookContext<'_>,
2709 ) -> AuthResult<HookControl> {
2710 if update.two_factor_enabled == Some(true) {
2711 self.observed.lock().unwrap().push("user".into());
2712 if self.mode == 3 {
2713 return Err(AuthError::SessionCreationCancelled);
2714 }
2715 }
2716 Ok(HookControl::Continue)
2717 }
2718 async fn before_create_session(
2719 &self,
2720 session: &mut CreateSession,
2721 context: &SeaOrmHookContext<'_>,
2722 ) -> AuthResult<HookControl> {
2723 if context
2724 .request
2725 .as_ref()
2726 .is_some_and(|request| request.path.ends_with("/two-factor/verify-otp"))
2727 {
2728 self.observed.lock().unwrap().push("session".into());
2729 assert_eq!(session.ip_address.as_deref(), Some("127.0.0.7"));
2730 assert_eq!(session.user_agent.as_deref(), Some("original-otp-agent"));
2731 return match self.mode {
2732 0 => Ok(HookControl::Cancel),
2733 1 => Err(AuthError::forbidden(
2734 "session creation cancelled by database hook",
2735 )),
2736 _ => Err(AuthError::bad_request(
2737 "session creation cancelled by database hook",
2738 )),
2739 };
2740 }
2741 Ok(HookControl::Continue)
2742 }
2743 }
2744 struct Sender(Arc<std::sync::Mutex<Option<String>>>);
2745 #[async_trait]
2746 impl SendTwoFactorOtp for Sender {
2747 async fn send(&self, _user: &UserView, otp: &str) -> AuthResult<()> {
2748 *self.0.lock().unwrap() = Some(otp.into());
2749 Ok(())
2750 }
2751 }
2752 for mode in 0..4 {
2753 let database = Database::connect("sqlite::memory:").await.unwrap();
2754 alibi_seaorm::store::__private_test_support::migrator::run_migrations(&database)
2755 .await
2756 .unwrap();
2757 let config = Arc::new(AuthConfig::new("authenticated-otp-hook-secret-at-least-32"));
2758 let mut ctx = AuthContext::new(
2759 Arc::clone(&config),
2760 Arc::new(SeaOrmStore::<TestSchema>::new(
2761 Arc::clone(&config),
2762 database.clone(),
2763 )),
2764 );
2765 let user = ctx
2766 .database
2767 .create_user(
2768 CreateUser::new()
2769 .with_name("Native OTP Owner")
2770 .with_email("native-otp@fixture.test"),
2771 )
2772 .await
2773 .unwrap();
2774 let session = ctx
2775 .database
2776 .create_session(CreateSession {
2777 token: None,
2778 user_id: user.id.clone(),
2779 expires_at: Utc::now() + Duration::hours(1),
2780 ip_address: Some("127.0.0.7".into()),
2781 user_agent: Some("original-otp-agent".into()),
2782 active_organization_id: None,
2783 active_team_id: None,
2784 impersonated_by: None,
2785 additional_fields: alibi_core::field_policy::FieldValues::default(),
2786 })
2787 .await
2788 .unwrap();
2789 let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2790 ctx.database = Arc::new(SeaOrmStore::<TestSchema>::new(config, database).with_hooks(
2791 vec![Arc::new(Hook {
2792 mode,
2793 observed: Arc::clone(&observed),
2794 })],
2795 ));
2796 let delivered = Arc::new(std::sync::Mutex::new(None));
2797 let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
2798 send_otp: Some(Arc::new(Sender(Arc::clone(&delivered)))),
2799 ..Default::default()
2800 });
2801 let mut init = alibi_core::AuthInitContext::new(
2802 Arc::clone(&ctx.config),
2803 Arc::clone(&ctx.database),
2804 );
2805 plugin.on_init(&mut init).await.unwrap();
2806 ctx.database = init.database_with_registered_transforms();
2807 let parts = init.into_parts();
2808 ctx.metadata = parts.metadata;
2809 ctx.extensions = parts.extensions;
2810 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/send-otp");
2811 request.headers.insert(
2812 "cookie".into(),
2813 create_session_cookie(&session.token, &ctx.config)
2814 .unwrap()
2815 .split(';')
2816 .next()
2817 .unwrap()
2818 .into(),
2819 );
2820 request.body = Some(b"{}".to_vec());
2821 assert_eq!(
2822 plugin
2823 .on_request(&request, &ctx)
2824 .await
2825 .unwrap()
2826 .unwrap()
2827 .status,
2828 200
2829 );
2830 let before = ctx.database.get_user_sessions(&user.id).await.unwrap();
2831 assert_eq!(before.len(), 1);
2832 assert_eq!(
2833 (before)
2834 .first()
2835 .expect("fixture contains the requested session")
2836 .id,
2837 session.id
2838 );
2839 assert_eq!(
2840 (before)
2841 .first()
2842 .expect("fixture contains the requested session")
2843 .token,
2844 session.token
2845 );
2846 let code = delivered
2847 .lock()
2848 .unwrap()
2849 .clone()
2850 .expect("real delivery required");
2851 request.path = "/two-factor/verify-otp".into();
2852 request.body = Some(
2853 serde_json::to_vec(&serde_json::json!({"code":code,"trustDevice":true})).unwrap(),
2854 );
2855 let result =
2856 alibi_core::with_request_hook_context(&request, plugin.on_request(&request, &ctx))
2857 .await;
2858 match mode {
2859 0 => {
2860 let response = result.unwrap().unwrap();
2861 assert_eq!(response.status, 500);
2862 assert_eq!(response.body.len(), 0);
2863 assert_eq!(response.headers.get_all("Set-Cookie").count(), 0);
2864 }
2865 1 => assert!(matches!(result, Err(AuthError::Forbidden(_)))),
2866 2 => assert!(matches!(result, Err(AuthError::BadRequest(_)))),
2867 _ => assert!(matches!(result, Err(AuthError::SessionCreationCancelled))),
2868 }
2869 assert_eq!(
2870 *observed.lock().unwrap(),
2871 if mode == 3 {
2872 vec!["user"]
2873 } else {
2874 vec!["user", "session"]
2875 }
2876 );
2877 let stored_user = ctx
2878 .database
2879 .get_user_by_id(&user.id)
2880 .await
2881 .unwrap()
2882 .unwrap();
2883 assert_eq!(stored_user.two_factor_enabled(), mode != 3);
2884 let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
2885 assert_eq!(
2886 serde_json::to_value(sessions).unwrap(),
2887 serde_json::to_value(&before).unwrap()
2888 );
2889 assert!(
2890 ctx.database
2891 .get_two_factor_by_user_id(&user.id)
2892 .await
2893 .unwrap()
2894 .is_none()
2895 );
2896 let key = format!("2fa-otp-{}!{}", user.id, session.id);
2897 assert!(
2898 ctx.database
2899 .get_verification_by_identifier(&key)
2900 .await
2901 .unwrap()
2902 .is_none()
2903 );
2904 let retry = plugin.on_request(&request, &ctx).await.unwrap_err();
2905 assert!(
2906 matches!(retry, AuthError::BadRequest(ref message) if message == "OTP has expired")
2907 );
2908 assert_eq!(
2909 ctx.database
2910 .get_user_by_id(&user.id)
2911 .await
2912 .unwrap()
2913 .unwrap()
2914 .two_factor_enabled(),
2915 mode != 3
2916 );
2917 }
2918 }
2919
2920 #[tokio::test]
2921 #[expect(
2922 clippy::too_many_lines,
2923 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2924 )]
2925 async fn otp_delivery_keeps_owned_request_context_after_dropped_rejecting_observer_and_rotation()
2926 {
2927 use alibi_core::{BackgroundTaskCompletion, BackgroundTaskHandler};
2928 use tokio::sync::oneshot;
2929 type Observations = Arc<std::sync::Mutex<Vec<(String, String, String, bool)>>>;
2930 struct Sender {
2931 entered: std::sync::Mutex<Option<oneshot::Sender<String>>>,
2932 release: std::sync::Mutex<Option<oneshot::Receiver<()>>>,
2933 finished: std::sync::Mutex<Option<oneshot::Sender<()>>>,
2934 observed: Observations,
2935 }
2936 impl Sender {
2937 fn record(&self, user: &UserView) {
2938 let request = alibi_core::hooks::current_request_hook_context()
2939 .expect("real delivery must retain initiating request context");
2940 self.observed.lock().unwrap().push((
2941 request.path,
2942 (*(request.headers)
2943 .get("x-delivery-origin")
2944 .expect("fixture contains the requested index"))
2945 .clone(),
2946 (*(request.query)
2947 .get("delivery")
2948 .expect("fixture contains the requested index"))
2949 .clone(),
2950 user.two_factor_enabled.unwrap_or(false),
2951 ));
2952 }
2953 }
2954 #[async_trait]
2955 impl SendTwoFactorOtp for Sender {
2956 async fn send(&self, user: &UserView, otp: &str) -> AuthResult<()> {
2957 self.record(user);
2958 _ = self
2959 .entered
2960 .lock()
2961 .unwrap()
2962 .take()
2963 .unwrap()
2964 .send(otp.into());
2965 let release = self.release.lock().unwrap().take().unwrap();
2966 release.await.unwrap();
2967 self.record(user);
2968 _ = self.finished.lock().unwrap().take().unwrap().send(());
2969 Err(AuthError::forbidden(
2970 "actual asynchronous delivery rejected",
2971 ))
2972 }
2973 }
2974 struct Observer;
2975 impl BackgroundTaskHandler for Observer {
2976 fn handle(&self, completion: BackgroundTaskCompletion) -> AuthResult<()> {
2977 drop(completion);
2978 Err(AuthError::forbidden("actual application observer rejected"))
2979 }
2980 }
2981 let mut ctx = test_helpers::create_test_context().await;
2982 ctx.config = Arc::new((*ctx.config).clone().background_tasks(Arc::new(Observer)));
2983 let (entered, entry) = oneshot::channel();
2984 let (release, gate) = oneshot::channel();
2985 let (finished, done) = oneshot::channel();
2986 let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2987 let plugin = Arc::new(TwoFactorPlugin::with_config(TwoFactorConfig {
2988 send_otp: Some(Arc::new(Sender {
2989 entered: std::sync::Mutex::new(Some(entered)),
2990 release: std::sync::Mutex::new(Some(gate)),
2991 finished: std::sync::Mutex::new(Some(finished)),
2992 observed: Arc::clone(&observed),
2993 })),
2994 ..Default::default()
2995 }));
2996 let mut init =
2997 alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
2998 plugin.on_init(&mut init).await.unwrap();
2999 ctx.database = init.database_with_registered_transforms();
3000 let parts = init.into_parts();
3001 ctx.metadata = parts.metadata;
3002 ctx.extensions = parts.extensions;
3003 let user = test_helpers::create_user(
3004 &ctx,
3005 CreateUser::new()
3006 .with_name("Owned Delivery")
3007 .with_email("delivery-owner@fixture.test"),
3008 )
3009 .await;
3010 let session = test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(1)).await;
3011 let ctx = Arc::new(ctx);
3012 let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/send-otp");
3013 _ = request.headers.insert(
3014 "cookie".into(),
3015 create_session_cookie(&session.token, &ctx.config)
3016 .unwrap()
3017 .split(';')
3018 .next()
3019 .unwrap()
3020 .into(),
3021 );
3022 _ = request
3023 .headers
3024 .insert("x-delivery-origin".into(), "original-sender".into());
3025 _ = request
3026 .query
3027 .insert("delivery".into(), "original-marker".into());
3028 request.body = Some(b"{}".to_vec());
3029 let running_ctx = Arc::clone(&ctx);
3030 let running_plugin = Arc::clone(&plugin);
3031 let running_request = request.clone();
3032 let mut running = tokio::spawn(async move {
3033 alibi_core::with_request_hook_context(
3034 &running_request,
3035 running_plugin.on_request(&running_request, &running_ctx),
3036 )
3037 .await
3038 });
3039 let code = entry.await.unwrap();
3040 let response = tokio::time::timeout(std::time::Duration::from_secs(1), &mut running)
3041 .await
3042 .expect("configured response must not await held application delivery")
3043 .unwrap()
3044 .unwrap()
3045 .unwrap();
3046 assert_eq!(response.status, 200);
3047 let identifier = format!("2fa-otp-{}!{}", user.id, session.id);
3048 let row = ctx
3049 .database
3050 .get_verification_by_identifier(&identifier)
3051 .await
3052 .unwrap()
3053 .unwrap();
3054 assert_eq!(row.value(), format!("{code}:0"));
3055 assert!(
3056 ctx.database
3057 .get_session(&session.token)
3058 .await
3059 .unwrap()
3060 .is_some()
3061 );
3062 request.path = "/two-factor/verify-otp".into();
3063 _ = request
3064 .headers
3065 .insert("x-delivery-origin".into(), "different-verifier".into());
3066 _ = request
3067 .query
3068 .insert("delivery".into(), "different-marker".into());
3069 request.body = Some(serde_json::to_vec(&serde_json::json!({"code":code})).unwrap());
3070 let verified =
3071 alibi_core::with_request_hook_context(&request, plugin.on_request(&request, &ctx))
3072 .await
3073 .unwrap()
3074 .unwrap();
3075 assert_eq!(verified.status, 200);
3076 assert!(
3077 ctx.database
3078 .get_user_by_id(&user.id)
3079 .await
3080 .unwrap()
3081 .unwrap()
3082 .two_factor_enabled()
3083 );
3084 assert!(
3085 ctx.database
3086 .get_session(&session.token)
3087 .await
3088 .unwrap()
3089 .is_none()
3090 );
3091 assert!(
3092 ctx.database
3093 .get_verification_by_identifier(&identifier)
3094 .await
3095 .unwrap()
3096 .is_none()
3097 );
3098 assert!(
3099 ctx.database
3100 .get_two_factor_by_user_id(&user.id)
3101 .await
3102 .unwrap()
3103 .is_none()
3104 );
3105 _ = release.send(());
3106 tokio::time::timeout(std::time::Duration::from_secs(1), done)
3107 .await
3108 .expect("owned delivery must finish after its actual release")
3109 .unwrap();
3110 assert_eq!(
3111 *observed.lock().unwrap(),
3112 vec![
3113 (
3114 "/two-factor/send-otp".into(),
3115 "original-sender".into(),
3116 "original-marker".into(),
3117 false
3118 );
3119 2
3120 ]
3121 );
3122 }
3123}
3124