Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -0,0 +1,612 @@
|
||||
//! 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))
|
||||
}
|
||||
|
||||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||
let token = token_from_headers(headers)?;
|
||||
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
||||
}
|
||||
|
||||
/// Paths reachable without a login.
|
||||
fn is_public(path: &str) -> bool {
|
||||
["/info", "/auth/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) => {
|
||||
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": env!("CARGO_PKG_VERSION"),
|
||||
"setup_required": app.setup_code.lock().unwrap().is_some(),
|
||||
"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 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 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||||
};
|
||||
let cookie = issue_login(&app, &headers, &user)?;
|
||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||
}
|
||||
|
||||
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()));
|
||||
};
|
||||
if body.code.trim() != expected {
|
||||
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;
|
||||
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)?;
|
||||
Ok(Json(json!(user)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserBody {
|
||||
display_name: Option<String>,
|
||||
role: Option<String>,
|
||||
disabled: Option<bool>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
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 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)?;
|
||||
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('&', "&").replace('<', "<").replace('>', ">");
|
||||
(
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user