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 AB6261FF16F for ; Tue, 14 Oct 2025 15:31:09 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 68E594C35; Tue, 14 Oct 2025 15:31:28 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Date: Tue, 14 Oct 2025 15:30:38 +0200 Message-ID: <20251014133044.337162-3-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: 1760448613553 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 yew-comp 2/5] login_panel/realm_selector: add support for openid realm 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" this commit adds support for the openid login flow. it also modifies the realm selector so that the currently selected realm can be communicated to the login panel, which can then only render the fields necessary for an openid realm. the future handling the openid login request is intentionally spawned with `wasm_bindgen_futures::spawn_local` so that it does not get aborted when the view is re-rendered by the CatalogueLoader. it is also wrapped in a OnceLock to avoid making the call several times. Signed-off-by: Shannon Sterz --- note: this was original part of a different series [1]. [1]: https://lore.proxmox.com/all/20251008151936.386950-1-s.sterz@proxmox.com/ src/login_panel.rs | 328 +++++++++++++++++++++++++++++++++--------- src/realm_selector.rs | 26 +++- 2 files changed, 285 insertions(+), 69 deletions(-) diff --git a/src/login_panel.rs b/src/login_panel.rs index 6c3aaa7..8926a32 100644 --- a/src/login_panel.rs +++ b/src/login_panel.rs @@ -1,5 +1,9 @@ +use std::collections::HashMap; use std::rc::Rc; +use std::sync::OnceLock; +use proxmox_login::api::CreateTicketResponse; +use pwt::css::ColorScheme; use pwt::props::PwtSpace; use pwt::state::PersistentState; use pwt::touch::{SnackBar, SnackBarContextExt}; @@ -11,12 +15,15 @@ use pwt::widget::form::{Checkbox, Field, Form, FormContext, InputType, ResetButt use pwt::widget::{Column, FieldLabel, InputPanel, LanguageSelector, Mask, Row}; use pwt::{prelude::*, AsyncPool}; -use proxmox_login::{Authentication, SecondFactorChallenge, TicketResult}; +use proxmox_login::{Authentication, SecondFactorChallenge, Ticket, TicketResult}; +use crate::common_api_types::BasicRealmInfo; use crate::{tfa::TfaDialog, RealmSelector}; use pwt_macros::builder; +static OPENID_LOGIN: OnceLock<()> = OnceLock::new(); + /// Proxmox login panel /// /// Should support all proxmox product and TFA. @@ -73,6 +80,9 @@ pub enum Msg { Yubico(String), RecoveryKey(String), WebAuthn(String), + UpdateRealm(BasicRealmInfo), + OpenIDLogin, + OpenIDAuthorization(HashMap), } pub struct ProxmoxLoginPanel { @@ -83,6 +93,7 @@ pub struct ProxmoxLoginPanel { save_username: PersistentState, last_username: PersistentState, async_pool: AsyncPool, + selected_realm: Option, } impl ProxmoxLoginPanel { @@ -125,6 +136,121 @@ impl ProxmoxLoginPanel { }); } + fn openid_redirect(&self, ctx: &Context) { + let link = ctx.link().clone(); + let Some(realm) = self.selected_realm.as_ref() else { + return; + }; + let Ok(location) = gloo_utils::window().location().origin() else { + return; + }; + + let data = serde_json::json!({ + "realm": realm.realm, + "redirect-url": location, + }); + + self.async_pool.spawn(async move { + match crate::http_post::("/access/openid/auth-url", Some(data)).await { + Ok(data) => { + let _ = gloo_utils::window().location().assign(&data); + } + Err(err) => { + link.send_message(Msg::LoginError(err.to_string())); + } + } + }); + } + + fn openid_redirection_authorization(ctx: &Context) { + let Ok(query_string) = gloo_utils::window().location().search() else { + return; + }; + + let mut auth = HashMap::new(); + let query_parameters = query_string.split('&'); + + for param in query_parameters { + let mut key_value = param.split('='); + + match (key_value.next(), key_value.next()) { + (Some("?code") | Some("code"), Some(value)) => { + auth.insert("code".to_string(), value.to_string()); + } + (Some("?state") | Some("state"), Some(value)) => { + if let Ok(decoded) = percent_decode(value.as_bytes()).decode_utf8() { + auth.insert("state".to_string(), decoded.to_string()); + } + } + _ => continue, + }; + } + + if auth.contains_key("code") && auth.contains_key("state") { + ctx.link().send_message(Msg::OpenIDAuthorization(auth)); + } + } + + fn openid_login(&self, ctx: &Context, mut auth: HashMap) { + let link = ctx.link().clone(); + let save_username = ctx.props().mobile || *self.save_username; + let Ok(origin) = gloo_utils::window().location().origin() else { + return; + }; + + auth.insert("redirect-url".into(), origin.clone()); + + let Ok(auth) = serde_json::to_value(auth) else { + return; + }; + + // run this only once, an openid state is only valid for one round trip. so resending it + // here will just fail. also use an unabortable future here for the same reason. otherwise + // we could be interrupted by, for example, the catalog loader needing to re-render the + // app. + OPENID_LOGIN.get_or_init(|| { + wasm_bindgen_futures::spawn_local(async move { + match crate::http_post::("/access/openid/login", Some(auth)) + .await + { + Ok(creds) => { + let Some(ticket) = creds + .ticket + .or(creds.ticket_info) + .and_then(|t| t.parse::().ok()) + else { + log::error!("neither ticket nor ticket-info in openid login response!"); + return; + }; + + let Some(csrfprevention_token) = creds.csrfprevention_token else { + log::error!("no CSRF prevention token in the openid login response!"); + return; + }; + + let auth = Authentication { + api_url: "".to_string(), + userid: creds.username, + ticket, + clustername: None, + csrfprevention_token, + }; + + // update the authentication, set the realm and user for the next login and + // reload without the query parameters. + crate::http_set_auth(auth.clone()); + if save_username { + PersistentState::::new("ProxmoxLoginPanelUsername") + .update(auth.userid.clone()); + } + let _ = gloo_utils::window().location().assign(&origin); + } + Err(err) => link.send_message(Msg::LoginError(err.to_string())), + } + }); + }); + } + fn get_defaults(&self, props: &LoginPanel) -> (String, Option) { let mut default_username = String::from("root"); let mut default_realm = props.default_realm.clone(); @@ -161,36 +287,64 @@ impl ProxmoxLoginPanel { .on_webauthn(ctx.link().callback(Msg::WebAuthn)) }); - let form_panel = Column::new() + let mut form_panel = Column::new() .class(pwt::css::FlexFit) .padding(2) - .with_flex_spacer() - .with_child( - FieldLabel::new(tr!("User name")) - .id(username_label_id.clone()) - .padding_bottom(PwtSpace::Em(0.25)), - ) - .with_child( - Field::new() - .name("username") - .label_id(username_label_id) - .default(default_username) - .required(true) - .autofocus(true), - ) - .with_child( - FieldLabel::new(tr!("Password")) - .id(password_label_id.clone()) - .padding_top(1) - .padding_bottom(PwtSpace::Em(0.25)), - ) - .with_child( - Field::new() - .name("password") - .label_id(password_label_id) - .required(true) - .input_type(InputType::Password), - ) + .with_flex_spacer(); + + if self + .selected_realm + .as_ref() + .map(|r| r.ty != "openid") + .unwrap_or(true) + { + form_panel = form_panel + .with_child( + FieldLabel::new(tr!("User name")) + .id(username_label_id.clone()) + .padding_bottom(PwtSpace::Em(0.25)), + ) + .with_child( + Field::new() + .name("username") + .label_id(username_label_id) + .default(default_username) + .required(true) + .autofocus(true), + ) + .with_child( + FieldLabel::new(tr!("Password")) + .id(password_label_id.clone()) + .padding_top(1) + .padding_bottom(PwtSpace::Em(0.25)), + ) + .with_child( + Field::new() + .name("password") + .label_id(password_label_id) + .input_type(InputType::Password), + ); + } + + let submit_button = SubmitButton::new().class(ColorScheme::Primary).margin_y(4); + + let submit_button = if self + .selected_realm + .as_ref() + .map(|r| r.ty == "openid") + .unwrap_or_default() + { + submit_button + .text(tr!("Login (OpenID redirect)")) + .check_dirty(false) + .on_submit(link.callback(move |_| Msg::OpenIDLogin)) + } else { + submit_button + .text(tr!("Login")) + .on_submit(link.callback(move |_| Msg::Submit)) + }; + + let form_panel = form_panel .with_child( FieldLabel::new(tr!("Realm")) .id(realm_label_id.clone()) @@ -202,15 +356,13 @@ impl ProxmoxLoginPanel { .name("realm") .label_id(realm_label_id) .path(props.domain_path.clone()) + .on_change({ + let link = link.clone(); + move |r: BasicRealmInfo| link.send_message(Msg::UpdateRealm(r)) + }) .default(default_realm), ) - .with_child( - SubmitButton::new() - .class("pwt-scheme-primary") - .margin_y(4) - .text(tr!("Login")) - .on_submit(link.callback(move |_| Msg::Submit)), - ) + .with_child(submit_button) .with_optional_child(self.login_error.as_ref().map(|msg| { let icon_class = classes!("fa-lg", "fa", "fa-align-center", "fa-exclamation-triangle"); let text = tr!("Login failed. Please try again ({0})", msg); @@ -244,32 +396,46 @@ impl ProxmoxLoginPanel { let (default_username, default_realm) = self.get_defaults(props); - let input_panel = InputPanel::new() + let mut input_panel = InputPanel::new() .class(pwt::css::Overflow::Auto) .width("initial") // don't try to minimize size - .padding(4) - .with_field( - tr!("User name"), - Field::new() - .name("username") - .default(default_username) - .required(true) - .autofocus(true), - ) - .with_field( - tr!("Password"), - Field::new() - .name("password") - .required(true) - .input_type(InputType::Password), - ) - .with_field( - tr!("Realm"), - RealmSelector::new() - .name("realm") - .path(props.domain_path.clone()) - .default(default_realm), - ); + .padding(4); + + if self + .selected_realm + .as_ref() + .map(|r| r.ty != "openid") + .unwrap_or(true) + { + input_panel = input_panel + .with_field( + tr!("User name"), + Field::new() + .name("username") + .default(default_username) + .required(true) + .autofocus(true), + ) + .with_field( + tr!("Password"), + Field::new() + .name("password") + .required(true) + .input_type(InputType::Password), + ); + } + + let input_panel = input_panel.with_field( + tr!("Realm"), + RealmSelector::new() + .name("realm") + .path(props.domain_path.clone()) + .on_change({ + let link = link.clone(); + move |r: BasicRealmInfo| link.send_message(Msg::UpdateRealm(r)) + }) + .default(default_realm), + ); let tfa_dialog = self.challenge.as_ref().map(|challenge| { TfaDialog::new(challenge.clone()) @@ -292,6 +458,24 @@ impl ProxmoxLoginPanel { .with_child(html! {}) .with_child(save_username_field); + let submit_button = SubmitButton::new().class(ColorScheme::Primary); + + let submit_button = if self + .selected_realm + .as_ref() + .map(|r| r.ty == "openid") + .unwrap_or_default() + { + submit_button + .text(tr!("Login (OpenID redirect)")) + .check_dirty(false) + .on_submit(link.callback(move |_| Msg::OpenIDLogin)) + } else { + submit_button + .text(tr!("Login")) + .on_submit(link.callback(move |_| Msg::Submit)) + }; + let toolbar = Row::new() .padding(2) .gap(2) @@ -301,12 +485,7 @@ impl ProxmoxLoginPanel { .with_flex_spacer() .with_child(save_username) .with_child(ResetButton::new()) - .with_child( - SubmitButton::new() - .class("pwt-scheme-primary") - .text(tr!("Login")) - .on_submit(link.callback(move |_| Msg::Submit)), - ); + .with_child(submit_button); let form_panel = Column::new() .class(pwt::css::FlexFit) @@ -342,6 +521,8 @@ impl Component for ProxmoxLoginPanel { let save_username = PersistentState::::new("ProxmoxLoginPanelSaveUsername"); let last_username = PersistentState::::new("ProxmoxLoginPanelUsername"); + Self::openid_redirection_authorization(ctx); + Self { form_ctx, loading: false, @@ -350,6 +531,7 @@ impl Component for ProxmoxLoginPanel { save_username, last_username, async_pool: AsyncPool::new(), + selected_realm: None, } } @@ -483,6 +665,20 @@ impl Component for ProxmoxLoginPanel { } true } + Msg::UpdateRealm(realm) => { + self.selected_realm = Some(realm); + true + } + Msg::OpenIDLogin => { + self.loading = true; + self.openid_redirect(ctx); + false + } + Msg::OpenIDAuthorization(auth) => { + self.loading = true; + self.openid_login(ctx, auth); + false + } } } diff --git a/src/realm_selector.rs b/src/realm_selector.rs index a882e39..305e544 100644 --- a/src/realm_selector.rs +++ b/src/realm_selector.rs @@ -1,4 +1,5 @@ use anyhow::{format_err, Error}; +use html::IntoEventCallback; use std::rc::Rc; use yew::html::IntoPropValue; @@ -47,6 +48,11 @@ pub struct RealmSelector { #[builder(IntoPropValue, into_prop_value)] #[prop_or("/access/domains".into())] pub path: AttrValue, + + /// Change callback + #[builder_cb(IntoEventCallback, into_event_callback, BasicRealmInfo)] + #[prop_or_default] + pub on_change: Option>, } impl Default for RealmSelector { @@ -131,10 +137,15 @@ impl Component for ProxmoxRealmSelector { .as_ref() .and_then(|d| data.iter().find(|r| &r.realm == d)) .or_else(|| data.iter().find(|r| r.default.unwrap_or_default())) - .or_else(|| data.iter().find(|r| r.ty == "pam")) - .map(|r| AttrValue::from(r.realm.clone())); + .or_else(|| data.iter().find(|r| r.ty == "pam")); - self.loaded_default_realm = realm; + if let Some(cb) = ctx.props().on_change.as_ref() { + if let Some(realm) = realm { + cb.emit(realm.clone()); + } + } + + self.loaded_default_realm = realm.map(|r| AttrValue::from(r.realm.clone())); self.store.set_data(data); true } @@ -153,12 +164,21 @@ impl Component for ProxmoxRealmSelector { .or_else(|| self.loaded_default_realm.clone()) .unwrap_or(AttrValue::from("pam")); + let on_change = props.on_change.clone().map(|c| { + Callback::from(move |k| { + if let Some(realm) = store.read().lookup_record(&k) { + c.emit(realm.clone()); + } + }) + }); + Selector::new(self.store.clone(), self.picker.clone()) .with_std_props(&props.std_props) .with_input_props(&props.input_props) .required(true) .default(&default) .validate(self.validate.clone()) + .on_change(on_change) // force re-render of the selector after load; returning `true` in update does not // re-render the selector by itself .key(format!("realm-selector-{default}")) -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel