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/// The key Subsonic app passwords are sealed under, derived from the server's
190/// signing key. A copy of the database alone does not open them, and
191/// regenerating the keypair retires every app password along with every
192/// session.
193pub 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
204/// Seal an app password for `user_id`: a random nonce, then the ciphertext and
205/// its tag. The user id is authenticated with it, so a sealed password moved to
206/// another account's row does not open.
207pub 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
235/// The app password `seal_app_password` sealed, if `key` and `user_id` are the
236/// ones it was sealed with.
237pub 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
253/// A new app password: four groups of five from an alphabet without the
254/// characters a phone keyboard or a reader confuses, about 98 bits. It is
255/// typed into an app once, so it is made to be typed.
256pub 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        // Rejecting the top of the range keeps every character equally likely.
268        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
280// ---------------------------------------------------------------------------
281// Ed25519 Keypair management
282// ---------------------------------------------------------------------------
283
284pub 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
296/// Derive a new Ed25519 keypair as PEM. Touches no filesystem state.
297/// Returns (private_pem, public_pem).
298pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
299    // jsonwebtoken's EncodingKey::from_ed_pem expects PKCS8 PEM.
300    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    // Extract public key from the keypair.
307    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    // Wrap public key in SubjectPublicKeyInfo DER (for Ed25519 this is a fixed prefix + 32 bytes).
312    // OID 1.3.101.112 = id-EdDSA (Ed25519).
313    let mut spki = vec![
314        0x30, 0x2a, // SEQUENCE, 42 bytes total
315        0x30, 0x05, // SEQUENCE (AlgorithmIdentifier), 5 bytes
316        0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
317        0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits
318    ];
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
325/// Generate a new Ed25519 keypair and write PEM files to the config dir.
326/// Returns (private_pem, public_pem).
327pub 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    // Ensure the auth directory is gitignored — keys must never be committed.
334    let gitignore = dir.join(".gitignore");
335    if !gitignore.exists() {
336        let _ = fs::write(&gitignore, "*\n");
337    }
338
339    // Write key files with restrictive permissions set BEFORE writing content
340    // to avoid a window where the file exists with default (world-readable) mode.
341    #[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
376/// Load the Ed25519 keypair from disk. Returns (private_pem, public_pem).
377pub 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
390/// Load or generate the keypair. Generates if missing.
391pub 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
399// ---------------------------------------------------------------------------
400// JWT encode / decode
401// ---------------------------------------------------------------------------
402
403/// Mint a new access token.
404pub 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
414/// Mint an access token, limited to `scope` when there is one.
415pub 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
440/// Validate a session's access token and return its claims. A scoped token is
441/// refused: it is good only where its scope is checked for.
442pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
443    validate_scoped_token(public_pem, token, None)
444}
445
446/// Validate an access token whose scope is exactly `scope`.
447pub 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    // Only require exp (expiry). sub and iat are custom fields, not JWT spec strings.
455    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
464// ---------------------------------------------------------------------------
465// Account changes
466// ---------------------------------------------------------------------------
467
468/// Changes to accounts, as a running count, and the count at each account's
469/// latest change.
470///
471/// A request is authenticated once, but a socket outlives it and asks the
472/// database nothing afterwards. So every change that can narrow what an
473/// account's sockets hold — a new role or password, a revoked key or app
474/// password, the account deleted — is announced here, and a socket closes when
475/// its account's is. The client reconnects and is authenticated as things now
476/// stand.
477///
478/// Revoking a refresh token is not such a change: no socket rests on one, and
479/// closing every socket on the account at each sign-out would only make them
480/// all reconnect.
481///
482/// Announced by the queries that make the change, so no caller can forget to.
483/// Only within this process: a change made by another, such as the CLI's,
484/// reaches sockets when they next reconnect.
485struct 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
498/// Announce a change to `user_id`'s account.
499pub 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
507/// Where the count of changes stands. Taken before a credential is checked,
508/// so a change made while it is being checked still counts against it.
509pub fn account_mark() -> u64 {
510    *account_changes().count.borrow()
511}
512
513/// Resolves once any account has changed after `mark`, with the mark it now
514/// stands at, to wait from next.
515pub 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
528/// Resolves once `user_id`'s account has changed after `mark`.
529pub 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
547// ---------------------------------------------------------------------------
548// Time helpers
549// ---------------------------------------------------------------------------
550
551pub fn now_unix() -> u64 {
552    SystemTime::now()
553        .duration_since(UNIX_EPOCH)
554        .unwrap()
555        .as_secs()
556}
557
558/// Parse a duration string like "15m", "7d", "24h", "3600s" into seconds.
559pub 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// ---------------------------------------------------------------------------
582// Tests
583// ---------------------------------------------------------------------------
584
585#[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    /// Hashed by argon2 0.5. Every stored password was, so this is what a
623    /// dependency bump must never stop accepting.
624    #[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        // Manually create a token that expired 10 minutes ago.
651        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, // expired 10 min ago
661            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}