1use std::time::Duration;
56
57use crate::signed_url::{sign, verify, SignedUrlError};
58
59#[derive(Debug, thiserror::Error, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum AuthFlowError {
63 #[error("token is missing or malformed")]
64 Malformed,
65 #[error("token signature does not match")]
66 InvalidSignature,
67 #[error("token has expired")]
68 Expired,
69 #[error("token is for the wrong purpose ({0})")]
70 WrongPurpose(String),
71 #[error("password rejected: {0}")]
76 WeakPassword(String),
77 #[error("database error: {0}")]
82 Database(String),
83 #[error("token already used")]
88 AlreadyUsed,
89 #[error("token is for another scope")]
91 WrongScope,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct LinkScope(String);
104
105impl LinkScope {
106 const PARAM: &'static str = "scope";
108
109 #[must_use]
112 pub fn audience(audience: &str) -> Self {
113 Self(format!("aud:{audience}"))
114 }
115
116 fn link(&self, base: &str, query: &str) -> String {
119 let (path, fragment) = base.find('#').map_or((base, ""), |i| base.split_at(i));
120 let path = path.trim_end_matches(['?', '&']);
121 let joiner = if path.contains('?') { '&' } else { '?' };
122 let scope = url_encode(&self.0);
123 format!("{path}{joiner}{query}&{}={scope}{fragment}", Self::PARAM)
124 }
125
126 fn check(&self, url: &str) -> Result<(), AuthFlowError> {
128 match extract_query(url, Self::PARAM) {
129 Some(s) if s == self.0 => Ok(()),
130 _ => Err(AuthFlowError::WrongScope),
131 }
132 }
133}
134
135#[cfg(feature = "tenancy")]
138impl From<&crate::tenancy::Org> for LinkScope {
139 fn from(org: &crate::tenancy::Org) -> Self {
140 let id = org.id.get().copied().unwrap_or_default();
141 Self(format!("tenant:{}:{id}", org.slug))
142 }
143}
144
145#[cfg(feature = "tenancy")]
146impl<DB: crate::sql::sqlx::Database> From<&crate::extractors::Tenant<DB>> for LinkScope {
147 fn from(tenant: &crate::extractors::Tenant<DB>) -> Self {
148 Self::from(&tenant.org)
149 }
150}
151
152#[cfg(feature = "tenancy")]
153impl From<&crate::extractors::TenantScope> for LinkScope {
154 fn from(tenant: &crate::extractors::TenantScope) -> Self {
155 Self::from(&tenant.org)
156 }
157}
158
159#[cfg(feature = "passwords")]
162pub struct LinkTarget<'a> {
163 pool: &'a crate::sql::Pool,
164 scope: LinkScope,
165}
166
167#[cfg(feature = "passwords")]
168impl<'a> LinkTarget<'a> {
169 #[must_use]
171 pub fn audience(pool: &'a crate::sql::Pool, audience: &str) -> Self {
172 Self {
173 pool,
174 scope: LinkScope::audience(audience),
175 }
176 }
177}
178
179#[cfg(all(feature = "passwords", feature = "tenancy", feature = "admin"))]
180impl<'a> LinkTarget<'a> {
181 pub(crate) fn tenant(pool: &'a crate::sql::Pool, org: &crate::tenancy::Org) -> Self {
183 Self {
184 pool,
185 scope: LinkScope::from(org),
186 }
187 }
188}
189
190#[cfg(all(feature = "passwords", feature = "tenancy"))]
191impl<'a, DB: crate::sql::sqlx::Database> From<&'a crate::extractors::Tenant<DB>>
192 for LinkTarget<'a>
193{
194 fn from(tenant: &'a crate::extractors::Tenant<DB>) -> Self {
195 Self {
196 pool: tenant.pool(),
197 scope: LinkScope::from(tenant),
198 }
199 }
200}
201
202#[cfg(all(feature = "passwords", feature = "tenancy"))]
204impl<'a> From<&'a crate::extractors::TenantScope> for LinkTarget<'a> {
205 fn from(tenant: &'a crate::extractors::TenantScope) -> Self {
206 Self {
207 pool: tenant.pool(),
208 scope: LinkScope::from(tenant),
209 }
210 }
211}
212
213fn check_link(
215 url: &str,
216 purpose: &str,
217 scope: &LinkScope,
218 secret: &[u8],
219) -> Result<(), AuthFlowError> {
220 verify(url, secret)?;
221 let found = extract_query(url, "purpose").ok_or(AuthFlowError::Malformed)?;
222 if found != purpose {
223 return Err(AuthFlowError::WrongPurpose(found));
224 }
225 scope.check(url)
226}
227
228impl From<SignedUrlError> for AuthFlowError {
229 fn from(e: SignedUrlError) -> Self {
230 match e {
231 SignedUrlError::MissingSignature | SignedUrlError::MalformedSignature => {
232 Self::Malformed
233 }
234 SignedUrlError::InvalidSignature => Self::InvalidSignature,
235 SignedUrlError::Expired => Self::Expired,
236 }
237 }
238}
239
240pub struct PasswordReset;
244
245impl PasswordReset {
246 const PURPOSE: &'static str = "pwreset";
247
248 #[must_use]
254 pub fn issue(
255 scope: &LinkScope,
256 base_url: &str,
257 user_id: i64,
258 secret: &[u8],
259 ttl: Duration,
260 ) -> String {
261 let query = format!(
262 "user_id={}&purpose={}&{}={}",
263 user_id,
264 Self::PURPOSE,
265 Self::ISSUED_AT,
266 chrono::Utc::now().timestamp_micros(),
267 );
268 sign(&scope.link(base_url, &query), secret, Some(ttl))
269 }
270
271 const ISSUED_AT: &'static str = "iat";
273
274 #[cfg(feature = "passwords")]
277 pub(crate) fn verify_issued(
278 scope: &LinkScope,
279 url: &str,
280 secret: &[u8],
281 ) -> Result<(i64, i64), AuthFlowError> {
282 let user_id = Self::verify(scope, url, secret)?;
283 let iat = extract_query(url, Self::ISSUED_AT)
284 .and_then(|s| s.parse::<i64>().ok())
285 .ok_or(AuthFlowError::Expired)?;
286 Ok((user_id, iat))
287 }
288
289 pub fn verify(scope: &LinkScope, url: &str, secret: &[u8]) -> Result<i64, AuthFlowError> {
296 check_link(url, Self::PURPOSE, scope, secret)?;
297 let user_id_str = extract_query(url, "user_id").ok_or(AuthFlowError::Malformed)?;
298 user_id_str
299 .parse::<i64>()
300 .map_err(|_| AuthFlowError::Malformed)
301 }
302
303 #[cfg(feature = "cache")]
312 pub async fn verify_single_use(
313 scope: &LinkScope,
314 url: &str,
315 secret: &[u8],
316 cache: &std::sync::Arc<dyn crate::cache::Cache>,
317 ) -> Result<i64, AuthFlowError> {
318 let user_id = Self::verify(scope, url, secret)?;
319 consume_single_use(url, cache).await?;
320 Ok(user_id)
321 }
322}
323
324#[cfg(feature = "passwords")]
356pub async fn confirm_password_reset_pool(
357 target: impl Into<LinkTarget<'_>>,
358 url: &str,
359 new_password: &str,
360 secret: &[u8],
361) -> Result<i64, AuthFlowError> {
362 let LinkTarget { pool, scope } = target.into();
363 let (user_id, iat) = PasswordReset::verify_issued(&scope, url, secret)?;
364 check_password_strength(new_password)?;
365 write_password_hash(
368 pool,
369 user_id,
370 new_password,
371 "rustango_users",
372 "id",
373 "password_hash",
374 Some(Rotation::since(iat)),
375 )
376 .await
377}
378
379#[cfg(feature = "passwords")]
391#[allow(clippy::too_many_arguments)]
392pub async fn confirm_password_reset_pool_into(
393 target: impl Into<LinkTarget<'_>>,
394 url: &str,
395 new_password: &str,
396 secret: &[u8],
397 user_table: &str,
398 pk_column: &str,
399 password_column: &str,
400) -> Result<i64, AuthFlowError> {
401 let LinkTarget { pool, scope } = target.into();
402 let user_id = PasswordReset::verify(&scope, url, secret)?;
403 check_password_strength(new_password)?;
404 write_password_hash(
405 pool,
406 user_id,
407 new_password,
408 user_table,
409 pk_column,
410 password_column,
411 None,
412 )
413 .await
414}
415
416#[cfg(all(feature = "passwords", feature = "cache"))]
431pub async fn confirm_password_reset_single_use(
432 target: impl Into<LinkTarget<'_>>,
433 url: &str,
434 new_password: &str,
435 secret: &[u8],
436 cache: &std::sync::Arc<dyn crate::cache::Cache>,
437) -> Result<i64, AuthFlowError> {
438 let LinkTarget { pool, scope } = target.into();
439 let (user_id, iat) = PasswordReset::verify_issued(&scope, url, secret)?;
440 check_password_strength(new_password)?;
441 consume_single_use(url, cache).await?;
442 write_password_hash(
445 pool,
446 user_id,
447 new_password,
448 "rustango_users",
449 "id",
450 "password_hash",
451 Some(Rotation::since(iat)),
452 )
453 .await
454}
455
456#[cfg(all(feature = "passwords", feature = "cache"))]
462#[allow(clippy::too_many_arguments)]
463pub async fn confirm_password_reset_single_use_into(
464 target: impl Into<LinkTarget<'_>>,
465 url: &str,
466 new_password: &str,
467 secret: &[u8],
468 cache: &std::sync::Arc<dyn crate::cache::Cache>,
469 user_table: &str,
470 pk_column: &str,
471 password_column: &str,
472) -> Result<i64, AuthFlowError> {
473 let LinkTarget { pool, scope } = target.into();
474 let user_id = PasswordReset::verify(&scope, url, secret)?;
475 check_password_strength(new_password)?;
478 consume_single_use(url, cache).await?;
479 write_password_hash(
480 pool,
481 user_id,
482 new_password,
483 user_table,
484 pk_column,
485 password_column,
486 None,
487 )
488 .await
489}
490
491#[cfg(feature = "passwords")]
493fn check_password_strength(new_password: &str) -> Result<(), AuthFlowError> {
494 crate::password_validators::check_builtin_form_password(new_password)
495 .map_err(|e| AuthFlowError::WeakPassword(e.message))
496}
497
498#[cfg(feature = "passwords")]
501struct Rotation {
502 link_issued_at: i64,
504}
505
506#[cfg(feature = "passwords")]
507impl Rotation {
508 const COLUMN: &'static str = "password_changed_at";
509
510 fn since(link_issued_at: i64) -> Self {
511 Self { link_issued_at }
512 }
513
514 fn issued(&self) -> Result<chrono::DateTime<chrono::Utc>, AuthFlowError> {
515 chrono::DateTime::from_timestamp_micros(self.link_issued_at).ok_or(AuthFlowError::Malformed)
516 }
517}
518
519#[cfg(feature = "passwords")]
527async fn write_password_hash(
528 pool: &crate::sql::Pool,
529 user_id: i64,
530 new_password: &str,
531 user_table: &str,
532 pk_column: &str,
533 password_column: &str,
534 rotation: Option<Rotation>,
535) -> Result<i64, AuthFlowError> {
536 let hash = crate::passwords::hash_async(new_password)
537 .await
538 .map_err(|e| AuthFlowError::Database(e.to_string()))?;
539 if let Some(rotation) = rotation {
540 let written = rotate_user_password(pool, user_id, hash, &rotation).await?;
541 return if written == 0 {
543 Err(AuthFlowError::Expired)
544 } else {
545 Ok(user_id)
546 };
547 }
548 let dialect = pool.dialect();
550 let sql = format!(
551 "UPDATE {} SET {} = {} WHERE {} = {}",
552 dialect.quote_ident(user_table),
553 dialect.quote_ident(password_column),
554 dialect.placeholder(1),
555 dialect.quote_ident(pk_column),
556 dialect.placeholder(2),
557 );
558 let args = vec![
559 crate::core::SqlValue::String(hash),
560 crate::core::SqlValue::I64(user_id),
561 ];
562 crate::sql::raw_execute_pool(pool, &sql, args)
563 .await
564 .map_err(|e| AuthFlowError::Database(e.to_string()))?;
565 Ok(user_id)
566}
567
568#[cfg(feature = "passwords")]
571static RESET_USERS: crate::core::ModelSchema = {
572 use crate::core::{FieldSchema, FieldType};
573 const FIELDS: &[FieldSchema] = &[
574 {
575 let mut f = FieldSchema::new("id", "id", FieldType::I64);
576 f.primary_key = true;
577 f
578 },
579 FieldSchema::new("password_hash", "password_hash", FieldType::String),
580 {
581 let mut f = FieldSchema::new(Rotation::COLUMN, Rotation::COLUMN, FieldType::DateTime);
582 f.nullable = true;
583 f
584 },
585 ];
586 let mut s = crate::core::ModelSchema::new("User", "rustango_users");
587 s.fields = FIELDS;
588 s
589};
590
591#[cfg(feature = "passwords")]
594async fn rotate_user_password(
595 pool: &crate::sql::Pool,
596 user_id: i64,
597 hash: String,
598 rotation: &Rotation,
599) -> Result<u64, AuthFlowError> {
600 use crate::core::{Assignment, SqlValue, UpdateQuery};
601 use crate::query::Q;
602 let since = rotation.issued()?;
603 let query = UpdateQuery {
604 model: &RESET_USERS,
605 set: vec![
606 Assignment::new("password_hash", SqlValue::String(hash)),
607 Assignment::new(Rotation::COLUMN, SqlValue::DateTime(chrono::Utc::now())),
608 ],
609 where_clause: (Q::eq("id", user_id)
610 & (Q::is_null(Rotation::COLUMN) | Q::lt(Rotation::COLUMN, since)))
611 .into(),
612 };
613 crate::sql::update_pool(pool, &query)
614 .await
615 .map_err(|e| AuthFlowError::Database(e.to_string()))
616}
617
618pub struct EmailVerification;
622
623impl EmailVerification {
624 const PURPOSE: &'static str = "verify_email";
625
626 #[must_use]
630 pub fn issue(
631 scope: &LinkScope,
632 base_url: &str,
633 user_id: i64,
634 email: &str,
635 secret: &[u8],
636 ttl: Duration,
637 ) -> String {
638 let query = format!(
639 "user_id={}&email={}&purpose={}",
640 user_id,
641 url_encode(email),
642 Self::PURPOSE,
643 );
644 sign(&scope.link(base_url, &query), secret, Some(ttl))
645 }
646
647 pub fn verify(
654 scope: &LinkScope,
655 url: &str,
656 secret: &[u8],
657 ) -> Result<(i64, String), AuthFlowError> {
658 check_link(url, Self::PURPOSE, scope, secret)?;
659 let user_id_str = extract_query(url, "user_id").ok_or(AuthFlowError::Malformed)?;
660 let user_id = user_id_str
661 .parse::<i64>()
662 .map_err(|_| AuthFlowError::Malformed)?;
663 let email = extract_query(url, "email").ok_or(AuthFlowError::Malformed)?;
664 Ok((user_id, email))
665 }
666
667 #[cfg(feature = "cache")]
673 pub async fn verify_single_use(
674 scope: &LinkScope,
675 url: &str,
676 secret: &[u8],
677 cache: &std::sync::Arc<dyn crate::cache::Cache>,
678 ) -> Result<(i64, String), AuthFlowError> {
679 let out = Self::verify(scope, url, secret)?;
680 consume_single_use(url, cache).await?;
681 Ok(out)
682 }
683}
684
685pub struct MagicLink;
689
690impl MagicLink {
691 const PURPOSE: &'static str = "magic_link";
692
693 #[must_use]
697 pub fn issue(
698 scope: &LinkScope,
699 base_url: &str,
700 email: &str,
701 secret: &[u8],
702 ttl: Duration,
703 ) -> String {
704 let query = format!("email={}&purpose={}", url_encode(email), Self::PURPOSE);
705 sign(&scope.link(base_url, &query), secret, Some(ttl))
706 }
707
708 pub fn verify(scope: &LinkScope, url: &str, secret: &[u8]) -> Result<String, AuthFlowError> {
719 check_link(url, Self::PURPOSE, scope, secret)?;
720 extract_query(url, "email").ok_or(AuthFlowError::Malformed)
721 }
722
723 #[cfg(feature = "cache")]
731 pub async fn verify_single_use(
732 scope: &LinkScope,
733 url: &str,
734 secret: &[u8],
735 cache: &std::sync::Arc<dyn crate::cache::Cache>,
736 ) -> Result<String, AuthFlowError> {
737 let email = Self::verify(scope, url, secret)?;
738 consume_single_use(url, cache).await?;
739 Ok(email)
740 }
741}
742
743fn extract_query(url: &str, key: &str) -> Option<String> {
746 let url = url.split_once('#').map_or(url, |(u, _)| u);
747 let query = url.split_once('?')?.1;
748 for pair in query.split('&') {
749 let Some((k, v)) = pair.split_once('=') else {
750 continue;
751 };
752 let k = url_decode(k);
753 if k == key {
754 return Some(url_decode(v));
755 }
756 }
757 None
758}
759
760#[cfg(feature = "cache")]
768async fn consume_single_use(
769 url: &str,
770 cache: &std::sync::Arc<dyn crate::cache::Cache>,
771) -> Result<(), AuthFlowError> {
772 let sig = extract_query(url, "signature").ok_or(AuthFlowError::Malformed)?;
773 if cache.stores_nothing() {
774 tracing::error!(
775 target: "rustango::auth_flows",
776 "single-use token refused: the cache keeps nothing (`NullCache`); use a shared cache"
777 );
778 return Err(AuthFlowError::AlreadyUsed);
779 }
780 let key = format!("authflow_used:{sig}");
781 match cache.add(&key, "1", Some(single_use_ttl(url))).await {
782 Ok(true) => Ok(()),
783 Ok(false) => Err(AuthFlowError::AlreadyUsed),
784 Err(e) => {
785 tracing::error!(target: "rustango::auth_flows", error = %e, "single-use token refused: cache failed");
786 Err(AuthFlowError::AlreadyUsed)
787 }
788 }
789}
790
791#[cfg(feature = "cache")]
795fn single_use_ttl(url: &str) -> Duration {
796 let now = std::time::SystemTime::now()
797 .duration_since(std::time::UNIX_EPOCH)
798 .map_or(0, |d| d.as_secs());
799 extract_query(url, "expires")
800 .and_then(|s| s.parse::<u64>().ok())
801 .map(|exp| Duration::from_secs(exp.saturating_sub(now)))
802 .filter(|d| !d.is_zero())
803 .unwrap_or(Duration::from_secs(3600))
804}
805
806use crate::url_codec::{url_decode, url_encode};
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 const SECRET: &[u8] = b"my-test-secret";
819
820 fn scope() -> LinkScope {
821 LinkScope::audience("acme")
822 }
823
824 #[cfg(all(feature = "passwords", feature = "tenancy"))]
826 #[test]
827 fn reset_users_schema_matches_the_user_model() {
828 use crate::core::Model as _;
829 let user = crate::tenancy::User::SCHEMA;
830 assert_eq!(RESET_USERS.table, user.table);
831 for f in RESET_USERS.fields {
832 let u = user.field_by_column(f.column).expect(f.column);
833 assert_eq!((f.ty, f.nullable), (u.ty, u.nullable), "{}", f.column);
834 }
835 }
836
837 #[test]
840 fn password_reset_issue_and_verify_roundtrip() {
841 let url = PasswordReset::issue(
842 &scope(),
843 "https://app.example.com/reset",
844 42,
845 SECRET,
846 Duration::from_secs(3600),
847 );
848 let user_id = PasswordReset::verify(&scope(), &url, SECRET).unwrap();
849 assert_eq!(user_id, 42);
850 }
851
852 #[test]
856 fn links_keep_the_base_query_and_fragment() {
857 let ttl = Duration::from_secs(600);
858 for base in [
859 "https://app.example.com/reset?lang=en",
860 "https://app.example.com/reset?next=/x&",
861 "https://app.example.com/reset#/spa",
862 "https://app.example.com/reset?lang=en#/spa?x=1",
863 ] {
864 let reset = PasswordReset::issue(&scope(), base, 42, SECRET, ttl);
865 let verify = EmailVerification::issue(&scope(), base, 42, "a@b.c", SECRET, ttl);
866 let magic = MagicLink::issue(&scope(), base, "a@b.c", SECRET, ttl);
867 for url in [&reset, &verify, &magic] {
868 let sent = url.split_once('#').map_or(url.as_str(), |(u, _)| u);
869 if let Some((_, frag)) = base.split_once('#') {
870 assert!(url.ends_with(&format!("#{frag}")), "{url}");
871 }
872 if base.contains("lang=en") {
873 assert_eq!(extract_query(sent, "lang").as_deref(), Some("en"), "{url}");
874 }
875 assert_eq!(
876 PasswordReset::verify(&scope(), sent, SECRET).is_ok(),
877 url == &reset
878 );
879 }
880 assert_eq!(
881 PasswordReset::verify(&scope(), &reset, SECRET),
882 Ok(42),
883 "{reset}"
884 );
885 assert_eq!(
886 EmailVerification::verify(&scope(), &verify, SECRET),
887 Ok((42, "a@b.c".to_owned())),
888 "{verify}"
889 );
890 let sent = magic.split_once('#').map_or(magic.as_str(), |(u, _)| u);
891 assert_eq!(
892 MagicLink::verify(&scope(), sent, SECRET).as_deref(),
893 Ok("a@b.c")
894 );
895 }
896 }
897
898 #[test]
900 fn links_are_refused_in_another_scope() {
901 let other = LinkScope::audience("globex");
902 let ttl = Duration::from_secs(600);
903 let reset = PasswordReset::issue(&scope(), "https://x/r", 1, SECRET, ttl);
904 let verify = EmailVerification::issue(&scope(), "https://x/v", 1, "a@x.com", SECRET, ttl);
905 let magic = MagicLink::issue(&scope(), "https://x/l", "a@x.com", SECRET, ttl);
906 assert_eq!(
907 PasswordReset::verify(&other, &reset, SECRET),
908 Err(AuthFlowError::WrongScope)
909 );
910 assert_eq!(
911 EmailVerification::verify(&other, &verify, SECRET),
912 Err(AuthFlowError::WrongScope)
913 );
914 assert_eq!(
915 MagicLink::verify(&other, &magic, SECRET),
916 Err(AuthFlowError::WrongScope)
917 );
918 }
919
920 #[test]
922 fn a_rewritten_scope_fails_the_signature() {
923 let url =
924 PasswordReset::issue(&scope(), "https://x/r", 1, SECRET, Duration::from_secs(600));
925 let forged = url.replace("aud%3Aacme", "aud%3Aglobex");
926 assert_ne!(forged, url);
927 let r = PasswordReset::verify(&LinkScope::audience("globex"), &forged, SECRET);
928 assert_eq!(r, Err(AuthFlowError::InvalidSignature));
929 }
930
931 #[cfg(feature = "tenancy")]
932 fn org(slug: &str, id: i64) -> crate::tenancy::Org {
933 crate::tenancy::Org {
934 id: crate::sql::Auto::Set(id),
935 slug: slug.into(),
936 ..crate::testkit::org()
937 }
938 }
939
940 #[cfg(all(feature = "tenancy", feature = "testkit"))]
943 #[test]
944 fn a_tenant_scope_is_its_org_not_its_slug() {
945 let old = LinkScope::from(&org("acme", 1));
946 let url = PasswordReset::issue(&old, "https://x/r", 1, SECRET, Duration::from_secs(600));
947 for other in [
948 LinkScope::from(&org("acme", 2)),
949 LinkScope::audience("acme"),
950 ] {
951 assert_eq!(
952 PasswordReset::verify(&other, &url, SECRET),
953 Err(AuthFlowError::WrongScope)
954 );
955 }
956 assert_eq!(PasswordReset::verify(&old, &url, SECRET), Ok(1));
957 }
958
959 #[cfg(all(feature = "tenancy", feature = "sqlite", feature = "testkit"))]
962 #[tokio::test]
963 async fn confirm_through_a_tenant_checks_its_scope() {
964 use crate::sql::sqlx;
965 let sq = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
966 let pool = crate::sql::Pool::from(sq.clone());
967 crate::testkit::create_tables_for::<crate::tenancy::User>(&pool)
968 .await
969 .unwrap();
970 let mut u = crate::tenancy::User {
971 username: "ada".into(),
972 password_hash: "OLD".into(),
973 ..crate::testkit::user()
974 };
975 u.insert_pool(&pool).await.unwrap();
976 let id = *u.id.get().unwrap();
977 let tenant = crate::extractors::Tenant::for_test_sqlite(org("b", 2), sq);
978 let ttl = Duration::from_secs(600);
979 let strong = "brand-new-strong-password-7!";
980
981 let foreign = PasswordReset::issue(
982 &LinkScope::from(&org("x", 1)),
983 "https://x/r",
984 id,
985 SECRET,
986 ttl,
987 );
988 let r = confirm_password_reset_pool(&tenant, &foreign, strong, SECRET).await;
989 assert_eq!(r, Err(AuthFlowError::WrongScope));
990
991 let own = PasswordReset::issue(&LinkScope::from(&tenant), "https://b/r", id, SECRET, ttl);
992 assert_eq!(
993 confirm_password_reset_pool(&tenant, &own, strong, SECRET).await,
994 Ok(id)
995 );
996 }
997
998 #[cfg(all(feature = "tenancy", feature = "sqlite", feature = "testkit"))]
1001 #[tokio::test]
1002 async fn confirm_through_a_tenant_scope_checks_its_scope() {
1003 use crate::sql::sqlx;
1004 let sq = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
1005 let pool = crate::sql::Pool::from(sq.clone());
1006 crate::testkit::create_tables_for::<crate::tenancy::User>(&pool)
1007 .await
1008 .unwrap();
1009 let mut u = crate::tenancy::User {
1010 username: "ada".into(),
1011 password_hash: "OLD".into(),
1012 ..crate::testkit::user()
1013 };
1014 u.insert_pool(&pool).await.unwrap();
1015 let id = *u.id.get().unwrap();
1016 let scope = crate::extractors::TenantScope::for_test(org("b", 2), pool);
1017 let ttl = Duration::from_secs(600);
1018 let strong = "brand-new-strong-password-7!";
1019
1020 let audience =
1021 PasswordReset::issue(&LinkScope::audience("app"), "https://b/r", id, SECRET, ttl);
1022 let r = confirm_password_reset_pool(&scope, &audience, strong, SECRET).await;
1023 assert_eq!(r, Err(AuthFlowError::WrongScope));
1024
1025 let own = PasswordReset::issue(&LinkScope::from(&scope), "https://b/r", id, SECRET, ttl);
1026 assert_eq!(
1027 confirm_password_reset_pool(&scope, &own, strong, SECRET).await,
1028 Ok(id)
1029 );
1030 }
1031
1032 #[test]
1034 fn an_unscoped_link_is_refused() {
1035 let url = sign(
1036 "https://x/r?user_id=1&purpose=pwreset&iat=1",
1037 SECRET,
1038 Some(Duration::from_secs(60)),
1039 );
1040 let r = PasswordReset::verify(&scope(), &url, SECRET);
1041 assert_eq!(r, Err(AuthFlowError::WrongScope));
1042 }
1043
1044 #[test]
1045 fn password_reset_wrong_secret_fails() {
1046 let url = PasswordReset::issue(
1047 &scope(),
1048 "https://x/r",
1049 42,
1050 SECRET,
1051 Duration::from_secs(3600),
1052 );
1053 let r = PasswordReset::verify(&scope(), &url, b"different");
1054 assert_eq!(r.unwrap_err(), AuthFlowError::InvalidSignature);
1055 }
1056
1057 #[test]
1058 fn password_reset_tampered_user_id_fails() {
1059 let url = PasswordReset::issue(
1060 &scope(),
1061 "https://x/r",
1062 42,
1063 SECRET,
1064 Duration::from_secs(3600),
1065 );
1066 let tampered = url.replace("user_id=42", "user_id=99");
1067 let r = PasswordReset::verify(&scope(), &tampered, SECRET);
1068 assert_eq!(r.unwrap_err(), AuthFlowError::InvalidSignature);
1069 }
1070
1071 #[test]
1072 fn password_reset_rejects_email_verification_token() {
1073 let url = EmailVerification::issue(
1075 &scope(),
1076 "https://x/r",
1077 42,
1078 "alice@x.com",
1079 SECRET,
1080 Duration::from_secs(3600),
1081 );
1082 let r = PasswordReset::verify(&scope(), &url, SECRET);
1083 assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
1084 }
1085
1086 #[test]
1089 fn email_verification_roundtrip() {
1090 let url = EmailVerification::issue(
1091 &scope(),
1092 "https://x/v",
1093 42,
1094 "alice@example.com",
1095 SECRET,
1096 Duration::from_secs(86_400),
1097 );
1098 let (uid, email) = EmailVerification::verify(&scope(), &url, SECRET).unwrap();
1099 assert_eq!(uid, 42);
1100 assert_eq!(email, "alice@example.com");
1101 }
1102
1103 #[test]
1104 fn email_verification_handles_special_chars() {
1105 let url = EmailVerification::issue(
1106 &scope(),
1107 "https://x/v",
1108 42,
1109 "a+b@example.com",
1110 SECRET,
1111 Duration::from_secs(86_400),
1112 );
1113 let (_, email) = EmailVerification::verify(&scope(), &url, SECRET).unwrap();
1114 assert_eq!(email, "a+b@example.com");
1115 }
1116
1117 #[test]
1118 fn email_verification_rejects_password_reset_token() {
1119 let url = PasswordReset::issue(
1120 &scope(),
1121 "https://x/v",
1122 42,
1123 SECRET,
1124 Duration::from_secs(3600),
1125 );
1126 let r = EmailVerification::verify(&scope(), &url, SECRET);
1127 assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
1128 }
1129
1130 #[test]
1133 fn magic_link_roundtrip() {
1134 let url = MagicLink::issue(
1135 &scope(),
1136 "https://x/login",
1137 "alice@example.com",
1138 SECRET,
1139 Duration::from_secs(900),
1140 );
1141 let email = MagicLink::verify(&scope(), &url, SECRET).unwrap();
1142 assert_eq!(email, "alice@example.com");
1143 }
1144
1145 #[test]
1146 fn magic_link_rejects_password_reset_token() {
1147 let url = PasswordReset::issue(
1148 &scope(),
1149 "https://x/r",
1150 42,
1151 SECRET,
1152 Duration::from_secs(3600),
1153 );
1154 let r = MagicLink::verify(&scope(), &url, SECRET);
1155 assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
1156 }
1157
1158 #[cfg(feature = "cache")]
1159 #[tokio::test]
1160 async fn magic_link_single_use_rejects_replay() {
1161 use crate::cache::InMemoryCache;
1162 let cache: std::sync::Arc<dyn crate::cache::Cache> =
1163 std::sync::Arc::new(InMemoryCache::new());
1164 let url = MagicLink::issue(
1165 &scope(),
1166 "https://x/login",
1167 "alice@example.com",
1168 SECRET,
1169 Duration::from_secs(900),
1170 );
1171 let email = MagicLink::verify_single_use(&scope(), &url, SECRET, &cache)
1173 .await
1174 .unwrap();
1175 assert_eq!(email, "alice@example.com");
1176 let replay = MagicLink::verify_single_use(&scope(), &url, SECRET, &cache).await;
1178 assert!(
1179 matches!(replay, Err(AuthFlowError::AlreadyUsed)),
1180 "{replay:?}"
1181 );
1182 let other = MagicLink::issue(
1184 &scope(),
1185 "https://x/login",
1186 "bob@example.com",
1187 SECRET,
1188 Duration::from_secs(900),
1189 );
1190 assert!(
1191 MagicLink::verify_single_use(&scope(), &other, SECRET, &cache)
1192 .await
1193 .is_ok()
1194 );
1195 }
1196
1197 #[cfg(feature = "cache")]
1198 fn link() -> String {
1199 MagicLink::issue(
1200 &scope(),
1201 "https://x/login",
1202 "alice@example.com",
1203 SECRET,
1204 Duration::from_secs(900),
1205 )
1206 }
1207
1208 #[cfg(feature = "cache")]
1210 struct Racy(crate::cache::InMemoryCache, tokio::sync::Barrier);
1211
1212 #[cfg(feature = "cache")]
1213 #[async_trait::async_trait]
1214 impl crate::cache::Cache for Racy {
1215 async fn get(&self, k: &str) -> Result<Option<String>, crate::cache::CacheError> {
1216 self.0.get(k).await
1217 }
1218 async fn set(
1219 &self,
1220 k: &str,
1221 v: &str,
1222 t: Option<Duration>,
1223 ) -> Result<(), crate::cache::CacheError> {
1224 self.0.set(k, v, t).await
1225 }
1226 async fn delete(&self, k: &str) -> Result<(), crate::cache::CacheError> {
1227 self.0.delete(k).await
1228 }
1229 async fn exists(&self, k: &str) -> Result<bool, crate::cache::CacheError> {
1230 let seen = self.0.exists(k).await;
1231 self.1.wait().await;
1232 seen
1233 }
1234 async fn clear(&self) -> Result<(), crate::cache::CacheError> {
1235 self.0.clear().await
1236 }
1237 async fn add(
1238 &self,
1239 k: &str,
1240 v: &str,
1241 t: Option<Duration>,
1242 ) -> Result<bool, crate::cache::CacheError> {
1243 self.0.add(k, v, t).await
1244 }
1245 }
1246
1247 #[cfg(feature = "cache")]
1249 #[tokio::test]
1250 async fn concurrent_redemptions_of_one_link_yield_one() {
1251 let cache: std::sync::Arc<dyn crate::cache::Cache> = std::sync::Arc::new(Racy(
1252 crate::cache::InMemoryCache::new(),
1253 tokio::sync::Barrier::new(2),
1254 ));
1255 let (url, scope) = (link(), scope());
1256 let (a, b) = tokio::time::timeout(Duration::from_secs(5), async {
1257 tokio::join!(
1258 MagicLink::verify_single_use(&scope, &url, SECRET, &cache),
1259 MagicLink::verify_single_use(&scope, &url, SECRET, &cache),
1260 )
1261 })
1262 .await
1263 .expect("no deadlock");
1264 assert_eq!(u8::from(a.is_ok()) + u8::from(b.is_ok()), 1, "{a:?} {b:?}");
1265 }
1266
1267 #[cfg(feature = "cache")]
1269 #[tokio::test]
1270 async fn a_null_cache_refuses_single_use() {
1271 let cache: std::sync::Arc<dyn crate::cache::Cache> =
1272 std::sync::Arc::new(crate::cache::NullCache);
1273 let r = MagicLink::verify_single_use(&scope(), &link(), SECRET, &cache).await;
1274 assert_eq!(r, Err(AuthFlowError::AlreadyUsed));
1275 }
1276
1277 #[cfg(feature = "cache")]
1279 struct WriteFails;
1280
1281 #[cfg(feature = "cache")]
1282 #[async_trait::async_trait]
1283 impl crate::cache::Cache for WriteFails {
1284 async fn get(&self, _: &str) -> Result<Option<String>, crate::cache::CacheError> {
1285 Ok(None)
1286 }
1287 async fn set(
1288 &self,
1289 _: &str,
1290 _: &str,
1291 _: Option<Duration>,
1292 ) -> Result<(), crate::cache::CacheError> {
1293 Err(crate::cache::CacheError::Connection("down".into()))
1294 }
1295 async fn delete(&self, _: &str) -> Result<(), crate::cache::CacheError> {
1296 Ok(())
1297 }
1298 async fn exists(&self, _: &str) -> Result<bool, crate::cache::CacheError> {
1299 Ok(false)
1300 }
1301 async fn clear(&self) -> Result<(), crate::cache::CacheError> {
1302 Ok(())
1303 }
1304 }
1305
1306 #[cfg(feature = "cache")]
1308 #[tokio::test]
1309 async fn a_failed_marker_write_refuses() {
1310 let cache: std::sync::Arc<dyn crate::cache::Cache> = std::sync::Arc::new(WriteFails);
1311 let r = MagicLink::verify_single_use(&scope(), &link(), SECRET, &cache).await;
1312 assert_eq!(r, Err(AuthFlowError::AlreadyUsed));
1313 }
1314
1315 #[test]
1318 fn extract_query_picks_right_param() {
1319 let url = "https://x/path?a=1&b=2&c=3";
1320 assert_eq!(extract_query(url, "b"), Some("2".to_owned()));
1321 assert_eq!(extract_query(url, "missing"), None);
1322 }
1323
1324 #[test]
1325 fn extract_query_handles_url_encoded_value() {
1326 let url = "https://x/path?email=alice%40x.com";
1327 assert_eq!(extract_query(url, "email"), Some("alice@x.com".to_owned()));
1328 }
1329
1330 #[test]
1331 fn missing_purpose_param_treated_as_malformed() {
1332 let url = sign(
1334 "https://x/r?user_id=42",
1335 SECRET,
1336 Some(Duration::from_secs(60)),
1337 );
1338 let r = PasswordReset::verify(&scope(), &url, SECRET);
1339 assert_eq!(r.unwrap_err(), AuthFlowError::Malformed);
1340 }
1341}