Skip to main content

slave_framework/
auth.rs

1//! Passwordless sign-in: one-time codes by email, passkeys, and sessions.
2//!
3//! 1. [`Auth::send_code`] emails a 6-digit code (valid 10 minutes, 5 tries,
4//!    at most 5 codes per address per 15 minutes); [`Auth::verify_code`]
5//!    checks it. The email address is the account.
6//! 2. Once signed in, a user can add a passkey ([`Auth::passkey_register_start`]
7//!    and `_finish`) and later sign in with it ([`Auth::passkey_login_start`]
8//!    and `_finish`).
9//! 3. Either way the app creates a session ([`Auth::create_session`]): an
10//!    opaque token for a cookie or an `Authorization: Bearer` header, valid
11//!    30 days and renewed while used. Ending it ([`Auth::end_session`])
12//!    takes effect at once.
13//!
14//! Only hashes of codes and tokens are stored. The tables (`login_code`,
15//! `session`, `passkey`, `auth_challenge`) are defined in the project's
16//! first migration.
17//!
18//! Settings: `SMTP_URL` (e.g. `smtps://user:pass@smtp.example.com`) and
19//! `MAIL_FROM` send the codes; without `SMTP_URL`, debug builds log them
20//! (and write them to `MAIL_DIR`, if set) instead. `PASSKEY_RP_ID` (default
21//! `localhost`) is the domain passkeys belong to, shared by its subdomains;
22//! `PASSKEY_ORIGIN` (default `http://localhost:8000`) is its web origin.
23
24use std::sync::Arc;
25
26use base64::Engine;
27use base64::engine::general_purpose::URL_SAFE_NO_PAD;
28use lettre::message::Mailbox;
29use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
30use serde_json::Value;
31use sha2::{Digest, Sha256};
32use surrealdb::types::SurrealValue;
33use webauthn_rs::prelude::*;
34
35use crate::database::Db;
36use crate::{Error, Result};
37
38const MAX_ATTEMPTS: i64 = 5;
39const MAX_SENDS: i64 = 5;
40
41/// A signed-in session.
42#[derive(Debug, Clone, SurrealValue)]
43pub struct Session {
44    pub user_id: i64,
45    /// Created by a customer-facing app (a storefront): limited access.
46    pub customer: bool,
47}
48
49/// A passkey ceremony to finish in the browser: `options` go to
50/// `navigator.credentials` (`publicKey`), `id` comes back with the result.
51#[derive(Debug, Clone)]
52pub struct Challenge {
53    pub id: String,
54    pub options: Value,
55}
56
57/// Sign-in for one application. Cheap to clone.
58#[derive(Clone)]
59pub struct Auth {
60    db: Db,
61    app_name: String,
62    mailer: Arc<Mailer>,
63    webauthn: Arc<Webauthn>,
64}
65
66impl std::fmt::Debug for Auth {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("Auth")
69            .field("app_name", &self.app_name)
70            .finish()
71    }
72}
73
74enum Mailer {
75    Smtp {
76        transport: AsyncSmtpTransport<Tokio1Executor>,
77        from: Mailbox,
78    },
79    Log,
80}
81
82impl Auth {
83    /// Reads the settings above. `app_name` names the app in emails and to
84    /// passkey prompts.
85    pub fn from_env(db: Db, app_name: &str) -> Result<Self> {
86        let var = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty());
87        let setup = |err: String| Error::AuthSetup(err);
88        let mailer = match var("SMTP_URL") {
89            Some(url) => Mailer::Smtp {
90                transport: AsyncSmtpTransport::<Tokio1Executor>::from_url(&url)
91                    .map_err(|err| setup(format!("SMTP_URL: {err}")))?
92                    .build(),
93                from: var("MAIL_FROM")
94                    .ok_or_else(|| setup("MAIL_FROM is not set".into()))?
95                    .parse()
96                    .map_err(|err| setup(format!("MAIL_FROM: {err}")))?,
97            },
98            None => Mailer::Log,
99        };
100        let rp_id = var("PASSKEY_RP_ID").unwrap_or_else(|| "localhost".into());
101        let origin = var("PASSKEY_ORIGIN").unwrap_or_else(|| "http://localhost:8000".into());
102        let origin = Url::parse(&origin).map_err(|err| setup(format!("PASSKEY_ORIGIN: {err}")))?;
103        let webauthn = WebauthnBuilder::new(&rp_id, &origin)
104            .and_then(|builder| {
105                builder
106                    .rp_name(app_name)
107                    .allow_subdomains(true)
108                    .allow_any_port(true)
109                    .build()
110            })
111            .map_err(|err| setup(format!("PASSKEY_RP_ID / PASSKEY_ORIGIN: {err}")))?;
112        Ok(Self {
113            db,
114            app_name: app_name.to_owned(),
115            mailer: Arc::new(mailer),
116            webauthn: Arc::new(webauthn),
117        })
118    }
119
120    /// Emails a new sign-in code to `email` (which must be normalized, see
121    /// [`normalize_email`]); earlier codes stop working.
122    pub async fn send_code(&self, email: &str) -> Result<()> {
123        #[derive(SurrealValue)]
124        struct Sent {
125            sent: i64,
126            in_window: bool,
127        }
128        let sent: Option<Sent> = self
129            .db
130            .query(
131                "SELECT sent, window_end > time::now() AS in_window
132                 FROM ONLY type::record('login_code', $email)",
133            )
134            .bind(("email", email.to_owned()))
135            .await?
136            .take(0)?;
137        if sent.is_some_and(|s| s.in_window && s.sent >= MAX_SENDS) {
138            return Err(Error::Auth(
139                "Too many codes were sent to this address; try again in 15 minutes.".into(),
140            ));
141        }
142        let code = format!("{:06}", rand::random_range(0..1_000_000));
143        self.db
144            .query(
145                "UPSERT type::record('login_code', $email) SET
146                     code_hash = $hash, attempts = 0, expires_at = time::now() + 10m,
147                     sent = IF window_end > time::now() { sent + 1 } ELSE { 1 },
148                     window_end = IF window_end > time::now() { window_end } ELSE { time::now() + 15m }",
149            )
150            .bind(("email", email.to_owned()))
151            .bind(("hash", digest(&format!("{email}:{code}"))))
152            .await?
153            .check()?;
154        self.mail(email, &code).await
155    }
156
157    /// Whether `code` is the live code for `email`. A correct code is used up.
158    pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool> {
159        #[derive(SurrealValue)]
160        struct Pending {
161            code_hash: String,
162            attempts: i64,
163            live: bool,
164        }
165        let pending: Option<Pending> = self
166            .db
167            .query(
168                "SELECT code_hash, attempts, expires_at > time::now() AS live
169                 FROM ONLY type::record('login_code', $email)",
170            )
171            .bind(("email", email.to_owned()))
172            .await?
173            .take(0)?;
174        let Some(pending) = pending.filter(|p| p.live && p.attempts < MAX_ATTEMPTS) else {
175            return Ok(false);
176        };
177        let correct = pending.code_hash == digest(&format!("{email}:{}", code.trim()));
178        let update = if correct {
179            "DELETE type::record('login_code', $email)"
180        } else {
181            "UPDATE type::record('login_code', $email) SET attempts += 1"
182        };
183        self.db
184            .query(update)
185            .bind(("email", email.to_owned()))
186            .await?
187            .check()?;
188        Ok(correct)
189    }
190
191    /// Starts a session for `user_id` and returns its token.
192    pub async fn create_session(&self, user_id: i64, customer: bool) -> Result<String> {
193        let token = random_token();
194        self.db
195            .query(
196                "CREATE type::record('session', $hash)
197                 SET user_id = $user_id, customer = $customer, expires_at = time::now() + 30d",
198            )
199            .bind(("hash", digest(&token)))
200            .bind(("user_id", user_id))
201            .bind(("customer", customer))
202            .await?
203            .check()?;
204        Ok(token)
205    }
206
207    /// The live session for `token`, renewed for 30 days when it is past half
208    /// its life.
209    pub async fn session(&self, token: &str) -> Result<Option<Session>> {
210        let session: Option<Session> = self
211            .db
212            .query(
213                "UPDATE type::record('session', $hash)
214                     SET expires_at = IF expires_at < time::now() + 15d { time::now() + 30d } ELSE { expires_at }
215                     WHERE expires_at > time::now()
216                     RETURN user_id, customer",
217            )
218            .bind(("hash", digest(token)))
219            .await?
220            .take(0)?;
221        Ok(session)
222    }
223
224    /// Ends the session for `token` (signing out).
225    pub async fn end_session(&self, token: &str) -> Result<()> {
226        self.db
227            .query("DELETE type::record('session', $hash)")
228            .bind(("hash", digest(token)))
229            .await?
230            .check()?;
231        Ok(())
232    }
233
234    /// Starts adding a passkey for a signed-in user.
235    pub async fn passkey_register_start(&self, user_id: i64, email: &str) -> Result<Challenge> {
236        let existing: Vec<CredentialID> = self
237            .passkeys(user_id)
238            .await?
239            .iter()
240            .map(|passkey| passkey.cred_id().clone())
241            .collect();
242        let (options, state) = self
243            .webauthn
244            .start_passkey_registration(user_uuid(user_id), email, email, Some(existing))
245            .map_err(passkey_error)?;
246        let mut challenge = self.challenge(Some(user_id), &state, &options).await?;
247        // Signing in picks the passkey on the device, so it must be stored there.
248        challenge.options["authenticatorSelection"]["residentKey"] = "required".into();
249        challenge.options["authenticatorSelection"]["requireResidentKey"] = true.into();
250        Ok(challenge)
251    }
252
253    /// Finishes adding a passkey: `credential` is the browser's
254    /// `PublicKeyCredential.toJSON()`.
255    pub async fn passkey_register_finish(
256        &self,
257        user_id: i64,
258        id: &str,
259        credential: Value,
260    ) -> Result<()> {
261        let (owner, state) = self.take_challenge(id).await?;
262        if owner != Some(user_id) {
263            return Err(expired());
264        }
265        let credential = serde_json::from_value(credential).map_err(passkey_error)?;
266        let passkey = self
267            .webauthn
268            .finish_passkey_registration(&credential, &serde_json::from_str(&state)?)
269            .map_err(passkey_error)?;
270        self.db
271            .query("CREATE passkey SET user_id = $user_id, cred_id = $cred_id, data = $data")
272            .bind(("user_id", user_id))
273            .bind(("cred_id", URL_SAFE_NO_PAD.encode(passkey.cred_id())))
274            .bind(("data", serde_json::to_string(&passkey)?))
275            .await?
276            .check()?;
277        Ok(())
278    }
279
280    /// Starts signing in with a passkey (the browser offers the user's own).
281    pub async fn passkey_login_start(&self) -> Result<Challenge> {
282        let (options, state) = self
283            .webauthn
284            .start_discoverable_authentication()
285            .map_err(passkey_error)?;
286        self.challenge(None, &state, &options).await
287    }
288
289    /// Finishes signing in with a passkey and returns whose it is.
290    pub async fn passkey_login_finish(&self, id: &str, credential: Value) -> Result<i64> {
291        let (_, state) = self.take_challenge(id).await?;
292        let credential: PublicKeyCredential =
293            serde_json::from_value(credential).map_err(passkey_error)?;
294        let (uuid, _) = self
295            .webauthn
296            .identify_discoverable_authentication(&credential)
297            .map_err(passkey_error)?;
298        let user_id = uuid.as_u64_pair().1 as i64;
299        let mut passkeys = self.passkeys(user_id).await?;
300        let keys: Vec<DiscoverableKey> = passkeys.iter().map(DiscoverableKey::from).collect();
301        let result = self
302            .webauthn
303            .finish_discoverable_authentication(&credential, serde_json::from_str(&state)?, &keys)
304            .map_err(passkey_error)?;
305        for passkey in &mut passkeys {
306            if passkey.update_credential(&result) == Some(true) {
307                self.db
308                    .query("UPDATE passkey SET data = $data WHERE cred_id = $cred_id")
309                    .bind(("cred_id", URL_SAFE_NO_PAD.encode(passkey.cred_id())))
310                    .bind(("data", serde_json::to_string(&passkey)?))
311                    .await?
312                    .check()?;
313            }
314        }
315        Ok(user_id)
316    }
317
318    async fn passkeys(&self, user_id: i64) -> Result<Vec<Passkey>> {
319        let data: Vec<String> = self
320            .db
321            .query("SELECT VALUE data FROM passkey WHERE user_id = $user_id")
322            .bind(("user_id", user_id))
323            .await?
324            .take(0)?;
325        Ok(data
326            .iter()
327            .filter_map(|data| serde_json::from_str(data).ok())
328            .collect())
329    }
330
331    async fn challenge(
332        &self,
333        user_id: Option<i64>,
334        state: &impl serde::Serialize,
335        options: &impl serde::Serialize,
336    ) -> Result<Challenge> {
337        let id = random_token();
338        self.db
339            .query(
340                "CREATE type::record('auth_challenge', $id)
341                 SET user_id = $user_id, state = $state, expires_at = time::now() + 5m",
342            )
343            .bind(("id", id.clone()))
344            .bind(("user_id", user_id))
345            .bind(("state", serde_json::to_string(state)?))
346            .await?
347            .check()?;
348        let mut options = serde_json::to_value(options)?;
349        Ok(Challenge {
350            id,
351            options: options["publicKey"].take(),
352        })
353    }
354
355    /// The owner and state of challenge `id`, which is used up.
356    async fn take_challenge(&self, id: &str) -> Result<(Option<i64>, String)> {
357        #[derive(SurrealValue)]
358        struct Pending {
359            user_id: Option<i64>,
360            state: String,
361        }
362        let mut response = self
363            .db
364            .query(
365                "LET $c = SELECT user_id, state FROM ONLY type::record('auth_challenge', $id)
366                     WHERE expires_at > time::now();
367                 DELETE type::record('auth_challenge', $id);
368                 RETURN $c;",
369            )
370            .bind(("id", id.to_owned()))
371            .await?;
372        let pending: Option<Pending> = response.take(2)?;
373        let pending = pending.ok_or_else(expired)?;
374        Ok((pending.user_id, pending.state))
375    }
376
377    async fn mail(&self, email: &str, code: &str) -> Result<()> {
378        let subject = format!("{code} is your {} sign-in code", self.app_name);
379        let body = format!(
380            "Your sign-in code for {} is {code}.\n\nIt expires in 10 minutes. \
381             If you did not ask for it, you can ignore this email.\n",
382            self.app_name
383        );
384        match &*self.mailer {
385            Mailer::Smtp { transport, from } => {
386                let to: Mailbox = email
387                    .parse()
388                    .map_err(|_| Error::Auth("That email address is not valid.".into()))?;
389                let message = Message::builder()
390                    .from(from.clone())
391                    .to(to)
392                    .subject(subject)
393                    .body(body)
394                    .map_err(|err| Error::AuthSetup(err.to_string()))?;
395                transport.send(message).await.map_err(|err| {
396                    tracing::error!("sending a sign-in code failed: {err}");
397                    Error::Auth("The code could not be sent; please try again.".into())
398                })?;
399                Ok(())
400            }
401            Mailer::Log if cfg!(debug_assertions) => {
402                tracing::info!("sign-in code for {email}: {code} (SMTP_URL is not set)");
403                if let Ok(dir) = std::env::var("MAIL_DIR") {
404                    std::fs::write(std::path::Path::new(&dir).join(email), code)
405                        .map_err(|err| Error::AuthSetup(format!("MAIL_DIR: {err}")))?;
406                }
407                Ok(())
408            }
409            Mailer::Log => Err(Error::AuthSetup(
410                "SMTP_URL is not set, so sign-in codes cannot be sent".into(),
411            )),
412        }
413    }
414}
415
416/// `email` trimmed and lowercased, if it looks like an address.
417pub fn normalize_email(email: &str) -> Option<String> {
418    let email = email.trim().to_lowercase();
419    let (local, domain) = email.split_once('@')?;
420    let valid = !local.is_empty()
421        && domain.contains('.')
422        && email.len() <= 254
423        && email.parse::<Mailbox>().is_ok();
424    valid.then_some(email)
425}
426
427impl From<serde_json::Error> for Error {
428    fn from(err: serde_json::Error) -> Self {
429        Error::AuthSetup(err.to_string())
430    }
431}
432
433fn random_token() -> String {
434    URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
435}
436
437fn digest(value: &str) -> String {
438    format!("{:x}", Sha256::digest(value.as_bytes()))
439}
440
441fn user_uuid(user_id: i64) -> Uuid {
442    Uuid::from_u64_pair(0, user_id as u64)
443}
444
445fn expired() -> Error {
446    Error::Auth("This sign-in expired; please try again.".into())
447}
448
449fn passkey_error(err: impl std::fmt::Display) -> Error {
450    tracing::debug!("passkey: {err}");
451    Error::Auth("The passkey was not accepted.".into())
452}