about summary refs log tree commit diff
path: root/kittybox-rs/src/bin/kittybox-indieauth-helper.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-09-19 17:30:38 +0300
committerVika <vika@fireburn.ru>2022-09-19 17:30:38 +0300
commit66049566ae865e1a4bd049257d6afc0abded16e9 (patch)
tree6013a26fa98a149d103eb4402ca91d698ef02ac2 /kittybox-rs/src/bin/kittybox-indieauth-helper.rs
parent696458657b26032e6e2a987c059fd69aaa10508d (diff)
downloadkittybox-66049566ae865e1a4bd049257d6afc0abded16e9.tar.zst
feat: indieauth support
Working:
 - Tokens and codes
 - Authenticating with a password

Not working:
 - Setting the password (need to patch onboarding)
 - WebAuthn (the JavaScript is too complicated)
Diffstat (limited to 'kittybox-rs/src/bin/kittybox-indieauth-helper.rs')
-rw-r--r--kittybox-rs/src/bin/kittybox-indieauth-helper.rs216
1 files changed, 216 insertions, 0 deletions
diff --git a/kittybox-rs/src/bin/kittybox-indieauth-helper.rs b/kittybox-rs/src/bin/kittybox-indieauth-helper.rs
new file mode 100644
index 0000000..37eee5b
--- /dev/null
+++ b/kittybox-rs/src/bin/kittybox-indieauth-helper.rs
@@ -0,0 +1,216 @@
+use kittybox_indieauth::{AuthorizationRequest, PKCEVerifier, PKCEChallenge, PKCEMethod, GrantRequest, Scope, AuthorizationResponse, TokenData, GrantResponse};
+use clap::Parser;
+use std::{borrow::Cow, io::Write};
+
+const DEFAULT_CLIENT_ID: &str = "https://kittybox.fireburn.ru/indieauth-helper";
+
+#[derive(Debug, thiserror::Error)]
+enum Error {
+    #[error("i/o error: {0}")]
+    IO(#[from] std::io::Error),
+    #[error("http request error: {0}")]
+    HTTP(#[from] reqwest::Error),
+    #[error("urlencoded encoding error: {0}")]
+    UrlencodedEncoding(#[from] serde_urlencoded::ser::Error),
+    #[error("url parsing error: {0}")]
+    UrlParse(#[from] url::ParseError),
+    #[error("indieauth flow error: {0}")]
+    IndieAuth(Cow<'static, str>)
+}
+
+#[derive(Parser, Debug)]
+#[clap(
+    name = "kittybox-indieauth-helper",
+    author = "Vika <vika@fireburn.ru>",
+    version = env!("CARGO_PKG_VERSION"),
+    about = "Retrieve an IndieAuth token for debugging",
+    long_about = None
+)]
+struct Args {
+    /// Profile URL to use for initiating IndieAuth metadata discovery.
+    #[clap(value_parser)]
+    me: url::Url,
+    /// Scopes to request for the token.
+    ///
+    /// All IndieAuth scopes are supported, including arbitrary custom scopes.
+    #[clap(short, long)]
+    scope: Vec<Scope>,
+    /// Client ID to use when requesting a token.
+    #[clap(short, long, value_parser, default_value = DEFAULT_CLIENT_ID)]
+    client_id: url::Url,
+}
+
+fn append_query_string<T: serde::Serialize>(
+    url: &url::Url,
+    query: T
+) -> Result<url::Url, Error> {
+    let mut new_url = url.clone();
+    let mut query = serde_urlencoded::to_string(query)?;
+    if let Some(old_query) = url.query() {
+        query.push('&');
+        query.push_str(old_query);
+    }
+    new_url.set_query(Some(&query));
+
+    Ok(new_url)
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Error> {
+    let args = Args::parse();
+
+    let http: reqwest::Client = {
+        #[allow(unused_mut)]
+        let mut builder = reqwest::Client::builder()
+            .user_agent(concat!(
+                env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")
+            ));
+
+        builder.build().unwrap()
+    };
+
+    let redirect_uri: url::Url = "http://localhost:60000/callback".parse().unwrap();
+    
+    eprintln!("Checking .well-known for metadata...");
+    let metadata = http.get(args.me.join("/.well-known/oauth-authorization-server")?)
+        .header("Accept", "application/json")
+        .send()
+        .await?
+        .json::<kittybox_indieauth::Metadata>()
+        .await?;
+
+    let verifier = PKCEVerifier::new();
+    
+    let authorization_request = AuthorizationRequest {
+        response_type: kittybox_indieauth::ResponseType::Code,
+        client_id: args.client_id.clone(),
+        redirect_uri: redirect_uri.clone(),
+        state: kittybox_indieauth::State::new(),
+        code_challenge: PKCEChallenge::new(verifier.clone(), PKCEMethod::default()),
+        scope: Some(kittybox_indieauth::Scopes::new(args.scope)),
+        me: Some(args.me)
+    };
+
+    let indieauth_url = append_query_string(
+        &metadata.authorization_endpoint,
+        authorization_request
+    )?;
+
+    // Prepare a callback
+    let (tx, rx) = tokio::sync::oneshot::channel::<AuthorizationResponse>();
+    let server = {
+        use axum::{routing::get, extract::Query, response::IntoResponse};
+
+        let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx)));
+        
+        let router = axum::Router::new()
+            .route("/callback", axum::routing::get(
+                move |query: Option<Query<AuthorizationResponse>>| async move {
+                    if let Some(Query(response)) = query {
+                        if let Some(tx) = tx.lock_owned().await.take() {
+                            tx.send(response).unwrap();
+                        
+                            (axum::http::StatusCode::OK,
+                             [("Content-Type", "text/plain")],
+                             "Thank you! This window can now be closed.")
+                                .into_response()
+                        } else {
+                            (axum::http::StatusCode::BAD_REQUEST,
+                             [("Content-Type", "text/plain")],
+                             "Oops. The callback was already received. Did you click twice?")
+                                .into_response()
+                        }
+                    } else {
+                        axum::http::StatusCode::BAD_REQUEST.into_response()
+                    }
+                }
+            ));
+
+        use std::net::{SocketAddr, IpAddr, Ipv4Addr};
+
+        let server = hyper::server::Server::bind(
+            &SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST),60000)
+        )
+            .serve(router.into_make_service());
+
+        tokio::task::spawn(server)
+    };
+    
+    eprintln!("Please visit the following URL in your browser:\n\n   {}\n", indieauth_url.as_str());
+
+    let authorization_response = rx.await.unwrap();
+
+    // Clean up after the server
+    tokio::task::spawn(async move {
+        // Wait for the server to settle -- it might need to send its response
+        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
+        // Abort the future -- this should kill the server
+        server.abort();
+    });
+
+    eprintln!("Got authorization response: {:#?}", authorization_response);
+    eprint!("Checking issuer field...");
+    std::io::stderr().lock().flush()?;
+    
+    if dbg!(authorization_response.iss.as_str()) == dbg!(metadata.issuer.as_str()) {
+        eprintln!(" Done");
+    } else {
+        eprintln!(" Failed");
+        #[cfg(not(debug_assertions))]
+        std::process::exit(1);
+    }
+    let grant_response: GrantResponse = http.post(metadata.token_endpoint)
+        .form(&GrantRequest::AuthorizationCode {
+            code: authorization_response.code,
+            client_id: args.client_id,
+            redirect_uri,
+            code_verifier: verifier
+        })
+        .header("Accept", "application/json")
+        .send()
+        .await?
+        .json()
+        .await?;
+
+    if let GrantResponse::AccessToken {
+        me,
+        profile,
+        access_token,
+        expires_in,
+        refresh_token
+    } = grant_response {
+        eprintln!("Congratulations, {}, access token is ready! {}",
+                  me.as_str(),
+                  if let Some(exp) = expires_in {
+                      format!("It expires in {exp} seconds.")
+                  } else {
+                      format!("It seems to have unlimited duration.")
+                  }
+        );
+        println!("{}", access_token);
+        if let Some(refresh_token) = refresh_token {
+            eprintln!("Save this refresh token, it will come in handy:");
+            println!("{}", refresh_token);
+        };
+
+        if let Some(profile) = profile {
+            eprintln!("\nThe token endpoint returned some profile information:");
+            if let Some(name) = profile.name {
+                eprintln!(" - Name: {name}")
+            }
+            if let Some(url) = profile.url {
+                eprintln!(" - URL: {url}")
+            }
+            if let Some(photo) = profile.photo {
+                eprintln!(" - Photo: {photo}")
+            }
+            if let Some(email) = profile.email {
+                eprintln!(" - Email: {email}")
+            }
+        }
+
+        Ok(())
+    } else {
+        return Err(Error::IndieAuth(Cow::Borrowed("IndieAuth token endpoint did not return an access token grant.")));
+    }
+}