//! 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 { 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 { 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 { 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, /// "full" (default) or "read" (GET requests only). scope: Option, } pub async fn list_api_tokens(Extension(user): Extension, State(app): State>) -> 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, State(app): State>, headers: HeaderMap, Json(b): Json) -> 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, State(app): State>, headers: HeaderMap, Path(id): Path) -> 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>, 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 { 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::())?; 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>, headers: HeaderMap) -> Json { 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>, headers: HeaderMap, Json(body): Json) -> ApiResult { 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: Option, } /// 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>, headers: HeaderMap, Json(body): Json) -> 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>, 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>, headers: HeaderMap, Json(body): Json) -> ApiResult { 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, new: String, } /// Change the own password (OIDC-only users may set one without `current`). pub async fn change_password(State(app): State>, Extension(user): Extension, headers: HeaderMap, Json(body): Json) -> 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, State(app): State>) -> 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>) -> ApiResult { let list: Vec = 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, #[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, State(app): State>, Json(body): Json) -> 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, role: Option, disabled: Option, password: Option, /// Monthly cost limit; a negative value removes it. monthly_quota: Option, } pub async fn update_user(Extension(me): Extension, State(app): State>, Path(id): Path, Json(body): Json) -> 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, State(app): State>, Path(id): Path) -> 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 { 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 { 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('&', "&").replace('<', "<").replace('>', ">"); ( StatusCode::FORBIDDEN, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], format!("CodeCraft

Sign-in failed

{msg}

Back

"), ) .into_response() } pub async fn oidc_start(State(app): State>) -> 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 { 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, state: Option, error: Option, error_description: Option, } pub async fn oidc_callback(State(app): State>, headers: HeaderMap, Query(q): Query) -> 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 { 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 { 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 { 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::()); } 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::().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"); } }