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, pub scope: kittybox_indieauth::Scopes, pub access_token: String, pub refresh_token: Option, pub expires_in: Option, pub profile: Option, } #[derive(Debug)] pub struct SignIn { client_id: glib::Uri, me_buffer: gtk::EntryBuffer, http: soup::Session, busy_guard: Option, callback_server: Option, state: kittybox_indieauth::State, code_verifier: kittybox_indieauth::PKCEVerifier, micropub_uri: Option, metadata: Option } #[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), } fn callback_handler(sender: AsyncComponentSender) -> 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::(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>, // 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::(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 scopes() -> kittybox_indieauth::Scopes { kittybox_indieauth::Scopes::new(vec![ kittybox_indieauth::Scope::Profile, kittybox_indieauth::Scope::Create, kittybox_indieauth::Scope::Media ]) } fn bail_out(&mut self, widgets: &mut ::Widgets, sender: AsyncComponentSender, 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 { 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, soup::Session); 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, ) -> impl std::future::Future> { let model = Self { client_id: init.0, me_buffer: Default::default(), http: init.1, 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, _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(Self::scopes()), 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::(&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(), scope: scope.unwrap_or_else(Self::scopes), 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::(&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) { if let Some(server) = self.callback_server.as_ref() { soup::prelude::ServerExt::disconnect(server); } } }