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 (_, api_key) =
329        api_keys::create_api_key(conn, user.id, &device_name(device)).map_err(other)?;
330    Ok(Redeemed {
331        username: user.username,
332        api_key,
333    })
334}
335
336/// What a device calls itself, as the name of the API key made for it: no
337/// control characters, at most 100 characters, and `koan` for none.
338pub fn device_name(device: &str) -> String {
339    let name: String = device
340        .trim()
341        .chars()
342        .filter(|c| !c.is_control())
343        .take(MAX_DEVICE_NAME)
344        .collect();
345    if name.is_empty() { "koan".into() } else { name }
346}
347
348// ---------------------------------------------------------------------------
349// Accounts
350// ---------------------------------------------------------------------------
351
352/// A password someone can type from an email: 20 characters with no
353/// lookalikes (0/O, 1/l/I), about 116 bits.
354pub fn generate_password() -> Result<String, auth::AuthError> {
355    use ring::rand::SecureRandom;
356
357    const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
358    let rng = ring::rand::SystemRandom::new();
359    let mut out = String::with_capacity(20);
360    let mut byte = [0u8; 1];
361    while out.len() < 20 {
362        rng.fill(&mut byte)
363            .map_err(|_| auth::AuthError::Hash("rng failure".into()))?;
364        // Reject the top of the range so every character is equally likely.
365        let limit = 256 - 256 % ALPHABET.len();
366        if (byte[0] as usize) < limit {
367            out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
368        }
369    }
370    Ok(out)
371}
372
373#[derive(Debug, thiserror::Error)]
374pub enum AccountError {
375    #[error("usernames are 1 to {MAX_USERNAME} characters, without spaces")]
376    BadUsername,
377    #[error("passwords are at least {MIN_PASSWORD} characters")]
378    ShortPassword,
379    #[error("there is already an account called {0}")]
380    Taken(String),
381    #[error("{0} is reserved")]
382    Reserved(String),
383    #[error("there is no account called {0}")]
384    NoSuchUser(String),
385    #[error("this invite is not valid here, or has expired")]
386    BadInvite,
387    #[error("the last admin cannot be removed or demoted")]
388    LastAdmin,
389    #[error("this server already has accounts")]
390    AlreadySetUp,
391    #[error(transparent)]
392    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
393}
394
395fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
396    AccountError::Other(Box::new(e))
397}
398
399/// An account just made: its id, for a token, and its generated password,
400/// for the email. The password is not kept anywhere it can be read back.
401#[derive(Debug)]
402pub struct NewAccount {
403    pub id: i64,
404    pub password: String,
405}
406
407/// Create an account with a generated password.
408pub fn create_account(
409    conn: &Connection,
410    username: &str,
411    role: Role,
412) -> Result<NewAccount, AccountError> {
413    let username = valid_username(username)?;
414    if users::get_user_by_username(conn, username)
415        .map_err(other)?
416        .is_some()
417    {
418        return Err(AccountError::Taken(username.to_owned()));
419    }
420    let password = generate_password().map_err(other)?;
421    let id = users::create_user(conn, username, &password, role).map_err(other)?;
422    Ok(NewAccount { id, password })
423}
424
425/// `username` trimmed, or why it cannot name an account.
426fn valid_username(username: &str) -> Result<&str, AccountError> {
427    let username = username.trim();
428    if username.is_empty()
429        || username.chars().count() > MAX_USERNAME
430        || username.chars().any(char::is_whitespace)
431    {
432        return Err(AccountError::BadUsername);
433    }
434    if username.eq_ignore_ascii_case(auth::ANONYMOUS) {
435        return Err(AccountError::Reserved(username.to_owned()));
436    }
437    Ok(username)
438}
439
440/// Make the first admin of a server that has no accounts, with the password
441/// they chose: the web UI's setup page. Refused once any account exists, and
442/// the check and the insert share one write lock, so two people racing for an
443/// empty server cannot both become its admin.
444pub fn create_first_admin(
445    conn: &Connection,
446    username: &str,
447    password: &str,
448) -> Result<i64, AccountError> {
449    let username = valid_username(username)?;
450    if password.chars().count() < MIN_PASSWORD {
451        return Err(AccountError::ShortPassword);
452    }
453    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
454        .map_err(other)?;
455    if users::has_users(&tx).map_err(other)? {
456        return Err(AccountError::AlreadySetUp);
457    }
458    let id = users::create_user(&tx, username, password, Role::Admin).map_err(other)?;
459    tx.commit().map_err(other)?;
460    Ok(id)
461}
462
463/// The account called `username`, or `NoSuchUser`.
464pub fn account(conn: &Connection, username: &str) -> Result<users::UserRow, AccountError> {
465    users::get_user_by_username(conn, username)
466        .map_err(other)?
467        .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))
468}
469
470/// Give an account `password`, or a generated one when `None`, which is
471/// returned. Signs every device out: sessions end and API keys are revoked
472/// (see `update_password`), invited devices included.
473pub fn set_password(
474    conn: &Connection,
475    username: &str,
476    password: Option<&str>,
477) -> Result<String, AccountError> {
478    account(conn, username)?;
479    let password = match password {
480        Some(p) if p.chars().count() < MIN_PASSWORD => return Err(AccountError::ShortPassword),
481        Some(p) => p.to_owned(),
482        None => generate_password().map_err(other)?,
483    };
484    users::update_password(conn, username, &password)
485        .map_err(|e| AccountError::Other(e.to_string().into()))?;
486    Ok(password)
487}
488
489/// Change an account's role, refusing to demote the last admin.
490pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
491    let user = account(conn, username)?;
492    if user.role == Role::Admin
493        && role != Role::Admin
494        && users::admin_count(conn).map_err(other)? <= 1
495    {
496        return Err(AccountError::LastAdmin);
497    }
498    users::update_role(conn, username, role).map_err(other)?;
499    Ok(())
500}
501
502/// Delete an account, refusing to delete the last admin.
503pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
504    let user = account(conn, username)?;
505    if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
506        return Err(AccountError::LastAdmin);
507    }
508    users::delete_user(conn, user.id).map_err(other)?;
509    Ok(())
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn the_first_admin_is_made_once() {
518        let dir = tempfile::tempdir().unwrap();
519        let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
520        assert!(matches!(
521            create_first_admin(&db.conn, "ada", "short"),
522            Err(AccountError::ShortPassword)
523        ));
524        assert!(matches!(
525            create_first_admin(&db.conn, "a da", "long enough"),
526            Err(AccountError::BadUsername)
527        ));
528        let id = create_first_admin(&db.conn, " ada ", "long enough").unwrap();
529        let ada = users::get_user_by_id(&db.conn, id).unwrap().unwrap();
530        assert_eq!((ada.username.as_str(), ada.role), ("ada", Role::Admin));
531        assert!(matches!(
532            create_first_admin(&db.conn, "mallory", "long enough"),
533            Err(AccountError::AlreadySetUp)
534        ));
535        assert!(
536            users::get_user_by_username(&db.conn, "mallory")
537                .unwrap()
538                .is_none()
539        );
540    }
541
542    fn invite() -> Invite {
543        Invite::with_token(
544            "https://music.example.com/",
545            "sarita",
546            "a.b-c_d",
547            Some("p&ss word=#?"),
548        )
549    }
550
551    #[test]
552    fn both_links_round_trip_without_the_password() {
553        let i = invite();
554        assert_eq!(i.server, "https://music.example.com");
555        assert!(i.link().starts_with("https://koan.rocks/join/#server="));
556        assert!(!i.link().contains("password"));
557        let read = Invite {
558            password: None,
559            ..i.clone()
560        };
561        assert_eq!(Invite::parse(&i.link()), Some(read.clone()));
562        assert_eq!(Invite::parse(&i.app_link()), Some(read));
563    }
564
565    #[test]
566    fn links_carrying_a_password_are_not_invites() {
567        let old =
568            "https://koan.rocks/join/#server=https%3A%2F%2Fa.example&username=u&password=p%26q";
569        assert_eq!(Invite::parse(old), None);
570    }
571
572    #[test]
573    fn the_join_page_without_its_slash_still_parses() {
574        let link = invite().link().replacen("/join/#", "/join#", 1);
575        assert_eq!(
576            Invite::parse(&link).unwrap().token.as_deref(),
577            Some("a.b-c_d")
578        );
579    }
580
581    #[test]
582    fn an_address_with_the_account_in_it_is_split() {
583        assert_eq!(
584            Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
585            Some(Invite::with_password(
586                "https://koan.example.com",
587                "sarita",
588                "p@ss+w"
589            ))
590        );
591        assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
592    }
593
594    #[test]
595    fn other_links_and_missing_fields_are_refused() {
596        assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
597        assert_eq!(
598            Invite::parse("https://koan.rocks/#server=https://a&username=u&invite=t"),
599            None
600        );
601        assert_eq!(
602            Invite::parse("koan://join?server=https://a&username=u"),
603            None
604        );
605        assert_eq!(
606            Invite::parse("koan://join?server=https://a&username=u&invite="),
607            None
608        );
609        assert_eq!(
610            Invite::parse("koan://join?server=ftp://a&username=u&invite=t"),
611            None
612        );
613        assert_eq!(Invite::parse("not a url"), None);
614    }
615
616    #[test]
617    fn credentials_stay_in_the_fragment() {
618        let url = Url::parse(&invite().link()).unwrap();
619        assert_eq!(url.query(), None);
620        assert!(!url.path().contains("sarita"));
621    }
622
623    #[test]
624    fn the_email_carries_the_link_and_a_new_accounts_password() {
625        let i = invite();
626        let text = i.email_text();
627        assert!(text.contains(&i.link()));
628        assert!(text.contains("Server URL: https://music.example.com"));
629        assert!(text.contains("Password: p&ss word=#?"));
630        assert!(i.email_html().contains("p&amp;ss word=#?"));
631        assert!(!i.mailto().contains(' '));
632
633        let again = Invite {
634            password: None,
635            ..invite()
636        };
637        assert!(!again.email_text().contains("Password:"));
638        assert!(again.email_text().contains("API key"));
639    }
640
641    #[test]
642    fn generated_passwords_are_typeable() {
643        let p = generate_password().unwrap();
644        assert_eq!(p.len(), 20);
645        assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
646        assert_ne!(p, generate_password().unwrap());
647    }
648
649    fn db() -> (tempfile::TempDir, crate::db::connection::Database) {
650        let dir = tempfile::tempdir().unwrap();
651        let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
652        (dir, db)
653    }
654
655    #[test]
656    fn a_token_is_redeemed_for_a_key_per_device() {
657        let (_dir, db) = db();
658        let conn = &db.conn;
659        let (private, public) = auth::generate_keypair_pem().unwrap();
660        let made = create_account(conn, "sarita", Role::User).unwrap();
661        let token = mint_token(conn, private.as_bytes(), made.id).unwrap();
662
663        let phone = redeem(conn, public.as_bytes(), &token, "Sarita's iPhone").unwrap();
664        let mac = redeem(conn, public.as_bytes(), &token, "").unwrap();
665        assert_eq!(phone.username, "sarita");
666        assert_ne!(phone.api_key, mac.api_key);
667        let keys = api_keys::list_api_keys(conn, Some(made.id)).unwrap();
668        let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
669        assert!(names.contains(&"Sarita's iPhone") && names.contains(&"koan"));
670        assert!(
671            api_keys::authenticate_api_key(conn, &phone.api_key)
672                .unwrap()
673                .is_some()
674        );
675
676        // Another server's key, a token for a deleted account, or something
677        // that is not a token are all refused alike.
678        let (_, elsewhere) = auth::generate_keypair_pem().unwrap();
679        assert!(matches!(
680            redeem(conn, elsewhere.as_bytes(), &token, "x"),
681            Err(AccountError::BadInvite)
682        ));
683        assert!(matches!(
684            redeem(conn, public.as_bytes(), "nonsense", "x"),
685            Err(AccountError::BadInvite)
686        ));
687        let session =
688            auth::mint_access_token(private.as_bytes(), made.id, "sarita", Role::User, 60).unwrap();
689        assert!(matches!(
690            redeem(conn, public.as_bytes(), &session, "x"),
691            Err(AccountError::BadInvite)
692        ));
693        delete_account(conn, "sarita").unwrap();
694        assert!(matches!(
695            redeem(conn, public.as_bytes(), &token, "x"),
696            Err(AccountError::BadInvite)
697        ));
698    }
699
700    #[test]
701    fn a_new_password_withdraws_links_already_sent() {
702        let (_dir, db) = db();
703        let conn = &db.conn;
704        let (private, public) = auth::generate_keypair_pem().unwrap();
705        let made = create_account(conn, "sarita", Role::User).unwrap();
706        let sent = mint_token(conn, private.as_bytes(), made.id).unwrap();
707        set_password(conn, "sarita", None).unwrap();
708        assert!(matches!(
709            redeem(conn, public.as_bytes(), &sent, "x"),
710            Err(AccountError::BadInvite)
711        ));
712        let again = mint_token(conn, private.as_bytes(), made.id).unwrap();
713        redeem(conn, public.as_bytes(), &again, "x").unwrap();
714    }
715
716    #[test]
717    fn accounts_are_created_and_guarded() {
718        let (_dir, db) = db();
719        let conn = &db.conn;
720        create_account(conn, "owner", Role::Admin).unwrap();
721        assert!(matches!(
722            create_account(conn, "owner", Role::User),
723            Err(AccountError::Taken(_))
724        ));
725        assert!(matches!(
726            create_account(conn, "two words", Role::User),
727            Err(AccountError::BadUsername)
728        ));
729        assert!(matches!(
730            create_account(conn, "anonymous", Role::User),
731            Err(AccountError::Reserved(_))
732        ));
733        assert!(matches!(
734            set_role(conn, "owner", Role::User),
735            Err(AccountError::LastAdmin)
736        ));
737        assert!(matches!(
738            delete_account(conn, "owner"),
739            Err(AccountError::LastAdmin)
740        ));
741        assert!(matches!(
742            set_password(conn, "nobody", None),
743            Err(AccountError::NoSuchUser(_))
744        ));
745    }
746
747    #[test]
748    fn a_new_password_signs_every_device_out() {
749        let (_dir, db) = db();
750        let conn = &db.conn;
751        let made = create_account(conn, "sarita", Role::Readonly).unwrap();
752        let hash = |conn| account(conn, "sarita").unwrap().password_hash;
753        auth::verify_password(&made.password, &hash(conn)).unwrap();
754        let (_, key) = api_keys::create_api_key(conn, made.id, "phone").unwrap();
755
756        assert!(matches!(
757            set_password(conn, "sarita", Some("short")),
758            Err(AccountError::ShortPassword)
759        ));
760        set_password(conn, "sarita", Some("correct horse")).unwrap();
761        auth::verify_password("correct horse", &hash(conn)).unwrap();
762        assert!(
763            api_keys::authenticate_api_key(conn, &key)
764                .unwrap()
765                .is_none()
766        );
767
768        let generated = set_password(conn, "sarita", None).unwrap();
769        assert_ne!(generated, made.password);
770        auth::verify_password(&generated, &hash(conn)).unwrap();
771    }
772}