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.html"; const DEFAULT_REDIRECT_URI: &str = "http://localhost:60000/callback"; #[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 ", 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, /// Client ID to use when requesting a token. #[clap(short, long, value_parser, default_value = DEFAULT_CLIENT_ID)] client_id: url::Url, /// Redirect URI to declare. Note: This will break the flow, use only for testing UI. #[clap(long, value_parser)] redirect_uri: Option } fn append_query_string( url: &url::Url, query: T ) -> Result { 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 = args.redirect_uri .clone() .unwrap_or_else(|| DEFAULT_REDIRECT_URI.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::() .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, 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 )?; eprintln!("Please visit the following URL in your browser:\n\n {}\n", indieauth_url.as_str()); if args.redirect_uri.is_some() { eprintln!("Custom redirect URI specified, won't be able to catch authorization response."); std::process::exit(0); } // Prepare a callback let (tx, rx) = tokio::sync::oneshot::channel::(); 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>| 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) }; 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, token_type, scope } = 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."))); } }