[yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins

Shannon Sterz s.sterz at proxmox.com
Thu Oct 9 12:14:00 CEST 2025


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 <s.sterz at proxmox.com>
> ---
>  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<String, String>),
>  }
>
>  pub struct ProxmoxLoginPanel {
> @@ -83,6 +94,7 @@ pub struct ProxmoxLoginPanel {
>      save_username: PersistentState<bool>,
>      last_username: PersistentState<String>,
>      async_pool: AsyncPool,
> +    selected_realm: Option<BasicRealmInfo>,
>  }
>
>  impl ProxmoxLoginPanel {
> @@ -125,6 +137,117 @@ impl ProxmoxLoginPanel {
>          });
>      }
>
> +    fn openid_redirect(&self, ctx: &Context<Self>) {
> +        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::<String>("/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<Self>, 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<Self>, mut auth: HashMap<String, String>) {
> +        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::<CreateTicketResponse>("/access/openid/login", Some(auth))
> +                    .await
> +                {
> +                    Ok(creds) => {
> +                        let Some(ticket) = creds
> +                            .ticket
> +                            .or(creds.ticket_info)
> +                            .and_then(|t| t.parse::<Ticket>().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::<String>::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<AttrValue>) {
>          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::<bool>::new("ProxmoxLoginPanelSaveUsername");
>          let last_username = PersistentState::<String>::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<Callback<BasicRealmInfo>>,
>  }
>
>  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}"))





More information about the yew-devel mailing list