1use std::sync::Arc;
4
5use crate::config::Config;
6use crate::remote::client::{Credential, SubsonicAuth, SubsonicClient, SubsonicError};
7
8pub 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#[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 #[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
34pub 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 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 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
77pub 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
87pub 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
99fn rejected(e: SubsonicError) -> SignInError {
101 match e {
102 SubsonicError::Api { code: 41, .. } => SignInError::NeedsKey,
103 e => SignInError::Rejected(e),
104 }
105}
106
107pub 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
123pub(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 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
153fn 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 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 crate::remote::proof::forget();
174 crate::remote::link::relink();
175 crate::remote::nearby::readvertise();
177 Ok(())
178}
179
180pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
184 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
185}
186
187pub 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
204pub 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
234pub 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 "the remote server could not be reached".into()
252}
253
254pub const SIGN_IN_REFUSED: &str = "the remote server refused the stored sign-in; sign in again";
256
257pub 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
272pub 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 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 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(¶m("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}