latchkey 0.3.0

Sans-IO sign-in building blocks and policy: Argon2id passwords, TOTP, recovery codes, passkeys, tokens, sealing, cookies, lockout, rate limits and sign-in flows as pure state machines
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
//! The sign-in flows as state machines over plain data.
//!
//! Each flow is an `enum` of states with an `advance` method that consumes the state, takes
//! one event (carrying the facts the app loaded: the account, its factor material, the
//! presented device cookie, a verification result) and `now`, and returns either a
//! [`Transition`] (the next state and the [`Effect`]s to carry out) or a [`Refusal`] (the
//! error to answer with and the effects that must still happen, such as counting a failure).
//!
//! The flows never touch storage, the network or the clock. The app:
//!
//! 1. loads what the event needs (and checks its own rate limits),
//! 2. calls `advance`,
//! 3. carries out **every** effect, in order, also on refusal: storage effects ideally in one
//!    transaction, then mails,
//! 4. answers the client from the new state or the refusal's [`Error`].
//!
//! | Flow | Module | Starts | Ends |
//! |---|---|---|---|
//! | Password, then TOTP, recovery code or passkey; or a passkey alone | [`sign_in`] | [`SignIn::Start`](sign_in::SignIn::Start) | [`SignIn::SignedIn`](sign_in::SignIn::SignedIn) |
//! | Emailed sign-in link | [`email_link`] | [`EmailLink::Start`](email_link::EmailLink::Start) | [`EmailLink::SignedIn`](email_link::EmailLink::SignedIn) |
//! | First-open owner setup, and an invitee's setup | [`setup`] | [`Setup::Start`](setup::Setup::Start) | [`Setup::Complete`](setup::Setup::Complete) |
//! | The owner invites a member | [`invite`] | [`Invite::Start`](invite::Invite::Start) | [`Invite::Sent`](invite::Invite::Sent) |
//! | Step-up re-authentication | [`step_up`] | [`StepUp::new`](step_up::StepUp::new) | [`StepUp::Done`](step_up::StepUp::Done) |
//!
//! Single-step actions are plain functions: [`issue_session`], [`sign_out`],
//! [`password_changed`] and [`regenerate_recovery_codes`].

pub mod email_link;
pub mod invite;
pub mod setup;
pub mod sign_in;
pub mod step_up;

use crate::error::InvalidRequestReason;
use rand_core::CryptoRng;
use time::OffsetDateTime;

use crate::Error;
use crate::account::{Account, AccountKey, NewAccount};
use crate::audit::{AuditEvent, AuditKind};
use crate::config::Config;
use crate::http::{IpTag, SetCookie};
use crate::ids::{SessionId, UserId};
use crate::mail::{Mail, MailKind, SignInDetails};
use crate::password::PasswordHash;
use crate::recovery::{self, RecoveryCode, RecoveryCodeHash};
use crate::session::{AppMethod, Session, SignInMethod, Stage};
use crate::token::{self, SecretToken, TokenHash};
use crate::totp::{self, TotpSecret, TotpStep};

/// What a one-time link is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LinkKind {
    /// Owner setup.
    Setup,
    /// A member's invite.
    Invite,
    /// Sign-in.
    SignIn,
}

impl LinkKind {
    /// `setup`, `invite` or `sign_in`, the values latchkey 0.2 stored.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            LinkKind::Setup => "setup",
            LinkKind::Invite => "invite",
            LinkKind::SignIn => "sign_in",
        }
    }
}

/// A one-time link as the app stores it, under its token's hash.
///
/// Redeeming must be single use: delete the row by hash and read it in one step (for
/// example `DELETE ... RETURNING`), then pass what was deleted (or `None`) to the flow,
/// which checks the kind and expiry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct LinkToken {
    /// What it is for.
    pub kind: LinkKind,
    /// Whose it is.
    pub user_id: UserId,
    /// When it was issued; the resend cooldown runs from the latest one.
    pub created_at: OffsetDateTime,
    /// When it stops working.
    pub expires_at: OffsetDateTime,
}

impl LinkToken {
    /// A link record read back from storage.
    #[must_use]
    pub const fn new(
        kind: LinkKind,
        user_id: UserId,
        created_at: OffsetDateTime,
        expires_at: OffsetDateTime,
    ) -> Self {
        Self {
            kind,
            user_id,
            created_at,
            expires_at,
        }
    }
}

impl LinkToken {
    /// Whether it can be redeemed as `kind` at `now`.
    #[must_use]
    pub fn is_redeemable(&self, kind: LinkKind, now: OffsetDateTime) -> bool {
        self.kind == kind && self.expires_at > now
    }
}

/// What the app knows about the client making the request.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Client {
    /// The client IP as an [`IpTag`].
    pub ip_tag: Option<IpTag>,
    /// The `User-Agent`, shortened with
    /// [`truncate_user_agent`](crate::http::truncate_user_agent).
    pub user_agent: Option<String>,
    /// The device cookie the client presented, unchecked.
    pub device_cookie: Option<SecretToken>,
    /// Whether that cookie's hash is already remembered for the account this request signs
    /// in. Look it up with [`TokenHash::of`] before calling the flow; `false` when there is no
    /// cookie.
    pub device_known: bool,
}

impl Client {
    /// A client with this IP tag and user agent, and no device cookie.
    #[must_use]
    pub fn new(ip_tag: Option<IpTag>, user_agent: Option<String>) -> Self {
        Self {
            ip_tag,
            user_agent,
            device_cookie: None,
            device_known: false,
        }
    }

    /// The device cookie the client presented, and whether its hash is remembered for the
    /// account this request signs in.
    #[must_use]
    pub fn with_device_cookie(mut self, cookie: Option<SecretToken>, known: bool) -> Self {
        self.device_known = known && cookie.is_some();
        self.device_cookie = cookie;
        self
    }
}

/// The TOTP material for checking a code.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TotpMaterial {
    /// The confirmed secret (opened with [`DataKey::open_totp`](crate::seal::DataKey::open_totp)).
    pub secret: TotpSecret,
    /// The last step accepted for this account.
    pub last_used_step: Option<TotpStep>,
}

impl TotpMaterial {
    /// The confirmed secret and the last step accepted.
    #[must_use]
    pub fn new(secret: TotpSecret, last_used_step: Option<TotpStep>) -> Self {
        Self {
            secret,
            last_used_step,
        }
    }
}

/// What a TOTP or recovery code is checked against.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct CodeMaterial {
    /// The account's TOTP, if enrolled.
    pub totp: Option<TotpMaterial>,
    /// The hashes of the account's unused recovery codes.
    pub recovery_codes: Vec<RecoveryCodeHash>,
}

impl CodeMaterial {
    /// The account's TOTP, if enrolled, and the hashes of its unused recovery codes.
    #[must_use]
    pub fn new(totp: Option<TotpMaterial>, recovery_codes: Vec<RecoveryCodeHash>) -> Self {
        Self {
            totp,
            recovery_codes,
        }
    }
}

/// Which code matched.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeMatch {
    /// A TOTP code for this step.
    Totp(TotpStep),
    /// This recovery code.
    RecoveryCode(RecoveryCodeHash),
}

/// Checks a code typed into a single "code" field: six digits are a TOTP code (accepted only
/// for a step newer than the last used one), anything else a recovery code.
#[must_use]
pub fn check_code(code: &str, material: &CodeMaterial, now: OffsetDateTime) -> Option<CodeMatch> {
    if totp::looks_like_code(code) {
        let totp = material.totp.as_ref()?;
        return totp::verify(&totp.secret, code, totp.last_used_step, now)
            .ok()
            .map(CodeMatch::Totp);
    }
    recovery::verify(code, &material.recovery_codes).map(CodeMatch::RecoveryCode)
}

/// Something the app must do. Carry out every effect of a [`Transition`] or [`Refusal`], in
/// order.
#[derive(Debug, Clone)]
#[must_use = "effects must be carried out"]
#[non_exhaustive]
pub enum Effect {
    /// Insert the session. With `replaces`, delete the session with that token hash in the
    /// same transaction (a stage change issues a new token).
    StoreSession {
        /// The session to insert.
        session: Session,
        /// The session it replaces.
        replaces: Option<TokenHash>,
    },
    /// Delete the session with this token hash.
    DeleteSession {
        /// Its token hash.
        token_hash: TokenHash,
    },
    /// Send this `Set-Cookie` header.
    SetCookie(SetCookie),
    /// Count one failure, **atomically**: `UPDATE ... SET failures = failures + 1 ...
    /// RETURNING failures` (never read-modify-write from the [`Account`] snapshot, or
    /// concurrent failures are lost). Then, with the returned count `n`, store
    /// [`LockoutPolicy::locked_until`](crate::LockoutPolicy::locked_until)`(n, at)` as
    /// `locked_until` when it is `Some`, and append
    /// [`LockoutPolicy::lockout_event`](crate::LockoutPolicy::lockout_event)`(user_id, n, at,
    /// ip_tag)` when it is `Some`.
    RecordFailure {
        /// Whose.
        user_id: UserId,
        /// Now.
        at: OffsetDateTime,
        /// The client, for the lockout audit event.
        ip_tag: Option<IpTag>,
    },
    /// Reset the account's failure count to 0 and clear `locked_until`.
    ClearFailures {
        /// Whose.
        user_id: UserId,
    },
    /// Count one wrong step-up guess against this session, **atomically**: `UPDATE sessions
    /// SET step_up_failures = step_up_failures + 1 WHERE token_hash = ? RETURNING
    /// step_up_failures`. Only under
    /// [`StepUpFailures::PerSession`](step_up::StepUpFailures::PerSession). When the guess
    /// reaches the limit the flow also ends the session (with
    /// [`DeleteSession`](Effect::DeleteSession)); an app may end it as soon as the returned
    /// count satisfies
    /// [`StepUpFailures::ends_session`](step_up::StepUpFailures::ends_session), which also
    /// covers guesses that raced, and otherwise the next step-up from it is refused and ends
    /// it.
    RecordStepUpFailure {
        /// The session's token hash.
        token_hash: TokenHash,
    },
    /// Reset this session's step-up failure count to 0, after a step-up succeeded. Only under
    /// [`StepUpFailures::PerSession`](step_up::StepUpFailures::PerSession).
    ClearStepUpFailures {
        /// The session's token hash.
        token_hash: TokenHash,
    },
    /// Record a wrong password for the throttle
    /// ([`WrongPasswordThrottle::record`](crate::limits::WrongPasswordThrottle::record)).
    RecordWrongPassword {
        /// The address it counts under.
        key: AccountKey,
    },
    /// Store `step` as the account's last used TOTP step, only if it is newer than the
    /// stored one (compare-and-set); if it is not, the code was raced and the sign-in must
    /// be refused.
    UseTotpStep {
        /// Whose.
        user_id: UserId,
        /// The matched step.
        step: TotpStep,
    },
    /// Mark this recovery code used, only if it is still unused (compare-and-set), as above.
    UseRecoveryCode {
        /// Whose.
        user_id: UserId,
        /// Which.
        hash: RecoveryCodeHash,
    },
    /// Replace the account's recovery codes with these.
    StoreRecoveryCodes {
        /// Whose.
        user_id: UserId,
        /// The new hashes.
        hashes: Vec<RecoveryCodeHash>,
    },
    /// Store a one-time link under its token hash.
    StoreLinkToken {
        /// Look-up key.
        token_hash: TokenHash,
        /// The record.
        link: LinkToken,
    },
    /// Send this mail.
    SendMail(Mail),
    /// Append this audit event.
    Audit(AuditEvent),
    /// Remember (or refresh `last_seen_at` of) the device with this cookie hash for the
    /// account. Forget devices unused for [`Config::device_ttl`].
    RememberDevice {
        /// Whose.
        user_id: UserId,
        /// The device cookie's hash.
        device_hash: TokenHash,
        /// Now.
        seen_at: OffsetDateTime,
    },
    /// Create this inactive account; refuse duplicates by email.
    CreateAccount(NewAccount),
    /// Store the account's new password hash.
    StorePassword {
        /// Whose.
        user_id: UserId,
        /// The hash.
        hash: PasswordHash,
    },
    /// Mark the account active.
    ActivateAccount {
        /// Whose.
        user_id: UserId,
    },
    /// Set the session's `authenticated_at` and `method`: the step-up window starts again.
    MarkStepUp {
        /// Which session.
        token_hash: TokenHash,
        /// Now.
        at: OffsetDateTime,
        /// [`SignInMethod::StepUp`].
        method: SignInMethod,
    },
}

/// A flow moved on: the new state and what to do.
#[derive(Debug)]
#[must_use = "the effects of a transition must be carried out"]
#[non_exhaustive]
pub struct Transition<S> {
    /// The new state.
    pub state: S,
    /// What to do, in order.
    pub effects: Vec<Effect>,
}

impl<S> Transition<S> {
    /// A new state and its effects, for an app's own flow steps and tests.
    pub fn new(state: S, effects: Vec<Effect>) -> Self {
        Self { state, effects }
    }
}

/// A flow refused an event: the error to answer with, and what must still be done (such as
/// counting the failure).
#[derive(Debug, thiserror::Error)]
#[error("{error}")]
#[must_use = "the effects of a refusal must still be carried out"]
#[non_exhaustive]
pub struct Refusal {
    /// The answer for the client.
    #[source]
    pub error: Error,
    /// What to do anyway, in order.
    pub effects: Vec<Effect>,
}

impl From<Error> for Refusal {
    fn from(error: Error) -> Self {
        Self {
            error,
            effects: Vec::new(),
        }
    }
}

impl Refusal {
    /// An error and the effects that must still happen, for an app's own flow steps and
    /// tests.
    pub fn new(error: Error, effects: Vec<Effect>) -> Self {
        Self { error, effects }
    }
}

const UNEXPECTED: Error = Error::InvalidRequest(InvalidRequestReason::UnexpectedFlowEvent);

fn audit(
    kind: AuditKind,
    user_id: Option<UserId>,
    client: &Client,
    now: OffsetDateTime,
) -> AuditEvent {
    AuditEvent::new(kind, user_id, now, client.ip_tag.clone())
}

#[allow(clippy::too_many_arguments)]
fn start_session<R: CryptoRng + ?Sized>(
    config: &Config,
    user_id: UserId,
    stage: Stage,
    method: Option<SignInMethod>,
    client: &Client,
    replaces: Option<TokenHash>,
    now: OffsetDateTime,
    rng: &mut R,
) -> (Session, Vec<Effect>) {
    let ttl = match stage {
        Stage::Full => config.session_ttl(),
        Stage::SecondFactor => config.pending_ttl(),
        Stage::Setup => config.setup_ttl(),
    };
    let issued = token::issue(rng);
    let session = Session {
        id: SessionId::random(rng),
        token_hash: issued.hash,
        user_id,
        stage,
        csrf_token: token::issue(rng).token,
        method,
        created_at: now,
        expires_at: now.saturating_add(ttl),
        authenticated_at: (stage == Stage::Full).then_some(now),
        ip_tag: client.ip_tag.clone(),
        user_agent: client.user_agent.clone(),
        step_up_failures: 0,
    };
    let effects = vec![
        Effect::StoreSession {
            session: session.clone(),
            replaces,
        },
        Effect::SetCookie(SetCookie::new(
            config.cookie_name().clone(),
            &issued.token,
            ttl,
        )),
    ];
    (session, effects)
}

/// Clears failures, starts a full session, audits the sign-in and recognises the device.
fn complete_sign_in<R: CryptoRng + ?Sized>(
    config: &Config,
    account: &Account,
    method: &SignInMethod,
    client: &Client,
    replaces: Option<TokenHash>,
    now: OffsetDateTime,
    rng: &mut R,
) -> (Session, Vec<Effect>) {
    let mut effects = vec![Effect::ClearFailures {
        user_id: account.id,
    }];
    let (session, started) = start_session(
        config,
        account.id,
        Stage::Full,
        Some(method.clone()),
        client,
        replaces,
        now,
        rng,
    );
    effects.extend(started);
    effects.push(Effect::Audit(
        audit(AuditKind::SignIn, Some(account.id), client, now).with_detail(method.as_str()),
    ));
    effects.extend(recognise_device(config, account, method, client, now, rng));
    (session, effects)
}

/// The device cookie is (re)set on every completed sign-in. A device the account has not
/// used before is audited and mailed, except when completing setup, which only records it.
fn recognise_device<R: CryptoRng + ?Sized>(
    config: &Config,
    account: &Account,
    method: &SignInMethod,
    client: &Client,
    now: OffsetDateTime,
    rng: &mut R,
) -> Vec<Effect> {
    let presented = client
        .device_cookie
        .as_ref()
        .and_then(|cookie| cookie.hash().map(|hash| (cookie.clone(), hash)));
    let (cookie, hash, known) = if let Some((cookie, hash)) = presented {
        (cookie, hash, client.device_known)
    } else {
        let issued = token::issue(rng);
        (issued.token, issued.hash, false)
    };
    let mut effects = vec![Effect::RememberDevice {
        user_id: account.id,
        device_hash: hash,
        seen_at: now,
    }];
    if !known && *method != SignInMethod::Setup {
        effects.push(Effect::Audit(
            audit(AuditKind::NewDevice, Some(account.id), client, now).with_detail(method.as_str()),
        ));
        effects.push(Effect::SendMail(Mail {
            kind: MailKind::NewDevice,
            to: account.email.clone(),
            link: config.sessions_url().to_owned(),
            expires_at: None,
            sign_in: Some(SignInDetails {
                at: now,
                method: method.clone(),
                user_agent: client.user_agent.clone(),
            }),
        }));
    }
    effects.push(Effect::SetCookie(SetCookie::new(
        config.device_cookie_name().clone(),
        &cookie,
        config.device_ttl(),
    )));
    effects
}

/// Audits the failure and counts it towards the lockout.
fn failure(account: &Account, detail: &str, client: &Client, now: OffsetDateTime) -> Vec<Effect> {
    vec![
        Effect::Audit(
            audit(AuditKind::SignInFailed, Some(account.id), client, now).with_detail(detail),
        ),
        Effect::RecordFailure {
            user_id: account.id,
            at: now,
            ip_tag: client.ip_tag.clone(),
        },
    ]
}

/// Issues a link and mails it, unless the resend cooldown is running (not for invites).
fn send_link<R: CryptoRng + ?Sized>(
    config: &Config,
    account: &Account,
    kind: LinkKind,
    last_sent_at: Option<OffsetDateTime>,
    client: &Client,
    now: OffsetDateTime,
    rng: &mut R,
) -> Vec<Effect> {
    if kind != LinkKind::Invite
        && last_sent_at.is_some_and(|last| last.saturating_add(config.resend_cooldown()) > now)
    {
        return Vec::new();
    }
    let (ttl, mut link, mail_kind, audit_kind) = match kind {
        LinkKind::Setup => (
            config.link_ttl(),
            config.setup_target().clone(),
            MailKind::Setup,
            AuditKind::SetupLinkSent,
        ),
        LinkKind::SignIn => (
            config.link_ttl(),
            config.sign_in_link_target().clone(),
            MailKind::SignInLink,
            AuditKind::SignInLinkSent,
        ),
        LinkKind::Invite => (
            config.invite_ttl(),
            config.invite_target().clone(),
            MailKind::Invite,
            AuditKind::InviteSent,
        ),
    };
    let issued = token::issue(rng);
    let expires_at = now.saturating_add(ttl);
    link.query_pairs_mut()
        .append_pair("token", issued.token.expose_secret());
    vec![
        Effect::StoreLinkToken {
            token_hash: issued.hash,
            link: LinkToken {
                kind,
                user_id: account.id,
                created_at: now,
                expires_at,
            },
        },
        Effect::SendMail(Mail {
            kind: mail_kind,
            to: account.email.clone(),
            link: link.into(),
            expires_at: Some(expires_at),
            sign_in: None,
        }),
        Effect::Audit(audit(audit_kind, Some(account.id), client, now)),
    ]
}

/// The effects of a code that matched: consume it, and audit a recovery code.
fn use_code(
    account: &Account,
    matched: CodeMatch,
    client: &Client,
    now: OffsetDateTime,
) -> Vec<Effect> {
    match matched {
        CodeMatch::Totp(step) => vec![Effect::UseTotpStep {
            user_id: account.id,
            step,
        }],
        CodeMatch::RecoveryCode(hash) => vec![
            Effect::UseRecoveryCode {
                user_id: account.id,
                hash,
            },
            Effect::Audit(audit(
                AuditKind::RecoveryCodeUsed,
                Some(account.id),
                client,
                now,
            )),
        ],
    }
}

/// Starts a full session for a sign-in the app completed itself, such as a desktop app
/// handed off from a signed-in browser.
///
/// **This checks no factor.** Only call it for an account the app has just verified through
/// latchkey (for example the user of a full session that started the hand-off), never with a
/// user id a client chose. Everything else is as after a normal sign-in: failures cleared, a
/// fresh token, `session_ttl`, `authenticated_at` = `now`, a `sign_in` audit event with
/// `method`, and the new-device check and mail.
///
/// # Errors
///
/// [`Error::Forbidden`] for an inactive account or one whose factors are not
/// [sufficient](crate::Factors::are_sufficient) under [`Config::password_only`] (by default
/// a password and a second factor); [`Error::TooManyAttempts`] while it is locked.
pub fn issue_session<R: CryptoRng + ?Sized>(
    config: &Config,
    account: &Account,
    method: AppMethod,
    client: &Client,
    now: OffsetDateTime,
    rng: &mut R,
) -> Result<Transition<Session>, Error> {
    if !account.active || !account.factors.are_sufficient(config.password_only()) {
        return Err(Error::Forbidden);
    }
    account.lockout.check(now)?;
    let (session, effects) = complete_sign_in(
        config,
        account,
        &SignInMethod::App(method),
        client,
        None,
        now,
        rng,
    );
    Ok(Transition {
        state: session,
        effects,
    })
}

/// Ends the session: delete it, clear its cookie, audit. The device cookie stays.
#[must_use = "effects must be carried out"]
pub fn sign_out(
    config: &Config,
    session: &Session,
    client: &Client,
    now: OffsetDateTime,
) -> Vec<Effect> {
    vec![
        Effect::DeleteSession {
            token_hash: session.token_hash,
        },
        Effect::SetCookie(SetCookie::removal(config.cookie_name().clone())),
        Effect::Audit(audit(
            AuditKind::SignOut,
            Some(session.user_id),
            client,
            now,
        )),
    ]
}

/// A password set from a session that may do so ([`Access::Recent`](crate::session::Access)),
/// hashed with
/// [`PasswordHasher::hash_new_password`](crate::password::PasswordHasher::hash_new_password).
#[must_use = "effects must be carried out"]
pub fn password_changed(
    user_id: UserId,
    hash: PasswordHash,
    client: &Client,
    now: OffsetDateTime,
) -> Vec<Effect> {
    vec![
        Effect::StorePassword { user_id, hash },
        Effect::Audit(audit(AuditKind::PasswordSet, Some(user_id), client, now)),
    ]
}

/// New recovery codes replacing the old ones (needs [`Access::Recent`](crate::session::Access)):
/// show the codes once, carry out the effects.
pub fn regenerate_recovery_codes<R: CryptoRng + ?Sized>(
    user_id: UserId,
    client: &Client,
    now: OffsetDateTime,
    rng: &mut R,
) -> Transition<Vec<RecoveryCode>> {
    let issued = recovery::generate(rng);
    Transition {
        state: issued.codes,
        effects: vec![
            Effect::StoreRecoveryCodes {
                user_id,
                hashes: issued.hashes,
            },
            Effect::Audit(audit(
                AuditKind::RecoveryCodesIssued,
                Some(user_id),
                client,
                now,
            )),
        ],
    }
}