1use std::fs;
7use std::path::PathBuf;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
11use ring::signature::KeyPair;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::config;
16
17pub const ANONYMOUS: &str = "anonymous";
21
22#[derive(Debug, Error)]
27pub enum AuthError {
28 #[error("jwt error: {0}")]
29 Jwt(#[from] jsonwebtoken::errors::Error),
30 #[error("argon2 hash error: {0}")]
31 Hash(String),
32 #[error("password verification failed")]
33 InvalidPassword,
34 #[error("io error: {0}")]
35 Io(#[from] std::io::Error),
36 #[error("keypair not found — run `koan auth setup` first")]
37 NoKeypair,
38 #[error("{0}")]
39 Other(String),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Role {
49 Admin,
50 User,
51 Readonly,
52}
53
54impl Role {
55 pub fn as_str(&self) -> &'static str {
56 match self {
57 Role::Admin => "admin",
58 Role::User => "user",
59 Role::Readonly => "readonly",
60 }
61 }
62
63 pub fn has_permission(&self, required: Role) -> bool {
66 match required {
67 Role::Readonly => true,
68 Role::User => matches!(self, Role::Admin | Role::User),
69 Role::Admin => matches!(self, Role::Admin),
70 }
71 }
72}
73
74impl std::str::FromStr for Role {
75 type Err = String;
76
77 fn from_str(s: &str) -> Result<Self, Self::Err> {
78 match s {
79 "admin" => Ok(Role::Admin),
80 "user" => Ok(Role::User),
81 "readonly" => Ok(Role::Readonly),
82 _ => Err(format!("invalid role: '{s}'")),
83 }
84 }
85}
86
87impl std::fmt::Display for Role {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.write_str(self.as_str())
90 }
91}
92
93#[derive(Debug, Serialize, Deserialize)]
98pub struct Claims {
99 pub sub: i64,
101 pub username: String,
103 pub role: String,
105 pub iat: u64,
107 pub exp: u64,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub scope: Option<String>,
113}
114
115pub const MCP_SCOPE: &str = "mcp";
119
120pub fn hash_password(password: &str) -> Result<String, AuthError> {
126 use argon2::Argon2;
127 use argon2::password_hash::PasswordHasher;
128
129 Argon2::default()
130 .hash_password(password.as_bytes())
131 .map(|h| h.to_string())
132 .map_err(|e| AuthError::Hash(e.to_string()))
133}
134
135pub fn verify_password(password: &str, hash: &str) -> Result<(), AuthError> {
137 use argon2::Argon2;
138 use argon2::password_hash::PasswordVerifier;
139 use argon2::password_hash::phc::PasswordHash;
140
141 let parsed = PasswordHash::new(hash).map_err(|e| AuthError::Hash(e.to_string()))?;
142 Argon2::default()
143 .verify_password(password.as_bytes(), &parsed)
144 .map_err(|_| AuthError::InvalidPassword)
145}
146
147pub fn random_token() -> Result<String, AuthError> {
157 use ring::rand::SecureRandom;
158
159 let mut bytes = [0u8; 32];
160 ring::rand::SystemRandom::new()
161 .fill(&mut bytes)
162 .map_err(|_| AuthError::Hash("rng failure".into()))?;
163 Ok(bytes.iter().map(|b| format!("{:02x}", b)).collect())
164}
165
166pub fn random_api_key() -> Result<String, AuthError> {
169 use base64::Engine as _;
170 use ring::rand::SecureRandom;
171
172 let mut bytes = [0u8; 32];
173 ring::rand::SystemRandom::new()
174 .fill(&mut bytes)
175 .map_err(|_| AuthError::Hash("rng failure".into()))?;
176 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
177}
178
179pub fn sha256_hex(input: &str) -> String {
182 ring::digest::digest(&ring::digest::SHA256, input.as_bytes())
183 .as_ref()
184 .iter()
185 .map(|b| format!("{:02x}", b))
186 .collect()
187}
188
189pub fn app_password_key(private_pem: &[u8]) -> [u8; 32] {
194 use ring::hkdf;
195
196 let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, b"koan").extract(private_pem);
197 let mut key = [0u8; 32];
198 prk.expand(&[b"subsonic app passwords v1"], hkdf::HKDF_SHA256)
199 .and_then(|okm| okm.fill(&mut key))
200 .expect("HKDF-SHA256 yields 32 bytes");
201 key
202}
203
204pub fn seal_app_password(
208 key: &[u8; 32],
209 user_id: i64,
210 password: &str,
211) -> Result<Vec<u8>, AuthError> {
212 use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
213 use ring::rand::SecureRandom;
214
215 let sealing = LessSafeKey::new(
216 UnboundKey::new(&CHACHA20_POLY1305, key).map_err(|_| AuthError::Hash("bad key".into()))?,
217 );
218 let mut nonce = [0u8; NONCE_LEN];
219 ring::rand::SystemRandom::new()
220 .fill(&mut nonce)
221 .map_err(|_| AuthError::Hash("rng failure".into()))?;
222 let mut sealed = password.as_bytes().to_vec();
223 sealing
224 .seal_in_place_append_tag(
225 Nonce::assume_unique_for_key(nonce),
226 Aad::from(user_id.to_le_bytes()),
227 &mut sealed,
228 )
229 .map_err(|_| AuthError::Hash("seal failure".into()))?;
230 let mut out = nonce.to_vec();
231 out.extend_from_slice(&sealed);
232 Ok(out)
233}
234
235pub fn open_app_password(key: &[u8; 32], user_id: i64, sealed: &[u8]) -> Option<String> {
238 use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
239
240 let opening = LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, key).ok()?);
241 let (nonce, ciphertext) = sealed.split_at_checked(NONCE_LEN)?;
242 let mut buf = ciphertext.to_vec();
243 let plain = opening
244 .open_in_place(
245 Nonce::try_assume_unique_for_key(nonce).ok()?,
246 Aad::from(user_id.to_le_bytes()),
247 &mut buf,
248 )
249 .ok()?;
250 String::from_utf8(plain.to_vec()).ok()
251}
252
253pub fn random_app_password() -> Result<String, AuthError> {
257 use ring::rand::SecureRandom;
258
259 const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
260 let rng = ring::rand::SystemRandom::new();
261 let mut out = String::with_capacity(23);
262 let mut byte = [0u8; 1];
263 let mut drawn = 0;
264 while drawn < 20 {
265 rng.fill(&mut byte)
266 .map_err(|_| AuthError::Hash("rng failure".into()))?;
267 if byte[0] as usize >= ALPHABET.len() * (256 / ALPHABET.len()) {
269 continue;
270 }
271 if drawn > 0 && drawn % 5 == 0 {
272 out.push('-');
273 }
274 out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
275 drawn += 1;
276 }
277 Ok(out)
278}
279
280pub fn keypair_dir() -> PathBuf {
285 config::config_dir().join("auth")
286}
287
288fn private_key_path() -> PathBuf {
289 keypair_dir().join("ed25519.pem")
290}
291
292fn public_key_path() -> PathBuf {
293 keypair_dir().join("ed25519.pub.pem")
294}
295
296pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
299 let rng = ring::rand::SystemRandom::new();
301 let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
302 .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
303
304 let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
305
306 let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
308 .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
309 let pub_bytes = kp.public_key().as_ref();
310
311 let mut spki = vec![
314 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ];
319 spki.extend_from_slice(pub_bytes);
320 let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
321
322 Ok((private_pem, public_pem))
323}
324
325pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
328 let (private_pem, public_pem) = generate_keypair_pem()?;
329
330 let dir = keypair_dir();
331 fs::create_dir_all(&dir)?;
332
333 let gitignore = dir.join(".gitignore");
335 if !gitignore.exists() {
336 let _ = fs::write(&gitignore, "*\n");
337 }
338
339 #[cfg(unix)]
342 {
343 use std::fs::OpenOptions;
344 use std::io::Write;
345 use std::os::unix::fs::OpenOptionsExt;
346 use std::os::unix::fs::PermissionsExt;
347
348 let mut f = OpenOptions::new()
349 .write(true)
350 .create(true)
351 .truncate(true)
352 .mode(0o600)
353 .open(private_key_path())?;
354 f.write_all(private_pem.as_bytes())?;
355
356 let mut f = OpenOptions::new()
357 .write(true)
358 .create(true)
359 .truncate(true)
360 .mode(0o644)
361 .open(public_key_path())?;
362 f.write_all(public_pem.as_bytes())?;
363
364 let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
365 }
366
367 #[cfg(not(unix))]
368 {
369 fs::write(private_key_path(), &private_pem)?;
370 fs::write(public_key_path(), &public_pem)?;
371 }
372
373 Ok((private_pem.into_bytes(), public_pem.into_bytes()))
374}
375
376pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
378 let priv_path = private_key_path();
379 let pub_path = public_key_path();
380
381 if !priv_path.exists() || !pub_path.exists() {
382 return Err(AuthError::NoKeypair);
383 }
384
385 let private_pem = fs::read(&priv_path)?;
386 let public_pem = fs::read(&pub_path)?;
387 Ok((private_pem, public_pem))
388}
389
390pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
392 match load_keypair() {
393 Ok(kp) => Ok(kp),
394 Err(AuthError::NoKeypair) => generate_keypair(),
395 Err(e) => Err(e),
396 }
397}
398
399pub fn mint_access_token(
405 private_pem: &[u8],
406 user_id: i64,
407 username: &str,
408 role: Role,
409 ttl_secs: u64,
410) -> Result<String, AuthError> {
411 mint_scoped_token(private_pem, user_id, username, role, ttl_secs, None)
412}
413
414pub fn mint_scoped_token(
416 private_pem: &[u8],
417 user_id: i64,
418 username: &str,
419 role: Role,
420 ttl_secs: u64,
421 scope: Option<&str>,
422) -> Result<String, AuthError> {
423 let now = now_unix();
424
425 let claims = Claims {
426 sub: user_id,
427 username: username.to_string(),
428 role: role.as_str().to_string(),
429 iat: now,
430 exp: now + ttl_secs,
431 scope: scope.map(str::to_owned),
432 };
433
434 let key = EncodingKey::from_ed_pem(private_pem)?;
435 let header = Header::new(Algorithm::EdDSA);
436 let token = jsonwebtoken::encode(&header, &claims, &key)?;
437 Ok(token)
438}
439
440pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
443 validate_scoped_token(public_pem, token, None)
444}
445
446pub fn validate_scoped_token(
448 public_pem: &[u8],
449 token: &str,
450 scope: Option<&str>,
451) -> Result<Claims, AuthError> {
452 let key = DecodingKey::from_ed_pem(public_pem)?;
453 let mut validation = Validation::new(Algorithm::EdDSA);
454 validation.set_required_spec_claims(&["exp"]);
456
457 let claims = jsonwebtoken::decode::<Claims>(token, &key, &validation)?.claims;
458 if claims.scope.as_deref() != scope {
459 return Err(AuthError::Other("token not valid here".into()));
460 }
461 Ok(claims)
462}
463
464struct AccountChanges {
486 count: tokio::sync::watch::Sender<u64>,
487 latest: parking_lot::Mutex<std::collections::HashMap<i64, u64>>,
488}
489
490fn account_changes() -> &'static AccountChanges {
491 static CHANGES: std::sync::OnceLock<AccountChanges> = std::sync::OnceLock::new();
492 CHANGES.get_or_init(|| AccountChanges {
493 count: tokio::sync::watch::Sender::new(0),
494 latest: Default::default(),
495 })
496}
497
498pub fn account_changed(user_id: i64) {
500 let changes = account_changes();
501 changes.count.send_modify(|count| {
502 *count += 1;
503 changes.latest.lock().insert(user_id, *count);
504 });
505}
506
507pub fn account_mark() -> u64 {
510 *account_changes().count.borrow()
511}
512
513pub async fn any_account_changed_since(mark: u64) -> u64 {
516 let mut count = account_changes().count.subscribe();
517 loop {
518 let now = *count.borrow_and_update();
519 if now > mark {
520 return now;
521 }
522 if count.changed().await.is_err() {
523 return std::future::pending().await;
524 }
525 }
526}
527
528pub async fn account_changed_since(user_id: i64, mark: u64) {
530 let changes = account_changes();
531 let mut count = changes.count.subscribe();
532 loop {
533 if changes
534 .latest
535 .lock()
536 .get(&user_id)
537 .is_some_and(|&at| at > mark)
538 {
539 return;
540 }
541 if count.changed().await.is_err() {
542 return std::future::pending().await;
543 }
544 }
545}
546
547pub fn now_unix() -> u64 {
552 SystemTime::now()
553 .duration_since(UNIX_EPOCH)
554 .unwrap()
555 .as_secs()
556}
557
558pub fn parse_duration_secs(s: &str) -> Option<u64> {
560 let s = s.trim();
561 if s.is_empty() {
562 return None;
563 }
564
565 let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
566 (n, 86400)
567 } else if let Some(n) = s.strip_suffix('h') {
568 (n, 3600)
569 } else if let Some(n) = s.strip_suffix('m') {
570 (n, 60)
571 } else if let Some(n) = s.strip_suffix('s') {
572 (n, 1)
573 } else {
574 (s, 1)
575 };
576
577 let num: u64 = num_str.parse().ok()?;
578 Some(num * multiplier)
579}
580
581#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn a_scoped_token_is_good_only_where_its_scope_is_asked_for() {
591 let (private, public) = generate_keypair_pem().unwrap();
592 let scoped = mint_scoped_token(
593 private.as_bytes(),
594 1,
595 "user",
596 Role::Admin,
597 900,
598 Some(MCP_SCOPE),
599 )
600 .unwrap();
601 assert!(validate_access_token(public.as_bytes(), &scoped).is_err());
602 assert!(validate_scoped_token(public.as_bytes(), &scoped, Some(MCP_SCOPE)).is_ok());
603 let session = mint_access_token(private.as_bytes(), 1, "user", Role::Admin, 900).unwrap();
604 assert!(validate_scoped_token(public.as_bytes(), &session, Some(MCP_SCOPE)).is_err());
605 }
606
607 #[test]
608 fn password_hash_and_verify() {
609 let password = "hunter2";
610 let hash = hash_password(password).unwrap();
611 assert!(hash.starts_with("$argon2"));
612 verify_password(password, &hash).unwrap();
613 }
614
615 #[test]
616 fn password_verify_wrong() {
617 let hash = hash_password("correct").unwrap();
618 let result = verify_password("wrong", &hash);
619 assert!(matches!(result, Err(AuthError::InvalidPassword)));
620 }
621
622 #[test]
625 fn password_verify_hash_from_argon2_0_5() {
626 let hash = "$argon2id$v=19$m=19456,t=2,p=1$M/zwWdjjbwOvNCjzP+5t5A$pflXrbL1iOYPBlbgtK59wr2PkBaH7UVLKoBisvJ+Yfk";
627 verify_password("correct horse", hash).unwrap();
628 assert!(matches!(
629 verify_password("wrong horse", hash),
630 Err(AuthError::InvalidPassword)
631 ));
632 }
633
634 #[test]
635 fn keypair_generate_and_jwt_roundtrip() {
636 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
637
638 let token =
639 mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
640 let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
641
642 assert_eq!(claims.sub, 42);
643 assert_eq!(claims.username, "testuser");
644 assert_eq!(claims.role, "admin");
645 }
646
647 #[test]
648 fn expired_token_rejected() {
649 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
650 let now = std::time::SystemTime::now()
652 .duration_since(std::time::UNIX_EPOCH)
653 .unwrap()
654 .as_secs();
655 let claims = Claims {
656 sub: 1,
657 username: "user".into(),
658 role: "user".into(),
659 iat: now - 1200,
660 exp: now - 600, scope: None,
662 };
663 let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
664 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
665 let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
666 let result = validate_access_token(pub_pem.as_bytes(), &token);
667 assert!(result.is_err());
668 }
669
670 #[test]
671 fn role_permissions() {
672 assert!(Role::Admin.has_permission(Role::Admin));
673 assert!(Role::Admin.has_permission(Role::User));
674 assert!(Role::Admin.has_permission(Role::Readonly));
675
676 assert!(!Role::User.has_permission(Role::Admin));
677 assert!(Role::User.has_permission(Role::User));
678 assert!(Role::User.has_permission(Role::Readonly));
679
680 assert!(!Role::Readonly.has_permission(Role::Admin));
681 assert!(!Role::Readonly.has_permission(Role::User));
682 assert!(Role::Readonly.has_permission(Role::Readonly));
683 }
684
685 #[test]
686 fn parse_duration() {
687 assert_eq!(parse_duration_secs("15m"), Some(900));
688 assert_eq!(parse_duration_secs("7d"), Some(604800));
689 assert_eq!(parse_duration_secs("24h"), Some(86400));
690 assert_eq!(parse_duration_secs("3600s"), Some(3600));
691 assert_eq!(parse_duration_secs("3600"), Some(3600));
692 assert_eq!(parse_duration_secs(""), None);
693 }
694}