From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id CE4C11FF165 for ; Thu, 9 Oct 2025 12:13:58 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 51E491BBAF; Thu, 9 Oct 2025 12:14:05 +0200 (CEST) Mime-Version: 1.0 Date: Thu, 09 Oct 2025 12:14:00 +0200 Message-Id: To: "Shannon Sterz" , X-Mailer: aerc 0.20.0 References: <20251008151936.386950-1-s.sterz@proxmox.com> <20251008151936.386950-3-s.sterz@proxmox.com> In-Reply-To: <20251008151936.386950-3-s.sterz@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1760004808549 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.054 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 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins X-BeenThere: yew-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Yew framework devel list at Proxmox List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Yew framework devel list at Proxmox Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: yew-devel-bounces@lists.proxmox.com Sender: "yew-devel" On Wed Oct 8, 2025 at 5:19 PM CEST, Shannon Sterz wrote: > 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 > --- > src/login_panel.rs | 317 ++++++++++++++++++++++++++++++++++-------- > src/realm_selector.rs | 26 +++- > 2 files changed, 279 insertions(+), 64 deletions(-) > > diff --git a/src/login_panel.rs b/src/login_panel.rs > index 6c3aaa7..6464bb5 100644 > --- a/src/login_panel.rs > +++ b/src/login_panel.rs > @@ -1,5 +1,10 @@ > +use std::collections::HashMap; > use std::rc::Rc; > +use std::sync::OnceLock; > > +use percent_encoding::percent_decode; > +use proxmox_login::api::CreateTicketResponse; > +use pwt::css::ColorScheme; > use pwt::props::PwtSpace; > use pwt::state::PersistentState; > use pwt::touch::{SnackBar, SnackBarContextExt}; > @@ -11,12 +16,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 +81,9 @@ pub enum Msg { > Yubico(String), > RecoveryKey(String), > WebAuthn(String), > + UpdateRealm(BasicRealmInfo), > + OpenIDLogin, > + OpenIDAuthentication(HashMap), > } > > pub struct ProxmoxLoginPanel { > @@ -83,6 +94,7 @@ pub struct ProxmoxLoginPanel { > save_username: PersistentState, > last_username: PersistentState, > async_pool: AsyncPool, > + selected_realm: Option, > } > > impl ProxmoxLoginPanel { > @@ -125,6 +137,117 @@ 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_parse_authentication(ctx: &Context, query_string: String) { > + 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::OpenIDAuthentication(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 +284,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 +353,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 +393,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()) > @@ -304,7 +467,18 @@ impl ProxmoxLoginPanel { > .with_child( > SubmitButton::new() > .class("pwt-scheme-primary") > - .text(tr!("Login")) > + .text( > + if self > + .selected_realm > + .as_ref() > + .map(|r| r.ty == "openid") > + .unwrap_or_default() > + { > + tr!("Login (OpenID redirect)") > + } else { > + tr!("Login") > + }, > + ) > .on_submit(link.callback(move |_| Msg::Submit)), > ); > > @@ -342,6 +516,11 @@ impl Component for ProxmoxLoginPanel { > let save_username = PersistentState::::new("ProxmoxLoginPanelSaveUsername"); > let last_username = PersistentState::::new("ProxmoxLoginPanelUsername"); > > + let search = gloo_utils::window().location().search(); > + if let Ok(qs) = search { > + Self::openid_parse_authentication(ctx, qs); > + } > + > Self { > form_ctx, > loading: false, > @@ -350,6 +529,7 @@ impl Component for ProxmoxLoginPanel { > save_username, > last_username, > async_pool: AsyncPool::new(), > + selected_realm: None, > } > } > > @@ -483,6 +663,21 @@ impl Component for ProxmoxLoginPanel { > } > true > } > + Msg::UpdateRealm(realm) => { > + log::info!("realm type: {:?}", realm.ty); just noticed that this log statement snuck in. sorry for that, will be droppedin a v2. > + self.selected_realm = Some(realm); > + true > + } > + Msg::OpenIDLogin => { > + self.loading = true; > + self.openid_redirect(ctx); > + false > + } > + Msg::OpenIDAuthentication(auth) => { > + self.loading = true; > + self.openid_login(ctx, auth); > + false > + } > } > } > > diff --git a/src/realm_selector.rs b/src/realm_selector.rs > index b11e924..3e57f3a 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 { > @@ -133,10 +139,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 > } > @@ -155,12 +166,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}")) _______________________________________________ yew-devel mailing list yew-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel