use error_stack::{Report, ResultExt};
use tower_cookies::Cookies;
use uuid::Uuid;
use super::{AuthError, UserId};
use crate::server::FiligreeState;
#[derive(Debug)]
pub struct PasswordlessLoginRequestAnswer {
pub token: Uuid,
pub new_user: bool,
}
pub async fn setup_passwordless_login(
state: &FiligreeState,
email: String,
) -> Result<PasswordlessLoginRequestAnswer, Report<AuthError>> {
let token = Uuid::new_v4();
let found_email = {
let result = sqlx::query!(
"UPDATE email_logins
SET passwordless_login_token = $2,
passwordless_login_expires_at = now() + interval '1 hour'
WHERE email = $1",
email,
&token
)
.execute(&state.db)
.await
.change_context(AuthError::Db)?;
result.rows_affected() > 0
};
if found_email {
Ok(PasswordlessLoginRequestAnswer {
token,
new_user: false,
})
} else if state.new_user_flags.allow_public_signup {
sqlx::query!(
"INSERT INTO user_invites (email, organization_id, token, token_expires_at)
VALUES ($1, NULL, $2, now() + interval '1 hour')
ON CONFLICT(email, organization_id)
DO UPDATE SET invite_sent_at = now(),
token = $2,
token_expires_at = now() + interval '1 hour'",
email,
token
)
.execute(&state.db)
.await
.change_context(AuthError::Db)?;
Ok(PasswordlessLoginRequestAnswer {
token,
new_user: true,
})
} else {
Err(Report::from(AuthError::UserNotFound))
}
}
pub async fn perform_passwordless_login(
state: &FiligreeState,
cookies: &Cookies,
email: String,
token: Uuid,
) -> Result<(), Report<AuthError>> {
let result = sqlx::query!(
r##"
UPDATE email_logins upd
SET passwordless_login_token = null,
passwordless_login_expires_at = null,
verified = upd.verified OR
(upd.passwordless_login_token = $2 AND upd.passwordless_login_expires_at > now())
-- self-join since it lets us get the token even while we clear it in the UPDATE
FROM email_logins old
WHERE old.email = upd.email
AND upd.email = $1
AND upd.passwordless_login_token IS NOT NULL
RETURNING old.user_id AS "user_id: UserId",
(old.passwordless_login_token = $2 AND old.passwordless_login_expires_at > now()) AS valid
"##,
email,
token
)
.fetch_optional(&state.db)
.await
.change_context(AuthError::Db)?;
let user = result
.as_ref()
.filter(|r| r.valid.unwrap_or(false))
.map(|r| r.user_id);
let Some(user_id) = user else {
return Err(Report::new(AuthError::InvalidToken));
};
state
.session_backend
.create_session(cookies, &user_id)
.await
.change_context(AuthError::SessionBackend)?;
Ok(())
}
pub async fn check_signup_request(
state: &FiligreeState,
email: &str,
token: Uuid,
) -> Result<(), Report<AuthError>> {
let result = sqlx::query!(
"DELETE FROM user_invites
WHERE email=$1 AND organization_id IS NULL
RETURNING token, token_expires_at",
email
)
.fetch_optional(&state.db)
.await
.change_context(AuthError::Db)?
.ok_or(AuthError::InvalidToken)?;
if result.token != token || result.token_expires_at < chrono::Utc::now() {
return Err(Report::new(AuthError::InvalidToken));
}
Ok(())
}