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
189fn subsonic_key_path() -> PathBuf {
204 keypair_dir().join("subsonic.key")
205}
206
207pub fn subsonic_key() -> Result<[u8; 32], AuthError> {
209 use ring::rand::{SecureRandom, SystemRandom};
210 let path = subsonic_key_path();
211 match fs::read(&path) {
212 Ok(bytes) => {
213 return bytes
214 .try_into()
215 .map_err(|_| AuthError::Other(format!("{} is not a 32-byte key", path.display())));
216 }
217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
218 Err(e) => return Err(e.into()),
219 }
220 let mut key = [0u8; 32];
221 SystemRandom::new()
222 .fill(&mut key)
223 .map_err(|_| AuthError::Other("no randomness for the Subsonic key".into()))?;
224 fs::create_dir_all(keypair_dir())?;
225 #[cfg(unix)]
226 {
227 use std::io::Write;
228 use std::os::unix::fs::OpenOptionsExt;
229 match fs::OpenOptions::new()
231 .write(true)
232 .create_new(true)
233 .mode(0o600)
234 .open(&path)
235 {
236 Ok(mut f) => f.write_all(&key)?,
237 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return subsonic_key(),
238 Err(e) => return Err(e.into()),
239 }
240 }
241 #[cfg(not(unix))]
242 fs::write(&path, key)?;
243 Ok(key)
244}
245
246fn sealing_key(key: &[u8; 32]) -> Result<ring::aead::LessSafeKey, AuthError> {
247 use ring::aead::{AES_256_GCM, LessSafeKey, UnboundKey};
248 UnboundKey::new(&AES_256_GCM, key)
249 .map(LessSafeKey::new)
250 .map_err(|_| AuthError::Other("invalid Subsonic key".into()))
251}
252
253pub fn seal_password(key: &[u8; 32], username: &str, password: &str) -> Result<Vec<u8>, AuthError> {
255 use ring::aead::{Aad, NONCE_LEN, Nonce};
256 use ring::rand::{SecureRandom, SystemRandom};
257 let mut nonce = [0u8; NONCE_LEN];
258 SystemRandom::new()
259 .fill(&mut nonce)
260 .map_err(|_| AuthError::Other("no randomness for a nonce".into()))?;
261 let mut sealed = password.as_bytes().to_vec();
262 sealing_key(key)?
263 .seal_in_place_append_tag(
264 Nonce::assume_unique_for_key(nonce),
265 Aad::from(username.as_bytes()),
266 &mut sealed,
267 )
268 .map_err(|_| AuthError::Other("sealing failed".into()))?;
269 let mut out = nonce.to_vec();
270 out.extend(sealed);
271 Ok(out)
272}
273
274pub fn open_password(key: &[u8; 32], username: &str, sealed: &[u8]) -> Option<String> {
276 use ring::aead::{Aad, NONCE_LEN, Nonce};
277 let (nonce, ciphertext) = sealed.split_at_checked(NONCE_LEN)?;
278 let mut buf = ciphertext.to_vec();
279 let plain = sealing_key(key)
280 .ok()?
281 .open_in_place(
282 Nonce::try_assume_unique_for_key(nonce).ok()?,
283 Aad::from(username.as_bytes()),
284 &mut buf,
285 )
286 .ok()?;
287 String::from_utf8(plain.to_vec()).ok()
288}
289
290pub fn keypair_dir() -> PathBuf {
291 config::config_dir().join("auth")
292}
293
294fn private_key_path() -> PathBuf {
295 keypair_dir().join("ed25519.pem")
296}
297
298fn public_key_path() -> PathBuf {
299 keypair_dir().join("ed25519.pub.pem")
300}
301
302pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
305 let rng = ring::rand::SystemRandom::new();
307 let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
308 .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
309
310 let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
311
312 let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
314 .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
315 let pub_bytes = kp.public_key().as_ref();
316
317 let mut spki = vec![
320 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ];
325 spki.extend_from_slice(pub_bytes);
326 let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
327
328 Ok((private_pem, public_pem))
329}
330
331pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
334 let (private_pem, public_pem) = generate_keypair_pem()?;
335
336 let dir = keypair_dir();
337 fs::create_dir_all(&dir)?;
338
339 let gitignore = dir.join(".gitignore");
341 if !gitignore.exists() {
342 let _ = fs::write(&gitignore, "*\n");
343 }
344
345 #[cfg(unix)]
348 {
349 use std::fs::OpenOptions;
350 use std::io::Write;
351 use std::os::unix::fs::OpenOptionsExt;
352 use std::os::unix::fs::PermissionsExt;
353
354 let mut f = OpenOptions::new()
355 .write(true)
356 .create(true)
357 .truncate(true)
358 .mode(0o600)
359 .open(private_key_path())?;
360 f.write_all(private_pem.as_bytes())?;
361
362 let mut f = OpenOptions::new()
363 .write(true)
364 .create(true)
365 .truncate(true)
366 .mode(0o644)
367 .open(public_key_path())?;
368 f.write_all(public_pem.as_bytes())?;
369
370 let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
371 }
372
373 #[cfg(not(unix))]
374 {
375 fs::write(private_key_path(), &private_pem)?;
376 fs::write(public_key_path(), &public_pem)?;
377 }
378
379 Ok((private_pem.into_bytes(), public_pem.into_bytes()))
380}
381
382pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
384 let priv_path = private_key_path();
385 let pub_path = public_key_path();
386
387 if !priv_path.exists() || !pub_path.exists() {
388 return Err(AuthError::NoKeypair);
389 }
390
391 let private_pem = fs::read(&priv_path)?;
392 let public_pem = fs::read(&pub_path)?;
393 Ok((private_pem, public_pem))
394}
395
396pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
398 match load_keypair() {
399 Ok(kp) => Ok(kp),
400 Err(AuthError::NoKeypair) => generate_keypair(),
401 Err(e) => Err(e),
402 }
403}
404
405pub fn mint_access_token(
411 private_pem: &[u8],
412 user_id: i64,
413 username: &str,
414 role: Role,
415 ttl_secs: u64,
416) -> Result<String, AuthError> {
417 mint_scoped_token(private_pem, user_id, username, role, ttl_secs, None)
418}
419
420pub fn mint_scoped_token(
422 private_pem: &[u8],
423 user_id: i64,
424 username: &str,
425 role: Role,
426 ttl_secs: u64,
427 scope: Option<&str>,
428) -> Result<String, AuthError> {
429 let now = now_unix();
430
431 let claims = Claims {
432 sub: user_id,
433 username: username.to_string(),
434 role: role.as_str().to_string(),
435 iat: now,
436 exp: now + ttl_secs,
437 scope: scope.map(str::to_owned),
438 };
439
440 let key = EncodingKey::from_ed_pem(private_pem)?;
441 let header = Header::new(Algorithm::EdDSA);
442 let token = jsonwebtoken::encode(&header, &claims, &key)?;
443 Ok(token)
444}
445
446pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
449 validate_scoped_token(public_pem, token, None)
450}
451
452pub fn validate_scoped_token(
454 public_pem: &[u8],
455 token: &str,
456 scope: Option<&str>,
457) -> Result<Claims, AuthError> {
458 let key = DecodingKey::from_ed_pem(public_pem)?;
459 let mut validation = Validation::new(Algorithm::EdDSA);
460 validation.set_required_spec_claims(&["exp"]);
462
463 let claims = jsonwebtoken::decode::<Claims>(token, &key, &validation)?.claims;
464 if claims.scope.as_deref() != scope {
465 return Err(AuthError::Other("token not valid here".into()));
466 }
467 Ok(claims)
468}
469
470pub fn now_unix() -> u64 {
475 SystemTime::now()
476 .duration_since(UNIX_EPOCH)
477 .unwrap()
478 .as_secs()
479}
480
481pub fn parse_duration_secs(s: &str) -> Option<u64> {
483 let s = s.trim();
484 if s.is_empty() {
485 return None;
486 }
487
488 let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
489 (n, 86400)
490 } else if let Some(n) = s.strip_suffix('h') {
491 (n, 3600)
492 } else if let Some(n) = s.strip_suffix('m') {
493 (n, 60)
494 } else if let Some(n) = s.strip_suffix('s') {
495 (n, 1)
496 } else {
497 (s, 1)
498 };
499
500 let num: u64 = num_str.parse().ok()?;
501 Some(num * multiplier)
502}
503
504#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn a_scoped_token_is_good_only_where_its_scope_is_asked_for() {
514 let (private, public) = generate_keypair_pem().unwrap();
515 let scoped = mint_scoped_token(
516 private.as_bytes(),
517 1,
518 "user",
519 Role::Admin,
520 900,
521 Some(MCP_SCOPE),
522 )
523 .unwrap();
524 assert!(validate_access_token(public.as_bytes(), &scoped).is_err());
525 assert!(validate_scoped_token(public.as_bytes(), &scoped, Some(MCP_SCOPE)).is_ok());
526 let session = mint_access_token(private.as_bytes(), 1, "user", Role::Admin, 900).unwrap();
527 assert!(validate_scoped_token(public.as_bytes(), &session, Some(MCP_SCOPE)).is_err());
528 }
529
530 #[test]
531 fn password_hash_and_verify() {
532 let password = "hunter2";
533 let hash = hash_password(password).unwrap();
534 assert!(hash.starts_with("$argon2"));
535 verify_password(password, &hash).unwrap();
536 }
537
538 #[test]
539 fn password_verify_wrong() {
540 let hash = hash_password("correct").unwrap();
541 let result = verify_password("wrong", &hash);
542 assert!(matches!(result, Err(AuthError::InvalidPassword)));
543 }
544
545 #[test]
548 fn password_verify_hash_from_argon2_0_5() {
549 let hash = "$argon2id$v=19$m=19456,t=2,p=1$M/zwWdjjbwOvNCjzP+5t5A$pflXrbL1iOYPBlbgtK59wr2PkBaH7UVLKoBisvJ+Yfk";
550 verify_password("correct horse", hash).unwrap();
551 assert!(matches!(
552 verify_password("wrong horse", hash),
553 Err(AuthError::InvalidPassword)
554 ));
555 }
556
557 #[test]
558 fn keypair_generate_and_jwt_roundtrip() {
559 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
560
561 let token =
562 mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
563 let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
564
565 assert_eq!(claims.sub, 42);
566 assert_eq!(claims.username, "testuser");
567 assert_eq!(claims.role, "admin");
568 }
569
570 #[test]
571 fn expired_token_rejected() {
572 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
573 let now = std::time::SystemTime::now()
575 .duration_since(std::time::UNIX_EPOCH)
576 .unwrap()
577 .as_secs();
578 let claims = Claims {
579 sub: 1,
580 username: "user".into(),
581 role: "user".into(),
582 iat: now - 1200,
583 exp: now - 600, scope: None,
585 };
586 let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
587 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
588 let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
589 let result = validate_access_token(pub_pem.as_bytes(), &token);
590 assert!(result.is_err());
591 }
592
593 #[test]
594 fn role_permissions() {
595 assert!(Role::Admin.has_permission(Role::Admin));
596 assert!(Role::Admin.has_permission(Role::User));
597 assert!(Role::Admin.has_permission(Role::Readonly));
598
599 assert!(!Role::User.has_permission(Role::Admin));
600 assert!(Role::User.has_permission(Role::User));
601 assert!(Role::User.has_permission(Role::Readonly));
602
603 assert!(!Role::Readonly.has_permission(Role::Admin));
604 assert!(!Role::Readonly.has_permission(Role::User));
605 assert!(Role::Readonly.has_permission(Role::Readonly));
606 }
607
608 #[test]
609 fn parse_duration() {
610 assert_eq!(parse_duration_secs("15m"), Some(900));
611 assert_eq!(parse_duration_secs("7d"), Some(604800));
612 assert_eq!(parse_duration_secs("24h"), Some(86400));
613 assert_eq!(parse_duration_secs("3600s"), Some(3600));
614 assert_eq!(parse_duration_secs("3600"), Some(3600));
615 assert_eq!(parse_duration_secs(""), None);
616 }
617}