diff options
author | Vika <vika@fireburn.ru> | 2024-11-15 07:17:20 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2024-11-15 07:17:20 +0300 |
commit | ef6b0f6ee3b769356f7bc852eb240e26f2d895cf (patch) | |
tree | 59561fa18c119117b003856a0e644e141eef8342 | |
parent | 64b9bf0ec12d33a9bb717e6a8cb8f0ca4d8bf991 (diff) | |
download | bowl-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.rs | 154 | ||||
-rw-r--r-- | src/lib.rs | 231 |
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() { |