summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2024-08-25 03:04:22 +0300
committerVika <vika@fireburn.ru>2024-08-25 03:04:22 +0300
commit847648330cc0e7af59fa6923f45222726d404250 (patch)
tree0c2e9c3d80e5de187889d43233011aae26a8b8b0 /src
parent96f1c3580e8dab10ad862c9e08baaf09b96e0174 (diff)
downloadbowl-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.
Diffstat (limited to 'src')
-rw-r--r--src/components/signin.rs535
-rw-r--r--src/lib.rs154
-rw-r--r--src/secrets.rs17
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 = &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);
+        }
+    }
+}
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::<&gtk::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)
+}