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
73impl AuthUser {
74 /// Anonymous admin user for when auth is disabled.
75 pub fn anonymous_admin() -> Self {
76 Self {
77 user_id: 0,
78 username: koan_core::auth::ANONYMOUS.into(),
79 role: Role::Admin,
80 }
81 }
82}