lib.rs
⎇
Raw
1//! The HTTP wire contract of filebrowser-ng in one place.
2//!
3//! Both the server (axum) and the web frontend (wasm `fetch`) import these
4//! endpoint paths, query params and serde types, so the two sides cannot
5//! drift apart. Serde only — no axum, no wasm dependencies.
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// Endpoint paths (single source of truth for the route table and the client)
11// ---------------------------------------------------------------------------
12
13pub const AUTH_LOGIN: &str = "/api/auth/login";
14pub const AUTH_LOGOUT: &str = "/api/auth/logout";
15pub const AUTH_ME: &str = "/api/auth/me";
16pub const AUTH_SETUP: &str = "/api/auth/setup";
17/// Change or set the signed-in user's password (`POST`), or remove it
18/// (`DELETE`, passkey-only accounts).
19pub const AUTH_PASSWORD: &str = "/api/auth/password";
20/// `PUT` the signed-in user's sign-in requirement ([`AuthMode`]).
21pub const AUTH_MODE: &str = "/api/auth/mode";
22/// The signed-in user's passkeys: `GET {AUTH_PASSKEYS}` lists them,
23/// `DELETE {AUTH_PASSKEYS}/{id}` removes one.
24pub const AUTH_PASSKEYS: &str = "/api/auth/passkeys";
25/// Start registering a new passkey (`POST`). Finished at
26/// `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
27pub const AUTH_PASSKEYS_REGISTER: &str = "/api/auth/passkeys/register";
28/// Start a passkey sign-in (`POST`, no session needed). Finished at
29/// `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
30pub const AUTH_PASSKEY_LOGIN: &str = "/api/auth/passkey/login";
31/// Second leg of both WebAuthn ceremonies: the browser's answer goes to the
32/// begin path plus this suffix.
33pub const FINISH_SUFFIX: &str = "/finish";
34/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
35pub const FILES: &str = "/api/files";
36/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
37pub const SHARES: &str = "/api/shares";
38/// Public share resolve (no login): `{SHARE}/{token}`.
39pub const SHARE: &str = "/api/share";
40/// Suffix on `{SHARE}/{token}`: submit the password of a protected share.
41pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
42/// `GET /api/search` — name and/or content search, streamed as SSE.
43pub const SEARCH: &str = "/api/search";
44
45/// WebDAV mount of the signed-in user's roots: `{DAV}` and `{DAV}/{path...}`.
46pub const DAV: &str = "/dav";
47/// WebDAV mount of one public share: `{DAV_SHARE}/{token}/{path...}`.
48///
49/// A separate top-level path, not a segment under [`DAV`]: there, the first
50/// segment is a root's display name, which a reserved word could collide with.
51pub const DAV_SHARE: &str = "/dav-share";
52
53/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
54pub const ADMIN_USERS: &str = "/api/admin/users";
55/// Admin view of every share on the server: `{ADMIN_SHARES}` and
56/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
57pub const ADMIN_SHARES: &str = "/api/admin/shares";
58pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
59/// Pseudo root id every signed-in admin has on the files API: the whole
60/// server root, read-only (the admin folder picker browses it). Real root
61/// ids are positive database ids. Not listed in `/me`.
62pub const ADMIN_ROOT: i64 = -1;
63
64// ---------------------------------------------------------------------------
65// Query params
66// ---------------------------------------------------------------------------
67
68/// `?action=...` on file URLs; without it the route lists the directory.
69pub const P_ACTION: &str = "action";
70pub const ACTION_DOWNLOAD: &str = "download";
71pub const ACTION_PREVIEW: &str = "preview";
72pub const ACTION_CONTENT: &str = "content";
73pub const ACTION_THUMB: &str = "thumb";
74/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
75/// POST route also carries uploads and mutations.
76pub const ACTION_MKDIR: &str = "mkdir";
77/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
78/// like `mkdir`, for the same reason.
79pub const ACTION_CREATE_FILE: &str = "create-file";
80/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
81/// pre-check for an upload: which of the given targets already exist.
82pub const ACTION_EXISTS: &str = "exists";
83/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
84pub const P_FORMAT: &str = "format";
85/// `?share=<token>` — authenticate file calls with a public share token.
86pub const P_SHARE: &str = "share";
87/// Search query text (`GET {SEARCH}`).
88pub const P_Q: &str = "q";
89/// Which index to search: `name`, `content` or `both`.
90pub const P_SCOPE: &str = "scope";
91/// Root id to search; omitted = the caller's first root.
92pub const P_ROOT: &str = "root";
93/// Folder inside the root to start a search in (relative to the root);
94/// omitted or empty = the whole root.
95pub const P_PATH: &str = "path";
96/// `?overwrite=true|1` on mutations and uploads.
97pub const P_OVERWRITE: &str = "overwrite";
98
99// ---------------------------------------------------------------------------
100// Wire enums
101// ---------------------------------------------------------------------------
102
103/// Access mode of a root or a share.
104///
105/// The serde names are also the values stored in the SQLite `mode` columns,
106/// so renaming a variant would break existing databases. The round-trip test
107/// below pins them.
108#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
109#[serde(rename_all = "lowercase")]
110pub enum Mode {
111 Rw,
112 Ro,
113}
114
115impl Mode {
116 /// The only question callers ask: may this root be written to?
117 pub fn is_writable(self) -> bool {
118 matches!(self, Mode::Rw)
119 }
120
121 /// The wire/database spelling, for `<select>` values and SQL params.
122 pub fn as_str(self) -> &'static str {
123 match self {
124 Mode::Rw => "rw",
125 Mode::Ro => "ro",
126 }
127 }
128
129 /// Parse the wire spelling. `None` for anything else.
130 pub fn from_wire(s: &str) -> Option<Self> {
131 match s {
132 "rw" => Some(Mode::Rw),
133 "ro" => Some(Mode::Ro),
134 _ => None,
135 }
136 }
137}
138
139/// Which mutation [`Mutation`] asks for.
140#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
141#[serde(rename_all = "lowercase")]
142pub enum Op {
143 Rename,
144 Move,
145 Copy,
146}
147
148// ---------------------------------------------------------------------------
149// Request bodies (client → server)
150// ---------------------------------------------------------------------------
151
152#[derive(Serialize, Deserialize)]
153pub struct Credentials {
154 pub name: String,
155 pub password: String,
156}
157
158/// Rename / move / copy (one body for all file mutations).
159#[derive(Serialize, Deserialize)]
160pub struct Mutation {
161 pub op: Op,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub new_name: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub dst_root_id: Option<i64>,
166 /// Destination directory, relative to `dst_root_id`.
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub dst: Option<String>,
169 #[serde(default)]
170 pub overwrite: bool,
171}
172
173/// A user folder: path relative to the server root + access mode.
174#[derive(Serialize, Deserialize)]
175pub struct Root {
176 /// Path relative to the server root; "." means the whole root.
177 pub path: String,
178 #[serde(default = "default_rw")]
179 pub mode: Mode,
180}
181
182fn default_rw() -> Mode {
183 Mode::Rw
184}
185
186#[derive(Serialize, Deserialize)]
187pub struct CreateUser {
188 pub name: String,
189 pub password: String,
190 #[serde(default)]
191 pub is_admin: bool,
192 #[serde(default)]
193 pub roots: Vec<Root>,
194}
195
196#[derive(Serialize, Deserialize)]
197pub struct UpdateUser {
198 /// Setting one is also the recovery path for a locked-out account: it
199 /// deletes every passkey and puts the account back on
200 /// [`AuthMode::Either`], leaving the new password as the one way in.
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub password: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub is_admin: Option<bool>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub active: Option<bool>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub roots: Option<Vec<Root>>,
209}
210
211/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
212#[derive(Serialize, Deserialize, Clone)]
213pub struct Settings {
214 pub allow_writable_shares: bool,
215 /// Folders left out of every search, as paths relative to the server
216 /// root. A path covers everything beneath it.
217 #[serde(default)]
218 pub search_excludes: Vec<String>,
219}
220
221#[derive(Serialize, Deserialize)]
222pub struct CreateShare {
223 pub root_id: i64,
224 /// Item path relative to the root ("" or "." for the root itself).
225 pub path: String,
226 #[serde(default)]
227 pub writable: bool,
228 /// Absolute expiry as RFC 3339; absent = never.
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub expires_at: Option<String>,
231 /// Password the visitor must enter before the share opens; absent = none.
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub password: Option<String>,
234}
235
236/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
237#[derive(Serialize, Deserialize)]
238pub struct UnlockShare {
239 pub password: String,
240}
241
242// ---------------------------------------------------------------------------
243// Responses (server → client)
244// ---------------------------------------------------------------------------
245
246/// What a listing entry actually is, decided by the server from the file's
247/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
248/// not from its name. Drives the icon and the preview the client offers.
249///
250/// Deliberately coarse: this answers "which viewer opens this", not "what
251/// exact format is it". Syntax highlighting still keys off the extension,
252/// because `.h` is C or C++ and no amount of sniffing decides that.
253#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
254#[serde(rename_all = "lowercase")]
255pub enum FileKind {
256 Dir,
257 Image,
258 Video,
259 Audio,
260 Pdf,
261 Archive,
262 /// Anything that decodes as text: source code, markup, config, plain text.
263 Text,
264 /// Recognized-but-not-viewable, or undecodable bytes.
265 Binary,
266}
267
268#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
269pub struct Entry {
270 pub name: String,
271 pub is_dir: bool,
272 pub size: u64,
273 /// RFC 3339 UTC modification time.
274 pub mtime: String,
275 /// Content-sniffed kind (see [`FileKind`]).
276 pub kind: FileKind,
277}
278
279#[derive(Serialize, Deserialize)]
280pub struct FilesResp {
281 pub entries: Vec<Entry>,
282 /// True when the folder had more entries than `MAX_LIST_ENTRIES`.
283 /// `entries` then holds only the first `MAX_LIST_ENTRIES` after sorting.
284 #[serde(default)]
285 pub truncated: bool,
286}
287
288/// Cap on entries in one directory listing.
289pub const MAX_LIST_ENTRIES: usize = 10_000;
290
291/// One streamed search result. Each is sent as one SSE event
292/// (`data: <json>`), in the order found; the `done` event always ends the
293/// stream.
294#[derive(Serialize, Deserialize, Clone)]
295#[serde(tag = "type", rename_all = "snake_case")]
296pub enum SearchEvent {
297 /// A file or folder whose name matched (scope `name`/`both`).
298 File {
299 root_id: i64,
300 /// Path relative to the root, `/`-separated.
301 path: String,
302 size: u64,
303 is_dir: bool,
304 /// Sniffed the same way as a directory listing's, so the client can
305 /// pick an icon and a viewer without a second guess at the name.
306 kind: FileKind,
307 },
308 /// One matching line (scope `content`/`both`). `path` is relative to the
309 /// root; `text` is the matched line, truncated to a fixed length.
310 Match {
311 root_id: i64,
312 path: String,
313 line: u64,
314 text: String,
315 },
316 /// Stream finished. `stopped` is true when the client aborted before the
317 /// search completed.
318 Done {
319 stopped: bool,
320 files: usize,
321 matches: usize,
322 /// Files examined (walked) before the stream ended.
323 scanned: usize,
324 /// Files skipped for content search (over the size cap).
325 skipped: usize,
326 elapsed_ms: u64,
327 },
328}
329
330#[derive(Serialize, Deserialize, Clone, PartialEq)]
331pub struct UserInfo {
332 pub id: i64,
333 pub name: String,
334 pub is_admin: bool,
335 /// Profile setting: single click opens entries (off = click selects,
336 /// double click opens).
337 pub single_click_open: bool,
338 /// Profile setting: show image and video thumbnails in the grid.
339 pub thumbnails: bool,
340 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
341 /// browser.
342 pub language: Option<String>,
343 /// Profile setting: the root the UI opens on page load and on the home
344 /// link. Always one of `Me::roots` (the server drops a stale id), or
345 /// None for the root picker.
346 #[serde(default)]
347 pub default_root_id: Option<i64>,
348 /// What this account needs to sign in.
349 #[serde(default)]
350 pub auth_mode: AuthMode,
351 /// Whether a password is set at all. False means passkeys only.
352 #[serde(default = "yes")]
353 pub has_password: bool,
354}
355
356fn yes() -> bool {
357 true
358}
359
360#[derive(Serialize, Deserialize, Clone)]
361pub struct RootInfo {
362 pub id: i64,
363 pub name: String,
364 pub path: String,
365 pub mode: Mode,
366}
367
368/// GET `{AUTH_ME}`.
369#[derive(Serialize, Deserialize, Clone)]
370pub struct Me {
371 /// True while no users exist yet (first-boot setup).
372 pub first_boot: bool,
373 /// None on first boot.
374 pub user: Option<UserInfo>,
375 pub roots: Vec<RootInfo>,
376 pub allow_writable_shares: bool,
377 /// Whether the server can make thumbnails at all (`--cache` is set).
378 /// The profile setting is only offered when this is true.
379 pub thumbnails_available: bool,
380 /// `--public-url`, if set. The UI builds share links from it instead of
381 /// the page origin.
382 pub public_url: Option<String>,
383}
384
385/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
386#[derive(Serialize, Deserialize, Clone)]
387pub struct ShareInfo {
388 pub id: i64,
389 pub token: String,
390 /// Display name (file/folder name, or the root's name for ".").
391 pub name: String,
392 pub is_file: bool,
393 pub writable: bool,
394 /// Path relative to the server root.
395 pub target: String,
396 /// RFC 3339 UTC creation time.
397 pub created_at: String,
398 /// RFC 3339 UTC expiry; None = never.
399 pub expires_at: Option<String>,
400 /// Synthetic root id to use in file API calls.
401 pub root_id: i64,
402 /// The file's kind for file shares (None for folder shares, and when
403 /// not sniffed — the public resolve endpoint fills it in).
404 pub kind: Option<FileKind>,
405 /// Whether the share asks for a password. Never the password itself.
406 #[serde(default)]
407 pub has_password: bool,
408}
409
410/// One share plus who owns it: GET `{ADMIN_SHARES}`.
411///
412/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
413/// Kept separate from [`ShareInfo`] because the public resolve route answers
414/// with a `ShareInfo` to anonymous visitors.
415#[derive(Serialize, Deserialize, Clone)]
416pub struct AdminShare {
417 #[serde(flatten)]
418 pub share: ShareInfo,
419 pub creator_id: i64,
420 pub creator_name: String,
421 /// Whether the creator's account can still sign in. Deactivating an account
422 /// does not revoke its shares, so `false` marks a live link its owner can no
423 /// longer manage.
424 pub creator_active: bool,
425}
426
427/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
428#[derive(Serialize, Deserialize, Clone)]
429pub struct AdminUser {
430 pub id: i64,
431 pub name: String,
432 pub is_admin: bool,
433 pub active: bool,
434 pub roots: Vec<RootInfo>,
435}
436
437/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
438/// the acknowledgement, so the body is the empty object.
439#[derive(Serialize, Deserialize)]
440pub struct OkResp {}
441
442/// DELETE `{FILES}/...`: whether the removed item was a folder (the client
443/// reports "folder deleted" vs "file deleted").
444#[derive(Serialize, Deserialize)]
445pub struct DeleteResp {
446 pub is_dir: bool,
447}
448
449/// `POST ...?action=exists` body: upload targets relative to the request
450/// directory (may contain subfolders, like upload part names).
451#[derive(Serialize, Deserialize)]
452pub struct ExistsReq {
453 pub paths: Vec<String>,
454}
455
456/// One existing upload target.
457#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
458pub struct Existing {
459 pub path: String,
460 pub is_dir: bool,
461}
462
463/// `POST ...?action=exists` response: the subset of the requested paths
464/// that exist, in request order.
465#[derive(Serialize, Deserialize)]
466pub struct ExistsResp {
467 pub existing: Vec<Existing>,
468}
469
470/// Upload success: how many files were written.
471#[derive(Serialize, Deserialize)]
472pub struct UploadResp {
473 pub uploaded: usize,
474}
475
476/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
477#[derive(Serialize, Deserialize)]
478pub struct SaveResp {
479 pub mtime: i64,
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 /// The serde spellings are the database values too, so they are pinned.
487 #[test]
488 fn mode_wire_format_is_rw_ro() {
489 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
490 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
491 for m in [Mode::Rw, Mode::Ro] {
492 let s = serde_json::to_string(&m).unwrap();
493 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
494 // `as_str`/`from_wire` must agree with serde.
495 assert_eq!(s, format!("\"{}\"", m.as_str()));
496 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
497 }
498 assert_eq!(Mode::from_wire("both"), None);
499 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
500 assert!(Mode::Rw.is_writable());
501 assert!(!Mode::Ro.is_writable());
502 }
503
504 #[test]
505 fn op_wire_format() {
506 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
507 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
508 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
509 for op in [Op::Rename, Op::Move, Op::Copy] {
510 let s = serde_json::to_string(&op).unwrap();
511 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
512 }
513 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
514 }
515
516 #[test]
517 fn mutation_round_trip_skips_absent_fields() {
518 let m = Mutation {
519 op: Op::Move,
520 new_name: None,
521 dst_root_id: Some(3),
522 dst: Some("docs".into()),
523 overwrite: true,
524 };
525 let s = serde_json::to_string(&m).unwrap();
526 assert!(!s.contains("new_name"));
527 let back: Mutation = serde_json::from_str(&s).unwrap();
528 assert_eq!(back.dst_root_id, Some(3));
529 assert_eq!(back.op, Op::Move);
530 }
531
532 #[test]
533 fn mutation_defaults_missing_fields() {
534 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
535 assert!(!m.overwrite);
536 assert_eq!(m.dst, None);
537 }
538
539 #[test]
540 fn root_defaults_mode_to_rw() {
541 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
542 assert_eq!(r.mode, Mode::Rw);
543 }
544
545 #[test]
546 fn me_round_trip() {
547 let me = Me {
548 first_boot: false,
549 user: Some(UserInfo {
550 id: 1,
551 name: "admin".into(),
552 is_admin: true,
553 single_click_open: false,
554 thumbnails: true,
555 language: None,
556 default_root_id: None,
557 auth_mode: AuthMode::Either,
558 has_password: true,
559 }),
560 roots: vec![RootInfo {
561 id: 1,
562 name: "root".into(),
563 path: ".".into(),
564 mode: Mode::Rw,
565 }],
566 allow_writable_shares: false,
567 thumbnails_available: true,
568 public_url: None,
569 };
570 let s = serde_json::to_string(&me).unwrap();
571 let back: Me = serde_json::from_str(&s).unwrap();
572 assert_eq!(back.roots.len(), 1);
573 }
574}
575
576// ---------------------------------------------------------------------------
577// Sign-in methods: password, passkeys, and how they combine
578// ---------------------------------------------------------------------------
579
580/// What an account needs to sign in.
581///
582/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
583/// passkey-only account, which is still two factors when the authenticator
584/// does user verification (the server always asks for it).
585#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
586#[serde(rename_all = "lowercase")]
587pub enum AuthMode {
588 /// Password *or* passkey. Either one alone signs the user in.
589 #[default]
590 Either,
591 /// Password *and* passkey. Both legs must pass, in either order.
592 Both,
593}
594
595impl AuthMode {
596 pub fn as_str(self) -> &'static str {
597 match self {
598 AuthMode::Either => "either",
599 AuthMode::Both => "both",
600 }
601 }
602
603 pub fn from_wire(s: &str) -> Option<Self> {
604 match s {
605 "either" => Some(AuthMode::Either),
606 "both" => Some(AuthMode::Both),
607 _ => None,
608 }
609 }
610}
611
612/// One registered passkey, as shown in profile settings. Never carries key
613/// material.
614#[derive(Serialize, Deserialize, Clone)]
615pub struct PasskeyInfo {
616 pub id: i64,
617 /// User-chosen label ("YubiKey", "Work laptop").
618 pub name: String,
619 /// RFC 3339 UTC.
620 pub created_at: String,
621 pub last_used_at: Option<String>,
622 /// Whether the browser reported this credential as discoverable, so it
623 /// can sign in without the account name. `None` when the browser did not
624 /// say — the `credProps` extension is optional and unsigned, so absence
625 /// means "unknown", never "no".
626 pub discoverable: Option<bool>,
627}
628
629/// `POST {AUTH_PASSWORD}` — set or change the password.
630///
631/// No current password to confirm: a passkey-only account has none to give.
632/// The session is the gate, and the server drops the account's other
633/// sessions on every change.
634#[derive(Serialize, Deserialize)]
635pub struct ChangePassword {
636 pub new_password: String,
637}
638
639/// `PUT {AUTH_MODE}`.
640#[derive(Serialize, Deserialize)]
641pub struct SetAuthMode {
642 pub mode: AuthMode,
643}
644
645/// A WebAuthn challenge on its way to the browser.
646///
647/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
648/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
649/// nested object. Neither side has to re-parse it: the server serializes the
650/// `webauthn-rs` type straight into it, and the client hands it to
651/// `JSON.parse` in the browser shim.
652#[derive(Serialize, Deserialize)]
653pub struct PasskeyChallenge {
654 /// Opaque handle for the server-side ceremony state. Echoed back on
655 /// finish. Not a credential, and useless on its own.
656 pub state_id: String,
657 pub options: String,
658}
659
660/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
661#[derive(Serialize, Deserialize)]
662pub struct PasskeyRegisterFinish {
663 pub state_id: String,
664 /// Label for the new passkey.
665 pub name: String,
666 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
667 pub credential: String,
668}
669
670/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
671#[derive(Serialize, Deserialize, Default)]
672pub struct PasskeyLoginBegin {
673 /// Account name, when the user typed one. Without it the server issues a
674 /// discoverable challenge, which only finds passkeys the authenticator
675 /// stores itself.
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub name: Option<String>,
678 /// Ask for a conditional-mediation (autofill) challenge instead of a
679 /// modal one.
680 #[serde(default)]
681 pub conditional: bool,
682}
683
684/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
685#[derive(Serialize, Deserialize)]
686pub struct PasskeyLoginFinish {
687 pub state_id: String,
688 pub credential: String,
689}
690
691/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
692#[derive(Serialize, Deserialize)]
693pub struct LoginReq {
694 /// Omitted only when `state_id` names a half-finished sign-in, which
695 /// already knows who the user is.
696 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub name: Option<String>,
698 pub password: String,
699 /// Handle from a passkey leg that still needs a password (an
700 /// [`AuthMode::Both`] account signing in passkey-first).
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub state_id: Option<String>,
703}
704
705/// The answer to either sign-in leg.
706///
707/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
708/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
709/// and which one appears depends only on which leg the user started with.
710#[derive(Serialize, Deserialize, Default)]
711pub struct LoginResp {
712 /// True when the session cookie is set and the user is in.
713 pub ok: bool,
714 /// Present when this leg passed but a passkey is still required.
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub passkey_challenge: Option<PasskeyChallenge>,
717 /// Present when this leg passed but the password is still required.
718 /// Carries the account name, so the form can show whose password it
719 /// wants, and the handle to send back with it.
720 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub password_required: Option<PasswordStep>,
722}
723
724#[derive(Serialize, Deserialize, Clone)]
725pub struct PasswordStep {
726 pub name: String,
727 pub state_id: String,
728}
729