use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "webauthn")]
use webauthn_rs::prelude::*;
#[derive(Debug, Error)]
pub enum PasswordlessError {
#[error("Invalid token")]
InvalidToken,
#[error("Token expired")]
TokenExpired,
#[error("Token already used")]
TokenUsed,
#[error("WebAuthn error: {0}")]
WebAuthn(String),
#[error("Feature not enabled: {0}")]
FeatureNotEnabled(&'static str),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MagicLinkToken {
pub token: String,
pub identifier: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub used: bool,
}
impl MagicLinkToken {
pub fn generate(
identifier: impl Into<String>,
ttl: std::time::Duration,
) -> Result<Self, PasswordlessError> {
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random()).collect();
let token = hex::encode(bytes);
let now = Utc::now();
let expires_at =
now + Duration::from_std(ttl).map_err(|_| PasswordlessError::InvalidToken)?;
Ok(Self {
token,
identifier: identifier.into(),
created_at: now,
expires_at,
used: false,
})
}
pub fn verify(&self) -> Result<bool, PasswordlessError> {
if self.used {
return Err(PasswordlessError::TokenUsed);
}
if Utc::now() > self.expires_at {
return Err(PasswordlessError::TokenExpired);
}
Ok(true)
}
pub fn mark_used(&mut self) {
self.used = true;
}
pub fn to_url(&self, base_url: &str) -> String {
format!("{}?token={}", base_url, self.token)
}
}
#[cfg(feature = "webauthn")]
#[derive(Debug, Clone)]
pub struct WebAuthnConfig {
pub rp_id: String,
pub rp_name: String,
pub origin: Url,
}
#[cfg(feature = "webauthn")]
impl WebAuthnConfig {
pub fn new(
rp_id: impl Into<String>,
rp_name: impl Into<String>,
origin: impl Into<String>,
) -> Result<Self, PasswordlessError> {
Ok(Self {
rp_id: rp_id.into(),
rp_name: rp_name.into(),
origin: Url::parse(&origin.into())
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?,
})
}
}
#[cfg(feature = "webauthn")]
pub struct WebAuthnManager {
webauthn: Webauthn,
}
#[cfg(feature = "webauthn")]
impl WebAuthnManager {
pub fn new(config: WebAuthnConfig) -> Result<Self, PasswordlessError> {
let rp_origin = config.origin.clone();
let builder = WebauthnBuilder::new(&config.rp_id, &rp_origin)
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?;
let builder = builder.rp_name(&config.rp_name);
let webauthn = builder
.build()
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?;
Ok(Self { webauthn })
}
pub fn start_registration(
&self,
user_id: &[u8],
username: &str,
display_name: &str,
) -> Result<(CreationChallengeResponse, PasskeyRegistration), PasswordlessError> {
let user_unique_id = UserId::from(user_id);
let (ccr, reg_state) = self
.webauthn
.start_passkey_registration(user_unique_id, username, display_name, None)
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?;
Ok((ccr, reg_state))
}
pub fn finish_registration(
&self,
reg: &RegisterPublicKeyCredential,
state: &PasskeyRegistration,
) -> Result<Passkey, PasswordlessError> {
self.webauthn
.finish_passkey_registration(reg, state)
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))
}
pub fn start_authentication(
&self,
passkeys: &[Passkey],
) -> Result<(RequestChallengeResponse, PasskeyAuthentication), PasswordlessError> {
self.webauthn
.start_passkey_authentication(passkeys)
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))
}
pub fn finish_authentication(
&self,
auth: &PublicKeyCredential,
state: &PasskeyAuthentication,
) -> Result<AuthenticationResult, PasswordlessError> {
self.webauthn
.finish_passkey_authentication(auth, state)
.map_err(|e| PasswordlessError::WebAuthn(e.to_string()))
}
}
#[cfg(not(feature = "webauthn"))]
pub struct WebAuthnManager;
#[cfg(not(feature = "webauthn"))]
impl WebAuthnManager {
pub fn new(_config: ()) -> Result<Self, PasswordlessError> {
Err(PasswordlessError::FeatureNotEnabled("webauthn"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_magic_link() {
let token =
MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
.unwrap();
assert!(!token.token.is_empty());
assert_eq!(token.identifier, "user@example.com");
assert!(!token.used);
}
#[test]
fn test_verify_magic_link() {
let token =
MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
.unwrap();
assert!(token.verify().is_ok());
}
#[test]
fn test_magic_link_url() {
let token =
MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
.unwrap();
let url = token.to_url("https://example.com/auth/verify");
assert!(url.starts_with("https://example.com/auth/verify?token="));
}
#[test]
fn test_magic_link_used() {
let mut token =
MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
.unwrap();
token.mark_used();
assert!(matches!(token.verify(), Err(PasswordlessError::TokenUsed)));
}
#[test]
fn test_expired_magic_link() {
let token = MagicLinkToken::generate(
"user@example.com",
std::time::Duration::from_secs(0), )
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(matches!(
token.verify(),
Err(PasswordlessError::TokenExpired)
));
}
}