Skip to main content

koan_core/
auth.rs

1//! Authentication primitives: Ed25519 JWT signing, Argon2id password hashing.
2//!
3//! Ed25519 keypair is generated once and stored in the config directory.
4//! JWTs are signed with EdDSA (Ed25519).
5
6use 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/// The name a server acts under when nobody signed in: auth switched off, or
18/// the local MCP. No account may take it, or it would inherit what is scoped
19/// to that name.
20pub const ANONYMOUS: &str = "anonymous";
21
22// ---------------------------------------------------------------------------
23// Errors
24// ---------------------------------------------------------------------------
25
26#[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// ---------------------------------------------------------------------------
43// Roles
44// ---------------------------------------------------------------------------
45
46#[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    /// Returns true if this role has at least the given permission level.
64    /// Admin > User > Readonly.
65    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// ---------------------------------------------------------------------------
94// JWT Claims
95// ---------------------------------------------------------------------------
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct Claims {
99    /// Subject — user ID.
100    pub sub: i64,
101    /// Username.
102    pub username: String,
103    /// Role.
104    pub role: String,
105    /// Issued at (unix timestamp).
106    pub iat: u64,
107    /// Expiration (unix timestamp).
108    pub exp: u64,
109    /// The one resource a scoped token is good for; see `MCP_SCOPE`. An
110    /// unscoped token is a session and goes anywhere a session does.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub scope: Option<String>,
113}
114
115/// The scope of a token granted to an MCP client through OAuth: valid at
116/// `/mcp` and nowhere else, so the limits the MCP sets on what a client may do
117/// cannot be stepped round by presenting the same token to GraphQL.
118pub const MCP_SCOPE: &str = "mcp";
119
120// ---------------------------------------------------------------------------
121// Password hashing (Argon2id)
122// ---------------------------------------------------------------------------
123
124/// Hash a password using Argon2id with a random salt.
125pub 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
135/// Verify a password against an Argon2id hash.
136pub 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
147// ---------------------------------------------------------------------------
148// Random secrets
149// ---------------------------------------------------------------------------
150
151/// Generate a 256-bit random secret, hex encoded.
152///
153/// Used for bearer-style secrets that are compared verbatim rather than hashed
154/// (introspection key, Subsonic shared secret), so the entropy has to carry the
155/// whole security argument.
156pub 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
166/// A new Subsonic API key: 32 random bytes, base64url without padding, so it
167/// travels in a query string unescaped.
168pub 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
179/// SHA-256 of `input`, hex encoded. Refresh tokens are stored under this so a
180/// database read does not yield usable credentials.
181pub 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
189// ---------------------------------------------------------------------------
190// Ed25519 Keypair management
191// ---------------------------------------------------------------------------
192
193pub 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
205/// Derive a new Ed25519 keypair as PEM. Touches no filesystem state.
206/// Returns (private_pem, public_pem).
207pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
208    // jsonwebtoken's EncodingKey::from_ed_pem expects PKCS8 PEM.
209    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    // Extract public key from the keypair.
216    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    // Wrap public key in SubjectPublicKeyInfo DER (for Ed25519 this is a fixed prefix + 32 bytes).
221    // OID 1.3.101.112 = id-EdDSA (Ed25519).
222    let mut spki = vec![
223        0x30, 0x2a, // SEQUENCE, 42 bytes total
224        0x30, 0x05, // SEQUENCE (AlgorithmIdentifier), 5 bytes
225        0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
226        0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits
227    ];
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
234/// Generate a new Ed25519 keypair and write PEM files to the config dir.
235/// Returns (private_pem, public_pem).
236pub 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    // Ensure the auth directory is gitignored — keys must never be committed.
243    let gitignore = dir.join(".gitignore");
244    if !gitignore.exists() {
245        let _ = fs::write(&gitignore, "*\n");
246    }
247
248    // Write key files with restrictive permissions set BEFORE writing content
249    // to avoid a window where the file exists with default (world-readable) mode.
250    #[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
285/// Load the Ed25519 keypair from disk. Returns (private_pem, public_pem).
286pub 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
299/// Load or generate the keypair. Generates if missing.
300pub 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
308// ---------------------------------------------------------------------------
309// JWT encode / decode
310// ---------------------------------------------------------------------------
311
312/// Mint a new access token.
313pub 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
323/// Mint an access token, limited to `scope` when there is one.
324pub 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
349/// Validate a session's access token and return its claims. A scoped token is
350/// refused: it is good only where its scope is checked for.
351pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
352    validate_scoped_token(public_pem, token, None)
353}
354
355/// Validate an access token whose scope is exactly `scope`.
356pub 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    // Only require exp (expiry). sub and iat are custom fields, not JWT spec strings.
364    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
373// ---------------------------------------------------------------------------
374// Time helpers
375// ---------------------------------------------------------------------------
376
377pub fn now_unix() -> u64 {
378    SystemTime::now()
379        .duration_since(UNIX_EPOCH)
380        .unwrap()
381        .as_secs()
382}
383
384/// Parse a duration string like "15m", "7d", "24h", "3600s" into seconds.
385pub 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// ---------------------------------------------------------------------------
408// Tests
409// ---------------------------------------------------------------------------
410
411#[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    /// Hashed by argon2 0.5. Every stored password was, so this is what a
449    /// dependency bump must never stop accepting.
450    #[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        // Manually create a token that expired 10 minutes ago.
477        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, // expired 10 min ago
487            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}