summary refs log tree commit diff
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2024-11-15 07:17:20 +0300
committerVika <vika@fireburn.ru>2024-11-15 07:17:20 +0300
commitef6b0f6ee3b769356f7bc852eb240e26f2d895cf (patch)
tree59561fa18c119117b003856a0e644e141eef8342
parent64b9bf0ec12d33a9bb717e6a8cb8f0ca4d8bf991 (diff)
downloadbowl-ef6b0f6ee3b769356f7bc852eb240e26f2d895cf.tar.zst
Actually use refresh tokens
This code is untested. I guess I'll need to revisit this in about a
week, when my access token expires. Then I'll be able to see if it
correctly refreshes.
-rw-r--r--src/components/signin.rs154
-rw-r--r--src/lib.rs231
2 files changed, 250 insertions, 135 deletions
diff --git a/src/components/signin.rs b/src/components/signin.rs
index 156686e..53c3670 100644
--- a/src/components/signin.rs
+++ b/src/components/signin.rs
@@ -37,7 +37,7 @@ pub struct SignIn {
 }
 
 #[derive(Debug, thiserror::Error)]
-enum Error {
+pub enum Error {
     #[error("glib error: {0}")]
     Glib(#[from] glib::Error),
     #[error("indieauth error: {0}")]
@@ -67,6 +67,66 @@ pub enum Input {
     Callback(Result<AuthorizationResponse, Error>),
 }
 
+pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metadata, glib::Uri), Error> {
+    // 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(
+        SignIn::well_known_metadata(http.clone(), url.clone())
+    );
+    let msg = soup::Message::from_uri("GET", &url);
+    let body = http.send_future(&msg, glib::Priority::DEFAULT).await?;
+
+    let mf2 = microformats::from_reader(
+        std::io::BufReader::new(body.into_read()),
+        url.to_string().parse().unwrap()
+    )?;
+
+    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 Err(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 Err(Error::MicropubLinkNotFound)
+    };
+
+    if let Ok(Some(metadata)) = metadata.await {
+        Ok((metadata, micropub_uri))
+    } else {
+        let msg = soup::Message::from_uri("GET", &metadata_url);
+        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 => {
+                let metadata = serde_json::from_slice(&body)?;
+                Ok((metadata, micropub_uri))
+            },
+            Ok(_) => Err(Error::MetadataEndpointFailed(msg.status())),
+            Err(err) => Err(err.into()),
+        }
+    }
+}
+
 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);
@@ -125,7 +185,7 @@ fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Serv
 }
 
 impl SignIn {
-    fn scopes() -> kittybox_indieauth::Scopes {
+    pub fn scopes() -> kittybox_indieauth::Scopes {
         kittybox_indieauth::Scopes::new(vec![
             kittybox_indieauth::Scope::Profile,
             kittybox_indieauth::Scope::Create,
@@ -314,93 +374,9 @@ impl AsyncComponent for SignIn {
                     },
                 };
 
-                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 (metadata, micropub_uri) = match get_metadata(self.http.clone(), url.clone()).await {
+                    Ok((metadata, micropub_uri)) => (metadata, micropub_uri),
+                    Err(err) => return self.bail_out(widgets, sender, err)
                 };
 
                 let auth_request = AuthorizationRequest {
diff --git a/src/lib.rs b/src/lib.rs
index a644411..6048e10 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,7 +4,6 @@ use libsecret::prelude::{RetrievableExtManual, RetrievableExt};
 use relm4::{actions::{RelmAction, RelmActionGroup}, gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt};
 
 pub mod components {
-    #[cfg(feature = "smart-summary")]
     pub(crate) mod smart_summary;
     #[cfg(feature = "smart-summary")]
     pub(crate) use smart_summary::{
@@ -27,6 +26,7 @@ pub mod components {
 }
 
 use components::{post_editor::Post, PostEditorInput};
+use soup::prelude::SessionExt;
 
 pub mod secrets;
 pub mod micropub;
@@ -50,6 +50,174 @@ pub struct App {
 }
 
 impl App {
+    async fn authorize(schema: &libsecret::Schema, http: soup::Session, data: Box<components::SignInOutput>) -> Result<micropub::Client, glib::Error> {
+        let mut attributes = std::collections::HashMap::new();
+        let _me = data.me.to_string();
+        let _micropub = data.micropub.to_string();
+        let scope = data.scope.to_string();
+        attributes.insert(secrets::ME, _me.as_str());
+        attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN);
+        attributes.insert(secrets::MICROPUB, _micropub.as_str());
+        attributes.insert(secrets::SCOPE, scope.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),
+            &gettext!("Micropub access token for {}", &data.me),
+            &data.access_token
+        ).await {
+            Ok(()) => {},
+            Err(err) => {
+                log::error!("Failed to store access token to the secret store: {}", err);
+                return Err(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),
+                &format!("Micropub refresh token for {}", &data.me),
+                refresh_token
+            ).await {
+                Ok(()) => {},
+                Err(err) => {
+                    log::error!("Failed to store refresh token to the secret store: {}", err);
+                    return Err(err)
+                },
+            }
+        }
+
+        Ok(micropub::Client::new(
+            http.clone(), data.micropub.clone(), data.access_token.clone()
+        ))
+    }
+
+    async fn refresh_token(schema: &libsecret::Schema, http: soup::Session, me: String) -> Result<Option<micropub::Client>, glib::Error> {
+        let mut retrievables = libsecret::password_search_future(Some(schema), {
+            let mut attrs = std::collections::HashMap::default();
+            attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN);
+            attrs.insert(crate::secrets::ME, &me);
+            attrs
+        }, libsecret::SearchFlags::ALL).await?;
+
+        if retrievables.is_empty() {
+            Ok(None)
+        } else {
+            retrievables.sort_by_key(|s| s.created());
+            let iterable = retrievables.iter().rev();
+            for retrievable in iterable {
+                let attrs = retrievable.attributes();
+                let attrs_ref: std::collections::HashMap<&str, &str> = attrs
+                    .iter()
+                    .map(|(k, v)| (k.as_str(), v.as_str()))
+                    .collect();
+
+                let token = retrievable.retrieve_secret_future().await?
+                    .unwrap()
+                    .text()
+                    .unwrap()
+                    .to_string();
+
+                let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?;
+
+                let (metadata, micropub_uri) = match crate::components::signin::get_metadata(http.clone(), url).await {
+                    Ok(res) => res,
+                    Err(err) => {
+                        tracing::warn!("failed to fetch metadata to refresh an expired token: {}", err);
+                        return Ok(None)
+                    }
+                };
+
+                let grant = kittybox_indieauth::GrantRequest::RefreshToken {
+                    refresh_token: token, client_id: CLIENT_ID_STR.parse().unwrap(), scope: None
+                };
+
+                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(grant).unwrap().into_bytes()
+                    ))
+                );
+
+                match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
+                    Ok(body) if msg.status() == soup::Status::Ok => {
+                        match serde_json::from_slice::<kittybox_indieauth::GrantResponse>(&body) {
+                            Ok(kittybox_indieauth::GrantResponse::ProfileUrl(_)) => unreachable!(),
+                            Ok(kittybox_indieauth::GrantResponse::AccessToken {
+                                me,
+                                token_type: _,
+                                scope,
+                                access_token,
+                                state: _,
+                                expires_in,
+                                profile,
+                                refresh_token
+                            }) => {
+                                if refresh_token.is_some() {
+                                    // Get rid of the old refresh token.
+                                    let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await;                                    
+                                };
+                                let micropub = Self::authorize(
+                                    schema, http, Box::new(components::SignInOutput {
+                                        me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(),
+                                        scope: scope.unwrap_or_else(components::SignIn::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,
+                                    })
+                                ).await?;
+
+                                return Ok(Some(micropub))
+                            },
+                            Err(err) => {
+                                tracing::warn!("failed to refresh token for {}: failed to parse grant response: {}", me, err);
+                                return Ok(None)
+                            },
+                        }
+                    },
+                    Ok(body) => {
+                        match serde_json::from_slice::<kittybox_indieauth::Error>(&body) {
+                            Ok(err) => {
+                                tracing::warn!("failed to refresh token for {}: token endpoint error: {}", me, err);
+                                continue;
+                            },
+                            Err(err) => {
+                                tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err);
+                                tracing::warn!("token endpoint response verbatim follows:\n{}", String::from_utf8_lossy(&body));
+                                return Ok(None)
+                            }
+                        }
+                    },
+                    Err(err) => return Err(err),
+                };
+
+            }
+            unreachable!()
+        }
+    }
+
     async fn get_login_state(schema: &libsecret::Schema, http: soup::Session) -> Result<Option<micropub::Client>, glib::Error> {
         let mut retrievables = libsecret::password_search_future(Some(schema), {
             let mut attrs = std::collections::HashMap::default();
@@ -93,7 +261,20 @@ impl App {
                 // Skip the token if we can't access ?q=config
                 if let Err(micropub::Error::Micropub(err)) = micropub.config().await {
                     if err.error == kittybox_util::micropub::ErrorKind::NotAuthorized {
-                        continue;
+                        // Token may have expired. See if we have a refresh token and renew.
+                        let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await;
+
+                        match Self::refresh_token(
+                            schema, http.clone(),
+                            attrs.get(crate::secrets::ME).unwrap().to_string()
+                        ).await {
+                            Ok(None) => continue,
+                            Err(err) => {
+                                tracing::warn!("error refreshing Micropub token for {}: {}", attrs.get(crate::secrets::ME).unwrap(), err);
+                                continue
+                            },
+                            Ok(Some(micropub)) => return Ok(Some(micropub))
+                        }
                     }
                 }
 
@@ -301,51 +482,9 @@ impl AsyncComponent for App {
                 }
             },
             Input::Authorize(data) => {
-                let mut attributes = std::collections::HashMap::new();
-                let _me = data.me.to_string();
-                let _micropub = data.micropub.to_string();
-                let scope = data.scope.to_string();
-                attributes.insert(secrets::ME, _me.as_str());
-                attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN);
-                attributes.insert(secrets::MICROPUB, _micropub.as_str());
-                attributes.insert(secrets::SCOPE, scope.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);
+                if let Ok(micropub) = Self::authorize(&self.secret_schema, self.http.clone(), data).await {
+                    self.micropub = Some(micropub);
                 }
-
-                match libsecret::password_store_future(
-                    Some(&self.secret_schema),
-                    attributes.clone(),
-                    Some(libsecret::COLLECTION_DEFAULT),
-                    &gettext!("Micropub access token for {}", &data.me),
-                    &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(&self.secret_schema),
-                        attributes,
-                        Some(libsecret::COLLECTION_DEFAULT),
-                        &format!("Micropub refresh token for {}", &data.me),
-                        refresh_token
-                    ).await {
-                        Ok(()) => {},
-                        Err(err) => log::error!("Failed to store refresh token to the secret store: {}", err),
-                    }
-                }
-
-                self.micropub = Some(crate::micropub::Client::new(
-                    self.http.clone(), data.micropub.clone(), data.access_token.clone()
-                ));
             },
             Input::SubmitButtonPressed => {
                 if self.micropub.is_some() {