summary refs log tree commit diff
path: root/src/components/signin.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/signin.rs')
-rw-r--r--src/components/signin.rs535
1 files changed, 535 insertions, 0 deletions
diff --git a/src/components/signin.rs b/src/components/signin.rs
new file mode 100644
index 0000000..159711e
--- /dev/null
+++ b/src/components/signin.rs
@@ -0,0 +1,535 @@
+use std::cell::RefCell;
+
+use adw::prelude::*;
+use kittybox_indieauth::{AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantResponse, Metadata};
+use relm4::prelude::*;
+use soup::prelude::{ServerExt, ServerExtManual, SessionExt};
+
+const REDIRECT_URI: &str = "http://localhost:60000/callback";
+
+#[derive(Debug)]
+pub struct Output {
+    pub me: glib::Uri,
+    pub micropub: glib::Uri,
+    pub userinfo: Option<glib::Uri>,
+
+    pub access_token: String,
+    pub refresh_token: Option<String>,
+    pub expires_in: Option<std::time::Duration>,
+    pub profile: Option<kittybox_indieauth::Profile>,
+}
+
+#[derive(Debug)]
+pub struct SignIn {
+    client_id: glib::Uri,
+    me_buffer: gtk::EntryBuffer,
+
+    http: soup::Session,
+    busy_guard: Option<gio::ApplicationBusyGuard>,
+    callback_server: Option<soup::Server>,
+
+    state: kittybox_indieauth::State,
+    code_verifier: kittybox_indieauth::PKCEVerifier,
+    micropub_uri: Option<glib::Uri>,
+    metadata: Option<Metadata>
+}
+
+#[derive(Debug, thiserror::Error)]
+enum Error {
+    #[error("glib error: {0}")]
+    Glib(#[from] glib::Error),
+    #[error("indieauth error: {0}")]
+    IndieAuth(#[from] IndieauthError),
+    #[error("json error: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error("error parsing query string: {0}")]
+    QueryDecode(#[from] serde_urlencoded::de::Error),
+    #[error("error parsing your homepage: {0}")]
+    Mf2(#[from] microformats::Error),
+
+    #[error("Your website doesn't support IndieAuth.")]
+    MetadataNotFound,
+    #[error("Your website doesn't support Micropub.")]
+    MicropubLinkNotFound,
+    #[error("Not a HTTP[S] URL!")]
+    WrongScheme,
+    #[error("Your IndieAuth metadata endpoint returned HTTP status {0:?}")]
+    MetadataEndpointFailed(soup::Status),
+}
+
+#[doc(hidden)]
+#[derive(Debug)]
+#[allow(private_interfaces)]
+pub enum Input {
+    Start,
+    Callback(Result<AuthorizationResponse, Error>),
+}
+
+fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Server, &soup::ServerMessage, &str, std::collections::HashMap<&str, &str>) {
+    move |server, msg, _, _| {
+        let server = ObjectExt::downgrade(server);
+        let sender = sender.clone();
+        let url = msg.uri().unwrap();
+        let q = url.query().unwrap_or_default();
+        match serde_urlencoded::from_str::<AuthorizationResponse>(q.as_str()) {
+            Ok(response) => {
+                // We're using RefCells here because of a deficiency in GLib types.
+                //
+                // The `finished` signal is only supposed to be fired once for a given
+                // SoupServerMessage, but the type system doesn't know about this.
+                //
+                // Thus we're forced to put our moved value into a RefCell<Option<T>>,
+                // from which it can only be taken once. Taking it twice results in a
+                // panic because of the `Option::unwrap()`.
+                let response = RefCell::new(Some(response));
+                msg.set_status(200, soup::Status::phrase(200).as_deref());
+                msg.set_response(
+                    Some("text/plain; charset=\"utf-8\""),
+                    soup::MemoryUse::Static,
+                    "Thank you! This window can now be closed.".as_bytes()
+                );
+                msg.connect_finished(move |_| {
+                    sender.input(Input::Callback(Ok(response.take().unwrap())));
+                    if let Some(server) = server.upgrade() {
+                        log::info!("Stopping callback receiver server...");
+                        soup::prelude::ServerExt::disconnect(&server);
+                    }
+                });
+            },
+            Err(err) => {
+                msg.set_status(400, soup::Status::phrase(400).as_deref());
+                if let Ok(err) = serde_urlencoded::from_str::<IndieauthError>(q.as_str()) {
+                    let err = RefCell::new(Some(err));
+                    msg.connect_finished(move |_| {
+                        sender.input(Input::Callback(Err(err.take().unwrap().into())));
+                        if let Some(server) = server.upgrade() {
+                            log::info!("Stopping callback receiver server...");
+                            soup::prelude::ServerExt::disconnect(&server);
+                        }
+                    });
+                } else {
+                    let err = RefCell::new(Some(err));
+                    msg.connect_finished(move |_| {
+                        sender.input(Input::Callback(Err(err.take().unwrap().into())));
+                        if let Some(server) = server.upgrade() {
+                            log::info!("Stopping callback receiver server...");
+                            soup::prelude::ServerExt::disconnect(&server);
+                        }
+                    });
+                }
+            }
+        };
+    }
+}
+
+impl SignIn {
+    fn bail_out(&mut self, widgets: &mut <Self as AsyncComponent>::Widgets, sender: AsyncComponentSender<Self>, err: Error) {
+        widgets.toasts.add_toast(adw::Toast::builder()
+            .title(err.to_string())
+            .priority(adw::ToastPriority::High)
+            .build()
+        );
+        // Reset all the state for the component for security reasons.
+        self.busy_guard = None;
+        self.callback_server = None;
+        self.metadata = None;
+        self.micropub_uri = None;
+        self.state = kittybox_indieauth::State::new();
+        self.code_verifier = kittybox_indieauth::PKCEVerifier::new();
+        self.update_view(widgets, sender);
+    }
+
+    async fn well_known_metadata(http: soup::Session, url: glib::Uri) -> Option<Metadata> {
+        let well_known = url.parse_relative(
+            "/.well-known/oauth-authorization-server",
+            glib::UriFlags::NONE
+        ).unwrap();
+        // Speculatively check for metadata at the well-known path
+        let msg = soup::Message::from_uri("GET", &well_known);
+        msg.request_headers().unwrap().append("Accept", "application/json");
+        match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
+            Ok(body) if msg.status() == soup::Status::Ok => {
+                match serde_json::from_slice(&body) {
+                    Ok(metadata) => {
+                        log::info!("Speculative metadata request successful: {:#?}", metadata);
+                        Some(metadata)
+                    },
+                    Err(err) => {
+                        log::warn!("Parsing OAuth2 metadata from {} failed: {}", well_known, err);
+                        None
+                    }
+                }
+            },
+            Ok(_) => {
+                log::warn!("Speculative request to {} returned {:?} ({})", well_known, msg.status(), msg.reason_phrase().unwrap());
+
+                None
+            },
+            Err(err) => {
+                log::warn!("Speculative request to {} failed: {}", well_known, err);
+
+                None
+            }
+        }
+    }
+}
+
+#[relm4::component(pub async)]
+impl AsyncComponent for SignIn {
+    type CommandOutput = ();
+    #[doc(hidden)]
+    type Input = Input;
+    type Output = Output;
+    /// Client ID for authorizing.
+    type Init = glib::Uri;
+
+    view! {
+        #[root]
+        #[name = "toasts"]
+        adw::ToastOverlay {
+            adw::Clamp {
+                set_maximum_size: 360,
+
+                gtk::Box {
+                    set_orientation: gtk::Orientation::Vertical,
+                    set_spacing: 16,
+                    set_margin_all: 16,
+
+                    gtk::Label {
+                        add_css_class: "title-1",
+                        set_text: "Sign in",
+                        set_justify: gtk::Justification::Center,
+                    },
+
+                    gtk::Label {
+                        set_text: "Please sign in with your website to use Bowl.\nYour website needs to support IndieAuth and Micropub for this app to work.",
+                        set_wrap: true,
+                        set_halign: gtk::Align::BaselineCenter,
+                        set_valign: gtk::Align::BaselineCenter,
+                        set_justify: gtk::Justification::Center,
+                    },
+
+                    gtk::Entry {
+                        set_buffer: &model.me_buffer,
+                        set_placeholder_text: Some("https://example.com/"),
+                        connect_activate => Self::Input::Start,
+                        #[watch] set_sensitive: model.callback_server.is_none() && model.busy_guard.is_none(),
+                    },
+
+                    gtk::Button {
+                        set_hexpand: false,
+                        set_halign: gtk::Align::Center,
+                        #[wrap(Some)]
+                        set_child = &gtk::Box {
+                            set_halign: gtk::Align::Center,
+                            set_orientation: gtk::Orientation::Horizontal,
+                            set_spacing: 5,
+                            gtk::Spinner {
+                                set_spinning: true,
+                                #[watch]
+                                set_visible: model.busy_guard.is_some() || model.callback_server.is_some(),
+                            },
+                            gtk::Label {
+                                #[watch]
+                                set_text: if model.busy_guard.is_some() {
+                                    "Talking to your website..."
+                                } else if model.callback_server.is_some() {
+                                    "Waiting for authorization..."
+                                } else {
+                                    "Sign in"
+                                },
+                            }
+                        },
+                        connect_clicked => Self::Input::Start,
+                        #[watch] set_sensitive: model.callback_server.is_none() && model.busy_guard.is_none(),
+                    },
+                }
+            }
+        }
+    }
+
+    fn init(
+        init: Self::Init,
+        root: Self::Root,
+        sender: AsyncComponentSender<Self>,
+    ) -> impl std::future::Future<Output = AsyncComponentParts<Self>> {
+        let model = Self {
+            client_id: init,
+            me_buffer: Default::default(),
+
+            http: soup::Session::new(),
+            callback_server: None,
+            busy_guard: None,
+
+            state: kittybox_indieauth::State::new(),
+            code_verifier: kittybox_indieauth::PKCEVerifier::new(),
+            micropub_uri: None,
+            metadata: None,
+        };
+
+        let widgets = view_output!();
+
+        std::future::ready(AsyncComponentParts { model, widgets })
+    }
+
+    async fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: AsyncComponentSender<Self>, _root: &Self::Root) {
+        match msg {
+            Input::Start => {
+                self.busy_guard = Some(relm4::main_adw_application().mark_busy());
+                self.update_view(widgets, sender.clone());
+
+                let mut url: String = self.me_buffer.text().into();
+                // Normalize bare domains by assuming `https://`
+                match glib::Uri::peek_scheme(url.as_str()) {
+                    None => {
+                        self.me_buffer.insert_text(0, "https://");
+                        url = self.me_buffer.text().into();
+                    },
+                    Some(scheme) => {
+                        if scheme != "https" && scheme != "http" {
+                            return self.bail_out(
+                                widgets, sender,
+                                Error::WrongScheme
+                            );
+                        }
+                    },
+                }
+                let url = match glib::Uri::parse(url.as_str(), glib::UriFlags::SCHEME_NORMALIZE) {
+                    Ok(url) => url,
+                    Err(err) => {
+                        return self.bail_out(
+                            widgets, sender,
+                            err.into()
+                        );
+                    },
+                };
+
+                let (metadata, micropub_uri) = {
+                    // Fire off a speculative request at the well-known URI. This could
+                    // improve UX by parallelizing how we query the user's website.
+                    //
+                    // Note that exposing the metadata at the .well-known URI is
+                    // RECOMMENDED though optional according to IndieAuth specification
+                    // ยงย 4.1.1, so we could use that if it's there to speed up the
+                    // process.
+                    let metadata = relm4::spawn_local(
+                        Self::well_known_metadata(self.http.clone(), url.clone())
+                    );
+                    let msg = soup::Message::from_uri("GET", &url);
+                    let body = match self.http.send_future(&msg, glib::Priority::DEFAULT).await {
+                        Ok(body) => body,
+                        Err(err) => {
+                            return self.bail_out(
+                                widgets, sender,
+                                err.into()
+                            );
+                        },
+                    };
+
+                    let mf2 = match microformats::from_reader(
+                        std::io::BufReader::new(body.into_read()),
+                        url.to_string().parse().unwrap()
+                    ) {
+                        Ok(mf2) => mf2,
+                        Err(err) => {
+                            return self.bail_out(
+                                widgets, sender,
+                                err.into()
+                            );
+                        }
+                    };
+
+                    let rels = mf2.rels.by_rels();
+                    let metadata_url = if let Some(url) = rels
+                        .get("indieauth-metadata")
+                        .map(Vec::as_slice)
+                        .and_then(<[_]>::first)
+                    {
+                        glib::Uri::parse(url.as_str(), glib::UriFlags::NONE).unwrap()
+                    } else {
+                        // I intentionally refuse to support older IndieAuth versions.
+                        // The new versions are superior by providing more features that
+                        // were previously proprietary extensions, and are more clearer in
+                        // general.
+                        return self.bail_out(
+                            widgets, sender,
+                            Error::MetadataNotFound
+                        );
+                    };
+
+                    let micropub_uri = if let Some(url) = rels
+                        .get("micropub")
+                        .map(Vec::as_slice)
+                        .and_then(<[_]>::first)
+                    {
+                        glib::Uri::parse(url.as_str(), glib::UriFlags::NONE).unwrap()
+                    } else {
+                        return self.bail_out(
+                            widgets, sender,
+                            Error::MicropubLinkNotFound
+                        );
+                    };
+
+                    if let Ok(Some(metadata)) = metadata.await {
+                        (metadata, micropub_uri)
+                    } else {
+                        let msg = soup::Message::from_uri("GET", &metadata_url);
+                        msg.request_headers().unwrap().append("Accept", "application/json");
+                        match self.http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
+                            Ok(body) if msg.status() == soup::Status::Ok => {
+                                match serde_json::from_slice(&body) {
+                                    Ok(metadata) => (metadata, micropub_uri),
+                                    Err(err) => return self.bail_out(widgets, sender, err.into()),
+                                }
+                            },
+                            Ok(_) => {
+                                return self.bail_out(
+                                    widgets, sender,
+                                    Error::MetadataEndpointFailed(msg.status())
+                                )
+                            },
+                            Err(err) => return self.bail_out(widgets, sender, err.into()),
+                        }
+                    }
+                };
+
+                let auth_request = AuthorizationRequest {
+                    response_type: kittybox_indieauth::ResponseType::Code,
+                    client_id: self.client_id.to_str().parse().unwrap(),
+                    redirect_uri: REDIRECT_URI.parse().unwrap(),
+                    state: self.state.clone(),
+                    code_challenge: kittybox_indieauth::PKCEChallenge::new(
+                        &self.code_verifier, kittybox_indieauth::PKCEMethod::S256
+                    ),
+                    scope: Some(kittybox_indieauth::Scopes::new(vec![
+                        kittybox_indieauth::Scope::Profile,
+                        kittybox_indieauth::Scope::Create,
+                        kittybox_indieauth::Scope::Media
+                    ])),
+                    me: Some(url.to_str().parse().unwrap())
+                };
+
+                let auth_url = {
+                    let mut url = metadata.authorization_endpoint.clone();
+                    url.query_pairs_mut().extend_pairs(auth_request.as_query_pairs());
+
+                    url
+                };
+
+                self.metadata = Some(metadata);
+                self.micropub_uri = Some(micropub_uri);
+                self.callback_server = Some({
+                    let server = soup::Server::builder().build();
+                    server.add_handler(None, callback_handler(sender.clone()));
+                    match server.listen_local(60000, soup::ServerListenOptions::empty()) {
+                        Ok(()) => server,
+                        Err(err) => return self.bail_out(widgets, sender, err.into())
+                    }
+                });
+
+                if let Err(err) = gtk::UriLauncher::new(auth_url.as_str()).launch_future(
+                    None::<&adw::ApplicationWindow>
+                ).await {
+                    return self.bail_out(widgets, sender, err.into())
+                };
+
+                self.busy_guard = None;
+                self.update_view(widgets, sender);
+            },
+            Input::Callback(Ok(res)) => {
+                // Immediately drop the event if we didn't take a server.
+                if self.callback_server.take().is_none() { return; }
+                self.busy_guard = Some(relm4::main_adw_application().mark_busy());
+                let metadata = self.metadata.take().unwrap();
+                let micropub_uri = self.micropub_uri.take().unwrap();
+
+                if res.state != self.state {
+                    return self.bail_out(widgets, sender, IndieauthError {
+                        kind: kittybox_indieauth::ErrorKind::InvalidRequest,
+                        msg: Some("state doesn't match what we remember, ceremony aborted".to_owned()),
+                        error_uri: None,
+                    }.into())
+                }
+
+                if res.iss != metadata.issuer {
+                    return self.bail_out(widgets, sender, IndieauthError {
+                        kind: kittybox_indieauth::ErrorKind::InvalidRequest,
+                        msg: Some("issuer doesn't match what we remember, ceremony aborted".to_owned()),
+                        error_uri: None,
+                    }.into())
+                }
+
+                let code = res.code;
+                let token_grant = kittybox_indieauth::GrantRequest::AuthorizationCode {
+                    code,
+                    client_id: self.client_id.to_string().parse().unwrap(),
+                    redirect_uri: REDIRECT_URI.parse().unwrap(),
+                    code_verifier: std::mem::replace(
+                        &mut self.code_verifier,
+                        kittybox_indieauth::PKCEVerifier::new()
+                    )
+                };
+
+                let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap();
+                let msg = soup::Message::from_uri("POST", &url);
+                let headers = msg.request_headers().unwrap();
+                headers.append("Accept", "application/json");
+                msg.set_request_body_from_bytes(
+                    Some("application/x-www-form-urlencoded"),
+                    Some(&glib::Bytes::from_owned(
+                        serde_urlencoded::to_string(token_grant).unwrap().into_bytes()
+                    ))
+                );
+                match self.http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
+                    Ok(body) if msg.status() == soup::Status::Ok => {
+                        match serde_json::from_slice::<GrantResponse>(&body) {
+                            Ok(GrantResponse::ProfileUrl(_)) => unreachable!(),
+                            Ok(GrantResponse::AccessToken {
+                                me,
+                                token_type: _,
+                                scope,
+                                access_token,
+                                state: _,
+                                expires_in,
+                                profile,
+                                refresh_token
+                            }) => {
+                                let _ = sender.output(Output {
+                                    me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(),
+                                    micropub: micropub_uri,
+                                    userinfo: metadata.userinfo_endpoint
+                                        .map(|u| glib::Uri::parse(
+                                            u.as_str(), glib::UriFlags::NONE
+                                        ).unwrap()),
+                                    access_token,
+                                    refresh_token,
+                                    expires_in: expires_in.map(std::time::Duration::from_secs),
+                                    profile,
+                                });
+                                self.busy_guard = None;
+                                self.update_view(widgets, sender);
+                            },
+                            Err(err) => self.bail_out(widgets, sender, err.into()),
+                        }
+                    },
+                    Ok(body) => {
+                        match serde_json::from_slice::<IndieauthError>(&body) {
+                            Ok(err) => self.bail_out(widgets, sender, err.into()),
+                            Err(err) => self.bail_out(widgets, sender, err.into())
+                        }
+                    },
+                    Err(err) => self.bail_out(widgets, sender, err.into())
+                }
+            },
+            Input::Callback(Err(err)) => {
+                self.bail_out(widgets, sender, err)
+            },
+        }
+    }
+
+    fn shutdown(&mut self, _: &mut Self::Widgets, _: relm4::Sender<Self::Output>) {
+        if let Some(server) = self.callback_server.as_ref() {
+            soup::prelude::ServerExt::disconnect(server);
+        }
+    }
+}