[yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins
Shannon Sterz
s.sterz at proxmox.com
Wed Oct 8 17:19:36 CEST 2025
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);
+ 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}"))
--
2.47.3
More information about the yew-devel
mailing list