[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