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