Skip to main content

koan_core/helpers/
sign_in.rs

1//! Signing in to the remote server, the credentials kept for it, and the client built from them.
2
3use std::sync::Arc;
4
5use crate::config::Config;
6use crate::remote::client::{Credential, SubsonicAuth, SubsonicClient, SubsonicError};
7
8/// What signs in to the remote server, from `config.local.toml` or a
9/// `KOAN_REMOTE__*` variable layered over it: the API key when there is one,
10/// else the password.
11pub fn remote_credential(cfg: &Config) -> Option<Credential> {
12    if !cfg.remote.api_key.is_empty() {
13        return Some(Credential::ApiKey(cfg.remote.api_key.clone()));
14    }
15    (!cfg.remote.password.is_empty()).then(|| Credential::Password(cfg.remote.password.clone()))
16}
17
18/// Why signing in to a remote server failed.
19#[derive(Debug, thiserror::Error)]
20pub enum SignInError {
21    #[error("the server did not accept those credentials: {0}")]
22    Rejected(#[from] crate::remote::client::SubsonicError),
23    /// Subsonic's error 41: the server cannot check the token a client signs
24    /// with over plain HTTP against the account's password, and wants an app
25    /// password or an API key instead.
26    #[error(
27        "this server needs an app password or API key: make one in the server's web UI and sign in with it"
28    )]
29    NeedsKey,
30    #[error("could not write the configuration: {0}")]
31    Config(#[from] crate::config::ConfigError),
32}
33
34/// Forget the server: its credentials, the shares and proof this device held
35/// for being signed in to it, and offline mode, which is from that server. The
36/// link and the network advert follow. The synced library is left alone:
37/// those tracks are still real, they just cannot be fetched until someone
38/// signs in again.
39pub fn sign_out() -> Result<(), crate::config::ConfigError> {
40    crate::remote::devices::forget_shares();
41    Config::persist(|cfg| {
42        cfg.remote.enabled = false;
43        cfg.remote.password = String::new();
44        cfg.remote.api_key = String::new();
45        cfg.remote.device_key = String::new();
46    })?;
47    crate::remote::proof::forget();
48    crate::remote::offline::set_manual(false);
49    crate::remote::link::relink();
50    crate::remote::nearby::readvertise();
51    Ok(())
52}
53
54/// Sign in to a Subsonic server with a password and remember it.
55///
56/// A koan server that offers `profile::SIGN_IN` is sent the password once and
57/// answers with an API key for this device, which is what is kept, as joining
58/// with an invite keeps one: the password never goes over the wire again, and
59/// the key can be revoked on its own. An app password or the shared secret is
60/// refused that trade, and is kept as typed. Any other server keeps the password in
61/// `config.local.toml`, gitignored and written `0600`; Subsonic signs every
62/// request with it or a salted MD5 of it, so what is kept is
63/// password-equivalent wherever it is kept.
64///
65/// The credentials are checked against the server before anything is written; a
66/// stored password that does not work is worse than none.
67///
68/// Shared by the CLI and the app so the two cannot disagree about where
69/// credentials live.
70pub fn set_remote_credentials(
71    url: &str,
72    username: &str,
73    password: &str,
74) -> Result<(), SignInError> {
75    use crate::remote::client::{koan_sign_in, offers_unsigned};
76    let url = url.trim_end_matches('/');
77    // Asked first, without credentials: the password goes as `p=enc:` only to
78    // a server that will trade it for a key.
79    if offers_unsigned(url, crate::remote::profile::SIGN_IN).unwrap_or(false) {
80        let device = crate::remote::link::LinkIdentity::this_device(None).name;
81        match koan_sign_in(url, username, password, &device) {
82            Ok(joined) => return adopt_api_key(url, &joined.username, &joined.api_key),
83            // Error 50: what was typed is a credential of its own, an app
84            // password or the shared secret, which the server will not trade
85            // for a key. It is kept as a password is anywhere else.
86            Err(SubsonicError::Api { code: 50, .. }) => {}
87            Err(e) => return Err(rejected(e)),
88        }
89    }
90    SubsonicClient::new(url, username, password)
91        .ping()
92        .map_err(rejected)?;
93
94    remember_remote(url, username, Credential::Password(password.to_string()))
95}
96
97/// Sign in with an API key made elsewhere — the server's web UI, or another
98/// device — for a device where typing a password is the harder thing. Checked
99/// against the server before anything is written, as a password is.
100pub fn set_remote_api_key(url: &str, username: &str, api_key: &str) -> Result<(), SignInError> {
101    let url = url.trim_end_matches('/');
102    let credential = Credential::ApiKey(api_key.to_string());
103    SubsonicClient::from_auth(SubsonicAuth::with(url, username, credential.clone())).ping()?;
104    remember_remote(url, username, credential)
105}
106
107/// Change the signed-in account's password on its koan server, keeping this
108/// device signed in. The change revokes every key the account had, so the
109/// server answers with a new one for this device, which is kept as a sign-in's
110/// is.
111pub fn change_own_password(current: &str, password: &str) -> Result<(), SignInError> {
112    let cfg = Config::load()?;
113    let client = subsonic_client(&cfg).ok_or(SignInError::Rejected(SubsonicError::BadResponse))?;
114    let device = crate::remote::link::LinkIdentity::this_device(None).name;
115    let joined = client.koan_change_own_password(current, password, &device)?;
116    adopt_api_key(&cfg.remote.url, &joined.username, &joined.api_key)
117}
118
119/// A server's refusal, with error 41 told apart: see `SignInError::NeedsKey`.
120fn rejected(e: SubsonicError) -> SignInError {
121    match e {
122        SubsonicError::Api { code: 41, .. } => SignInError::NeedsKey,
123        e => SignInError::Rejected(e),
124    }
125}
126
127/// Join a server with an invite. A token is traded for an API key named after
128/// this device; an address with the account in it carries the password, which
129/// signs in as `set_remote_credentials` does.
130pub fn join_with_invite(invite: &crate::invite::Invite) -> Result<(), SignInError> {
131    let url = invite.server.trim_end_matches('/');
132    match (&invite.token, &invite.password) {
133        (Some(token), _) => {
134            let device = crate::remote::link::LinkIdentity::this_device(None).name;
135            let joined = crate::remote::client::redeem_invite(url, token, &device)?;
136            adopt_api_key(url, &joined.username, &joined.api_key)
137        }
138        (None, Some(password)) => set_remote_credentials(url, &invite.username, password),
139        (None, None) => Err(SignInError::Rejected(SubsonicError::BadResponse)),
140    }
141}
142
143/// Sign in with an API key the server just made for this device: by an
144/// invite, or by pairing. The key this device held on the same account is
145/// revoked, since left valid it would sit in the key list unused; so is the new
146/// one if it cannot be stored.
147pub(crate) fn adopt_api_key(url: &str, username: &str, api_key: &str) -> Result<(), SignInError> {
148    let url = url.trim_end_matches('/');
149    let replaced = Config::load()
150        .ok()
151        .filter(|c| c.remote.url.trim_end_matches('/') == url)
152        .filter(|c| c.remote.username == username)
153        .map(|c| c.remote.api_key)
154        .filter(|k| !k.is_empty() && k != api_key);
155    // Revoked best-effort: a key signs in to give itself up.
156    let revoke = |key: &str| {
157        let credential = Credential::ApiKey(key.to_string());
158        let client = SubsonicClient::from_auth(SubsonicAuth::with(url, username, credential));
159        if let Err(e) = client.koan_revoke_own_key() {
160            log::warn!("could not revoke an unused API key: {e}");
161        }
162    };
163    if let Err(e) = remember_remote(url, username, Credential::ApiKey(api_key.to_string())) {
164        revoke(api_key);
165        return Err(e);
166    }
167    if let Some(old) = replaced {
168        revoke(&old);
169    }
170    Ok(())
171}
172
173/// Store a credential already checked against the server, replacing whichever
174/// kind was there.
175fn remember_remote(url: &str, username: &str, credential: Credential) -> Result<(), SignInError> {
176    Config::persist(|cfg| {
177        cfg.remote.enabled = true;
178        cfg.remote.url = url.to_string();
179        cfg.remote.username = username.to_string();
180        (cfg.remote.password, cfg.remote.api_key) = match &credential {
181            Credential::Password(p) => (p.clone(), String::new()),
182            Credential::ApiKey(k) => (String::new(), k.clone()),
183        };
184        // A new keypair with each sign-in, registered against the new API
185        // key; a password has no key row to register it on.
186        cfg.remote.device_key = match &credential {
187            Credential::ApiKey(_) => crate::remote::proof::new_device_key().unwrap_or_default(),
188            Credential::Password(_) => String::new(),
189        };
190    })?;
191    // Whatever account was here before, its devices are not this one's, and
192    // the link it had open closes, to open again as this one.
193    crate::remote::proof::forget();
194    crate::remote::link::relink();
195    // This device's announcement names the server it is signed in to.
196    crate::remote::nearby::readvertise();
197    Ok(())
198}
199
200/// Shared secret for koan's own Subsonic API.
201///
202/// Deliberately not the same secret as `remote_credential` — see `SubsonicConfig`.
203pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
204    (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
205}
206
207/// Upstream Subsonic credentials from the merged config, returning `None` if
208/// remote is disabled or has no URL configured.
209///
210/// Prefer this over `subsonic_client` when only a signed URL is needed:
211/// building a client constructs blocking `reqwest` clients, which panics from
212/// inside a tokio runtime.
213pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
214    if !cfg.remote.enabled || cfg.remote.url.is_empty() {
215        return None;
216    }
217    Some(SubsonicAuth::with(
218        &cfg.remote.url,
219        &cfg.remote.username,
220        remote_credential(cfg)?,
221    ))
222}
223
224/// One `SubsonicClient` per set of credentials, shared process-wide.
225///
226/// Constructing one builds two blocking `reqwest` clients, each carrying its
227/// own runtime on its own thread, and each starting with a cold connection
228/// pool — so a client per call means a fresh TLS handshake for every cover art
229/// request.
230///
231/// Keyed on the credentials, so logging in as someone else replaces the client
232/// rather than serving the old one. Never call from async code: building the
233/// inner clients panics inside a tokio runtime.
234pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
235    let auth = subsonic_auth(cfg)?;
236
237    let mut slot = SUBSONIC_CLIENT.lock();
238    if let Some((cached, client)) = slot.as_ref()
239        && *cached == auth
240    {
241        return Some(client.clone());
242    }
243
244    let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
245    *slot = Some((auth, client.clone()));
246    Some(client)
247}
248
249/// Why the signed-in server is not answering downloads, while it is not. Reads
250/// only the client already made.
251pub fn server_unreachable() -> Option<String> {
252    let client = SUBSONIC_CLIENT.lock().as_ref().map(|(_, c)| c.clone())?;
253    client.outage().reason()
254}
255
256/// The device moved to another network, or lost or regained one: what was
257/// learnt about reaching the server belongs to the last one. Connections are
258/// made afresh, a server that was not answering is tried now, and the other
259/// devices are looked for again where `quiet` and `devices.nearby` allow it.
260pub fn network_changed() {
261    let client = SUBSONIC_CLIENT.lock().as_ref().map(|(_, c)| c.clone());
262    if let Some(client) = client {
263        client.renew();
264    }
265    crate::remote::devices::resume();
266}
267
268type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
269
270static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
271    std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
272
273/// Why there is no remote client, in words worth showing someone.
274///
275/// Every caller of `subsonic_client` gets `None` for three different reasons,
276/// and reporting one for all of them makes "koan has no password", which sends
277/// you to sign in, look like a server that is merely down.
278pub fn remote_unavailable(cfg: &Config) -> String {
279    if !cfg.remote.enabled {
280        return "no remote server is configured".into();
281    }
282    if cfg.remote.url.is_empty() {
283        return "the remote server has no address".into();
284    }
285    if remote_credential(cfg).is_none() {
286        return "no password or API key is stored for the remote server".into();
287    }
288    // A credential resolved, so the client should have built. Nothing else
289    // returns `None`, but saying so beats claiming a cause that is wrong.
290    "the remote server could not be reached".into()
291}
292
293/// What every front end says when the server refused the stored credential.
294pub const SIGN_IN_REFUSED: &str = "the remote server refused the stored sign-in; sign in again";
295
296/// Why the configured server cannot be used, if it cannot: no credential, or
297/// one the server has refused since (a revoked API key, a changed password).
298/// `None` when no server is configured, or the one that is works as far as
299/// anything has heard.
300pub fn remote_problem(cfg: &Config) -> Option<String> {
301    if !cfg.remote.enabled || cfg.remote.url.is_empty() {
302        return None;
303    }
304    match subsonic_auth(cfg) {
305        None => Some(remote_unavailable(cfg)),
306        Some(auth) if crate::remote::refusal::refused(&auth) => Some(SIGN_IN_REFUSED.into()),
307        Some(_) => None,
308    }
309}
310
311/// Whether the server refused the configured credential when it was last used.
312pub fn sign_in_refused(cfg: &Config) -> bool {
313    subsonic_auth(cfg).is_some_and(|auth| crate::remote::refusal::refused(&auth))
314}
315
316#[cfg(test)]
317mod client_cache_tests {
318    use super::*;
319
320    #[test]
321    fn one_subsonic_client_is_shared_per_credentials() {
322        crate::config::isolate_config_for_tests();
323        let mut cfg = Config::default();
324        cfg.remote.enabled = true;
325        cfg.remote.url = "https://shared-client.invalid".into();
326        cfg.remote.username = "koan".into();
327        cfg.remote.password = "first".into();
328
329        let first = subsonic_client(&cfg).expect("a configured remote yields a client");
330        let again = subsonic_client(&cfg).expect("a configured remote yields a client");
331        assert!(
332            Arc::ptr_eq(&first, &again),
333            "rebuilding drops the connection pool and re-handshakes TLS per request"
334        );
335
336        cfg.remote.password = "second".into();
337        let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
338        assert!(
339            !Arc::ptr_eq(&first, &relogged),
340            "new credentials must not keep serving the client signed with the old ones"
341        );
342    }
343}
344
345#[cfg(test)]
346mod sign_in_tests {
347    use super::*;
348
349    /// A koan server as `set_remote_credentials` meets it over plain HTTP:
350    /// `mate`'s account password is traded for a key, while `mate`'s app
351    /// password and `testuser`'s shared secret are refused that trade with
352    /// error 50 and accepted as tokens.
353    fn serve() -> String {
354        use std::io::{BufRead, Write};
355        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
356        let url = format!("http://{}", listener.local_addr().unwrap());
357        std::thread::spawn(move || {
358            for mut stream in listener.incoming().flatten() {
359                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
360                let mut request = String::new();
361                reader.read_line(&mut request).unwrap();
362                let mut line = String::new();
363                while reader.read_line(&mut line).unwrap_or(0) > 2 {
364                    line.clear();
365                }
366                let target = request.split_whitespace().nth(1).unwrap_or("");
367                let (path, query) = target.split_once('?').unwrap_or((target, ""));
368                let param = |name: &str| {
369                    query
370                        .split('&')
371                        .filter_map(|kv| kv.split_once('='))
372                        .find(|(k, _)| *k == name)
373                        .map(|(_, v)| v.replace("%3A", ":"))
374                        .unwrap_or_default()
375                };
376                let secret = match param("u").as_str() {
377                    "mate" => "app-secret",
378                    "testuser" => "shared-secret",
379                    _ => "",
380                };
381                let ok = r#"{"subsonic-response":{"status":"ok"}}"#.to_owned();
382                let refused = |code: i32| {
383                    format!(
384                        r#"{{"subsonic-response":{{"status":"failed","error":{{"code":{code},"message":"refused"}}}}}}"#
385                    )
386                };
387                let body = match path.rsplit('/').next().unwrap() {
388                    "getOpenSubsonicExtensions" => r#"{"subsonic-response":{"status":"ok","openSubsonicExtensions":[{"name":"koanSignIn","versions":[1]}]}}"#.to_owned(),
389                    "koanSignIn" => {
390                        let hex = param("p").trim_start_matches("enc:").to_owned();
391                        let typed: String = (0..hex.len())
392                            .step_by(2)
393                            .filter_map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
394                            .map(char::from)
395                            .collect();
396                        match (param("u").as_str(), typed.as_str()) {
397                            ("mate", "hunter22") => r#"{"subsonic-response":{"status":"ok","join":{"username":"mate","apiKey":"minted"}}}"#.to_owned(),
398                            (_, typed) if typed == secret => refused(50),
399                            _ => refused(40),
400                        }
401                    }
402                    "ping" if param("apiKey") == "minted" => ok,
403                    "ping" => {
404                        let expected =
405                            format!("{:x}", md5::compute(format!("{secret}{}", param("s"))));
406                        if !secret.is_empty() && param("t") == expected {
407                            ok
408                        } else {
409                            refused(40)
410                        }
411                    }
412                    _ => ok,
413                };
414                let _ = write!(
415                    stream,
416                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
417                    body.len()
418                );
419            }
420        });
421        url
422    }
423
424    #[test]
425    fn a_password_ends_in_a_key_and_other_credentials_are_kept_as_typed() {
426        let _guard = crate::config::tests::PERSIST_LOCK
427            .lock()
428            .unwrap_or_else(|e| e.into_inner());
429        let dir = tempfile::tempdir().unwrap();
430        crate::config::set_config_dir(dir.path());
431        let url = serve();
432        let kept = || {
433            let remote = Config::load().unwrap().remote;
434            (remote.username, remote.password, remote.api_key)
435        };
436
437        set_remote_credentials(&url, "mate", "hunter22").unwrap();
438        assert_eq!(kept(), ("mate".into(), String::new(), "minted".into()));
439
440        set_remote_credentials(&url, "mate", "app-secret").unwrap();
441        assert_eq!(
442            kept(),
443            ("mate".into(), "app-secret".into(), String::new()),
444            "an app password is refused a key and kept"
445        );
446
447        set_remote_credentials(&url, "testuser", "shared-secret").unwrap();
448        assert_eq!(
449            kept(),
450            ("testuser".into(), "shared-secret".into(), String::new()),
451            "the shared secret is refused a key and kept"
452        );
453
454        assert!(matches!(
455            set_remote_credentials(&url, "mate", "wrong"),
456            Err(SignInError::Rejected(SubsonicError::Api { code: 40, .. }))
457        ));
458        assert_eq!(
459            kept().1,
460            "shared-secret",
461            "a refused sign-in writes nothing"
462        );
463    }
464}
465
466#[cfg(test)]
467mod refusal_tests {
468    use super::*;
469    use crate::db::connection::Database;
470    use crate::helpers::*;
471    use std::collections::HashSet;
472    use std::sync::Mutex;
473
474    /// A koan server holding API keys that can be revoked. `koanSignIn` with
475    /// `mate`'s password mints `second`.
476    fn serve(keys: Arc<Mutex<HashSet<String>>>) -> String {
477        use std::io::{BufRead, Write};
478        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
479        let url = format!("http://{}", listener.local_addr().unwrap());
480        std::thread::spawn(move || {
481            for mut stream in listener.incoming().flatten() {
482                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
483                let mut request = String::new();
484                reader.read_line(&mut request).unwrap();
485                let mut line = String::new();
486                while reader.read_line(&mut line).unwrap_or(0) > 2 {
487                    line.clear();
488                }
489                let target = request.split_whitespace().nth(1).unwrap_or("");
490                let (path, query) = target.split_once('?').unwrap_or((target, ""));
491                let param = |name: &str| {
492                    query
493                        .split('&')
494                        .filter_map(|kv| kv.split_once('='))
495                        .find(|(k, _)| *k == name)
496                        .map(|(_, v)| v.to_owned())
497                        .unwrap_or_default()
498                };
499                let endpoint = path.rsplit('/').next().unwrap();
500                let body = if endpoint == "koanSignIn" {
501                    keys.lock().unwrap().insert("second".into());
502                    r#"{"subsonic-response":{"status":"ok","join":{"username":"mate","apiKey":"second"}}}"#.to_owned()
503                } else if endpoint == "getOpenSubsonicExtensions" {
504                    r#"{"subsonic-response":{"status":"ok","openSubsonicExtensions":[{"name":"koanSignIn","versions":[1]}]}}"#.to_owned()
505                } else if !keys.lock().unwrap().contains(&param("apiKey")) {
506                    r#"{"subsonic-response":{"status":"failed","error":{"code":44,"message":"invalid API key"}}}"#.to_owned()
507                } else {
508                    r#"{"subsonic-response":{"status":"ok","indexes":{"lastModified":1}}}"#
509                        .to_owned()
510                };
511                let _ = write!(
512                    stream,
513                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
514                    body.len()
515                );
516            }
517        });
518        url
519    }
520
521    /// Offline mode is from a server: signing out of it ends it, so the next
522    /// account signs in online.
523    #[test]
524    fn signing_out_forgets_the_account_and_offline_mode() {
525        let _guard = crate::config::tests::PERSIST_LOCK
526            .lock()
527            .unwrap_or_else(|e| e.into_inner());
528        let dir = tempfile::tempdir().unwrap();
529        crate::config::set_config_dir(dir.path());
530        Config::persist(|c| {
531            c.remote.enabled = true;
532            c.remote.url = "https://music.example.com".into();
533            c.remote.username = "mate".into();
534            c.remote.api_key = "key".into();
535        })
536        .unwrap();
537        crate::remote::offline::set_manual(true);
538
539        sign_out().unwrap();
540        let cfg = Config::load().unwrap();
541        assert!(!cfg.remote.enabled);
542        assert!(cfg.remote.api_key.is_empty());
543        assert_eq!(remote_credential(&cfg).map(|_| ()), None);
544        assert!(!crate::remote::offline::manual());
545    }
546
547    #[test]
548    fn a_revoked_key_is_reported_until_signing_in_again() {
549        let _guard = crate::config::tests::PERSIST_LOCK
550            .lock()
551            .unwrap_or_else(|e| e.into_inner());
552        let dir = tempfile::tempdir().unwrap();
553        crate::config::set_config_dir(dir.path());
554        let keys = Arc::new(Mutex::new(HashSet::from(["first".to_owned()])));
555        let url = serve(keys.clone());
556        Config::persist(|c| {
557            c.remote.enabled = true;
558            c.remote.url = url.clone();
559            c.remote.username = "mate".into();
560            c.remote.api_key = "first".into();
561        })
562        .unwrap();
563
564        let db = Database::open(&dir.path().join("koan.db")).unwrap();
565        let sync = || {
566            let cfg = Config::load().unwrap();
567            let client = SubsonicClient::from_auth(subsonic_auth(&cfg).unwrap());
568            let _ = sync_remote(&db, &client, Walk::IfChanged, &url, "mate", &|_| {});
569        };
570
571        sync();
572        assert_eq!(remote_problem(&Config::load().unwrap()), None);
573
574        keys.lock().unwrap().remove("first");
575        sync();
576        let cfg = Config::load().unwrap();
577        assert_eq!(remote_problem(&cfg).as_deref(), Some(SIGN_IN_REFUSED));
578        assert!(sign_in_refused(&cfg));
579
580        set_remote_credentials(&url, "mate", "hunter22").unwrap();
581        let cfg = Config::load().unwrap();
582        assert_eq!(cfg.remote.api_key, "second");
583        assert_eq!(remote_problem(&cfg), None, "a new credential starts clean");
584        sync();
585        assert_eq!(remote_problem(&Config::load().unwrap()), None);
586    }
587}