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
17#[derive(Debug, Error)]
22pub enum AuthError {
23 #[error("jwt error: {0}")]
24 Jwt(#[from] jsonwebtoken::errors::Error),
25 #[error("argon2 hash error: {0}")]
26 Hash(String),
27 #[error("password verification failed")]
28 InvalidPassword,
29 #[error("io error: {0}")]
30 Io(#[from] std::io::Error),
31 #[error("keypair not found — run `koan auth setup` first")]
32 NoKeypair,
33 #[error("{0}")]
34 Other(String),
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Role {
44 Admin,
45 User,
46 Readonly,
47}
48
49impl Role {
50 pub fn as_str(&self) -> &'static str {
51 match self {
52 Role::Admin => "admin",
53 Role::User => "user",
54 Role::Readonly => "readonly",
55 }
56 }
57
58 pub fn has_permission(&self, required: Role) -> bool {
61 match required {
62 Role::Readonly => true,
63 Role::User => matches!(self, Role::Admin | Role::User),
64 Role::Admin => matches!(self, Role::Admin),
65 }
66 }
67}
68
69impl std::str::FromStr for Role {
70 type Err = String;
71
72 fn from_str(s: &str) -> Result<Self, Self::Err> {
73 match s {
74 "admin" => Ok(Role::Admin),
75 "user" => Ok(Role::User),
76 "readonly" => Ok(Role::Readonly),
77 _ => Err(format!("invalid role: '{s}'")),
78 }
79 }
80}
81
82impl std::fmt::Display for Role {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88#[derive(Debug, Serialize, Deserialize)]
93pub struct Claims {
94 pub sub: i64,
96 pub username: String,
98 pub role: String,
100 pub iat: u64,
102 pub exp: u64,
104}
105
106pub fn hash_password(password: &str) -> Result<String, AuthError> {
112 use argon2::Argon2;
113 use argon2::password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
114
115 let salt = SaltString::generate(&mut OsRng);
116 let argon2 = Argon2::default();
117 argon2
118 .hash_password(password.as_bytes(), &salt)
119 .map(|h| h.to_string())
120 .map_err(|e| AuthError::Hash(e.to_string()))
121}
122
123pub fn verify_password(password: &str, hash: &str) -> Result<(), AuthError> {
125 use argon2::Argon2;
126 use argon2::password_hash::{PasswordHash, PasswordVerifier};
127
128 let parsed = PasswordHash::new(hash).map_err(|e| AuthError::Hash(e.to_string()))?;
129 Argon2::default()
130 .verify_password(password.as_bytes(), &parsed)
131 .map_err(|_| AuthError::InvalidPassword)
132}
133
134pub fn random_token() -> Result<String, AuthError> {
144 use ring::rand::SecureRandom;
145
146 let mut bytes = [0u8; 32];
147 ring::rand::SystemRandom::new()
148 .fill(&mut bytes)
149 .map_err(|_| AuthError::Hash("rng failure".into()))?;
150 Ok(bytes.iter().map(|b| format!("{:02x}", b)).collect())
151}
152
153pub fn sha256_hex(input: &str) -> String {
156 ring::digest::digest(&ring::digest::SHA256, input.as_bytes())
157 .as_ref()
158 .iter()
159 .map(|b| format!("{:02x}", b))
160 .collect()
161}
162
163pub fn keypair_dir() -> PathBuf {
168 config::config_dir().join("auth")
169}
170
171fn private_key_path() -> PathBuf {
172 keypair_dir().join("ed25519.pem")
173}
174
175fn public_key_path() -> PathBuf {
176 keypair_dir().join("ed25519.pub.pem")
177}
178
179pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
182 let rng = ring::rand::SystemRandom::new();
184 let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
185 .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
186
187 let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
188
189 let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
191 .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
192 let pub_bytes = kp.public_key().as_ref();
193
194 let mut spki = vec![
197 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ];
202 spki.extend_from_slice(pub_bytes);
203 let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
204
205 Ok((private_pem, public_pem))
206}
207
208pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
211 let (private_pem, public_pem) = generate_keypair_pem()?;
212
213 let dir = keypair_dir();
214 fs::create_dir_all(&dir)?;
215
216 let gitignore = dir.join(".gitignore");
218 if !gitignore.exists() {
219 let _ = fs::write(&gitignore, "*\n");
220 }
221
222 #[cfg(unix)]
225 {
226 use std::fs::OpenOptions;
227 use std::io::Write;
228 use std::os::unix::fs::OpenOptionsExt;
229 use std::os::unix::fs::PermissionsExt;
230
231 let mut f = OpenOptions::new()
232 .write(true)
233 .create(true)
234 .truncate(true)
235 .mode(0o600)
236 .open(private_key_path())?;
237 f.write_all(private_pem.as_bytes())?;
238
239 let mut f = OpenOptions::new()
240 .write(true)
241 .create(true)
242 .truncate(true)
243 .mode(0o644)
244 .open(public_key_path())?;
245 f.write_all(public_pem.as_bytes())?;
246
247 let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
248 }
249
250 #[cfg(not(unix))]
251 {
252 fs::write(private_key_path(), &private_pem)?;
253 fs::write(public_key_path(), &public_pem)?;
254 }
255
256 Ok((private_pem.into_bytes(), public_pem.into_bytes()))
257}
258
259pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
261 let priv_path = private_key_path();
262 let pub_path = public_key_path();
263
264 if !priv_path.exists() || !pub_path.exists() {
265 return Err(AuthError::NoKeypair);
266 }
267
268 let private_pem = fs::read(&priv_path)?;
269 let public_pem = fs::read(&pub_path)?;
270 Ok((private_pem, public_pem))
271}
272
273pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
275 match load_keypair() {
276 Ok(kp) => Ok(kp),
277 Err(AuthError::NoKeypair) => generate_keypair(),
278 Err(e) => Err(e),
279 }
280}
281
282pub fn mint_access_token(
288 private_pem: &[u8],
289 user_id: i64,
290 username: &str,
291 role: Role,
292 ttl_secs: u64,
293) -> Result<String, AuthError> {
294 mint_access_token_with_role_str(private_pem, user_id, username, role.as_str(), ttl_secs)
295}
296
297pub fn mint_access_token_with_role_str(
302 private_pem: &[u8],
303 user_id: i64,
304 username: &str,
305 role: &str,
306 ttl_secs: u64,
307) -> Result<String, AuthError> {
308 let now = SystemTime::now()
309 .duration_since(UNIX_EPOCH)
310 .unwrap()
311 .as_secs();
312
313 let claims = Claims {
314 sub: user_id,
315 username: username.to_string(),
316 role: role.to_string(),
317 iat: now,
318 exp: now + ttl_secs,
319 };
320
321 let key = EncodingKey::from_ed_pem(private_pem)?;
322 let header = Header::new(Algorithm::EdDSA);
323 let token = jsonwebtoken::encode(&header, &claims, &key)?;
324 Ok(token)
325}
326
327pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
329 let key = DecodingKey::from_ed_pem(public_pem)?;
330 let mut validation = Validation::new(Algorithm::EdDSA);
331 validation.set_required_spec_claims(&["exp"]);
333
334 let data = jsonwebtoken::decode::<Claims>(token, &key, &validation)?;
335 Ok(data.claims)
336}
337
338pub fn now_unix() -> u64 {
343 SystemTime::now()
344 .duration_since(UNIX_EPOCH)
345 .unwrap()
346 .as_secs()
347}
348
349pub fn parse_duration_secs(s: &str) -> Option<u64> {
351 let s = s.trim();
352 if s.is_empty() {
353 return None;
354 }
355
356 let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
357 (n, 86400)
358 } else if let Some(n) = s.strip_suffix('h') {
359 (n, 3600)
360 } else if let Some(n) = s.strip_suffix('m') {
361 (n, 60)
362 } else if let Some(n) = s.strip_suffix('s') {
363 (n, 1)
364 } else {
365 (s, 1)
366 };
367
368 let num: u64 = num_str.parse().ok()?;
369 Some(num * multiplier)
370}
371
372#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn password_hash_and_verify() {
382 let password = "hunter2";
383 let hash = hash_password(password).unwrap();
384 assert!(hash.starts_with("$argon2"));
385 verify_password(password, &hash).unwrap();
386 }
387
388 #[test]
389 fn password_verify_wrong() {
390 let hash = hash_password("correct").unwrap();
391 let result = verify_password("wrong", &hash);
392 assert!(matches!(result, Err(AuthError::InvalidPassword)));
393 }
394
395 #[test]
396 fn keypair_generate_and_jwt_roundtrip() {
397 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
398
399 let token =
400 mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
401 let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
402
403 assert_eq!(claims.sub, 42);
404 assert_eq!(claims.username, "testuser");
405 assert_eq!(claims.role, "admin");
406 }
407
408 #[test]
409 fn expired_token_rejected() {
410 let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
411 let now = std::time::SystemTime::now()
413 .duration_since(std::time::UNIX_EPOCH)
414 .unwrap()
415 .as_secs();
416 let claims = Claims {
417 sub: 1,
418 username: "user".into(),
419 role: "user".into(),
420 iat: now - 1200,
421 exp: now - 600, };
423 let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
424 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
425 let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
426 let result = validate_access_token(pub_pem.as_bytes(), &token);
427 assert!(result.is_err());
428 }
429
430 #[test]
431 fn role_permissions() {
432 assert!(Role::Admin.has_permission(Role::Admin));
433 assert!(Role::Admin.has_permission(Role::User));
434 assert!(Role::Admin.has_permission(Role::Readonly));
435
436 assert!(!Role::User.has_permission(Role::Admin));
437 assert!(Role::User.has_permission(Role::User));
438 assert!(Role::User.has_permission(Role::Readonly));
439
440 assert!(!Role::Readonly.has_permission(Role::Admin));
441 assert!(!Role::Readonly.has_permission(Role::User));
442 assert!(Role::Readonly.has_permission(Role::Readonly));
443 }
444
445 #[test]
446 fn parse_duration() {
447 assert_eq!(parse_duration_secs("15m"), Some(900));
448 assert_eq!(parse_duration_secs("7d"), Some(604800));
449 assert_eq!(parse_duration_secs("24h"), Some(86400));
450 assert_eq!(parse_duration_secs("3600s"), Some(3600));
451 assert_eq!(parse_duration_secs("3600"), Some(3600));
452 assert_eq!(parse_duration_secs(""), None);
453 }
454}