Files
ELY-Browser/crates/ely_sync_client/src/email_otp.rs
T

161 lines
5.8 KiB
Rust

//! Email + OTP sign-in flow against the Better Auth `email-otp` plugin
//! exposed by the Cloudflare worker.
//!
//! Two endpoints are involved, both unauthenticated and rate-limited
//! by the worker:
//!
//! - `POST /api/auth/email-otp/send-verification-otp` triggers the
//! email send. Body: `{ email, type: "sign-in" }`. Returns 200 on
//! success.
//! - `POST /api/auth/sign-in/email-otp` exchanges the typed code for
//! a Better Auth session. Body: `{ email, otp }`. Returns 200 with
//! `{ token, user, … }` and a `Set-Cookie: better-auth.session_token`
//! header. We accept either delivery channel — the JSON body's
//! `token` field wins, with the cookie as the canonical fallback.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use ureq::{Agent, AgentBuilder};
use zeroize::Zeroizing;
use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION"));
#[derive(Serialize)]
struct SendOtpRequest<'a> {
email: &'a str,
#[serde(rename = "type")]
purpose: &'static str,
}
#[derive(Serialize)]
struct VerifyOtpRequest<'a> {
email: &'a str,
otp: &'a str,
}
#[derive(Deserialize)]
struct VerifyOtpResponse {
#[serde(default)]
token: Option<String>,
}
/// Ask the worker to email a one-time code to `email`. The actual
/// delivery happens through the Cloudflare `SEND_EMAIL` binding
/// (sender `browser@elydora.com`). Returns when the worker has
/// accepted the request — it does not wait for the recipient's MTA.
pub fn send_email_otp(config: &ApiClientConfig, email: &str) -> Result<(), SyncClientError> {
let endpoint = format!(
"{}/api/auth/email-otp/send-verification-otp",
config.base_url().trim_end_matches('/')
);
let agent = build_agent();
let response = agent
.post(&endpoint)
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(SendOtpRequest { email, purpose: "sign-in" }).map_err(
|error| SyncClientError::Json { endpoint: endpoint.clone(), source: error },
)?);
match response {
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, raw)) => {
let body = raw.into_string().unwrap_or_default();
Err(SyncClientError::HttpStatus { endpoint, status, body })
}
Err(other) => Err(SyncClientError::Http { endpoint, source: Box::new(other) }),
}
}
/// Verify the user's OTP and return the freshly-issued Better Auth
/// session token wrapped as a [`BearerToken`].
pub fn verify_email_otp(
config: &ApiClientConfig,
email: &str,
otp: &str,
) -> Result<BearerToken, SyncClientError> {
let endpoint =
format!("{}/api/auth/sign-in/email-otp", config.base_url().trim_end_matches('/'));
let agent = build_agent();
let response = agent
.post(&endpoint)
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(VerifyOtpRequest { email, otp }).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?);
match response {
Ok(ok) => {
let cookie_token = better_auth_cookie_token(ok.header("set-cookie"));
let body =
Zeroizing::new(ok.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.clone(),
status: 200,
body: error.to_string(),
})?);
let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?;
let token = json.token.map(Zeroizing::new).or(cookie_token).ok_or_else(|| {
SyncClientError::TokenStorage(
"sign-in response did not include a session token".to_string(),
)
})?;
BearerToken::new(token.as_str())
}
Err(ureq::Error::Status(status, raw)) => {
let body = raw.into_string().unwrap_or_default();
Err(SyncClientError::HttpStatus { endpoint, status, body })
}
Err(other) => Err(SyncClientError::Http { endpoint, source: Box::new(other) }),
}
}
fn build_agent() -> Agent {
AgentBuilder::new().timeout(REQUEST_TIMEOUT).user_agent(USER_AGENT).build()
}
/// Better Auth ships the session through a `Set-Cookie:
/// better-auth.session_token=<token>; …` header. Strip the cookie's
/// attributes and return just the value. Multi-cookie responses are
/// concatenated by `ureq` into a single header line per spec.
fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<Zeroizing<String>> {
let header = set_cookie?;
for cookie in header.split(',') {
let trimmed = cookie.trim();
if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") {
let token = rest.split(';').next()?.trim();
if !token.is_empty() {
return Some(Zeroizing::new(token.to_string()));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::better_auth_cookie_token;
#[test]
fn picks_session_token_out_of_set_cookie_header() {
let header =
"better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax";
assert_eq!(
better_auth_cookie_token(Some(header)).as_deref().map(String::as_str),
Some("abc.def.ghi"),
);
}
#[test]
fn ignores_unrelated_cookies() {
let header = "csrf=value; Path=/, other=cookie";
assert_eq!(better_auth_cookie_token(Some(header)), None);
}
#[test]
fn returns_none_when_header_is_absent() {
assert_eq!(better_auth_cookie_token(None), None);
}
}