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