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