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