diff options
author | Vika <vika@fireburn.ru> | 2024-08-25 03:04:22 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2024-08-25 03:04:22 +0300 |
commit | 847648330cc0e7af59fa6923f45222726d404250 (patch) | |
tree | 0c2e9c3d80e5de187889d43233011aae26a8b8b0 | |
parent | 96f1c3580e8dab10ad862c9e08baaf09b96e0174 (diff) | |
download | bowl-847648330cc0e7af59fa6923f45222726d404250.tar.zst |
Prototype for signing in with IndieAuth
The code is really janky and unpolished, the error handling is TERRIBLE, and I think I can't publish it like this. This'll need a refactor, but it'll come tomorrow.
-rw-r--r-- | src/components/signin.rs | 535 | ||||
-rw-r--r-- | src/lib.rs | 154 | ||||
-rw-r--r-- | src/secrets.rs | 17 |
3 files changed, 670 insertions, 36 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); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index f2436f8..d530212 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,8 @@ use std::{borrow::Borrow, sync::Arc}; use adw::prelude::*; -use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; +use libsecret::prelude::{RetrievableExtManual, RetrievableExt}; +use relm4::{gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; pub mod components { pub(crate) mod smart_summary; @@ -16,6 +17,9 @@ pub mod components { pub(crate) mod tag_pill; pub(crate) use tag_pill::{TagPill, TagPillDelete}; + + pub mod signin; + pub use signin::{SignIn, Output as SignInOutput}; } use components::post_editor::Post; @@ -24,6 +28,7 @@ pub mod secrets; pub mod micropub; pub mod util; pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; +pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/"; pub const VISIBILITY: [&str; 2] = ["public", "private"]; @@ -33,7 +38,7 @@ pub struct App { } #[derive(Debug)] enum AuthState { - LoggedOut(gtk::EntryBuffer), + LoggedOut(AsyncController<components::SignIn>), LoggedIn { submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, post_editor: Controller<components::PostEditor<micropub::Error>>, @@ -45,7 +50,7 @@ enum AuthState { #[doc(hidden)] pub enum Input { SubmitButtonPressed, - Authorize, + Authorize(Box<components::SignInOutput>), PostEditor(Option<Post>) } @@ -55,10 +60,6 @@ pub struct AppRootWidgets { toolbar_view: adw::ToolbarView, top_bar: adw::HeaderBar, top_bar_btn: gtk::Button, - - login_box: gtk::Box, - login_label: gtk::Label, - login_button: gtk::Button, } //#[relm4::component(pub async)] @@ -79,20 +80,82 @@ impl AsyncComponent for App { fn init_root() -> Self::Root { let window = Self::Root::default(); window.set_size_request(360, 294); + window.set_default_size(360, 640); #[cfg(debug_assertions)] window.add_css_class("devel"); window } + fn init_loading_widgets(_root: Self::Root) -> Option<relm4::loading_widgets::LoadingWidgets> { + let root = gtk::Box::default(); + let spinner = gtk::Spinner::builder() + .spinning(true) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + + root.append(&spinner); + Some(LoadingWidgets::new(root, spinner)) + } + /// Initialize the UI and model. async fn init( _init: Self::Init, window: Self::Root, sender: AsyncComponentSender<Self>, ) -> AsyncComponentParts<Self> { + let schema = crate::secrets::get_schema(); + let state = match libsecret::password_search_future(Some(&schema), { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); + attrs + }, libsecret::SearchFlags::ALL).await { + Ok(mut retrievables) => { + if retrievables.is_empty() { + AuthState::LoggedOut( + components::SignIn::builder() + .launch(glib::Uri::parse( + CLIENT_ID_STR, glib::UriFlags::NONE + ).unwrap()) + .forward(sender.input_sender(), |o| Self::Input::Authorize(Box::new(o))) + ) + } else { + retrievables.sort_by_key(|s| s.created()); + let retrievable = retrievables.last().unwrap(); + let attrs = retrievable.attributes(); + + let micropub_uri = attrs + .get(crate::secrets::MICROPUB) + .and_then(|v| glib::Uri::parse(v, glib::UriFlags::NONE).ok()) + .unwrap(); + + AuthState::LoggedIn { + post_editor: components::PostEditor::builder() + .launch(None) + .forward(sender.clone().input_sender(), Self::Input::PostEditor), + micropub: crate::micropub::Client::new( + micropub_uri, + retrievable.retrieve_secret_future().await.unwrap().unwrap().text().unwrap().to_string() + ), + submit_busy_guard: None + } + } + }, + Err(err) => { + log::warn!("Error retrieving secrets: {}", err); + AuthState::LoggedOut( + components::SignIn::builder() + .launch(glib::Uri::parse( + CLIENT_ID_STR, glib::UriFlags::NONE + ).unwrap()) + .forward(sender.input_sender(), |o| Self::Input::Authorize(Box::new(o))) + ) + }, + + }; let model = App { - state: AuthState::LoggedOut(Default::default()) + state, }; let mut widgets = Self::Widgets { @@ -111,19 +174,6 @@ impl AsyncComponent for App { move |_button| sender.input(Self::Input::SubmitButtonPressed) )); - widgets.login_box.set_orientation(gtk::Orientation::Vertical); - widgets.login_box.append(&widgets.login_label); - widgets.login_box.append(&widgets.login_button); - - widgets.login_label.set_text("You need to authorize first."); - - widgets.login_button.set_label("Pretend to authorize"); - widgets.login_button.set_tooltip("(check MICROPUB_URI and MICROPUB_TOKEN environment variables)"); - widgets.login_button.connect_clicked(glib::clone!( - #[strong] sender, - move |_button| sender.input(Self::Input::Authorize) - )); - widgets.root.set_content(Some(&widgets.toolbar_view)); // Separate component choosing logic from initialization. We @@ -137,11 +187,10 @@ impl AsyncComponent for App { fn update_view(&self, widgets: &mut Self::Widgets, _sender: AsyncComponentSender<Self>) { // Bind the child component, if any, here. match &self.state { - AuthState::LoggedOut(_entry_buffer) => { + AuthState::LoggedOut(signin) => { widgets.root.set_title(Some("Sign in with your website")); - widgets.toolbar_view.set_content(None::<>k::Box>); + widgets.toolbar_view.set_content(Some(signin.widget())); widgets.top_bar_btn.set_visible(false); - widgets.toolbar_view.set_content(Some(&widgets.login_box)); }, AuthState::LoggedIn { post_editor, @@ -164,22 +213,55 @@ impl AsyncComponent for App { _root: &Self::Root ) { match message { - Input::Authorize => { + Input::Authorize(data) => { + let schema = crate::secrets::get_schema(); + let mut attributes = std::collections::HashMap::new(); + let _me = data.me.to_string(); + let _micropub = data.micropub.to_string(); + attributes.insert(secrets::ME, _me.as_str()); + attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN); + attributes.insert(secrets::MICROPUB, _micropub.as_str()); + let exp = data.expires_in + .as_ref() + .map(std::time::Duration::as_secs) + .as_ref() + .map(u64::to_string); + if let Some(expires_in) = exp.as_deref() { + attributes.insert(secrets::EXPIRES_IN, expires_in); + } + + match libsecret::password_store_future( + Some(&schema), + attributes.clone(), + Some(libsecret::COLLECTION_DEFAULT), + data.me.to_str().as_str(), + &data.access_token + ).await { + Ok(()) => {}, + Err(err) => log::error!("Failed to store access token to the secret store: {}", err), + } + if let Some(refresh_token) = data.refresh_token.as_deref() { + attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN); + attributes.remove(secrets::EXPIRES_IN); + match libsecret::password_store_future( + Some(&schema), + attributes, + Some(libsecret::COLLECTION_DEFAULT), + data.me.to_str().as_str(), + refresh_token + ).await { + Ok(()) => {}, + Err(err) => log::error!("Failed to store refresh token to the secret store: {}", err), + } + } + self.state = AuthState::LoggedIn { post_editor: components::PostEditor::builder() .launch(None) .forward(_sender.clone().input_sender(), Self::Input::PostEditor), - micropub: std::env::var("MICROPUB_TOKEN").ok() - .and_then(|token| { - Some((token, glib::Uri::parse( - &std::env::var("MICROPUB_URI").ok()?, - glib::UriFlags::NONE - ).ok()?)) - }) - .map(|(token, uri)| crate::micropub::Client::new( - uri, token - )) - .unwrap(), + micropub: crate::micropub::Client::new( + data.micropub.clone(), data.access_token.clone() + ), submit_busy_guard: None }; }, diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..c8c9bd7 --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,17 @@ +pub const ACCESS_TOKEN: &str = "access_token"; +pub const REFRESH_TOKEN: &str = "refresh_token"; + +pub const ME: &str = "me"; +pub const TOKEN_KIND: &str = "token_kind"; +pub const EXPIRES_IN: &str = "expires_in"; +pub const MICROPUB: &str = "micropub"; + +pub fn get_schema() -> libsecret::Schema { + let mut attrs = std::collections::HashMap::new(); + attrs.insert(ME, libsecret::SchemaAttributeType::String); + attrs.insert(TOKEN_KIND, libsecret::SchemaAttributeType::String); + attrs.insert(MICROPUB, libsecret::SchemaAttributeType::String); + attrs.insert(EXPIRES_IN, libsecret::SchemaAttributeType::Integer); + + libsecret::Schema::new("org.indieweb.indieauth.BearerCredential", libsecret::SchemaFlags::NONE, attrs) +} |