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 <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,
/// Redirect URI to declare. Note: This will break the flow, use only for testing UI.
#[clap(long, value_parser)]
redirect_uri: Option<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 = 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::<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, 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::<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)
};
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.")));
}
}