Skip to main content

koan_core/
invite.rs

1//! Invite links: an account's server and username, and a token the app trades
2//! for an API key of its own.
3//!
4//! The token is a JWT signed with the server's key, naming the account, a mark
5//! of its password hash, and when it stops working. Nothing is stored when one
6//! is made, so an admin can make another at any time, and one link signs in as
7//! many devices as the account holder has until it expires, or until the
8//! account's password changes, which changes the mark: a reset is how a link
9//! sent to the wrong place is withdrawn. The server checks its own signature
10//! when the app redeems it (`redeem`), and answers with a new API key: from
11//! then on the app signs in the OpenSubsonic way, and every device it was
12//! opened on has a key of its own to revoke.
13//!
14//! The server never keeps a password it can read back. A new account's
15//! password is generated, shown once in the invite email for the web UI and
16//! other Subsonic apps, and stored only as a hash.
17//!
18//! The link travels in the fragment of a koan.rocks address, which browsers
19//! do not send, so the site serving the page never sees it. With the app
20//! installed the address is a universal link and opens it directly; without,
21//! the page offers the downloads and a `koan://join` button.
22//!
23//! A server address with the account in it (`https://user:password@host`),
24//! which is what someone pasting into the server field may have, reads as an
25//! invite too, and signs in with the password.
26//!
27//! The server never sends mail. It produces the email for the admin to send
28//! from their own client.
29
30use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
31use rusqlite::Connection;
32use serde::{Deserialize, Serialize};
33use url::{Url, form_urlencoded};
34
35use crate::auth::{self, Role};
36use crate::db::queries::api_keys;
37use crate::db::queries::auth as users;
38
39pub const JOIN_PAGE: &str = "https://koan.rocks/join/";
40pub const APP_STORE: &str = "https://apps.apple.com/app/id6817137172";
41pub const MAC_DOWNLOAD: &str = "https://github.com/radiosilence/koan/releases/latest";
42
43/// How long an invite link signs devices in for.
44pub const TOKEN_TTL_SECS: u64 = 7 * 24 * 60 * 60;
45const TOKEN_TYP: &str = "koan-invite";
46
47const MAX_USERNAME: usize = 64;
48const MIN_PASSWORD: usize = 8;
49const MAX_DEVICE_NAME: usize = 100;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Invite {
53    pub server: String,
54    pub username: String,
55    /// What a koan server trades for an API key. Absent only from an address
56    /// with the account in it.
57    pub token: Option<String>,
58    /// The account's password: generated with the account and put in the
59    /// email once, or taken from an address. Never in a link.
60    pub password: Option<String>,
61}
62
63impl Invite {
64    /// An invite carrying `token`, and the account's password for the email
65    /// when it was just made.
66    pub fn with_token(server: &str, username: &str, token: &str, password: Option<&str>) -> Self {
67        Self {
68            server: server.trim().trim_end_matches('/').to_owned(),
69            username: username.to_owned(),
70            token: Some(token.to_owned()),
71            password: password.map(str::to_owned),
72        }
73    }
74
75    /// An account that signs in with its password: an address with the account
76    /// in it.
77    pub fn with_password(server: &str, username: &str, password: &str) -> Self {
78        Self {
79            server: server.trim().trim_end_matches('/').to_owned(),
80            username: username.to_owned(),
81            token: None,
82            password: Some(password.to_owned()),
83        }
84    }
85
86    fn params(&self) -> String {
87        let mut p = form_urlencoded::Serializer::new(String::new());
88        p.append_pair("server", &self.server)
89            .append_pair("username", &self.username);
90        if let Some(token) = &self.token {
91            p.append_pair("invite", token);
92        }
93        p.finish()
94    }
95
96    /// The link to send: a universal link into the app, or the join page.
97    pub fn link(&self) -> String {
98        format!("{JOIN_PAGE}#{}", self.params())
99    }
100
101    /// The app's own scheme, for where universal links do not reach.
102    pub fn app_link(&self) -> String {
103        format!("koan://join?{}", self.params())
104    }
105
106    /// Reads either form of the link, or a server address with the account
107    /// in it. Anything else, or a link missing a field, is `None`.
108    pub fn parse(link: &str) -> Option<Self> {
109        let url = Url::parse(link.trim()).ok()?;
110        if matches!(url.scheme(), "http" | "https") && !url.username().is_empty() {
111            let decode = |s: &str| percent_decode(s);
112            let (username, password) = (decode(url.username()), decode(url.password()?));
113            if password.is_empty() {
114                return None;
115            }
116            let mut server = url;
117            server.set_username("").ok()?;
118            server.set_password(None).ok()?;
119            return Some(Self::with_password(server.as_str(), &username, &password));
120        }
121        let params = match (url.scheme(), url.host_str()) {
122            ("koan", Some("join")) => url.query().or(url.fragment()),
123            ("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/join" => {
124                url.fragment()
125            }
126            _ => None,
127        }?;
128        let (mut server, mut username, mut token) = (None, None, None);
129        for (k, v) in form_urlencoded::parse(params.as_bytes()) {
130            let v = Some(v.into_owned()).filter(|v| !v.is_empty());
131            match &*k {
132                "server" => server = v,
133                "username" => username = v,
134                "invite" => token = v,
135                _ => {}
136            }
137        }
138        let (server, username) = (server?, username?);
139        let scheme = Url::parse(&server).ok()?.scheme().to_owned();
140        if !matches!(scheme.as_str(), "http" | "https") {
141            return None;
142        }
143        Some(Self::with_token(&server, &username, &token?, None))
144    }
145
146    pub fn email_subject(&self) -> String {
147        "Your koan account".to_owned()
148    }
149
150    /// How to sign in to anything that is not koan: the password when the
151    /// email carries it, or where to make an API key when it does not.
152    fn other_apps_text(&self) -> String {
153        match &self.password {
154            Some(password) => format!(
155                "Using a different Subsonic app, or the web player at {server}? Sign in with:\n\
156                 \n\
157                 Server URL: {server}\n\
158                 Username: {username}\n\
159                 Password: {password}\n",
160                server = self.server,
161                username = self.username,
162            ),
163            None => format!(
164                "Using a different Subsonic app? Sign in at {server} with your password and \
165                 make an API key under API keys.\n",
166                server = self.server,
167            ),
168        }
169    }
170
171    pub fn email_text(&self) -> String {
172        format!(
173            "I've made you an account on my music server.\n\
174             \n\
175             1. Install koan: from the App Store on an iPhone or iPad ({APP_STORE}), \
176             or for a Mac from {MAC_DOWNLOAD}\n\
177             2. On that device, open this link:\n\
178             \n\
179             {link}\n\
180             \n\
181             koan signs in and loads the library by itself. The link works on each of \
182             your devices for a week.\n\
183             \n\
184             {other}",
185            link = self.link(),
186            other = self.other_apps_text(),
187        )
188    }
189
190    /// The same email with the link as a button, for pasting into a mail
191    /// client as rich text.
192    pub fn email_html(&self) -> String {
193        let e = html_escape;
194        let other = match &self.password {
195            Some(password) => format!(
196                "<p>Using a different Subsonic app, or the web player at {server}? Sign in \
197                 with:</p><p>Server URL: {server}<br>Username: {username}<br>Password: \
198                 {password}</p>",
199                server = e(&self.server),
200                username = e(&self.username),
201                password = e(password),
202            ),
203            None => format!(
204                "<p>Using a different Subsonic app? Sign in at {server} with your password \
205                 and make an API key under API keys.</p>",
206                server = e(&self.server),
207            ),
208        };
209        format!(
210            "<p>I've made you an account on my music server.</p>\
211             <ol><li>Install koan: from the <a href=\"{APP_STORE}\">App Store</a> on an iPhone \
212             or iPad, or <a href=\"{MAC_DOWNLOAD}\">for a Mac</a>.</li>\
213             <li>On that device, open this link:</li></ol>\
214             <p><a href=\"{link}\" style=\"display:inline-block;padding:10px 18px;\
215             border-radius:8px;background:#111;color:#fff;text-decoration:none;\
216             font-weight:600\">Open in koan</a></p>\
217             <p>koan signs in and loads the library by itself. The link works on each of \
218             your devices for a week.</p>{other}",
219            link = e(&self.link()),
220        )
221    }
222
223    /// A `mailto:` with the subject and plain body filled in.
224    pub fn mailto(&self) -> String {
225        let enc = |s: &str| {
226            form_urlencoded::byte_serialize(s.as_bytes())
227                .collect::<String>()
228                .replace('+', "%20")
229        };
230        format!(
231            "mailto:?subject={}&body={}",
232            enc(&self.email_subject()),
233            enc(&self.email_text())
234        )
235    }
236}
237
238fn percent_decode(s: &str) -> String {
239    form_urlencoded::parse(format!("x={}", s.replace('+', "%2B")).as_bytes())
240        .next()
241        .map(|(_, v)| v.into_owned())
242        .unwrap_or_default()
243}
244
245fn html_escape(s: &str) -> String {
246    s.replace('&', "&amp;")
247        .replace('<', "&lt;")
248        .replace('>', "&gt;")
249        .replace('"', "&quot;")
250}
251
252// ---------------------------------------------------------------------------
253// Tokens
254// ---------------------------------------------------------------------------
255
256#[derive(Serialize, Deserialize)]
257struct Claims {
258    typ: String,
259    /// The account's id. Ids are never reused, so a token outlives no
260    /// account it was made for.
261    sub: i64,
262    username: String,
263    /// `password_mark` of the account's password hash when the token was
264    /// made.
265    pwd: String,
266    iat: u64,
267    exp: u64,
268}
269
270/// Enough of a digest of the password hash to tell when it changed, and too
271/// little to say anything about the password.
272fn password_mark(password_hash: &str) -> String {
273    auth::sha256_hex(password_hash)[..16].to_owned()
274}
275
276/// A token for the account `user_id`, signed with the server's private key.
277pub fn mint_token(
278    conn: &Connection,
279    private_pem: &[u8],
280    user_id: i64,
281) -> Result<String, AccountError> {
282    let user = users::get_user_by_id(conn, user_id)
283        .map_err(other)?
284        .ok_or_else(|| AccountError::NoSuchUser(user_id.to_string()))?;
285    let now = auth::now_unix();
286    let claims = Claims {
287        typ: TOKEN_TYP.into(),
288        sub: user.id,
289        username: user.username,
290        pwd: password_mark(&user.password_hash),
291        iat: now,
292        exp: now + TOKEN_TTL_SECS,
293    };
294    let key = EncodingKey::from_ed_pem(private_pem).map_err(other)?;
295    jsonwebtoken::encode(&Header::new(Algorithm::EdDSA), &claims, &key).map_err(other)
296}
297
298/// What redeeming an invite gives the app: the account, and a key to sign in
299/// with.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct Redeemed {
302    pub username: String,
303    pub api_key: String,
304}
305
306/// Check `token` against the server's public key and make an API key for the
307/// account it names, called `device`.
308pub fn redeem(
309    conn: &Connection,
310    public_pem: &[u8],
311    token: &str,
312    device: &str,
313) -> Result<Redeemed, AccountError> {
314    let key = DecodingKey::from_ed_pem(public_pem).map_err(other)?;
315    let mut validation = Validation::new(Algorithm::EdDSA);
316    validation.set_required_spec_claims(&["exp"]);
317    let claims = jsonwebtoken::decode::<Claims>(token, &key, &validation)
318        .map_err(|_| AccountError::BadInvite)?
319        .claims;
320    if claims.typ != TOKEN_TYP {
321        return Err(AccountError::BadInvite);
322    }
323    let user = users::get_user_by_id(conn, claims.sub)
324        .map_err(other)?
325        .filter(|u| u.username == claims.username)
326        .filter(|u| password_mark(&u.password_hash) == claims.pwd)
327        .ok_or(AccountError::BadInvite)?;
328    let name: String = device
329        .trim()
330        .chars()
331        .filter(|c| !c.is_control())
332        .take(MAX_DEVICE_NAME)
333        .collect();
334    let name = if name.is_empty() { "koan" } else { &name };
335    let (_, api_key) = api_keys::create_api_key(conn, user.id, name).map_err(other)?;
336    Ok(Redeemed {
337        username: user.username,
338        api_key,
339    })
340}
341
342// ---------------------------------------------------------------------------
343// Accounts
344// ---------------------------------------------------------------------------
345
346/// A password someone can type from an email: 20 characters with no
347/// lookalikes (0/O, 1/l/I), about 116 bits.
348pub fn generate_password() -> Result<String, auth::AuthError> {
349    use ring::rand::SecureRandom;
350
351    const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
352    let rng = ring::rand::SystemRandom::new();
353    let mut out = String::with_capacity(20);
354    let mut byte = [0u8; 1];
355    while out.len() < 20 {
356        rng.fill(&mut byte)
357            .map_err(|_| auth::AuthError::Hash("rng failure".into()))?;
358        // Reject the top of the range so every character is equally likely.
359        let limit = 256 - 256 % ALPHABET.len();
360        if (byte[0] as usize) < limit {
361            out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
362        }
363    }
364    Ok(out)
365}
366
367#[derive(Debug, thiserror::Error)]
368pub enum AccountError {
369    #[error("usernames are 1 to {MAX_USERNAME} characters, without spaces")]
370    BadUsername,
371    #[error("passwords are at least {MIN_PASSWORD} characters")]
372    ShortPassword,
373    #[error("there is already an account called {0}")]
374    Taken(String),
375    #[error("{0} is reserved")]
376    Reserved(String),
377    #[error("there is no account called {0}")]
378    NoSuchUser(String),
379    #[error("this invite is not valid here, or has expired")]
380    BadInvite,
381    #[error("the last admin cannot be removed or demoted")]
382    LastAdmin,
383    #[error(transparent)]
384    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
385}
386
387fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
388    AccountError::Other(Box::new(e))
389}
390
391/// An account just made: its id, for a token, and its generated password,
392/// for the email. The password is not kept anywhere it can be read back.
393#[derive(Debug)]
394pub struct NewAccount {
395    pub id: i64,
396    pub password: String,
397}
398
399/// Create an account with a generated password.
400pub fn create_account(
401    conn: &Connection,
402    username: &str,
403    role: Role,
404) -> Result<NewAccount, AccountError> {
405    let username = username.trim();
406    if username.is_empty()
407        || username.chars().count() > MAX_USERNAME
408        || username.chars().any(char::is_whitespace)
409    {
410        return Err(AccountError::BadUsername);
411    }
412    if username.eq_ignore_ascii_case(auth::ANONYMOUS) {
413        return Err(AccountError::Reserved(username.to_owned()));
414    }
415    if users::get_user_by_username(conn, username)
416        .map_err(other)?
417        .is_some()
418    {
419        return Err(AccountError::Taken(username.to_owned()));
420    }
421    let password = generate_password().map_err(other)?;
422    let id = users::create_user(conn, username, &password, role).map_err(other)?;
423    Ok(NewAccount { id, password })
424}
425
426/// The account called `username`, or `NoSuchUser`.
427pub fn account(conn: &Connection, username: &str) -> Result<users::UserRow, AccountError> {
428    users::get_user_by_username(conn, username)
429        .map_err(other)?
430        .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))
431}
432
433/// Give an account `password`, or a generated one when `None`, which is
434/// returned. Signs every device out: sessions end and API keys are revoked
435/// (see `update_password`), invited devices included.
436pub fn set_password(
437    conn: &Connection,
438    username: &str,
439    password: Option<&str>,
440) -> Result<String, AccountError> {
441    account(conn, username)?;
442    let password = match password {
443        Some(p) if p.chars().count() < MIN_PASSWORD => return Err(AccountError::ShortPassword),
444        Some(p) => p.to_owned(),
445        None => generate_password().map_err(other)?,
446    };
447    users::update_password(conn, username, &password)
448        .map_err(|e| AccountError::Other(e.to_string().into()))?;
449    Ok(password)
450}
451
452/// Change an account's role, refusing to demote the last admin.
453pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
454    let user = account(conn, username)?;
455    if user.role == Role::Admin
456        && role != Role::Admin
457        && users::admin_count(conn).map_err(other)? <= 1
458    {
459        return Err(AccountError::LastAdmin);
460    }
461    users::update_role(conn, username, role).map_err(other)?;
462    Ok(())
463}
464
465/// Delete an account, refusing to delete the last admin.
466pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
467    let user = account(conn, username)?;
468    if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
469        return Err(AccountError::LastAdmin);
470    }
471    users::delete_user(conn, user.id).map_err(other)?;
472    Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn invite() -> Invite {
480        Invite::with_token(
481            "https://music.example.com/",
482            "sarita",
483            "a.b-c_d",
484            Some("p&ss word=#?"),
485        )
486    }
487
488    #[test]
489    fn both_links_round_trip_without_the_password() {
490        let i = invite();
491        assert_eq!(i.server, "https://music.example.com");
492        assert!(i.link().starts_with("https://koan.rocks/join/#server="));
493        assert!(!i.link().contains("password"));
494        let read = Invite {
495            password: None,
496            ..i.clone()
497        };
498        assert_eq!(Invite::parse(&i.link()), Some(read.clone()));
499        assert_eq!(Invite::parse(&i.app_link()), Some(read));
500    }
501
502    #[test]
503    fn links_carrying_a_password_are_not_invites() {
504        let old =
505            "https://koan.rocks/join/#server=https%3A%2F%2Fa.example&username=u&password=p%26q";
506        assert_eq!(Invite::parse(old), None);
507    }
508
509    #[test]
510    fn the_join_page_without_its_slash_still_parses() {
511        let link = invite().link().replacen("/join/#", "/join#", 1);
512        assert_eq!(
513            Invite::parse(&link).unwrap().token.as_deref(),
514            Some("a.b-c_d")
515        );
516    }
517
518    #[test]
519    fn an_address_with_the_account_in_it_is_split() {
520        assert_eq!(
521            Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
522            Some(Invite::with_password(
523                "https://koan.example.com",
524                "sarita",
525                "p@ss+w"
526            ))
527        );
528        assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
529    }
530
531    #[test]
532    fn other_links_and_missing_fields_are_refused() {
533        assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
534        assert_eq!(
535            Invite::parse("https://koan.rocks/#server=https://a&username=u&invite=t"),
536            None
537        );
538        assert_eq!(
539            Invite::parse("koan://join?server=https://a&username=u"),
540            None
541        );
542        assert_eq!(
543            Invite::parse("koan://join?server=https://a&username=u&invite="),
544            None
545        );
546        assert_eq!(
547            Invite::parse("koan://join?server=ftp://a&username=u&invite=t"),
548            None
549        );
550        assert_eq!(Invite::parse("not a url"), None);
551    }
552
553    #[test]
554    fn credentials_stay_in_the_fragment() {
555        let url = Url::parse(&invite().link()).unwrap();
556        assert_eq!(url.query(), None);
557        assert!(!url.path().contains("sarita"));
558    }
559
560    #[test]
561    fn the_email_carries_the_link_and_a_new_accounts_password() {
562        let i = invite();
563        let text = i.email_text();
564        assert!(text.contains(&i.link()));
565        assert!(text.contains("Server URL: https://music.example.com"));
566        assert!(text.contains("Password: p&ss word=#?"));
567        assert!(i.email_html().contains("p&amp;ss word=#?"));
568        assert!(!i.mailto().contains(' '));
569
570        let again = Invite {
571            password: None,
572            ..invite()
573        };
574        assert!(!again.email_text().contains("Password:"));
575        assert!(again.email_text().contains("API key"));
576    }
577
578    #[test]
579    fn generated_passwords_are_typeable() {
580        let p = generate_password().unwrap();
581        assert_eq!(p.len(), 20);
582        assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
583        assert_ne!(p, generate_password().unwrap());
584    }
585
586    fn db() -> (tempfile::TempDir, crate::db::connection::Database) {
587        let dir = tempfile::tempdir().unwrap();
588        let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
589        (dir, db)
590    }
591
592    #[test]
593    fn a_token_is_redeemed_for_a_key_per_device() {
594        let (_dir, db) = db();
595        let conn = &db.conn;
596        let (private, public) = auth::generate_keypair_pem().unwrap();
597        let made = create_account(conn, "sarita", Role::User).unwrap();
598        let token = mint_token(conn, private.as_bytes(), made.id).unwrap();
599
600        let phone = redeem(conn, public.as_bytes(), &token, "Sarita's iPhone").unwrap();
601        let mac = redeem(conn, public.as_bytes(), &token, "").unwrap();
602        assert_eq!(phone.username, "sarita");
603        assert_ne!(phone.api_key, mac.api_key);
604        let keys = api_keys::list_api_keys(conn, Some(made.id)).unwrap();
605        let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
606        assert!(names.contains(&"Sarita's iPhone") && names.contains(&"koan"));
607        assert!(
608            api_keys::authenticate_api_key(conn, &phone.api_key)
609                .unwrap()
610                .is_some()
611        );
612
613        // Another server's key, a token for a deleted account, or something
614        // that is not a token are all refused alike.
615        let (_, elsewhere) = auth::generate_keypair_pem().unwrap();
616        assert!(matches!(
617            redeem(conn, elsewhere.as_bytes(), &token, "x"),
618            Err(AccountError::BadInvite)
619        ));
620        assert!(matches!(
621            redeem(conn, public.as_bytes(), "nonsense", "x"),
622            Err(AccountError::BadInvite)
623        ));
624        let session =
625            auth::mint_access_token(private.as_bytes(), made.id, "sarita", Role::User, 60).unwrap();
626        assert!(matches!(
627            redeem(conn, public.as_bytes(), &session, "x"),
628            Err(AccountError::BadInvite)
629        ));
630        delete_account(conn, "sarita").unwrap();
631        assert!(matches!(
632            redeem(conn, public.as_bytes(), &token, "x"),
633            Err(AccountError::BadInvite)
634        ));
635    }
636
637    #[test]
638    fn a_new_password_withdraws_links_already_sent() {
639        let (_dir, db) = db();
640        let conn = &db.conn;
641        let (private, public) = auth::generate_keypair_pem().unwrap();
642        let made = create_account(conn, "sarita", Role::User).unwrap();
643        let sent = mint_token(conn, private.as_bytes(), made.id).unwrap();
644        set_password(conn, "sarita", None).unwrap();
645        assert!(matches!(
646            redeem(conn, public.as_bytes(), &sent, "x"),
647            Err(AccountError::BadInvite)
648        ));
649        let again = mint_token(conn, private.as_bytes(), made.id).unwrap();
650        redeem(conn, public.as_bytes(), &again, "x").unwrap();
651    }
652
653    #[test]
654    fn accounts_are_created_and_guarded() {
655        let (_dir, db) = db();
656        let conn = &db.conn;
657        create_account(conn, "owner", Role::Admin).unwrap();
658        assert!(matches!(
659            create_account(conn, "owner", Role::User),
660            Err(AccountError::Taken(_))
661        ));
662        assert!(matches!(
663            create_account(conn, "two words", Role::User),
664            Err(AccountError::BadUsername)
665        ));
666        assert!(matches!(
667            create_account(conn, "anonymous", Role::User),
668            Err(AccountError::Reserved(_))
669        ));
670        assert!(matches!(
671            set_role(conn, "owner", Role::User),
672            Err(AccountError::LastAdmin)
673        ));
674        assert!(matches!(
675            delete_account(conn, "owner"),
676            Err(AccountError::LastAdmin)
677        ));
678        assert!(matches!(
679            set_password(conn, "nobody", None),
680            Err(AccountError::NoSuchUser(_))
681        ));
682    }
683
684    #[test]
685    fn a_new_password_signs_every_device_out() {
686        let (_dir, db) = db();
687        let conn = &db.conn;
688        let made = create_account(conn, "sarita", Role::Readonly).unwrap();
689        let hash = |conn| account(conn, "sarita").unwrap().password_hash;
690        auth::verify_password(&made.password, &hash(conn)).unwrap();
691        let (_, key) = api_keys::create_api_key(conn, made.id, "phone").unwrap();
692
693        assert!(matches!(
694            set_password(conn, "sarita", Some("short")),
695            Err(AccountError::ShortPassword)
696        ));
697        set_password(conn, "sarita", Some("correct horse")).unwrap();
698        auth::verify_password("correct horse", &hash(conn)).unwrap();
699        assert!(
700            api_keys::authenticate_api_key(conn, &key)
701                .unwrap()
702                .is_none()
703        );
704
705        let generated = set_password(conn, "sarita", None).unwrap();
706        assert_ne!(generated, made.password);
707        auth::verify_password(&generated, &hash(conn)).unwrap();
708    }
709}