cnats: NATS-native chat with Leptos SSR and Kanidm SSO

Initial import plus deployment packaging: multi-stage Dockerfile
(cargo-leptos build -> debian-slim runtime), .dockerignore, and a
dev-only docker-compose (app + local NATS). Production deployment
lives in the infrastructure repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-08 21:53:56 +02:00
co-authored by Claude Fable 5
commit 6b97ec3b0f
18 changed files with 5884 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
pub mod oidc;
pub mod sse;
use axum::extract::FromRef;
use leptos::prelude::LeptosOptions;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub leptos_options: LeptosOptions,
pub nats: async_nats::Client,
pub oidc: Arc<oidc::Oidc>,
}
impl FromRef<AppState> for LeptosOptions {
fn from_ref(state: &AppState) -> Self {
state.leptos_options.clone()
}
}
+209
View File
@@ -0,0 +1,209 @@
//! OIDC authorization-code + PKCE flow against Kanidm.
//!
//! Kanidm serves per-client OIDC discovery documents at
//! `<KANIDM_URL>/oauth2/openid/<client_id>/.well-known/openid-configuration`,
//! and enforces PKCE, so this module always sends a S256 challenge.
use axum::{
extract::{Query, State},
http::StatusCode,
response::Redirect,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
TokenResponse,
};
use serde::Deserialize;
use tower_sessions::Session;
use crate::auth::{User, SESSION_USER_KEY};
use super::AppState;
type OidcClient = CoreClient<
EndpointSet, // auth endpoint
EndpointNotSet, // device auth
EndpointNotSet, // introspection
EndpointNotSet, // revocation
EndpointMaybeSet, // token endpoint (from discovery)
EndpointMaybeSet, // userinfo endpoint (from discovery)
>;
pub struct Oidc {
client: OidcClient,
http: openidconnect::reqwest::Client,
}
const PKCE_KEY: &str = "oidc_pkce_verifier";
const CSRF_KEY: &str = "oidc_csrf_state";
const NONCE_KEY: &str = "oidc_nonce";
impl Oidc {
/// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
pub async fn from_env() -> anyhow::Result<Self> {
let kanidm_url = require_env("KANIDM_URL")?;
let client_id = require_env("OAUTH2_CLIENT_ID")?;
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
let public_url = require_env("PUBLIC_URL")?;
let issuer = IssuerUrl::new(format!(
"{}/oauth2/openid/{}",
kanidm_url.trim_end_matches('/'),
client_id
))?;
let redirect = RedirectUrl::new(format!(
"{}/auth/callback",
public_url.trim_end_matches('/')
))?;
// Never follow redirects when talking to the IdP (SSRF hygiene).
let http = openidconnect::reqwest::ClientBuilder::new()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()?;
tracing::info!(issuer = %issuer.as_str(), "discovering OIDC provider");
let metadata = CoreProviderMetadata::discover_async(issuer, &http).await?;
let client = CoreClient::from_provider_metadata(
metadata,
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
)
.set_redirect_uri(redirect);
Ok(Self { client, http })
}
}
fn require_env(name: &str) -> anyhow::Result<String> {
std::env::var(name).map_err(|_| anyhow::anyhow!("missing required env var {name}"))
}
type HandlerError = (StatusCode, String);
fn internal(err: impl std::fmt::Display) -> HandlerError {
tracing::error!("oidc error: {err}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"authentication failed; see server logs".to_string(),
)
}
/// GET /auth/login — stash PKCE/state/nonce in the session and bounce to Kanidm.
pub async fn login(State(state): State<AppState>, session: Session) -> Result<Redirect, HandlerError> {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state
.oidc
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
session
.insert(PKCE_KEY, pkce_verifier.secret())
.await
.map_err(internal)?;
session
.insert(CSRF_KEY, csrf_state.secret())
.await
.map_err(internal)?;
session
.insert(NONCE_KEY, nonce.secret())
.await
.map_err(internal)?;
Ok(Redirect::to(auth_url.as_str()))
}
#[derive(Deserialize)]
pub struct CallbackParams {
code: String,
state: String,
}
/// GET /auth/callback — verify state, exchange the code, verify the ID token,
/// and store the user in the session.
pub async fn callback(
State(state): State<AppState>,
session: Session,
Query(params): Query<CallbackParams>,
) -> Result<Redirect, HandlerError> {
let stored_csrf: Option<String> = session.remove(CSRF_KEY).await.map_err(internal)?;
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
(stored_csrf, pkce_verifier, nonce)
else {
return Err((
StatusCode::BAD_REQUEST,
"no login in progress; start again at /auth/login".to_string(),
));
};
if params.state != stored_csrf {
return Err((
StatusCode::BAD_REQUEST,
"state mismatch; start again at /auth/login".to_string(),
));
}
let oidc = &state.oidc;
let token_response = oidc
.client
.exchange_code(AuthorizationCode::new(params.code))
.map_err(internal)?
.set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
.request_async(&oidc.http)
.await
.map_err(internal)?;
let id_token = token_response
.id_token()
.ok_or_else(|| internal("provider returned no ID token"))?;
let claims = id_token
.claims(&oidc.client.id_token_verifier(), &Nonce::new(nonce))
.map_err(internal)?;
let username = claims
.preferred_username()
.map(|u| u.as_str().to_string())
.or_else(|| claims.email().map(|e| e.as_str().to_string()))
.unwrap_or_else(|| claims.subject().as_str().to_string());
let display_name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.as_str().to_string())
.unwrap_or_else(|| username.clone());
let user = User {
sub: claims.subject().as_str().to_string(),
username,
display_name,
};
// Rotate the session id on privilege change, then store the user.
session.cycle_id().await.map_err(internal)?;
session
.insert(SESSION_USER_KEY, &user)
.await
.map_err(internal)?;
tracing::info!(user = %user.username, "signed in");
Ok(Redirect::to("/"))
}
/// GET /auth/logout — drop the session.
pub async fn logout(session: Session) -> Result<Redirect, HandlerError> {
session.flush().await.map_err(internal)?;
Ok(Redirect::to("/"))
}
+58
View File
@@ -0,0 +1,58 @@
//! Server-Sent Events bridge: one NATS subscription per connected browser,
//! scoped to a single room subject.
use std::{convert::Infallible, time::Duration};
use axum::{
extract::{Path, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
};
use futures::{Stream, StreamExt};
use tower_sessions::Session;
use crate::auth::{User, SESSION_USER_KEY};
use crate::chat::{is_valid_room, room_subject};
use super::AppState;
/// GET /sse/{room} — stream the room's NATS subject to the browser.
pub async fn room_events(
Path(room): Path<String>,
State(state): State<AppState>,
session: Session,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, &'static str)> {
let signed_in = session
.get::<User>(SESSION_USER_KEY)
.await
.ok()
.flatten()
.is_some();
if !signed_in {
return Err((StatusCode::UNAUTHORIZED, "sign in first"));
}
if !is_valid_room(&room) {
return Err((StatusCode::NOT_FOUND, "unknown room"));
}
let subscriber = state
.nats
.subscribe(room_subject(&room))
.await
.map_err(|e| {
tracing::error!("nats subscribe failed: {e}");
(StatusCode::BAD_GATEWAY, "message bus unavailable")
})?;
let stream = subscriber.map(|msg| {
Ok(Event::default()
.event("message")
.data(String::from_utf8_lossy(&msg.payload).into_owned()))
});
Ok(Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("ping"),
))
}