use std::sync::Arc;
use axum::Json;
use axum::Router;
use axum::extract::{Query, State};
use axum::routing::{get, post};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use cheers::email::magic_link::{MagicLinkProvider, UsedJtiStore};
use cheers::email::{MagicLinkEmail, Mailer};
use cheers_core::{DeviceBinding, DeviceId, TokenMinter};
use cheers_server::{
NewUser, ProviderKey, RefreshStore, RevocationWriter, SessionAuthority, UserStore,
};
use crate::error::RouteError;
use crate::me::SessionRecorder;
use crate::session::SessionBody;
pub struct MagicLinkAuthState<M, R, U, W, S, MA> {
pub provider: Arc<MagicLinkProvider<S>>,
pub mailer: Arc<MA>,
pub authority: Arc<SessionAuthority<M, R, U, W>>,
pub template: MagicLinkEmail,
pub recorder: Arc<dyn SessionRecorder>,
}
impl<M, R, U, W, S, MA> std::fmt::Debug for MagicLinkAuthState<M, R, U, W, S, MA> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MagicLinkAuthState")
.field("template", &self.template)
.finish_non_exhaustive()
}
}
pub fn router<M, R, U, W, S, MA>(state: Arc<MagicLinkAuthState<M, R, U, W, S, MA>>) -> Router
where
M: TokenMinter + Send + Sync + 'static,
R: RefreshStore + Send + Sync + 'static,
U: UserStore + Send + Sync + 'static,
W: RevocationWriter + Send + Sync + 'static,
S: UsedJtiStore + 'static,
MA: Mailer + 'static,
{
Router::new()
.route("/magic-link/request", post(request::<M, R, U, W, S, MA>))
.route("/magic-link/verify", get(verify::<M, R, U, W, S, MA>))
.with_state(state)
}
#[derive(Debug, Clone, Deserialize)]
pub struct RequestBody {
pub email: String,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct RequestAck {
pub ok: bool,
}
pub async fn request<M, R, U, W, S, MA>(
State(state): State<Arc<MagicLinkAuthState<M, R, U, W, S, MA>>>,
Json(body): Json<RequestBody>,
) -> Result<Json<RequestAck>, RouteError>
where
M: TokenMinter + Send + Sync + 'static,
R: RefreshStore + Send + Sync + 'static,
U: UserStore + Send + Sync + 'static,
W: RevocationWriter + Send + Sync + 'static,
S: UsedJtiStore + 'static,
MA: Mailer + 'static,
{
let now = now_unix();
let req = state.provider.request(&body.email, now).await?;
let msg = state.template.render(&body.email, &req);
state.mailer.send(&msg).await?;
Ok(Json(RequestAck { ok: true }))
}
#[derive(Debug, Clone, Deserialize)]
pub struct VerifyQuery {
pub token: String,
}
pub async fn verify<M, R, U, W, S, MA>(
State(state): State<Arc<MagicLinkAuthState<M, R, U, W, S, MA>>>,
Query(q): Query<VerifyQuery>,
) -> Result<Json<SessionBody>, RouteError>
where
M: TokenMinter + Send + Sync + 'static,
R: RefreshStore + Send + Sync + 'static,
U: UserStore + Send + Sync + 'static,
W: RevocationWriter + Send + Sync + 'static,
S: UsedJtiStore + 'static,
MA: Mailer + 'static,
{
let now = now_unix();
let claims = state.provider.consume(&q.token, now).await?;
let users = state.authority.users();
let provider_key = ProviderKey::Email;
let user = match users.find_by_provider(&provider_key, &claims.email).await? {
Some(u) => u,
None => {
let u = users
.create(NewUser::new().with_email(&claims.email))
.await?;
users
.link_provider(&u.id, &provider_key, &claims.email)
.await?;
u
}
};
let device_id = DeviceId::new(generate_device_id());
let session = state
.authority
.establish(
user.id.clone(),
device_id,
DeviceBinding::EmailMagicLink,
now,
)
.await?;
state.recorder.record_new_session(&session).await?;
Ok(Json(SessionBody::from_new_session(session)))
}
fn generate_device_id() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
URL_SAFE_NO_PAD.encode(bytes)
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_device_id_produces_unique_b64url() {
let a = generate_device_id();
let b = generate_device_id();
assert_ne!(a, b);
assert_eq!(a.len(), 22);
assert!(!a.contains('='));
}
}