Skip to main content

fractal/login/
mod.rs

1use std::net::{Ipv4Addr, Ipv6Addr};
2
3use adw::{prelude::*, subclass::prelude::*};
4use gettextrs::gettext;
5use gtk::{gio, glib, glib::clone};
6use matrix_sdk::{
7    Client,
8    authentication::oauth::{
9        ClientRegistrationData,
10        registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
11    },
12    sanitize_server_name,
13    utils::local_server::LocalServerRedirectHandle,
14};
15use ruma::{OwnedServerName, api::client::session::get_login_types::v3::LoginType, serde::Raw};
16use tracing::warn;
17use url::Url;
18
19mod advanced_dialog;
20mod greeter;
21mod homeserver_page;
22mod in_browser_page;
23mod local_server;
24mod method_page;
25mod session_setup_view;
26mod sso_idp_button;
27
28use self::{
29    advanced_dialog::LoginAdvancedDialog,
30    greeter::Greeter,
31    homeserver_page::LoginHomeserverPage,
32    in_browser_page::{LoginInBrowserData, LoginInBrowserPage},
33    local_server::spawn_local_server,
34    method_page::LoginMethodPage,
35    session_setup_view::SessionSetupView,
36};
37use crate::{
38    APP_HOMEPAGE_URL, APP_NAME, Application, RUNTIME, SETTINGS_KEY_CURRENT_SESSION, Window,
39    components::OfflineBanner, prelude::*, secret::Secret, session::Session, spawn, spawn_tokio,
40    toast,
41};
42
43/// A page of the login stack.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr)]
45#[strum(serialize_all = "kebab-case")]
46enum LoginPage {
47    /// The greeter page.
48    Greeter,
49    /// The homeserver page.
50    Homeserver,
51    /// The page to select a login method.
52    Method,
53    /// The page to log in with the browser.
54    InBrowser,
55    /// The session setup stack.
56    SessionSetup,
57    /// The login is completed.
58    Completed,
59}
60
61mod imp {
62    use std::cell::{Cell, RefCell};
63
64    use glib::subclass::InitializingObject;
65
66    use super::*;
67
68    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
69    #[template(resource = "/org/gnome/Fractal/ui/login/mod.ui")]
70    #[properties(wrapper_type = super::Login)]
71    pub struct Login {
72        #[template_child]
73        navigation: TemplateChild<adw::NavigationView>,
74        #[template_child]
75        greeter: TemplateChild<Greeter>,
76        #[template_child]
77        homeserver_page: TemplateChild<LoginHomeserverPage>,
78        #[template_child]
79        method_page: TemplateChild<LoginMethodPage>,
80        #[template_child]
81        in_browser_page: TemplateChild<LoginInBrowserPage>,
82        #[template_child]
83        done_button: TemplateChild<gtk::Button>,
84        /// Whether auto-discovery is enabled.
85        #[property(get, set = Self::set_autodiscovery, construct, explicit_notify, default = true)]
86        autodiscovery: Cell<bool>,
87        /// The Matrix client used to log in.
88        client: RefCell<Option<Client>>,
89        /// The session that was just logged in.
90        session: RefCell<Option<Session>>,
91    }
92
93    #[glib::object_subclass]
94    impl ObjectSubclass for Login {
95        const NAME: &'static str = "Login";
96        type Type = super::Login;
97        type ParentType = adw::Bin;
98
99        fn class_init(klass: &mut Self::Class) {
100            OfflineBanner::ensure_type();
101
102            Self::bind_template(klass);
103            Self::bind_template_callbacks(klass);
104
105            klass.set_css_name("login");
106            klass.set_accessible_role(gtk::AccessibleRole::Group);
107
108            klass.install_action_async(
109                "login.sso",
110                Some(&Option::<String>::static_variant_type()),
111                |obj, _, variant| async move {
112                    let idp = variant.and_then(|v| v.get::<Option<String>>()).flatten();
113                    obj.imp().init_matrix_sso_login(idp).await;
114                },
115            );
116
117            klass.install_action_async("login.open-advanced", None, |obj, _, _| async move {
118                obj.imp().open_advanced_dialog().await;
119            });
120        }
121
122        fn instance_init(obj: &InitializingObject<Self>) {
123            obj.init_template();
124        }
125    }
126
127    #[glib::derived_properties]
128    impl ObjectImpl for Login {
129        fn constructed(&self) {
130            self.parent_constructed();
131            let obj = self.obj();
132
133            let monitor = gio::NetworkMonitor::default();
134            monitor.connect_network_changed(clone!(
135                #[weak]
136                obj,
137                move |_, available| {
138                    obj.action_set_enabled("login.sso", available);
139                }
140            ));
141            obj.action_set_enabled("login.sso", monitor.is_network_available());
142
143            self.navigation.connect_visible_page_notify(clone!(
144                #[weak(rename_to = imp)]
145                self,
146                move |_| {
147                    imp.visible_page_changed();
148                }
149            ));
150        }
151
152        fn dispose(&self) {
153            self.drop_client();
154            self.drop_session();
155        }
156    }
157
158    impl WidgetImpl for Login {
159        fn grab_focus(&self) -> bool {
160            match self.visible_page() {
161                LoginPage::Greeter => self.greeter.grab_focus(),
162                LoginPage::Homeserver => self.homeserver_page.grab_focus(),
163                LoginPage::Method => self.method_page.grab_focus(),
164                LoginPage::InBrowser => self.in_browser_page.grab_focus(),
165                LoginPage::SessionSetup => {
166                    if let Some(session_setup) = self.session_setup() {
167                        session_setup.grab_focus()
168                    } else {
169                        false
170                    }
171                }
172                LoginPage::Completed => self.done_button.grab_focus(),
173            }
174        }
175    }
176
177    impl BinImpl for Login {}
178    impl AccessibleImpl for Login {}
179
180    #[gtk::template_callbacks]
181    impl Login {
182        /// The visible page of the view.
183        pub(super) fn visible_page(&self) -> LoginPage {
184            self.navigation
185                .visible_page()
186                .and_then(|p| p.tag())
187                .and_then(|s| s.as_str().try_into().ok())
188                .unwrap()
189        }
190
191        /// Set whether auto-discovery is enabled.
192        pub fn set_autodiscovery(&self, autodiscovery: bool) {
193            if self.autodiscovery.get() == autodiscovery {
194                return;
195            }
196
197            self.autodiscovery.set(autodiscovery);
198            self.obj().notify_autodiscovery();
199        }
200
201        /// Get the session setup view, if any.
202        pub(super) fn session_setup(&self) -> Option<SessionSetupView> {
203            self.navigation
204                .find_page(LoginPage::SessionSetup.as_ref())
205                .and_downcast()
206        }
207
208        /// The visible page changed.
209        fn visible_page_changed(&self) {
210            match self.visible_page() {
211                LoginPage::Greeter => {
212                    self.clean();
213                }
214                LoginPage::Homeserver => {
215                    // Drop the client because it is bound to the homeserver.
216                    self.drop_client();
217                    // Drop the session because it is bound to the homeserver and account.
218                    self.drop_session();
219                    self.method_page.clean();
220                }
221                LoginPage::Method => {
222                    // Drop the session because it is bound to the account.
223                    self.drop_session();
224                }
225                _ => {}
226            }
227        }
228
229        /// The Matrix client.
230        pub(super) async fn client(&self) -> Option<Client> {
231            if let Some(client) = self.client.borrow().clone() {
232                return Some(client);
233            }
234
235            // If the client was dropped, try to recreate it.
236            let autodiscovery = self.autodiscovery.get();
237            let client = self.homeserver_page.build_client(autodiscovery).await.ok();
238            self.set_client(client.clone());
239
240            client
241        }
242
243        /// Set the Matrix client.
244        pub(super) fn set_client(&self, client: Option<Client>) {
245            self.client.replace(client);
246        }
247
248        /// Drop the Matrix client.
249        pub(super) fn drop_client(&self) {
250            if let Some(client) = self.client.take() {
251                // The `Client` needs to access a tokio runtime when it is dropped.
252                let _guard = RUNTIME.enter();
253                drop(client);
254            }
255        }
256
257        /// Drop the session and clean up its data from the system.
258        fn drop_session(&self) {
259            if let Some(session) = self.session.take() {
260                spawn!(async move {
261                    let _ = session.log_out().await;
262                });
263            }
264        }
265
266        /// Open the login advanced dialog.
267        async fn open_advanced_dialog(&self) {
268            let obj = self.obj();
269            let dialog = LoginAdvancedDialog::new();
270            obj.bind_property("autodiscovery", &dialog, "autodiscovery")
271                .sync_create()
272                .bidirectional()
273                .build();
274            dialog.run_future(&*obj).await;
275        }
276
277        /// Prepare to log in via the OAuth 2.0 API.
278        pub(super) async fn init_oauth_login(&self) {
279            let Some(client) = self.client.borrow().clone() else {
280                return;
281            };
282
283            let Ok((redirect_uri, local_server_handle)) = spawn_local_server().await else {
284                return;
285            };
286
287            let oauth = client.oauth();
288            let handle = spawn_tokio!(async move {
289                oauth
290                    .login(redirect_uri, None, Some(client_registration_data()), None)
291                    .build()
292                    .await
293            });
294
295            let authorization_data = match handle.await.expect("task was not aborted") {
296                Ok(authorization_data) => authorization_data,
297                Err(error) => {
298                    warn!("Could not construct OAuth 2.0 authorization URL: {error}");
299                    toast!(self.obj(), gettext("Could not set up login"));
300                    return;
301                }
302            };
303
304            self.show_in_browser_page(
305                local_server_handle,
306                LoginInBrowserData::Oauth(authorization_data),
307            );
308        }
309
310        /// Prepare to log in via the Matrix native API.
311        pub(super) async fn init_matrix_login(&self) {
312            let Some(client) = self.client.borrow().clone() else {
313                return;
314            };
315
316            let matrix_auth = client.matrix_auth();
317            let handle = spawn_tokio!(async move { matrix_auth.get_login_types().await });
318
319            let login_types = match handle.await.expect("task was not aborted") {
320                Ok(response) => response.flows,
321                Err(error) => {
322                    warn!("Could not get available Matrix login types: {error}");
323                    toast!(self.obj(), gettext("Could not set up login"));
324                    return;
325                }
326            };
327
328            let supports_password = login_types
329                .iter()
330                .any(|login_type| matches!(login_type, LoginType::Password(_)));
331
332            if supports_password {
333                let server_name = self
334                    .autodiscovery
335                    .get()
336                    .then(|| self.homeserver_page.homeserver())
337                    .and_then(|s| sanitize_server_name(&s).ok());
338
339                self.show_method_page(&client.homeserver(), server_name.as_ref(), login_types);
340            } else {
341                self.init_matrix_sso_login(None).await;
342            }
343        }
344
345        /// Prepare to log in via the Matrix SSO API.
346        pub(super) async fn init_matrix_sso_login(&self, idp: Option<String>) {
347            let Some(client) = self.client.borrow().clone() else {
348                return;
349            };
350
351            let Ok((redirect_uri, local_server_handle)) = spawn_local_server().await else {
352                return;
353            };
354
355            let matrix_auth = client.matrix_auth();
356            let handle = spawn_tokio!(async move {
357                matrix_auth
358                    .get_sso_login_url(redirect_uri.as_str(), idp.as_deref())
359                    .await
360            });
361
362            match handle.await.expect("task was not aborted") {
363                Ok(url) => {
364                    let url = Url::parse(&url).expect("Matrix SSO URL should be a valid URL");
365                    self.show_in_browser_page(local_server_handle, LoginInBrowserData::Matrix(url));
366                }
367                Err(error) => {
368                    warn!("Could not build Matrix SSO URL: {error}");
369                    toast!(self.obj(), gettext("Could not set up login"));
370                }
371            }
372        }
373
374        /// Show the page to chose a login method with the given data.
375        fn show_method_page(
376            &self,
377            homeserver: &Url,
378            server_name: Option<&OwnedServerName>,
379            login_types: Vec<LoginType>,
380        ) {
381            self.method_page
382                .update(homeserver, server_name, login_types);
383            self.navigation.push_by_tag(LoginPage::Method.as_ref());
384        }
385
386        /// Show the page to log in with the browser with the given data.
387        fn show_in_browser_page(
388            &self,
389            local_server_handle: LocalServerRedirectHandle,
390            data: LoginInBrowserData,
391        ) {
392            self.in_browser_page.set_up(local_server_handle, data);
393            self.navigation.push_by_tag(LoginPage::InBrowser.as_ref());
394        }
395
396        /// Create the session after a successful login.
397        pub(super) async fn create_session(&self) {
398            let client = self.client().await.expect("client should be constructed");
399
400            match Session::create(&client).await {
401                Ok(session) => {
402                    self.init_session(session).await;
403                }
404                Err(error) => {
405                    warn!("Could not create session: {error}");
406                    toast!(self.obj(), error.to_user_facing());
407
408                    self.navigation.pop();
409                }
410            }
411        }
412
413        /// Initialize the given session.
414        async fn init_session(&self, session: Session) {
415            let setup_view = SessionSetupView::new(&session);
416            setup_view.connect_completed(clone!(
417                #[weak(rename_to = imp)]
418                self,
419                move |_| {
420                    imp.navigation.push_by_tag(LoginPage::Completed.as_ref());
421                }
422            ));
423            self.navigation.push(&setup_view);
424
425            self.drop_client();
426            self.session.replace(Some(session.clone()));
427
428            // Save ID of logging in session to GSettings
429            let settings = Application::default().settings();
430            if let Err(err) =
431                settings.set_string(SETTINGS_KEY_CURRENT_SESSION, session.session_id())
432            {
433                warn!("Could not save current session: {err}");
434            }
435
436            let session_info = session.info().clone();
437
438            if Secret::store_session(session_info).await.is_err() {
439                toast!(self.obj(), gettext("Could not store session"));
440            }
441
442            session.prepare().await;
443        }
444
445        /// Finish the login process and show the session.
446        #[template_callback]
447        fn finish_login(&self) {
448            let Some(window) = self.obj().root().and_downcast::<Window>() else {
449                return;
450            };
451
452            if let Some(session) = self.session.take() {
453                window.add_session(session);
454            }
455
456            self.clean();
457        }
458
459        /// Reset the login stack.
460        pub(super) fn clean(&self) {
461            // Clean pages.
462            self.homeserver_page.clean();
463            self.method_page.clean();
464
465            // Clean data.
466            self.set_autodiscovery(true);
467            self.drop_client();
468            self.drop_session();
469
470            // Reinitialize UI.
471            self.navigation.pop_to_tag(LoginPage::Greeter.as_ref());
472            self.unfreeze();
473        }
474
475        /// Freeze the login screen.
476        pub(super) fn freeze(&self) {
477            self.navigation.set_sensitive(false);
478        }
479
480        /// Unfreeze the login screen.
481        pub(super) fn unfreeze(&self) {
482            self.navigation.set_sensitive(true);
483        }
484    }
485}
486
487glib::wrapper! {
488    /// A widget managing the login flows.
489    pub struct Login(ObjectSubclass<imp::Login>)
490        @extends gtk::Widget, adw::Bin,
491        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
492}
493
494impl Login {
495    pub fn new() -> Self {
496        glib::Object::new()
497    }
498
499    /// Set the Matrix client.
500    fn set_client(&self, client: Option<Client>) {
501        self.imp().set_client(client);
502    }
503
504    /// The Matrix client.
505    async fn client(&self) -> Option<Client> {
506        self.imp().client().await
507    }
508
509    /// Drop the Matrix client.
510    fn drop_client(&self) {
511        self.imp().drop_client();
512    }
513
514    /// Freeze the login screen.
515    fn freeze(&self) {
516        self.imp().freeze();
517    }
518
519    /// Unfreeze the login screen.
520    fn unfreeze(&self) {
521        self.imp().unfreeze();
522    }
523
524    /// Prepare to log in via the OAuth 2.0 API.
525    async fn init_oauth_login(&self) {
526        self.imp().init_oauth_login().await;
527    }
528
529    /// Prepare to log in via the Matrix native API.
530    async fn init_matrix_login(&self) {
531        self.imp().init_matrix_login().await;
532    }
533
534    /// Create the session after a successful login.
535    async fn create_session(&self) {
536        self.imp().create_session().await;
537    }
538}
539
540/// Client registration data for the OAuth 2.0 API.
541fn client_registration_data() -> ClientRegistrationData {
542    // Register the IPv4 and IPv6 localhost APIs as we use a local server for the
543    // redirection.
544    let ipv4_localhost_uri = Url::parse(&format!("http://{}/", Ipv4Addr::LOCALHOST))
545        .expect("IPv4 localhost address should be a valid URL");
546    let ipv6_localhost_uri = Url::parse(&format!("http://[{}]/", Ipv6Addr::LOCALHOST))
547        .expect("IPv6 localhost address should be a valid URL");
548
549    let client_uri =
550        Url::parse(APP_HOMEPAGE_URL).expect("application homepage URL should be a valid URL");
551
552    let mut client_metadata = ClientMetadata::new(
553        ApplicationType::Native,
554        vec![OAuthGrantType::AuthorizationCode {
555            redirect_uris: vec![ipv4_localhost_uri, ipv6_localhost_uri],
556        }],
557        Localized::new(client_uri, None),
558    );
559    client_metadata.client_name = Some(Localized::new(APP_NAME.to_owned(), None));
560
561    Raw::new(&client_metadata)
562        .expect("client metadata should serialize to JSON successfully")
563        .into()
564}