From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 05BBD1FF16F for ; Tue, 14 Oct 2025 15:30:41 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 3AC444B29; Tue, 14 Oct 2025 15:30:59 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Date: Tue, 14 Oct 2025 15:30:43 +0200 Message-ID: <20251014133044.337162-8-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251014133044.337162-1-s.sterz@proxmox.com> References: <20251014133044.337162-1-s.sterz@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1760448613794 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.055 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH datacenter-manager 2/3] server: api: add support for adding openid realms and openid logins X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" only supports the new HttpOnly authentication flow, as PDM does not support non-HttpOnly authentication at the moment. Signed-off-by: Shannon Sterz --- Cargo.toml | 2 +- server/Cargo.toml | 1 + server/src/api/access/mod.rs | 2 + server/src/api/access/openid.rs | 311 +++++++++++++++++++++++++ server/src/api/config/access/mod.rs | 2 + server/src/api/config/access/openid.rs | 290 +++++++++++++++++++++++ server/src/auth/mod.rs | 6 +- 7 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 server/src/api/access/openid.rs create mode 100644 server/src/api/config/access/openid.rs diff --git a/Cargo.toml b/Cargo.toml index f820409..39a0b23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,7 @@ proxmox-uuid = "1" # other proxmox crates proxmox-acme = "0.5" -proxmox-openid = "0.10" +proxmox-openid = "1.0.2" # api implementation creates proxmox-config-digest = "1" diff --git a/server/Cargo.toml b/server/Cargo.toml index 0dfcb6c..94420b4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -44,6 +44,7 @@ proxmox-lang.workspace = true proxmox-ldap.workspace = true proxmox-log.workspace = true proxmox-login.workspace = true +proxmox-openid.workspace = true proxmox-rest-server = { workspace = true, features = [ "templates" ] } proxmox-router = { workspace = true, features = [ "cli", "server"] } proxmox-rrd.workspace = true diff --git a/server/src/api/access/mod.rs b/server/src/api/access/mod.rs index 345b22f..a6874a5 100644 --- a/server/src/api/access/mod.rs +++ b/server/src/api/access/mod.rs @@ -14,6 +14,7 @@ use proxmox_sortable_macro::sortable; use pdm_api_types::{Authid, ACL_PATH_SCHEMA, PRIVILEGES, PRIV_ACCESS_AUDIT}; mod domains; +mod openid; mod tfa; mod users; @@ -33,6 +34,7 @@ const SUBDIRS: SubdirMap = &sorted!([ .post(&proxmox_auth_api::api::API_METHOD_CREATE_TICKET_HTTP_ONLY) .delete(&proxmox_auth_api::api::API_METHOD_LOGOUT), ), + ("openid", &openid::ROUTER), ("users", &users::ROUTER), ]); diff --git a/server/src/api/access/openid.rs b/server/src/api/access/openid.rs new file mode 100644 index 0000000..5f662f0 --- /dev/null +++ b/server/src/api/access/openid.rs @@ -0,0 +1,311 @@ +//! OpenID redirect/login API +use anyhow::{bail, format_err, Error}; +use hyper::http::request::Parts; +use hyper::Response; +use pdm_api_types::{OpenIdRealmConfig, OPENID_DEFAULT_SCOPE_LIST, REALM_ID_SCHEMA}; +use pdm_buildcfg::PDM_RUN_DIR_M; +use proxmox_auth_api::api::ApiTicket; +use proxmox_login::api::CreateTicketResponse; +use serde_json::{json, Value}; + +use proxmox_auth_api::api::{assemble_csrf_prevention_token, AuthContext}; +use proxmox_auth_api::ticket::Ticket; +use proxmox_router::{ + http_err, list_subdirs_api_method, ApiHandler, ApiMethod, ApiResponseFuture, Permission, + Router, RpcEnvironment, SubdirMap, +}; +use proxmox_schema::{api, BooleanSchema, ObjectSchema, ParameterSchema, StringSchema}; +use proxmox_sortable_macro::sortable; + +use proxmox_openid::{OpenIdAuthenticator, OpenIdConfig}; + +use proxmox_access_control::types::{User, EMAIL_SCHEMA, FIRST_NAME_SCHEMA, LAST_NAME_SCHEMA}; +use proxmox_auth_api::types::Userid; + +use proxmox_access_control::CachedUserInfo; + +use crate::auth; + +fn openid_authenticator( + realm_config: &OpenIdRealmConfig, + redirect_url: &str, +) -> Result { + let scopes: Vec = realm_config + .scopes + .as_deref() + .unwrap_or(OPENID_DEFAULT_SCOPE_LIST) + .split(|c: char| c == ',' || c == ';' || char::is_ascii_whitespace(&c)) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + + let mut acr_values = None; + if let Some(ref list) = realm_config.acr_values { + acr_values = Some( + list.split(|c: char| c == ',' || c == ';' || char::is_ascii_whitespace(&c)) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(), + ); + } + + let config = OpenIdConfig { + issuer_url: realm_config.issuer_url.clone(), + client_id: realm_config.client_id.clone(), + client_key: realm_config.client_key.clone(), + prompt: realm_config.prompt.clone(), + scopes: Some(scopes), + acr_values, + }; + OpenIdAuthenticator::discover(&config, redirect_url) +} + +#[sortable] +pub const API_METHOD_OPENID_LOGIN: ApiMethod = ApiMethod::new_full( + &ApiHandler::AsyncHttpBodyParameters(&create_ticket_http_only), + ParameterSchema::Object(&ObjectSchema::new( + "Get a new ticket as an HttpOnly cookie. Supports tickets via cookies.", + &sorted!([ + ("state", false, &StringSchema::new("OpenId state.").schema()), + ( + "code", + false, + &StringSchema::new("OpenId authorization code.").schema(), + ), + ( + "redirect-url", + false, + &StringSchema::new( + "Redirection Url. The client should set this to used server url.", + ) + .schema(), + ), + ]), + )), +) +.returns(::proxmox_schema::ReturnType::new( + false, + &ObjectSchema::new( + "An authentication ticket with additional infos.", + &sorted!([ + ("username", false, &StringSchema::new("User name.").schema()), + ( + "ticket", + true, + &StringSchema::new( + "Auth ticket, present if http-only was not provided or is false." + ) + .schema() + ), + ("ticket-info", + true, + &StringSchema::new( + "Informational ticket, can only be used to check if the ticket is expired. Present if http-only was true." + ).schema()), + ( + "CSRFPreventionToken", + false, + &StringSchema::new("Cross Site Request Forgery Prevention Token.").schema(), + ), + ]), + ) + .schema(), +)) +.protected(true) +.access(None, &Permission::World) +.reload_timezone(true); + +fn create_ticket_http_only( + _parts: Parts, + param: Value, + _info: &ApiMethod, + rpcenv: Box, +) -> ApiResponseFuture { + Box::pin(async move { + use proxmox_rest_server::RestEnvironment; + + let code = param["code"] + .as_str() + .ok_or_else(|| format_err!("missing non-optional parameter: code"))? + .to_owned(); + let state = param["state"] + .as_str() + .ok_or_else(|| format_err!("missing non-optional parameter: state"))? + .to_owned(); + let redirect_url = param["redirect-url"] + .as_str() + .ok_or_else(|| format_err!("missing non-optional parameter: redirect-url"))? + .to_owned(); + + let env: &RestEnvironment = rpcenv + .as_any() + .downcast_ref::() + .ok_or_else(|| format_err!("detected wrong RpcEnvironment type"))?; + + let user_info = CachedUserInfo::new()?; + let auth_context = auth::get_auth_context() + .ok_or_else(|| format_err!("could not get authentication context"))?; + + let mut tested_username = None; + + let result = (|| { + let (realm, private_auth_state) = + OpenIdAuthenticator::verify_public_auth_state(PDM_RUN_DIR_M!(), &state)?; + + let (domains, _digest) = pdm_config::domains::config()?; + let config: OpenIdRealmConfig = domains.lookup("openid", &realm)?; + let open_id = openid_authenticator(&config, &redirect_url)?; + let info = open_id.verify_authorization_code_simple(&code, &private_auth_state)?; + let name_attr = config.username_claim.as_deref().unwrap_or("sub"); + + // Try to be compatible with previous versions + let try_attr = match name_attr { + "subject" => Some("sub"), + "username" => Some("preferred_username"), + _ => None, + }; + + let unique_name = if let Some(name) = info[name_attr] + .as_str() + .or_else(|| try_attr.and_then(|att| info[att].as_str())) + { + name.to_owned() + } else { + bail!("missing claim '{name_attr}'"); + }; + + let user_id = Userid::try_from(format!("{unique_name}@{realm}"))?; + tested_username = Some(unique_name); + + if !user_info.is_active_user_id(&user_id) { + if config.autocreate.unwrap_or(false) { + let _lock = proxmox_access_control::user::lock_config()?; + let (mut user_config, _digest) = proxmox_access_control::user::config()?; + + if let Ok(old_user) = user_config.lookup::("user", user_id.as_str()) { + if let Some(false) = old_user.enable { + bail!("user '{user_id}' is disabled."); + } else { + bail!("autocreate user failed - '{user_id}' already exists."); + } + } + + let firstname = info["given_name"] + .as_str() + .map(|n| n.to_string()) + .filter(|n| FIRST_NAME_SCHEMA.parse_simple_value(n).is_ok()); + + let lastname = info["family_name"] + .as_str() + .map(|n| n.to_string()) + .filter(|n| LAST_NAME_SCHEMA.parse_simple_value(n).is_ok()); + + let email = info["email"] + .as_str() + .map(|n| n.to_string()) + .filter(|n| EMAIL_SCHEMA.parse_simple_value(n).is_ok()); + + let user = User { + userid: user_id.clone(), + comment: None, + enable: None, + expire: None, + firstname, + lastname, + email, + }; + + user_config.set_data(user.userid.as_str(), "user", &user)?; + proxmox_access_control::user::save_config(&user_config)?; + } else { + bail!("user account '{user_id}' missing, disabled or expired."); + } + } + + let api_ticket = ApiTicket::Full(user_id.clone()); + let ticket = Ticket::new(auth_context.auth_prefix(), &api_ticket)?; + let token = assemble_csrf_prevention_token(auth_context.csrf_secret(), &user_id); + env.log_auth(user_id.as_str()); + + Ok((user_id, ticket, token)) + })(); + + let (user_id, mut ticket, token) = result.map_err(|err| { + let msg = err.to_string(); + env.log_failed_auth(tested_username, &msg); + http_err!(UNAUTHORIZED, "{msg}") + })?; + + let cookie = format!( + "{}={}; Secure; SameSite=Lax; HttpOnly; Path=/;", + auth_context.prefixed_auth_cookie_name(), + ticket.sign(auth_context.keyring(), None)?, + ); + + let response = Response::builder() + .header(hyper::http::header::CONTENT_TYPE, "application/json") + .header(hyper::header::SET_COOKIE, cookie); + + let data = CreateTicketResponse { + csrfprevention_token: Some(token), + clustername: None, + ticket: None, + ticket_info: Some(ticket.ticket_info()), + username: user_id.to_string(), + }; + + Ok(response.body( + json!({"data": data, "status": 200, "success": true }) + .to_string() + .into(), + )?) + }) +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + "redirect-url": { + description: "Redirection Url. The client should set this to used server url.", + type: String, + }, + }, + }, + returns: { + description: "Redirection URL.", + type: String, + }, + access: { + description: "Anyone can access this (before the user is authenticated).", + permission: &Permission::World, + }, +)] +/// Create OpenID Redirect Session +pub fn openid_auth_url( + realm: String, + redirect_url: String, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let (domains, _digest) = pdm_config::domains::config()?; + let config: OpenIdRealmConfig = domains.lookup("openid", &realm)?; + + let open_id = openid_authenticator(&config, &redirect_url)?; + + let url = open_id.authorize_url(PDM_RUN_DIR_M!(), &realm)?; + + Ok(url) +} + +#[sortable] +const SUBDIRS: SubdirMap = &sorted!([ + ("login", &Router::new().post(&API_METHOD_OPENID_LOGIN)), + ("auth-url", &Router::new().post(&API_METHOD_OPENID_AUTH_URL)), +]); + +pub const ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(SUBDIRS)) + .subdirs(SUBDIRS); diff --git a/server/src/api/config/access/mod.rs b/server/src/api/config/access/mod.rs index 7454f53..5776152 100644 --- a/server/src/api/config/access/mod.rs +++ b/server/src/api/config/access/mod.rs @@ -4,12 +4,14 @@ use proxmox_sortable_macro::sortable; mod ad; mod ldap; +mod openid; pub mod tfa; #[sortable] const SUBDIRS: SubdirMap = &sorted!([ ("tfa", &tfa::ROUTER), ("ldap", &ldap::ROUTER), + ("openid", &openid::ROUTER), ("ad", &ad::ROUTER), ]); diff --git a/server/src/api/config/access/openid.rs b/server/src/api/config/access/openid.rs new file mode 100644 index 0000000..555a1e1 --- /dev/null +++ b/server/src/api/config/access/openid.rs @@ -0,0 +1,290 @@ +use ::serde::{Deserialize, Serialize}; +/// Configure OpenId realms +use anyhow::Error; +use pdm_api_types::ConfigDigest; +use serde_json::Value; + +use proxmox_router::{http_bail, Permission, Router, RpcEnvironment}; +use proxmox_schema::{api, param_bail}; + +use pdm_api_types::{ + OpenIdRealmConfig, OpenIdRealmConfigUpdater, PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT, + REALM_ID_SCHEMA, +}; + +use pdm_config::domains; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List of configured OpenId realms.", + type: Array, + items: { type: OpenIdRealmConfig }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// List configured OpenId realms +pub fn list_openid_realms( + _param: Value, + rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + let (config, digest) = domains::config()?; + + let list = config.convert_to_typed_array("openid")?; + + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(list) +} + +#[api( + protected: true, + input: { + properties: { + config: { + type: OpenIdRealmConfig, + flatten: true, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Create a new OpenId realm +pub fn create_openid_realm(config: OpenIdRealmConfig) -> Result<(), Error> { + let _lock = domains::lock_config()?; + + let (mut domains, _digest) = domains::config()?; + + if domains::exists(&domains, &config.realm) { + param_bail!("realm", "realm '{}' already exists.", config.realm); + } + + if let Some(true) = config.default { + domains::unset_default_realm(&mut domains)?; + } + + domains.set_data(&config.realm, "openid", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + digest: { + optional: true, + type: ConfigDigest, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Remove a OpenID realm configuration +pub fn delete_openid_realm( + realm: String, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = domains::lock_config()?; + + let (mut domains, expected_digest) = domains::config()?; + expected_digest.detect_modification(digest.as_ref())?; + + if domains.sections.remove(&realm).is_none() { + http_bail!(NOT_FOUND, "realm '{realm}' does not exist."); + } + + domains::save_config(&domains)?; + + Ok(()) +} + +#[api( + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + }, + }, + returns: { type: OpenIdRealmConfig }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false), + }, +)] +/// Read the OpenID realm configuration +pub fn read_openid_realm( + realm: String, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let (domains, digest) = domains::config()?; + + let config = domains.lookup("openid", &realm)?; + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(config) +} + +#[api()] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable property name +pub enum DeletableProperty { + /// Delete the client key. + ClientKey, + /// Delete the comment property. + Comment, + /// Delete the default property. + Default, + /// Delete the autocreate property + Autocreate, + /// Delete the scopes property + Scopes, + /// Delete the prompt property + Prompt, + /// Delete the acr_values property + AcrValues, +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + update: { + type: OpenIdRealmConfigUpdater, + flatten: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeletableProperty, + } + }, + digest: { + optional: true, + type: ConfigDigest, + }, + }, + }, + returns: { type: OpenIdRealmConfig }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Update an OpenID realm configuration +pub fn update_openid_realm( + realm: String, + update: OpenIdRealmConfigUpdater, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = domains::lock_config()?; + + let (mut domains, expected_digest) = domains::config()?; + expected_digest.detect_modification(digest.as_ref())?; + + let mut config: OpenIdRealmConfig = domains.lookup("openid", &realm)?; + + if let Some(delete) = delete { + for delete_prop in delete { + match delete_prop { + DeletableProperty::ClientKey => { + config.client_key = None; + } + DeletableProperty::Comment => { + config.comment = None; + } + DeletableProperty::Default => { + config.default = None; + } + DeletableProperty::Autocreate => { + config.autocreate = None; + } + DeletableProperty::Scopes => { + config.scopes = None; + } + DeletableProperty::Prompt => { + config.prompt = None; + } + DeletableProperty::AcrValues => { + config.acr_values = None; + } + } + } + } + + if let Some(comment) = update.comment { + let comment = comment.trim().to_string(); + if comment.is_empty() { + config.comment = None; + } else { + config.comment = Some(comment); + } + } + + if let Some(true) = update.default { + domains::unset_default_realm(&mut domains)?; + config.default = Some(true); + } else { + config.default = None; + } + + if let Some(issuer_url) = update.issuer_url { + config.issuer_url = issuer_url; + } + if let Some(client_id) = update.client_id { + config.client_id = client_id; + } + + if update.client_key.is_some() { + config.client_key = update.client_key; + } + if update.autocreate.is_some() { + config.autocreate = update.autocreate; + } + if update.scopes.is_some() { + config.scopes = update.scopes; + } + if update.prompt.is_some() { + config.prompt = update.prompt; + } + if update.acr_values.is_some() { + config.acr_values = update.acr_values; + } + + domains.set_data(&realm, "openid", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_READ_OPENID_REALM) + .put(&API_METHOD_UPDATE_OPENID_REALM) + .delete(&API_METHOD_DELETE_OPENID_REALM); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_OPENID_REALMS) + .post(&API_METHOD_CREATE_OPENID_REALM) + .match_all("realm", &ITEM_ROUTER); diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index 532350d..9413a83 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -81,7 +81,11 @@ fn setup_auth_context(use_private_key: bool) { proxmox_auth_api::set_auth_context(AUTH_CONTEXT.get().unwrap()); } -struct PdmAuthContext { +pub(crate) fn get_auth_context() -> Option<&'static PdmAuthContext> { + AUTH_CONTEXT.get() +} + +pub(crate) struct PdmAuthContext { keyring: Keyring, csrf_secret: &'static HMACKey, } -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel