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