Archify Sync server, Dockerfile and Helm chart

Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
This commit is contained in:
Oleg Lukasonok committed 2026-11-06 12:06:59 +02:00
commit 99778d7489
67 files changed
+8362

No files matched your search

+368
View File
@@ -0,0 +1,368 @@
//! Single sign-on through an OpenID Connect provider (Keycloak, Authentik,
//! Entra ID, …). The editor's sign-in stays as it is: the browser page offers
//! "Sign in with …", the provider signs the person in and comes back to
//! `/oidc/callback`, and the server hands the editor its one-time code as for
//! a password sign-in. One provider client serves every address the server is
//! reached at; each address only needs its callback listed at the provider.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail, ensure};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::auth::random_token;
use crate::pages::AuthorizeQuery;
/// How long the provider may take to send the person back.
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
const PENDING_MAX: usize = 10_000;
const CACHE_TTL: Duration = Duration::from_secs(60 * 60);
/// Unknown signing keys trigger a fresh key set at most this often.
const JWKS_REFRESH_MIN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct OidcConfig {
/// `https://keycloak.example.com/realms/home`, as the provider names itself.
pub issuer: String,
pub client_id: String,
/// Confidential clients (Keycloak's default) have one; public clients rely on PKCE alone.
pub client_secret: Option<String>,
/// The button: "Sign in with {label}".
pub label: String,
pub scopes: String,
/// The claim that lists the person's groups (Keycloak's group mapper: `groups`).
pub groups_claim: String,
/// When set, only members of one of these groups may sign in, and they
/// get an account on first sign-in without an invite.
pub allowed_groups: Vec<String>,
/// Members of these groups are admins of the server.
pub admin_groups: Vec<String>,
}
/// Who the provider says signed in.
#[derive(Debug, Clone)]
pub struct Person {
pub subject: String,
/// `preferred_username`, or the part of the e-mail before the `@`.
pub username: Option<String>,
pub display_name: Option<String>,
pub email: Option<String>,
pub groups: Vec<String>,
}
impl Person {
pub fn in_any(&self, groups: &[String]) -> bool {
self.groups.iter().any(|g| groups.contains(g))
}
}
#[derive(Debug, Clone, Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
}
struct Pending {
query: AuthorizeQuery,
nonce: String,
verifier: String,
created: Instant,
}
#[derive(Deserialize)]
struct TokenReply {
id_token: String,
}
pub struct Oidc {
pub config: OidcConfig,
http: reqwest::Client,
discovery: Mutex<Option<(Instant, Discovery)>>,
jwks: Mutex<Option<(Instant, JwkSet)>>,
pending: Mutex<HashMap<String, Pending>>,
}
impl Oidc {
pub fn new(config: OidcConfig) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self {
config,
http,
discovery: Mutex::new(None),
jwks: Mutex::new(None),
pending: Mutex::new(HashMap::new()),
})
}
/// Where the provider sends people back; list it in the provider's client.
pub fn callback_url(public_url: &str) -> String {
format!("{public_url}/oidc/callback")
}
async fn discovery(&self) -> Result<Discovery> {
let mut cached = self.discovery.lock().await;
if let Some((at, d)) = cached.as_ref()
&& at.elapsed() < CACHE_TTL
{
return Ok(d.clone());
}
let url = format!(
"{}/.well-known/openid-configuration",
self.config.issuer.trim_end_matches('/')
);
let d: Discovery = self
.http
.get(&url)
.send()
.await
.and_then(|r| r.error_for_status())
.with_context(|| format!("the identity provider does not answer at {url}"))?
.json()
.await
.context("the identity provider's configuration does not read")?;
ensure!(
d.issuer.trim_end_matches('/') == self.config.issuer.trim_end_matches('/'),
"the identity provider calls itself {}, not {}",
d.issuer,
self.config.issuer
);
*cached = Some((Instant::now(), d.clone()));
Ok(d)
}
async fn key_for(&self, kid: Option<&str>) -> Result<DecodingKey> {
let d = self.discovery().await?;
let mut cached = self.jwks.lock().await;
let find = |set: &JwkSet| match kid {
Some(kid) => set.find(kid).cloned(),
None => set.keys.first().cloned(),
};
let stale = cached.as_ref().is_none_or(|(at, set)| {
at.elapsed() >= CACHE_TTL || (find(set).is_none() && at.elapsed() >= JWKS_REFRESH_MIN)
});
if stale {
let set: JwkSet = self
.http
.get(&d.jwks_uri)
.send()
.await
.and_then(|r| r.error_for_status())
.context("the identity provider's signing keys do not load")?
.json()
.await
.context("the identity provider's signing keys do not read")?;
*cached = Some((Instant::now(), set));
}
let (_, set) = cached.as_ref().expect("filled above");
let jwk = find(set).ok_or_else(|| anyhow!("the sign-in was signed with an unknown key"))?;
Ok(DecodingKey::from_jwk(&jwk)?)
}
/// The provider's sign-in address for this editor sign-in.
pub async fn start(&self, public_url: &str, query: AuthorizeQuery) -> Result<String> {
let d = self.discovery().await?;
let state = random_token();
let nonce = random_token();
let verifier = random_token();
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
{
let mut pending = self.pending.lock().await;
pending.retain(|_, p| p.created.elapsed() < PENDING_TTL);
ensure!(
pending.len() < PENDING_MAX,
"too many sign-ins are waiting; try again in a few minutes"
);
pending.insert(
state.clone(),
Pending {
query,
nonce: nonce.clone(),
verifier,
created: Instant::now(),
},
);
}
let mut url = url::Url::parse(&d.authorization_endpoint)
.context("the identity provider's sign-in address is not valid")?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.config.client_id)
.append_pair("redirect_uri", &Self::callback_url(public_url))
.append_pair("scope", &self.config.scopes)
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
Ok(url.into())
}
/// The provider sent the person back: exchanges the code and checks the ID token.
pub async fn finish(
&self,
public_url: &str,
code: &str,
state: &str,
) -> Result<(AuthorizeQuery, Person)> {
let pending = self
.pending
.lock()
.await
.remove(state)
.filter(|p| p.created.elapsed() < PENDING_TTL)
.ok_or_else(|| anyhow!("this sign-in has expired; start again from VS Code"))?;
let d = self.discovery().await?;
let redirect_uri = Self::callback_url(public_url);
let mut form = vec![
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri.as_str()),
("client_id", self.config.client_id.as_str()),
("code_verifier", pending.verifier.as_str()),
];
if let Some(secret) = &self.config.client_secret {
form.push(("client_secret", secret.as_str()));
}
let response = self
.http
.post(&d.token_endpoint)
.form(&form)
.send()
.await
.context("the identity provider does not answer")?;
if !response.status().is_success() {
let status = response.status();
let body: Value = response.json().await.unwrap_or(Value::Null);
// The provider's own words (invalid_client, …); never the request itself.
bail!(
"the identity provider refused the sign-in ({status}{})",
body.get("error")
.and_then(Value::as_str)
.map(|e| format!(", {e}"))
.unwrap_or_default()
);
}
let reply: TokenReply = response
.json()
.await
.context("the identity provider sent no ID token")?;
let person = self
.check_id_token(&d, &reply.id_token, &pending.nonce)
.await?;
Ok((pending.query, person))
}
async fn check_id_token(&self, d: &Discovery, token: &str, nonce: &str) -> Result<Person> {
let header = decode_header(token).context("the ID token does not read")?;
let key = self.key_for(header.kid.as_deref()).await?;
let mut validation = Validation::new(header.alg);
validation.set_issuer(&[d.issuer.as_str()]);
validation.set_audience(&[self.config.client_id.as_str()]);
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
validation.leeway = 60;
let claims: HashMap<String, Value> = decode(token, &key, &validation)
.context("the ID token is not valid")?
.claims;
ensure!(
claims.get("nonce").and_then(Value::as_str) == Some(nonce),
"the ID token belongs to another sign-in"
);
Ok(person_from(&claims, &self.config.groups_claim))
}
}
fn person_from(claims: &HashMap<String, Value>, groups_claim: &str) -> Person {
let text = |key: &str| {
claims
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
};
let email = text("email");
let groups = match claims.get(groups_claim) {
Some(Value::Array(items)) => items
.iter()
.filter_map(Value::as_str)
// Keycloak sends "/team" when the mapper keeps full paths.
.map(|g| g.trim_start_matches('/').to_owned())
.collect(),
Some(Value::String(one)) => vec![one.trim_start_matches('/').to_owned()],
_ => vec![],
};
Person {
subject: text("sub").unwrap_or_default(),
username: text("preferred_username").or_else(|| {
email
.as_deref()
.and_then(|e| e.split('@').next())
.map(str::to_owned)
}),
display_name: text("name"),
email,
groups,
}
}
/// A sign-in name from what the provider calls the person: letters, digits and `. _ - @`.
pub fn account_name(person: &Person) -> String {
let raw = person.username.as_deref().unwrap_or("user");
let mut name: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@') {
c.to_ascii_lowercase()
} else {
'-'
}
})
.take(56)
.collect();
if name.len() < 2 {
name = format!("user-{name}");
}
name
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn people_from_keycloak_claims() {
let claims: HashMap<String, Value> = serde_json::from_value(serde_json::json!({
"sub": "f3c1",
"preferred_username": "Ana Šmit",
"name": "Ana Šmit",
"email": "ana@example.com",
"groups": ["/archify-users", "admins"],
}))
.unwrap();
let person = person_from(&claims, "groups");
assert_eq!(person.subject, "f3c1");
assert_eq!(person.groups, ["archify-users", "admins"]);
assert!(person.in_any(&["archify-users".into()]));
assert!(!person.in_any(&["others".into()]));
assert_eq!(account_name(&person), "ana--mit");
let bare: HashMap<String, Value> =
serde_json::from_value(serde_json::json!({ "sub": "x", "email": "bo@example.com" }))
.unwrap();
let person = person_from(&bare, "groups");
assert_eq!(account_name(&person), "bo");
assert!(person.groups.is_empty());
}
}