Skip to main content

watchword_core/
lib.rs

1//! [<img alt="github" src="https://img.shields.io/badge/github-udoprog/watchword-8da0cb?style=for-the-badge&logo=github" height="20">](https://github.com/udoprog/watchword)
2//! [<img alt="crates.io" src="https://img.shields.io/crates/v/watchword-core.svg?style=for-the-badge&color=fc8d62&logo=rust" height="20">](https://crates.io/crates/watchword-core)
3//! [<img alt="docs.rs" src="https://img.shields.io/badge/docs.rs-watchword--core-66c2a5?style=for-the-badge&logoColor=white&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K" height="20">](https://docs.rs/watchword-core)
4//!
5//! The types [watchword]'s server and frontends share: identities, roles,
6//! account state, the JSON wire types of the `/api/auth` routes,
7//! [`AuthError`], and input validation.
8//!
9//! Apps normally use these through watchword, which re-exports them as
10//! `watchword::types` and at its crate root, or through a frontend crate such
11//! as `watchword-yew023`. Released in lockstep with watchword.
12//!
13//! <br>
14//!
15//! ## Features
16//!
17//! The crate is `no_std`, and safe to build for `wasm32-unknown-unknown` with
18//! any feature but `axum08` and `sqll015`. Only `std` is on by default.
19//!
20//! | Feature | What it provides |
21//! |---|---|
22//! | *(none)* | `UserId`, `Role`, `Access`, `Method`, `Conflict`, `AuthConfig`, `MIN_PASSWORD_LEN` and `is_same_origin_path`, the rule a `?next=` is followed by |
23//! | `alloc` | the wire types that hold strings (`UserInfo`, `LoginRequest`, `CreatedUser`, ...), [`AuthError`] and its `Invalid` reasons, `validate_password` and `normalize_email`, and `AuthRequest` and `AuthResponse`, every operation of watchword's service as one value, which the server's `Auth::dispatch` runs and a frontend's transport carries |
24//! | `std` *(default)* | `alloc`, and the standard library for the dependencies that have it |
25//! | `musli02` | musli 0.2 `Encode`/`Decode` on the types, so an app can embed them in its own WebSocket messages |
26//! | `musliweb05` | `alloc` + `musli02` + [`musliweb05`]: the account, user-management and Cloudflare Access settings endpoints and `AuthBroadcast` as musli-web 0.5 API types, with their ids pinned in 32000–32255 |
27//! | `axum08` | `std` + axum 0.8 `IntoResponse` for [`AuthError`], answering with its JSON body and status, and for `AuthResponse`; watchword's `axum08` feature turns it on (not for wasm) |
28//! | `sqll015` | `std` + sqll 0.15 `BindValue`, `Bind` and `FromColumn` for `UserId` (an integer with the same bits) and for `Role`, `Access` and `Method` (their names as text), so an app's own statements bind and read them directly; watchword's `sqll015` feature turns it on. It takes sqll with `threadsafe` only; the app names `bundled` or links its own SQLite (not for wasm) |
29//! | `phrasebookcatalog01` | [`phrasebookcatalog01`]: `L10N`, watchword's messages (the text of its Yew components) as a [phrasebook-catalog] 0.1 catalog, which [phrasebook] 0.2 takes, for a tool such as an app's localization checker that needs them without building a frontend; it needs neither `std` nor `alloc`. watchword-yew023 re-exports it, and watchword's `phrasebookcatalog01` and `yew023` features turn it on |
30//!
31//! [`AuthError`]: https://docs.rs/watchword-core/latest/watchword_core/enum.AuthError.html
32//! [`musliweb05`]: https://docs.rs/watchword-core/latest/watchword_core/musliweb05/index.html
33//! [`phrasebookcatalog01`]: https://docs.rs/watchword-core/latest/watchword_core/phrasebookcatalog01/index.html
34//! [phrasebook-catalog]: https://docs.rs/phrasebook-catalog
35//! [phrasebook]: https://docs.rs/phrasebook
36//! [watchword]: https://docs.rs/watchword
37
38#![no_std]
39#![cfg_attr(watchword_docsrs, feature(doc_cfg))]
40
41#[cfg(feature = "alloc")]
42extern crate alloc;
43
44#[cfg(feature = "std")]
45extern crate std;
46
47use core::fmt;
48use core::num::TryFromIntError;
49use core::str::FromStr;
50
51#[cfg(feature = "alloc")]
52use alloc::format;
53#[cfg(feature = "alloc")]
54use alloc::string::String;
55
56#[cfg(feature = "alloc")]
57use jiff::Timestamp;
58use serde::{Deserialize, Serialize};
59
60#[cfg(feature = "musli02")]
61use musli_core02::{Decode, Encode};
62
63#[cfg(feature = "axum08")]
64mod axum08;
65
66#[cfg(feature = "alloc")]
67mod request;
68
69#[cfg(feature = "alloc")]
70#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
71pub use self::request::{AuthRequest, AuthResponse, CookieChange};
72
73#[cfg(feature = "sqll015")]
74mod sqll015;
75
76#[cfg(feature = "musliweb05")]
77#[cfg_attr(watchword_docsrs, doc(cfg(feature = "musliweb05")))]
78pub mod musliweb05;
79
80#[cfg(feature = "phrasebookcatalog01")]
81#[cfg_attr(watchword_docsrs, doc(cfg(feature = "phrasebookcatalog01")))]
82pub mod phrasebookcatalog01;
83
84#[cfg(all(test, feature = "alloc"))]
85mod tests;
86
87/// The shortest password [`validate_password`] accepts, in bytes.
88pub const MIN_PASSWORD_LEN: usize = 8;
89
90#[cfg(feature = "alloc")]
91#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
92/// Returns why the password is not acceptable, if it is not.
93pub fn validate_password(password: &str) -> Option<Invalid> {
94    if password.len() < MIN_PASSWORD_LEN {
95        return Some(Invalid::PasswordTooShort {
96            min: MIN_PASSWORD_LEN,
97        });
98    }
99
100    None
101}
102
103#[cfg(feature = "alloc")]
104#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
105/// Trims and lowercases an email so lookups and comparisons are
106/// case-insensitive.
107pub fn normalize_email(email: &str) -> String {
108    email.trim().to_lowercase()
109}
110
111/// Whether `path`, a `?next=` value once decoded, is a path on this origin,
112/// and so safe to send a browser to after signing in.
113///
114/// It must start with a single `/` and hold no backslash or control
115/// character, so it can never name another host (`//host`, `/\host`, or
116/// `/<tab>/host`, which browsers read as `//host`) or a scheme. watchword's
117/// Google sign-in and watchword-yew023's `AuthProvider` follow a `next` only
118/// when this holds; anything else is an open redirect.
119///
120/// Check the value once it is decoded, as it is followed: an escape such as
121/// `%2F` decoded once more stays in the path as it is.
122///
123/// ```
124/// use watchword_core::is_same_origin_path;
125///
126/// assert!(is_same_origin_path("/notes?q=1#top"));
127/// assert!(!is_same_origin_path("//evil.example"));
128/// assert!(!is_same_origin_path("/\\evil.example"));
129/// assert!(!is_same_origin_path("https://evil.example"));
130/// ```
131pub fn is_same_origin_path(path: &str) -> bool {
132    let bytes = path.as_bytes();
133
134    bytes.first() == Some(&b'/')
135        && !matches!(bytes.get(1), Some(b'/' | b'\\'))
136        && !bytes.iter().any(|&b| b == b'\\' || b.is_ascii_control())
137}
138
139/// A user's id. Apps convert to and from their own `u64` ids with [`new`]
140/// and [`get`] or `From`, and to and from signed integers with `TryFrom`,
141/// which fails on a value out of the other's range. With the `sqll015`
142/// feature it binds and reads as a sqll column directly.
143///
144/// [`new`]: UserId::new
145/// [`get`]: UserId::get
146#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
147#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
148#[cfg_attr(feature = "musli02", musli(crate = musli_core02, transparent))]
149#[serde(transparent)]
150pub struct UserId(pub u64);
151
152impl UserId {
153    /// The id with the given value.
154    #[inline]
155    pub const fn new(id: u64) -> Self {
156        Self(id)
157    }
158
159    /// The id's value.
160    #[inline]
161    pub const fn get(self) -> u64 {
162        self.0
163    }
164}
165
166impl From<u64> for UserId {
167    #[inline]
168    fn from(id: u64) -> Self {
169        Self(id)
170    }
171}
172
173impl From<UserId> for u64 {
174    #[inline]
175    fn from(id: UserId) -> Self {
176        id.0
177    }
178}
179
180/// Fails on a negative value.
181impl TryFrom<i64> for UserId {
182    type Error = TryFromIntError;
183
184    #[inline]
185    fn try_from(id: i64) -> Result<Self, Self::Error> {
186        Ok(Self(u64::try_from(id)?))
187    }
188}
189
190/// Fails on an id beyond `i64::MAX`.
191impl TryFrom<UserId> for i64 {
192    type Error = TryFromIntError;
193
194    #[inline]
195    fn try_from(id: UserId) -> Result<Self, Self::Error> {
196        i64::try_from(id.0)
197    }
198}
199
200impl fmt::Display for UserId {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        self.0.fmt(f)
203    }
204}
205
206/// Implements `as_str`, `Display` and `FromStr` for a fieldless enum, using
207/// the same lowercase names as its serde form.
208macro_rules! names {
209    ($ty:ident, $error:ident, $what:literal, $($variant:ident => $name:literal),* $(,)?) => {
210        impl $ty {
211            #[doc = concat!("The ", $what, "'s name, as stored and as serialized.")]
212            pub fn as_str(self) -> &'static str {
213                match self {
214                    $($ty::$variant => $name,)*
215                }
216            }
217        }
218
219        impl fmt::Display for $ty {
220            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221                f.write_str(self.as_str())
222            }
223        }
224
225        #[doc = concat!("The error parsing an unknown ", $what, " name.")]
226        #[derive(Debug, Clone, PartialEq, Eq)]
227        pub struct $error;
228
229        impl fmt::Display for $error {
230            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231                f.write_str(concat!("unknown ", $what))
232            }
233        }
234
235        impl core::error::Error for $error {}
236
237        impl FromStr for $ty {
238            type Err = $error;
239
240            fn from_str(s: &str) -> Result<Self, Self::Err> {
241                match s {
242                    $($name => Ok($ty::$variant),)*
243                    _ => Err($error),
244                }
245            }
246        }
247    };
248}
249
250/// A user's role.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
252#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
253#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
254#[serde(rename_all = "lowercase")]
255pub enum Role {
256    /// Manages users, and whatever else the app reserves for admins.
257    Admin,
258    /// An ordinary user.
259    Regular,
260}
261
262names!(Role, UnknownRole, "role", Admin => "admin", Regular => "regular");
263
264/// A user's account state.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
266#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
267#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
268#[serde(rename_all = "lowercase")]
269pub enum Access {
270    /// Created but not yet registered. Signs in only through Cloudflare
271    /// Access, which activates the user.
272    Pending,
273    /// May sign in.
274    Active,
275    /// May not sign in by any path until issued a new link.
276    Revoked,
277}
278
279names!(Access, UnknownAccess, "access state", Pending => "pending", Active => "active", Revoked => "revoked");
280
281/// The credential a user has stored. A user has exactly one, and can switch
282/// between them. Cloudflare Access is not a stored credential.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
284#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
285#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
286#[serde(rename_all = "lowercase")]
287pub enum Method {
288    /// A password (which a pending user may not have set yet).
289    Password,
290    /// A bound Google account.
291    Google,
292}
293
294names!(Method, UnknownMethod, "sign-in method", Password => "password", Google => "google");
295
296#[cfg(feature = "alloc")]
297#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
298/// What the frontend and the admin pages see of a user.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
301#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
302pub struct UserInfo {
303    /// The user's id.
304    pub id: UserId,
305    /// The unique login name, if the user has one.
306    pub login: Option<String>,
307    /// The unique, normalized email, if the user has one.
308    pub email: Option<String>,
309    /// The user's role.
310    pub role: Role,
311    /// The user's account state.
312    pub access: Access,
313    /// The user's stored credential.
314    pub method: Method,
315    /// Whether a password is set.
316    pub has_password: bool,
317    /// When the user was created.
318    #[cfg_attr(feature = "musli02", musli(with = musli_core02::display))]
319    pub created_at: Timestamp,
320    /// The admin acting as this user, when this is the signed-in user of a
321    /// session an admin created by impersonating them. Only the answers about
322    /// the request's own user (`GET /api/auth/me`, sign-in, impersonation and
323    /// the account routes) set it; it is absent from JSON when `None`.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub impersonated_by: Option<Impersonator>,
326}
327
328#[cfg(feature = "alloc")]
329#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
330/// The admin behind an impersonated session; see
331/// [`UserInfo::impersonated_by`].
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
334#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
335pub struct Impersonator {
336    /// The admin's id.
337    pub id: UserId,
338    /// The admin's name to show, as [`UserInfo::name`] gives it.
339    pub name: String,
340}
341
342#[cfg(feature = "alloc")]
343impl UserInfo {
344    /// The name to show: the login if there is one, otherwise the email.
345    pub fn name(&self) -> &str {
346        self.login
347            .as_deref()
348            .or(self.email.as_deref())
349            .unwrap_or_default()
350    }
351
352    /// Whether the user is an admin.
353    pub fn is_admin(&self) -> bool {
354        self.role == Role::Admin
355    }
356}
357
358#[cfg(feature = "alloc")]
359#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
360/// A user in the admin list.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
363#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
364pub struct UserEntry {
365    /// The user.
366    pub user: UserInfo,
367    /// When the user's live registration or reset link expires, if there is
368    /// one.
369    #[cfg_attr(feature = "musli02", musli(with = musli_core02::display::option))]
370    pub link_expires_at: Option<Timestamp>,
371}
372
373#[cfg(feature = "alloc")]
374#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
375/// The answer to `POST /api/auth/users`: the new user and their
376/// registration link, whose token is shown only this once.
377///
378/// Its JSON form is `{"entry": UserEntry, "link": Link}`.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
381#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
382pub struct CreatedUser {
383    /// The new user.
384    pub entry: UserEntry,
385    /// The new user's registration link.
386    pub link: Link,
387}
388
389/// What the sign-in page offers, from `GET /api/auth/config`.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
392#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
393pub struct AuthConfig {
394    /// Password sign-in is offered.
395    pub password: bool,
396    /// Google sign-in is offered.
397    pub google: bool,
398    /// This request carries Cloudflare Access credentials that map to a user.
399    pub cloudflare: bool,
400    /// The app uses login names, not only emails.
401    pub logins: bool,
402}
403
404#[cfg(feature = "alloc")]
405#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
406/// The body of `POST /api/auth/login`.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
409#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
410pub struct LoginRequest {
411    /// A login name or an email.
412    pub login: String,
413    /// The password.
414    pub password: String,
415}
416
417#[cfg(feature = "alloc")]
418#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
419/// What a registration or reset link is for, from
420/// `GET /api/auth/register/{token}`.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
423#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
424pub struct RegisterInfo {
425    /// The user's login name, if any.
426    pub login: Option<String>,
427    /// The user's email, if any.
428    pub email: Option<String>,
429    /// Registering with Google is offered.
430    pub google: bool,
431}
432
433#[cfg(feature = "alloc")]
434#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
435/// The body of `POST /api/auth/register/{token}`.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
438#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
439pub struct RegisterRequest {
440    /// The new password.
441    pub password: String,
442}
443
444#[cfg(feature = "alloc")]
445#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
446/// A registration or reset link's token, shown once when issued.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
449#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
450pub struct Link {
451    /// The token, for the frontend route `/register/{token}`.
452    pub token: String,
453    /// When the link stops working.
454    #[cfg_attr(feature = "musli02", musli(with = musli_core02::display))]
455    pub expires_at: Timestamp,
456}
457
458#[cfg(feature = "alloc")]
459impl Link {
460    /// The link's path on the app, `/register/{token}`, the route the
461    /// frontend registers or resets from.
462    pub fn path(&self) -> String {
463        format!("/register/{}", self.token)
464    }
465
466    /// The link as a full URL under `public_url`, the URL the app is reached
467    /// at (with or without a trailing slash), or as [`path`](Link::path)
468    /// without one.
469    pub fn url(&self, public_url: Option<&str>) -> String {
470        match public_url {
471            Some(base) => format!("{}{}", base.trim_end_matches('/'), self.path()),
472            None => self.path(),
473        }
474    }
475}
476
477#[cfg(feature = "alloc")]
478#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
479/// A user to create. At least one of `login` and `email` is required.
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
482#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
483pub struct NewUser {
484    /// The login name.
485    pub login: Option<String>,
486    /// The email.
487    pub email: Option<String>,
488    /// The role.
489    pub role: Role,
490}
491
492#[cfg(feature = "alloc")]
493#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
494/// A change to a user. `None` leaves a field as it is; `Some(None)` clears an
495/// optional one.
496#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
497#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
498#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
499pub struct UserChange {
500    /// The new login name.
501    #[serde(
502        default,
503        skip_serializing_if = "Option::is_none",
504        with = "double_option"
505    )]
506    pub login: Option<Option<String>>,
507    /// The new email.
508    #[serde(
509        default,
510        skip_serializing_if = "Option::is_none",
511        with = "double_option"
512    )]
513    pub email: Option<Option<String>>,
514    /// The new role.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub role: Option<Role>,
517}
518
519#[cfg(feature = "alloc")]
520#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
521/// The body of `POST /api/auth/account/password`.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
524#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
525pub struct PasswordChange {
526    /// The current password, required when one is set.
527    pub old_password: Option<String>,
528    /// The new password.
529    pub new_password: String,
530}
531
532#[cfg(feature = "alloc")]
533#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
534/// The Cloudflare Access settings an app stores and edits.
535///
536/// The [`Default`] is the safe starting point for a settings form: sign-in
537/// through Access is disabled, the email header is not trusted, and the
538/// Access JWT is verified once it is enabled, so an app that fills in
539/// [`enabled`], [`team_domain`] and [`audience`] on top of it checks every
540/// request's token.
541///
542/// A field missing from its JSON takes its value from the [`Default`], so
543/// settings stored or sent without [`verify_jwt`] still verify the token.
544///
545/// [`enabled`]: Self::enabled
546/// [`verify_jwt`]: Self::verify_jwt
547/// [`team_domain`]: Self::team_domain
548/// [`audience`]: Self::audience
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550#[serde(default)]
551#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
552#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
553pub struct CloudflareConfig {
554    /// Signing in through Cloudflare Access is enabled. Off by default.
555    pub enabled: bool,
556    /// The team's host, such as `example.cloudflareaccess.com`.
557    pub team_domain: String,
558    /// The Access application's audience (AUD) tag.
559    pub audience: String,
560    /// Accept the email header that Access sets. Off by default.
561    ///
562    /// Without [`verify_jwt`](Self::verify_jwt), anyone who can reach the
563    /// server without going through Cloudflare can sign in as any user by
564    /// sending the header, so turn this on alone only when the server is
565    /// reachable through Cloudflare and nothing else.
566    pub trust_email_header: bool,
567    /// Require and verify the Access JWT against the team's signing keys
568    /// and the audience. On by default.
569    ///
570    /// Turning it off leaves [`trust_email_header`](Self::trust_email_header)
571    /// as the only way to sign in, with the email header taken on trust.
572    pub verify_jwt: bool,
573}
574
575#[cfg(feature = "alloc")]
576impl Default for CloudflareConfig {
577    /// Disabled, with the email header untrusted and JWT verification on.
578    fn default() -> Self {
579        Self {
580            enabled: false,
581            team_domain: String::new(),
582            audience: String::new(),
583            trust_email_header: false,
584            verify_jwt: true,
585        }
586    }
587}
588
589#[cfg(feature = "alloc")]
590#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
591/// The error of an `/api/auth` route, sent as the JSON body of its 4xx
592/// response, such as `{"error":"conflict","detail":"email"}` or
593/// `{"error":"invalid","detail":{"reason":"password_too_short","min":8}}`.
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
595#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
596#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
597#[serde(tag = "error", content = "detail", rename_all = "snake_case")]
598pub enum AuthError {
599    /// Not signed in, or the credentials are wrong (401).
600    #[error("not signed in")]
601    Unauthorized,
602    /// Signed in, but not allowed (403).
603    #[error("not allowed")]
604    Forbidden,
605    /// No such user or link (404).
606    #[error("not found")]
607    NotFound,
608    /// The link was used or has expired (410).
609    #[error("the link was used or has expired")]
610    Gone,
611    /// Too many sign-in attempts (429).
612    #[error("too many attempts, try again later")]
613    TooManyRequests,
614    /// The login or email is taken (409).
615    #[error("{0} is already taken")]
616    Conflict(Conflict),
617    /// The request is invalid, and why (400).
618    #[error("{0}")]
619    Invalid(Invalid),
620}
621
622#[cfg(feature = "alloc")]
623impl AuthError {
624    /// The HTTP status code the error is sent with.
625    pub fn status(&self) -> u16 {
626        match self {
627            AuthError::Unauthorized => 401,
628            AuthError::Forbidden => 403,
629            AuthError::NotFound => 404,
630            AuthError::Gone => 410,
631            AuthError::TooManyRequests => 429,
632            AuthError::Conflict(_) => 409,
633            AuthError::Invalid(_) => 400,
634        }
635    }
636}
637
638#[cfg(feature = "alloc")]
639#[cfg_attr(watchword_docsrs, doc(cfg(feature = "alloc")))]
640/// Why a request is invalid, the detail of [`AuthError::Invalid`].
641///
642/// On the wire a reason is a stable code with its parameters, such as
643/// `{"reason":"password_too_short","min":8}`, so a frontend can phrase it in
644/// its own language. [`Display`](fmt::Display) gives an English message for
645/// the user.
646///
647/// New reasons may be added without a breaking release. One this version
648/// does not know, sent by a newer server, decodes as [`Invalid::Unknown`],
649/// so an older frontend still shows that the request was refused.
650#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, thiserror::Error)]
651#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
652#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
653#[serde(tag = "reason", rename_all = "snake_case")]
654#[non_exhaustive]
655pub enum Invalid {
656    /// The password is shorter than `min` bytes; see [`validate_password`].
657    #[error("Must be at least {min} characters.")]
658    PasswordTooShort {
659        /// The shortest password accepted, [`MIN_PASSWORD_LEN`].
660        min: usize,
661    },
662    /// The current password, given to change it, is wrong.
663    #[error("The current password is incorrect.")]
664    PasswordIncorrect,
665    /// The app uses only emails, and the user would have none.
666    #[error("An email is required.")]
667    EmailRequired,
668    /// The user would have neither a login nor an email.
669    #[error("A login or an email is required.")]
670    LoginOrEmailRequired,
671    /// The login name is blank.
672    #[error("The login cannot be empty.")]
673    LoginEmpty,
674    /// A login name was given, but the app uses only emails.
675    #[error("Login names are not used.")]
676    LoginsDisabled,
677    /// A Google user tried to change their own email, which their Google
678    /// account manages.
679    #[error("Your email is managed by your Google account.")]
680    EmailManagedByGoogle,
681    /// The Google account is already linked to another user.
682    #[error("This Google account is already linked to another user.")]
683    GoogleAccountTaken,
684    /// The role cannot be changed through this route, such as on the user's
685    /// own account.
686    #[error("The role cannot be changed here.")]
687    RoleNotChangeable,
688    /// The request body could not be read, such as malformed JSON or a
689    /// missing field.
690    #[error("The request is malformed.")]
691    MalformedRequest,
692    /// A reason this version does not know, from a newer server; its
693    /// parameters are dropped. A server never sends it.
694    ///
695    /// New reasons go after it, at the end, since musli numbers variants by
696    /// position.
697    #[error("The request is invalid.")]
698    #[serde(other)]
699    #[cfg_attr(feature = "musli02", musli(default))]
700    Unknown,
701}
702
703/// Which unique field a change conflicts on.
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
705#[cfg_attr(feature = "musli02", derive(Encode, Decode))]
706#[cfg_attr(feature = "musli02", musli(crate = musli_core02))]
707#[serde(rename_all = "lowercase")]
708pub enum Conflict {
709    /// The login name is taken.
710    Login,
711    /// The email is taken.
712    Email,
713}
714
715impl fmt::Display for Conflict {
716    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
717        match self {
718            Conflict::Login => f.write_str("login"),
719            Conflict::Email => f.write_str("email"),
720        }
721    }
722}
723
724#[cfg(feature = "alloc")]
725/// Serde for `Option<Option<T>>`, so that an absent field is `None` and an
726/// explicit `null` is `Some(None)`.
727mod double_option {
728    use serde::{Deserialize, Deserializer, Serialize, Serializer};
729
730    pub fn serialize<T, S>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
731    where
732        T: Serialize,
733        S: Serializer,
734    {
735        match value {
736            Some(inner) => inner.serialize(serializer),
737            None => serializer.serialize_none(),
738        }
739    }
740
741    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
742    where
743        T: Deserialize<'de>,
744        D: Deserializer<'de>,
745    {
746        Option::<T>::deserialize(deserializer).map(Some)
747    }
748}