Add email + OTP sign-in flow for cloud sync
Replace the "drop a session token in a file" workflow with a real Chrome-style email login. The Cloudflare worker already had Better Auth's `email-otp` plugin wired into `SEND_EMAIL`; this commit builds the renderer-side counterpart. Worker side: - Move the OTP sender from `auth@elydora.com` to `browser@elydora.com` (wrangler.toml `allowed_sender_addresses` + better_auth.ts `EMAIL_OTP_FROM_ADDRESS`). Worker must be redeployed to pick this up. Client side (`ely_sync_client::email_otp`): - `send_email_otp(config, email)` POSTs `/api/auth/email-otp/send-verification-otp` with `{ email, type: "sign-in" }`. - `verify_email_otp(config, email, otp)` POSTs `/api/auth/sign-in/email-otp`, reads the Better Auth session token from the JSON body's `token` field with the `Set-Cookie: better-auth.session_token=…` header as the documented fallback channel, and returns it as a `BearerToken`. Shell side (`shell/auth.rs` + `shell/internal_pages/sync.rs`): - New `AuthFlowPhase` (Idle / SendingCode / AwaitingOtp / Verifying / Error) tracks the in-flight form. Two off-thread workers run the HTTP exchanges so the GPUI render loop never blocks. - Successful verify saves the bearer via `SyncEngine::install_bearer` and triggers an immediate snapshot upload, so the user is signed in + initial-synced in one click. - Sync settings page replaces the bare "Sync now" button row with an account card: when SignedOut → email field + Send code → OTP field + Verify / Resend; when signed in → an account chip + Sign out. - `trigger_cloud_sync_upload` no longer takes a `Context` param so the post-auth path can fire it from the inbox-drain pass without needing a window context.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
//! 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 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 = 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.or(cookie_token).ok_or_else(|| {
|
||||
SyncClientError::TokenStorage(
|
||||
"sign-in response did not include a session token".to_string(),
|
||||
)
|
||||
})?;
|
||||
BearerToken::new(token)
|
||||
}
|
||||
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<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(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)), Some("abc.def.ghi".to_string()),);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user