diff options
Diffstat (limited to 'src/components/signin.rs')
-rw-r--r-- | src/components/signin.rs | 535 |
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 = >k::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); + } + } +} |