Files
codecraft/server/src/auth.rs
T
Richard ZinkandClaude Opus 5.5 ed9942829a Release 0.1.0: MIT license, changelog, multi-arch Docker image without CLI builds
- LICENSE (MIT), CHANGELOG for 0.1.0, README overhaul
- docker-compose.release.yml pulling the registry image
- Dockerfile cross-compiles the server with cargo-zigbuild for amd64 and arm64;
  the terminal client is no longer built into the image
- Server, install script and settings point to a source install of the
  terminal client when no prebuilt binaries are available (/info: cli_builds)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:50:57 +01:00

769 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Users, sign-in (local passwords + OpenID Connect) and access control.
use crate::api::{ApiError, ApiResult};
use crate::app::App;
use crate::config::expand_env;
use crate::store::User;
use anyhow::{Context, Result, anyhow, bail};
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use axum::extract::{Path, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Extension, Json};
use base64::Engine;
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub const COOKIE: &str = "codecraft_session";
const MIN_PASSWORD: usize = 8;
// --- primitives ---------------------------------------------------------------
pub fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::fill(&mut buf[..]);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
}
pub fn sha256_hex(s: &str) -> String {
Sha256::digest(s.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
pub fn hash_password(password: &str) -> Result<String> {
let mut salt = [0u8; 16];
rand::fill(&mut salt);
let hash = Argon2::default()
.hash_password_with_salt(password.as_bytes(), &salt)
.map_err(|e| anyhow!("hashing failed: {e}"))?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> bool {
Argon2::default().verify_password(password.as_bytes(), hash).is_ok()
}
fn valid_username(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
&& !name.starts_with('.')
}
/// Usernames become directory names; derive a safe one from an IdP claim.
fn sanitize_username(raw: &str) -> String {
let local = raw.split('@').next().unwrap_or(raw);
let s: String = local
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c.to_ascii_lowercase() } else { '_' })
.collect();
s.trim_start_matches('.').chars().take(64).collect()
}
// --- cookies & middleware -------------------------------------------------------
fn cookie_secure(app: &App, headers: &HeaderMap) -> bool {
match app.config().auth.cookie_secure.as_str() {
"true" => true,
"false" => false,
_ => {
app.config().auth.public_url.starts_with("https://")
|| headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) == Some("https")
}
}
}
fn session_cookie(app: &App, headers: &HeaderMap, token: &str, max_age_secs: i64) -> HeaderValue {
let secure = if cookie_secure(app, headers) { "; Secure" } else { "" };
HeaderValue::from_str(&format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}{secure}"))
.expect("valid cookie")
}
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
if let Some(t) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
return Some(t.trim().to_string());
}
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|c| c.split(';'))
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
}
/// Client address: the reverse proxy's X-Forwarded-For (the port is bound to localhost).
pub fn client_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
.unwrap_or("local")
.trim()
.to_string()
}
const MAX_FAILURES: usize = 5;
const LOCK_MS: i64 = 15 * 60 * 1000;
/// True if this ip or username has too many recent failures.
fn locked(app: &App, keys: &[String]) -> bool {
let now = crate::types::now_ms();
let mut map = app.login_failures.lock().unwrap();
keys.iter().any(|k| {
let v = map.entry(k.clone()).or_default();
v.retain(|t| now - t < LOCK_MS);
v.len() >= MAX_FAILURES
})
}
fn record_failure(app: &App, keys: &[String]) {
let now = crate::types::now_ms();
let mut map = app.login_failures.lock().unwrap();
for k in keys {
map.entry(k.clone()).or_default().push(now);
}
}
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
let token = token_from_headers(headers)?;
let hash = sha256_hex(&token);
if token.starts_with(API_TOKEN_PREFIX) {
return app.store.user_by_api_token(&hash).ok().flatten();
}
app.store.user_by_token(&hash).ok().flatten()
}
/// Prefix of personal API tokens (CLI, scripts).
pub const API_TOKEN_PREFIX: &str = "cc_";
#[derive(Deserialize)]
pub struct NewApiToken {
name: String,
/// Days until it expires; None = never.
days: Option<u32>,
/// "full" (default) or "read" (GET requests only).
scope: Option<String>,
}
pub async fn list_api_tokens(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
Ok(Json(serde_json::json!(app.store.api_tokens(&user.id)?)))
}
/// Create a token; the plain value is returned once and only stored hashed.
pub async fn create_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Json(b): Json<NewApiToken>) -> ApiResult {
let name = b.name.trim();
if name.is_empty() || name.len() > 80 {
return Err(ApiError(StatusCode::BAD_REQUEST, "the token needs a name (max. 80 characters)".into()));
}
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
let prefix: String = token.chars().take(10).collect();
let expires = b.days.filter(|d| *d > 0).map(|d| crate::types::now_ms() + d as i64 * 86_400_000);
let scope = match b.scope.as_deref() {
Some("read") => "read",
None | Some("full") => "full",
Some(other) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid scope '{other}'"))),
};
let id = app.store.api_token_add(&user.id, name, &sha256_hex(&token), &prefix, expires, scope)?;
app.audit(Some(&user), "api_token.create", format!("{name} ({scope})"), &client_ip(&headers));
Ok(Json(serde_json::json!({"id": id, "name": name, "token": token, "prefix": prefix, "expires_at": expires, "scope": scope})))
}
pub async fn delete_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Path(id): Path<String>) -> ApiResult {
let ok = app.store.api_token_delete(&user.id, &id)?;
app.audit(Some(&user), "api_token.delete", &id, &client_ip(&headers));
Ok(Json(serde_json::json!({"ok": ok})))
}
/// Paths reachable without a login.
fn is_public(path: &str) -> bool {
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
}
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
if is_public(req.uri().path()) {
return next.run(req).await;
}
match current_user(&app, req.headers()) {
Some(user) => {
// Read-only API tokens: no state-changing requests.
if let Some(token) = token_from_headers(req.headers()).filter(|t| t.starts_with(API_TOKEN_PREFIX)) {
let read_only = app.store.api_token_scope(&sha256_hex(&token)).as_deref() == Some("read");
if read_only && !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) {
return ApiError(StatusCode::FORBIDDEN, "this API token is read-only".into()).into_response();
}
// Tokens cannot manage tokens.
if req.uri().path().contains("/account/tokens") {
return ApiError(StatusCode::FORBIDDEN, "API tokens are managed in the web UI".into()).into_response();
}
}
req.extensions_mut().insert(user);
next.run(req).await
}
None => ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response(),
}
}
pub fn require_admin(user: &User) -> Result<(), ApiError> {
if user.is_admin() { Ok(()) } else { Err(ApiError(StatusCode::FORBIDDEN, "admin only".into())) }
}
fn issue_login(app: &App, headers: &HeaderMap, user: &User) -> Result<HeaderValue> {
let token = random_token(32);
let days = app.config().auth.session_days.max(1) as i64;
let ua = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
app.store.add_token(&sha256_hex(&token), &user.id, crate::types::now_ms() + days * 86_400_000, &ua.chars().take(200).collect::<String>())?;
Ok(session_cookie(app, headers, &token, days * 86_400))
}
// --- bootstrap ------------------------------------------------------------------
/// Create the first admin from the environment, or generate a setup code.
pub fn bootstrap(app: &App) -> Result<()> {
if app.store.user_count()? > 0 {
return Ok(());
}
if let (Ok(user), Ok(pass)) = (std::env::var("CODECRAFT_ADMIN_USER"), std::env::var("CODECRAFT_ADMIN_PASSWORD")) {
if valid_username(&user) && pass.len() >= MIN_PASSWORD {
let u = app.store.create_user(&user, &user, "admin", Some(&hash_password(&pass)?), None)?;
let n = app.store.adopt_orphans(&u.id)?;
tracing::info!("created admin '{user}' from the environment ({n} existing sessions assigned)");
return Ok(());
}
tracing::warn!("CODECRAFT_ADMIN_USER/PASSWORD ignored: invalid username or password shorter than {MIN_PASSWORD}");
}
let code = random_token(9);
tracing::warn!("no users yet – open the web UI and create the first admin with setup code: {code}");
*app.setup_code.lock().unwrap() = Some(code);
Ok(())
}
// --- endpoints: session -------------------------------------------------------
pub async fn info(State(app): State<Arc<App>>, headers: HeaderMap) -> Json<Value> {
let cfg = app.config();
let user = current_user(&app, &headers);
Json(json!({
"name": "CodeCraft",
"version": crate::update::VERSION,
"commit": crate::update::commit(),
"update": if user.as_ref().is_some_and(|u| u.is_admin()) { app.update.latest.lock().unwrap().clone() } else { None },
"setup_required": app.setup_code.lock().unwrap().is_some(),
"cli_builds": crate::api::cli_builds_available(),
"oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null },
"user": user,
}))
}
#[derive(Deserialize)]
pub struct LoginBody {
username: String,
password: String,
}
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
if locked(&app, &keys) {
app.store.audit(None, body.username.trim(), "login.blocked", "", &ip);
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
}
let found = app.store.user_by_name(body.username.trim())?;
// Verify even for unknown users so timing does not reveal which names exist.
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
record_failure(&app, &keys);
app.store.audit(None, body.username.trim(), "login.failed", "", &ip);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
app.audit(Some(&user), "login", "password", &ip);
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
#[derive(Deserialize)]
pub struct CliLoginBody {
username: String,
password: String,
/// Shown in the token name ("CLI on <hostname>").
hostname: Option<String>,
}
/// Command line login: checks the password (same rate limit as the web login)
/// and creates a personal API token for this machine.
pub async fn cli_login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<CliLoginBody>) -> ApiResult {
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
if locked(&app, &keys) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
}
let found = app.store.user_by_name(body.username.trim())?;
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
record_failure(&app, &keys);
app.store.audit(None, body.username.trim(), "login.failed", "cli", &ip);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
let host: String = body.hostname.unwrap_or_default().chars().filter(|c| c.is_alphanumeric() || "-._".contains(*c)).take(40).collect();
let name = if host.is_empty() { "CLI".to_string() } else { format!("CLI on {host}") };
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
let prefix: String = token.chars().take(10).collect();
app.store.api_token_add(&user.id, &name, &sha256_hex(&token), &prefix, None, "full")?;
app.audit(Some(&user), "login", &format!("cli ({name})"), &ip);
Ok(Json(json!({"token": token, "user": user, "token_name": name})))
}
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
if let Some(t) = token_from_headers(&headers) {
let _ = app.store.delete_token(&sha256_hex(&t));
}
([(header::SET_COOKIE, session_cookie(&app, &headers, "", 0))], Json(json!({"ok": true}))).into_response()
}
#[derive(Deserialize)]
pub struct SetupBody {
code: String,
username: String,
password: String,
}
pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<SetupBody>) -> ApiResult<Response> {
let expected = app.setup_code.lock().unwrap().clone();
let Some(expected) = expected else {
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
};
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}")];
if locked(&app, &keys) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many attempts – try again in 15 minutes".into()));
}
if body.code.trim() != expected {
record_failure(&app, &keys);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
}
let name = body.username.trim();
if !valid_username(name) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username".into()));
}
if body.password.len() < MIN_PASSWORD {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
app.store.adopt_orphans(&user.id)?;
*app.setup_code.lock().unwrap() = None;
app.audit(Some(&user), "setup", "first admin created", &ip);
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
#[derive(Deserialize)]
pub struct PasswordBody {
current: Option<String>,
new: String,
}
/// Change the own password (OIDC-only users may set one without `current`).
pub async fn change_password(State(app): State<Arc<App>>, Extension(user): Extension<User>, headers: HeaderMap, Json(body): Json<PasswordBody>) -> ApiResult {
if body.new.len() < MIN_PASSWORD {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
if let Some((_, Some(hash))) = app.store.user_by_name(&user.username)? {
if !verify_password(body.current.as_deref().unwrap_or(""), &hash) {
return Err(ApiError(StatusCode::UNAUTHORIZED, "current password is wrong".into()));
}
}
app.store.update_user(&user.id, None, None, None, Some(&hash_password(&body.new)?), None)?;
// Sign out all other devices.
app.store.delete_user_tokens(&user.id)?;
let _ = issue_login(&app, &headers, &user)?;
Ok(Json(json!({"ok": true})))
}
// --- endpoints: user management (admin) ------------------------------------------
pub async fn list_users(Extension(me): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
require_admin(&me)?;
Ok(Json(json!(app.store.users()?)))
}
/// Minimal user list for sharing dialogs (all signed-in users).
pub async fn directory(State(app): State<Arc<App>>) -> ApiResult {
let list: Vec<Value> = app
.store
.users()?
.into_iter()
.filter(|u| !u.disabled)
.map(|u| json!({"id": u.id, "username": u.username, "display_name": u.display_name}))
.collect();
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
pub struct CreateUserBody {
username: String,
#[serde(default)]
display_name: String,
password: Option<String>,
#[serde(default = "default_role")]
role: String,
}
fn default_role() -> String {
"user".into()
}
fn check_role(role: &str) -> Result<(), ApiError> {
if role == "admin" || role == "user" { Ok(()) } else { Err(ApiError(StatusCode::BAD_REQUEST, "role must be admin or user".into())) }
}
pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Json(body): Json<CreateUserBody>) -> ApiResult {
require_admin(&me)?;
let name = body.username.trim();
if !valid_username(name) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username (letters, digits, - _ .)".into()));
}
check_role(&body.role)?;
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
Some(p) if p.len() < MIN_PASSWORD => {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
Some(p) => Some(hash_password(p)?),
None => None,
};
if app.store.user_by_name(name)?.is_some() {
return Err(ApiError(StatusCode::CONFLICT, "username already exists".into()));
}
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
app.audit(Some(&me), "user.create", format!("{} ({})", user.username, user.role), "");
Ok(Json(json!(user)))
}
#[derive(Deserialize)]
pub struct UpdateUserBody {
display_name: Option<String>,
role: Option<String>,
disabled: Option<bool>,
password: Option<String>,
/// Monthly cost limit; a negative value removes it.
monthly_quota: Option<f64>,
}
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
require_admin(&me)?;
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
if let Some(r) = &body.role {
check_role(r)?;
}
let loses_admin = target.is_admin() && !target.disabled && (body.role.as_deref().is_some_and(|r| r != "admin") || body.disabled == Some(true));
if loses_admin && app.store.admin_count()? <= 1 {
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be demoted or disabled".into()));
}
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
Some(p) if p.len() < MIN_PASSWORD => {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
Some(p) => Some(hash_password(p)?),
None => None,
};
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
if let Some(q) = body.monthly_quota {
app.store.set_user_quota(&id, (q >= 0.0).then_some(q))?;
}
let mut changes = Vec::new();
if let Some(r) = &body.role { changes.push(format!("role={r}")); }
if let Some(d) = body.disabled { changes.push(format!("disabled={d}")); }
if hash.is_some() { changes.push("password".into()); }
if let Some(q) = body.monthly_quota { changes.push(format!("quota={q}")); }
app.audit(Some(&me), "user.update", format!("{}: {}", target.username, changes.join(", ")), "");
if hash.is_some() {
app.store.delete_user_tokens(&id)?;
}
Ok(Json(json!(app.store.user(&id)?)))
}
pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>) -> ApiResult {
require_admin(&me)?;
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
if target.id == me.id {
return Err(ApiError(StatusCode::CONFLICT, "you cannot delete yourself".into()));
}
if target.is_admin() && app.store.admin_count()? <= 1 {
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be deleted".into()));
}
// Sessions stay (statistics); they become visible to admins only.
app.store.delete_user(&id)?;
app.audit(Some(&me), "user.delete", &target.username, "");
Ok(Json(json!({"ok": true})))
}
// --- OpenID Connect -----------------------------------------------------------------
pub struct OidcPending {
nonce: String,
verifier: String,
created: std::time::Instant,
}
async fn discovery(app: &App) -> Result<Value> {
let cfg = app.config();
let issuer = expand_env(&cfg.auth.oidc.issuer);
if let Some((iss, doc)) = app.oidc_discovery.lock().unwrap().clone() {
if iss == issuer {
return Ok(doc);
}
}
let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'));
let doc: Value = crate::provider::http_client().get(&url).send().await?.error_for_status()?.json().await?;
*app.oidc_discovery.lock().unwrap() = Some((issuer, doc.clone()));
Ok(doc)
}
fn redirect_uri(app: &App) -> Result<String> {
let base = app.config().auth.public_url.trim_end_matches('/').to_string();
if base.is_empty() {
bail!("auth.public_url must be set for single sign-on");
}
Ok(format!("{base}/api/auth/oidc/callback"))
}
fn error_page(message: &str) -> Response {
let msg = message.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
(
StatusCode::FORBIDDEN,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
format!("<!doctype html><meta charset=utf-8><title>CodeCraft</title><body style=\"font-family:sans-serif;padding:2em\"><h2>Sign-in failed</h2><p>{msg}</p><p><a href=\"/\">Back</a></p>"),
)
.into_response()
}
pub async fn oidc_start(State(app): State<Arc<App>>) -> Response {
match oidc_start_inner(&app).await {
Ok(url) => Redirect::to(&url).into_response(),
Err(e) => error_page(&format!("{e:#}")),
}
}
async fn oidc_start_inner(app: &App) -> Result<String> {
let cfg = app.config();
if !cfg.auth.oidc.enabled {
bail!("single sign-on is not enabled");
}
let doc = discovery(app).await?;
let endpoint = doc["authorization_endpoint"].as_str().context("no authorization_endpoint")?;
let state = random_token(24);
let nonce = random_token(24);
let verifier = random_token(48);
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
{
let mut pending = app.oidc_pending.lock().unwrap();
pending.retain(|_, p| p.created.elapsed().as_secs() < 600);
pending.insert(state.clone(), OidcPending { nonce: nonce.clone(), verifier, created: std::time::Instant::now() });
}
let mut url = reqwest::Url::parse(endpoint)?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &expand_env(&cfg.auth.oidc.client_id))
.append_pair("redirect_uri", &redirect_uri(app)?)
.append_pair("scope", &cfg.auth.oidc.scopes.join(" "))
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
Ok(url.to_string())
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Query(q): Query<CallbackQuery>) -> Response {
if let Some(e) = q.error {
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
}
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
Ok(user) => match { app.audit(Some(&user), "login", "oidc", &client_ip(&headers)); issue_login(&app, &headers, &user) } {
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
Err(e) => error_page(&format!("{e:#}")),
},
Err(e) => {
tracing::warn!("oidc sign-in failed: {e:#}");
error_page(&format!("{e:#}"))
}
}
}
fn decode_jwt_claims(jwt: &str) -> Result<Value> {
let payload = jwt.split('.').nth(1).context("malformed id_token")?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.context("malformed id_token payload")?;
Ok(serde_json::from_slice(&bytes)?)
}
fn claim_list(v: &Value) -> Vec<String> {
match v {
Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
Value::String(s) => s.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect(),
_ => vec![],
}
}
async fn oidc_callback_inner(app: &App, code: String, state: String) -> Result<User> {
let cfg = app.config();
let oc = &cfg.auth.oidc;
let pending = app.oidc_pending.lock().unwrap().remove(&state).context("unknown or expired sign-in attempt – please try again")?;
let doc = discovery(app).await?;
let token_endpoint = doc["token_endpoint"].as_str().context("no token_endpoint")?;
let client_id = expand_env(&oc.client_id);
let resp = crate::provider::http_client()
.post(token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", redirect_uri(app)?.as_str()),
("client_id", client_id.as_str()),
("client_secret", expand_env(&oc.client_secret).as_str()),
("code_verifier", pending.verifier.as_str()),
])
.send()
.await?;
if !resp.status().is_success() {
bail!("token exchange failed: HTTP {} {}", resp.status(), resp.text().await.unwrap_or_default().chars().take(300).collect::<String>());
}
let tokens: Value = resp.json().await?;
// The id_token comes straight from the token endpoint over TLS, which
// authenticates the issuer (OIDC Core 3.1.3.7); we still check its claims.
let mut claims = decode_jwt_claims(tokens["id_token"].as_str().context("no id_token in response")?)?;
let issuer = expand_env(&oc.issuer);
if claims["iss"].as_str().map(|s| s.trim_end_matches('/')) != Some(issuer.trim_end_matches('/')) {
bail!("id_token issuer mismatch");
}
let aud_ok = match &claims["aud"] {
Value::String(a) => *a == client_id,
Value::Array(a) => a.iter().any(|x| x.as_str() == Some(&client_id)),
_ => false,
};
if !aud_ok {
bail!("id_token audience mismatch");
}
if claims["nonce"].as_str() != Some(pending.nonce.as_str()) {
bail!("id_token nonce mismatch");
}
if claims["exp"].as_i64().unwrap_or(0) < chrono::Utc::now().timestamp() {
bail!("id_token expired");
}
// Groups and names are often only in the userinfo response.
if let (Some(ep), Some(at)) = (doc["userinfo_endpoint"].as_str(), tokens["access_token"].as_str()) {
if let Ok(r) = crate::provider::http_client().get(ep).bearer_auth(at).send().await {
if let Ok(info) = r.json::<Value>().await {
if info["sub"] == claims["sub"] {
for (k, v) in info.as_object().cloned().unwrap_or_default() {
if claims.get(&k).is_none() {
claims[&k] = v;
}
}
}
}
}
}
let sub = claims["sub"].as_str().context("no sub claim")?.to_string();
let groups = claim_list(&claims[&oc.groups_claim]);
if !oc.allowed_group.is_empty() && !groups.contains(&oc.allowed_group) {
bail!("your account is not allowed to use CodeCraft");
}
let is_admin = !oc.admin_group.is_empty() && groups.contains(&oc.admin_group);
let display = claims["name"].as_str().unwrap_or("").to_string();
let user = if let Some(u) = app.store.user_by_oidc(&sub)? {
u
} else {
let raw = claims[&oc.username_claim]
.as_str()
.or_else(|| claims["preferred_username"].as_str())
.or_else(|| claims["email"].as_str())
.unwrap_or(&sub);
let name = sanitize_username(raw);
if !valid_username(&name) {
bail!("cannot derive a valid username from the '{}' claim", oc.username_claim);
}
match app.store.user_by_name(&name)? {
Some((existing, _)) if oc.link_by_username && existing.oidc_sub_free() => {
app.store.update_user(&existing.id, None, None, None, None, Some(&sub))?;
existing
}
Some(_) => bail!("the username '{name}' is already taken by a local account"),
None if oc.auto_create => {
let first = app.store.user_count()? == 0;
let role = if is_admin || first { "admin" } else { "user" };
let u = app.store.create_user(&name, if display.is_empty() { &name } else { &display }, role, None, Some(&sub))?;
if first {
app.store.adopt_orphans(&u.id)?;
*app.setup_code.lock().unwrap() = None;
}
u
}
None => bail!("no CodeCraft account exists for '{name}'"),
}
};
if user.disabled {
bail!("this account is disabled");
}
// Keep the role in sync with the IdP group (never removing the last admin).
if !oc.admin_group.is_empty() && user.is_admin() != is_admin && (is_admin || app.store.admin_count()? > 1) {
app.store.update_user(&user.id, None, Some(if is_admin { "admin" } else { "user" }), None, None, None)?;
}
Ok(app.store.user(&user.id)?.context("user vanished")?)
}
impl User {
fn oidc_sub_free(&self) -> bool {
!self.oidc
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_roundtrip() {
let h = hash_password("correct horse").unwrap();
assert!(verify_password("correct horse", &h));
assert!(!verify_password("wrong", &h));
}
#[test]
fn usernames() {
assert!(valid_username("zink"));
assert!(!valid_username("../etc"));
assert!(!valid_username(".hidden"));
assert_eq!(sanitize_username("Richard.Zink@example.org"), "richard.zink");
assert_eq!(sanitize_username("a/b c"), "a_b_c");
}
#[test]
fn jwt_claims() {
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"1","aud":"x"}"#);
let v = decode_jwt_claims(&format!("h.{payload}.s")).unwrap();
assert_eq!(v["sub"], "1");
}
}