Skip to main content

koan_server/auth/
mod.rs

1//! Authentication layer for the koan server.
2//!
3//! When `auth_enabled = true`:
4//!   - GraphQL requests must carry a valid JWT (see `middleware` for where it is
5//!     read from); the web UI takes it only as the `koan_access` cookie
6//!   - Auth routes (/auth/login, /auth/refresh, /auth/logout) are always accessible
7//!
8//! When `auth_enabled = false` (opt-in, not the default):
9//!   - All requests are treated as admin — no auth required.
10
11pub mod middleware;
12pub mod password;
13pub mod routes;
14
15use std::sync::Arc;
16
17use parking_lot::RwLock;
18
19use koan_core::auth::{Claims, Role};
20use koan_core::db::pool::Pool;
21use koan_core::db::queries::auth as auth_queries;
22
23/// The server's signing keypair as PEM, private then public.
24pub(crate) type Keypair = (Arc<Vec<u8>>, Arc<Vec<u8>>);
25
26static SIGNING: RwLock<Option<Keypair>> = RwLock::new(None);
27
28/// Set at each start to the keys sessions are signed with, so invite tokens
29/// are signed and checked with the same pair on every path, whatever happens
30/// to the files on disk while the server runs.
31pub(crate) fn set_signing_keys(private: Arc<Vec<u8>>, public: Arc<Vec<u8>>) {
32    *SIGNING.write() = Some((private, public));
33}
34
35/// The keys `set_signing_keys` was given; read from disk, once, where nothing
36/// set them, as in tests.
37pub(crate) fn signing_keys() -> Result<Keypair, koan_core::auth::AuthError> {
38    if let Some(keys) = SIGNING.read().clone() {
39        return Ok(keys);
40    }
41    let (private, public) = koan_core::auth::load_or_generate_keypair()?;
42    Ok(SIGNING
43        .write()
44        .get_or_insert_with(|| (Arc::new(private), Arc::new(public)))
45        .clone())
46}
47
48/// Authenticated user context injected into request extensions and GraphQL context.
49#[derive(Debug, Clone)]
50pub struct AuthUser {
51    pub user_id: i64,
52    pub username: String,
53    pub role: Role,
54}
55
56/// The account a token names, as it stands now.
57///
58/// A token's claims hold for its whole lifetime, so taken at their word a role
59/// change or a deletion would not reach GraphQL or the web UI until it
60/// expired: time enough for a demoted admin to restore the role. `None` once
61/// the account is gone, or when its id now belongs to another account.
62pub(crate) async fn current_user(pool: &Arc<Pool>, claims: Claims) -> Option<AuthUser> {
63    let pool = pool.clone();
64    tokio::task::spawn_blocking(move || {
65        let db = pool.get().ok()?;
66        let user = auth_queries::get_user_by_id(&db.conn, claims.sub).ok()??;
67        (user.username == claims.username).then_some(AuthUser {
68            user_id: user.id,
69            username: user.username,
70            role: user.role,
71        })
72    })
73    .await
74    .ok()
75    .flatten()
76}
77
78/// The account an API key belongs to, as it stands now. `None` for a key
79/// that is not live.
80pub(crate) async fn key_user(pool: &Arc<Pool>, key: String) -> Option<AuthUser> {
81    let pool = pool.clone();
82    tokio::task::spawn_blocking(move || {
83        let db = pool.get().ok()?;
84        let user =
85            koan_core::db::queries::api_keys::authenticate_api_key(&db.conn, &key).ok()??;
86        Some(AuthUser {
87            user_id: user.id,
88            username: user.username,
89            role: user.role,
90        })
91    })
92    .await
93    .ok()
94    .flatten()
95}
96
97/// What a socket opened with a credential is held under: its account as of
98/// `mark` (see `koan_core::auth::account_mark`), and for a token, when it
99/// lapses.
100#[derive(Debug, Clone, Copy)]
101pub(crate) struct Lease {
102    pub user_id: i64,
103    pub mark: u64,
104    /// Unix seconds.
105    pub expires: Option<u64>,
106}
107
108impl Lease {
109    /// Resolves when a socket held under this lease must close: its account
110    /// changed, or its token lapsed. The client reconnects and authenticates
111    /// again, so it holds no more than its credential now gives it.
112    pub(crate) async fn ended(self) {
113        let lapsed = async {
114            match self.expires {
115                Some(at) => {
116                    let left = at.saturating_sub(koan_core::clock::unix_secs() as u64);
117                    tokio::time::sleep(std::time::Duration::from_secs(left)).await;
118                }
119                None => std::future::pending().await,
120            }
121        };
122        tokio::select! {
123            _ = koan_core::auth::account_changed_since(self.user_id, self.mark) => {}
124            _ = lapsed => {}
125        }
126    }
127}
128
129impl AuthUser {
130    /// Anonymous admin user for when auth is disabled.
131    pub fn anonymous_admin() -> Self {
132        Self {
133            user_id: 0,
134            username: koan_core::auth::ANONYMOUS.into(),
135            role: Role::Admin,
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::time::Duration;
144
145    #[tokio::test]
146    async fn a_lease_ends_when_its_account_changes_and_no_other() {
147        let lease = Lease {
148            user_id: 9001,
149            mark: koan_core::auth::account_mark(),
150            expires: None,
151        };
152        koan_core::auth::account_changed(9002);
153        assert!(
154            tokio::time::timeout(Duration::from_millis(50), lease.ended())
155                .await
156                .is_err()
157        );
158        koan_core::auth::account_changed(9001);
159        tokio::time::timeout(Duration::from_secs(1), lease.ended())
160            .await
161            .unwrap();
162    }
163
164    #[tokio::test]
165    async fn a_lease_ends_when_its_token_lapses() {
166        let lease = Lease {
167            user_id: 9003,
168            mark: koan_core::auth::account_mark(),
169            expires: Some(koan_core::clock::unix_secs() as u64),
170        };
171        tokio::time::timeout(Duration::from_secs(1), lease.ended())
172            .await
173            .unwrap();
174    }
175}