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 keypair_dir() -> PathBuf {
194 config::config_dir().join("auth")
195}
196
197fn private_key_path() -> PathBuf {
198 keypair_dir().join("ed25519.pem")
199}
200
201fn public_key_path() -> PathBuf {
202 keypair_dir().join("ed25519.pub.pem")
203}
204
205pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
208 let rng = ring::rand::SystemRandom::new();
210 let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
211 .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
212
213 let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
214
215 let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
217 .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
218 let pub_bytes = kp.public_key().as_ref();
219
220 let mut spki = vec![
223 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ];
228 spki.extend_from_slice(pub_bytes);
229 let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
230
231 Ok((private_pem, public_pem))
232}
233
234pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
237 let (private_pem, public_pem) = generate_keypair_pem()?;
238
239 let dir = keypair_dir();
240 fs::create_dir_all(&dir)?;
241
242 let gitignore = dir.join(".gitignore");
244 if !gitignore.exists() {
245 let _ = fs::write(&gitignore, "*\n");
246 }
247
248 #[cfg(unix)]
251 {
252 use std::fs::OpenOptions;
253 use std::io::Write;
254 use std::os::unix::fs::OpenOptionsExt;
255 use std::os::unix::fs::PermissionsExt;
256
257 let mut f = OpenOptions::new()
258 .write(true)
259 .create(true)
260 .truncate(true)
261 .mode(0o600)
262 .open(private_key_path())?;
263 f.write_all(private_pem.as_bytes())?;
264
265 let mut f = OpenOptions::new()
266 .write(true)
267 .create(true)
268 .truncate(true)
269 .mode(0o644)
270 .open(public_key_path())?;
271 f.write_all(public_pem.as_bytes())?;
272
273 let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
274 }
275
276 #[cfg(not(unix))]
277 {
278 fs::write(private_key_path(), &private_pem)?;
279 fs::write(public_key_path(), &public_pem)?;
280 }
281
282 Ok((private_pem.into_bytes(), public_pem.into_bytes()))
283}
284
285pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
287 let priv_path = private_key_path();
288 let pub_path = public_key_path();
289
290 if !priv_path.exists() || !pub_path.exists() {
291 return Err(AuthError::NoKeypair);
292 }
293
294 let private_pem = fs::read(&priv_path)?;
295 let public_pem = fs::read(&pub_path)?;
296 Ok((private_pem, public_pem))
297}
298
299pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
301 match load_keypair() {
302 Ok(kp) => Ok(kp),
303 Err(AuthError::NoKeypair) => generate_keypair(),
304 Err(e) => Err(e),
305 }
306}
307
308pub fn mint_access_token(
314 private_pem: &[u8],
315 user_id: i64,
316 username: &str,
317 role: Role,
318 ttl_secs: u64,
319) -> Result<String, AuthError> {
320 mint_scoped_token(private_pem, user_id, username, role, ttl_secs, None)
321}
322
323pub fn mint_scoped_token(
325 private_pem: &[u8],
326 user_id: i64,
327 username: &str,
328 role: Role,
329 ttl_secs: u64,
330 scope: Option<&str>,
331) -> Result<String, AuthError> {
332 let now = now_unix();
333
334 let claims = Claims {
335 sub: user_id,
336 username: username.to_string(),
337 role: role.as_str().to_string(),
338 iat: now,
339 exp: now + ttl_secs,
340 scope: scope.map(str::to_owned),
341 };
342
343 let key = EncodingKey::from_ed_pem(private_pem)?;
344 let header = Header::new(Algorithm::EdDSA);
345 let token = jsonwebtoken::encode(&header, &claims, &key)?;
346 Ok(token)
347}
348
349pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
352 validate_scoped_token(public_pem, token, None)
353}
354
355pub fn validate_scoped_token(
357 public_pem: &[u8],
358 token: &str,
359 scope: Option<&str>,
360) -> Result<Claims, AuthError> {
361 let key = DecodingKey::from_ed_pem(public_pem)?;
362 let mut validation = Validation::new(Algorithm::EdDSA);
363 validation.set_required_spec_claims(&["exp"]);
365
366 let claims = jsonwebtoken::decode::<Claims>(token, &key, &validation)?.claims;
367 if claims.scope.as_deref() != scope {
368 return Err(AuthError::Other("token not valid here".into()));
369 }
370 Ok(claims)
371}
372
373pub fn now_unix() -> u64 {
378 SystemTime::now()
379 .duration_since(UNIX_EPOCH)
380 .unwrap()
381 .as_secs()
382}
383
384pub fn parse_duration_secs(s: &str) -> Option<u64> {
386 let s = s.trim();
387 if s.is_empty() {
388 return None;
389 }
390
391 let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
392 (n, 86400)
393 } else if let Some(n) = s.strip_suffix('h') {
394 (n, 3600)
395 } else if let Some(n) = s.strip_suffix('m') {
396 (n, 60)
397 } else if let Some(n) = s.strip_suffix('s') {
398 (n, 1)
399 } else {
400 (s, 1)
401 };
402
403 let num: u64 = num_str.parse().ok()?;
404 Some(num * multiplier)
405}
406
407#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn a_scoped_token_is_good_only_where_its_scope_is_asked_for() {
417 let (private, public) = generate_keypair_pem().unwrap();
418 let scoped = mint_scoped_token(
419 private.as_bytes(),
420 1,
421 "user",
422 Role::Admin,
423 900,
424 Some(MCP_SCOPE),
425 )
426 .unwrap();
427 assert!(validate_access_token(public.as_bytes(), &scoped).is_err());
428 assert!(validate_scoped_token(public.as_bytes(), &scoped, Some(MCP_SCOPE)).is_ok());
429 let session = mint_access_token(private.as_bytes(), 1, "user", Role::Admin, 900).unwrap();
430 assert!(validate_scoped_token(public.as_bytes(), &session, Some(MCP_SCOPE)).is_err());
431 }
432
433 #[test]
434 fn password_hash_and_verify() {
435 let password = "hunter2";
436 let hash = hash_password(password).unwrap();
437 assert!(hash.starts_with("$argon2"));
438 verify_password(password, &hash).unwrap();
439 }
440
441 #[test]
442 fn password_verify_wrong() {
443 let hash = hash_password("correct").unwrap();
444 let result = verify_password("wrong", &hash);
445 assert!(matches!(result, Err(AuthError::InvalidPassword)));
446 }
447
448 #[test]
451 fn password_verify_hash_from_argon2_0_5() {
452 let hash = "$argon2id$v=19$m=19456,t=2,p=1$M/zwWdjjbwOvNCjzP+5t5A$pflXrbL1iOYPBlbgtK59wr2PkBaH7UVLKoBisvJ+Yfk";
453 verify_password("correct horse", hash).unwrap();
454 assert!(matches!(
455 verify_password("wrong horse", hash),
456 Err(AuthError::InvalidPassword)
457 ));
458 }
459
460 #[test]
461 fn keypair_generate_and_jwt_roundtrip() {
462 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
463
464 let token =
465 mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
466 let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
467
468 assert_eq!(claims.sub, 42);
469 assert_eq!(claims.username, "testuser");
470 assert_eq!(claims.role, "admin");
471 }
472
473 #[test]
474 fn expired_token_rejected() {
475 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
476 let now = std::time::SystemTime::now()
478 .duration_since(std::time::UNIX_EPOCH)
479 .unwrap()
480 .as_secs();
481 let claims = Claims {
482 sub: 1,
483 username: "user".into(),
484 role: "user".into(),
485 iat: now - 1200,
486 exp: now - 600, scope: None,
488 };
489 let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
490 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
491 let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
492 let result = validate_access_token(pub_pem.as_bytes(), &token);
493 assert!(result.is_err());
494 }
495
496 #[test]
497 fn role_permissions() {
498 assert!(Role::Admin.has_permission(Role::Admin));
499 assert!(Role::Admin.has_permission(Role::User));
500 assert!(Role::Admin.has_permission(Role::Readonly));
501
502 assert!(!Role::User.has_permission(Role::Admin));
503 assert!(Role::User.has_permission(Role::User));
504 assert!(Role::User.has_permission(Role::Readonly));
505
506 assert!(!Role::Readonly.has_permission(Role::Admin));
507 assert!(!Role::Readonly.has_permission(Role::User));
508 assert!(Role::Readonly.has_permission(Role::Readonly));
509 }
510
511 #[test]
512 fn parse_duration() {
513 assert_eq!(parse_duration_secs("15m"), Some(900));
514 assert_eq!(parse_duration_secs("7d"), Some(604800));
515 assert_eq!(parse_duration_secs("24h"), Some(86400));
516 assert_eq!(parse_duration_secs("3600s"), Some(3600));
517 assert_eq!(parse_duration_secs("3600"), Some(3600));
518 assert_eq!(parse_duration_secs(""), None);
519 }
520}