From: Shannon Sterz <s.sterz@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH yew-comp 2/5] login_panel/realm_selector: add support for openid realm logins
Date: Tue, 14 Oct 2025 15:30:38 +0200 [thread overview]
Message-ID: <20251014133044.337162-3-s.sterz@proxmox.com> (raw)
In-Reply-To: <20251014133044.337162-1-s.sterz@proxmox.com>
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@proxmox.com>
---
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<String, String>),
}
pub struct ProxmoxLoginPanel {
@@ -83,6 +93,7 @@ pub struct ProxmoxLoginPanel {
save_username: PersistentState<bool>,
last_username: PersistentState<String>,
async_pool: AsyncPool,
+ selected_realm: Option<BasicRealmInfo>,
}
impl ProxmoxLoginPanel {
@@ -125,6 +136,121 @@ 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_redirection_authorization(ctx: &Context<Self>) {
+ 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<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 +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! {<label id={save_username_label_id} style="user-select:none;">{tr!("Save User name")}</label>})
.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::<bool>::new("ProxmoxLoginPanelSaveUsername");
let last_username = PersistentState::<String>::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<Callback<BasicRealmInfo>>,
}
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
next prev parent reply other threads:[~2025-10-14 13:31 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-10-14 13:30 [pdm-devel] [PATCH datacenter-manager/yew-comp 0/8] openid support for PDM Shannon Sterz
2025-10-14 13:30 ` [pdm-devel] [PATCH yew-comp 1/5] login_panel/realm_selector: use default realm provided by api Shannon Sterz
2025-10-14 13:30 ` Shannon Sterz [this message]
2025-10-14 13:30 ` [pdm-devel] [PATCH yew-comp 3/5] auth view: add openid icon to openid menu option Shannon Sterz
2025-10-14 13:30 ` [pdm-devel] [PATCH yew-comp 4/5] auth edit openid: add a default realm checkbox Shannon Sterz
2025-10-14 13:30 ` [pdm-devel] [PATCH yew-comp 5/5] utils/login panel: move openid redirection authorization helper to utils Shannon Sterz
2025-10-14 13:30 ` [pdm-devel] [PATCH datacenter-manager 1/3] api-types: add default field to openid realm config Shannon Sterz
2025-10-14 13:30 ` [pdm-devel] [PATCH datacenter-manager 2/3] server: api: add support for adding openid realms and openid logins Shannon Sterz
2025-10-17 7:57 ` Fabian Grünbichler
2025-10-17 13:36 ` Shannon Sterz
2025-10-14 13:30 ` [pdm-devel] [PATCH datacenter-manager 3/3] ui: enable openid realms in realm panel Shannon Sterz
2025-10-17 8:01 ` [pdm-devel] [PATCH datacenter-manager/yew-comp 0/8] openid support for PDM Fabian Grünbichler
2025-10-17 14:36 ` Shannon Sterz
2025-10-17 14:13 ` [pdm-devel] Superseded: " Shannon Sterz
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20251014133044.337162-3-s.sterz@proxmox.com \
--to=s.sterz@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox