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}