use futures::{FutureExt, TryFutureExt};
use kittybox_indieauth::{
AuthorizationRequest, PKCEVerifier,
PKCEChallenge, PKCEMethod, GrantRequest, Scope,
AuthorizationResponse, GrantResponse,
Error as IndieauthError
};
use clap::Parser;
use tokio::net::TcpListener;
use std::{borrow::Cow, future::IntoFuture, 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("url parsing error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("indieauth flow error: {0}")]
IndieAuth(#[from] IndieauthError)
}
#[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>
}
#[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")
));
// This only works on debug builds. Don't get any funny thoughts.
#[cfg(debug_assertions)]
if std::env::var("KITTYBOX_DANGER_INSECURE_TLS")
.map(|y| y == "1")
.unwrap_or(false)
{
builder = builder.danger_accept_invalid_certs(true);
}
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 = {
let mut url = metadata.authorization_endpoint.clone();
let mut q = url.query_pairs_mut();
q.extend_pairs(authorization_request.as_query_pairs());
drop(q);
url
};
eprintln!("Please visit the following URL in your browser:\n\n {}\n", indieauth_url.as_str());
#[cfg(target_os = "linux")]
match std::process::Command::new("xdg-open").arg(indieauth_url.as_str()).spawn() {
Ok(child) => drop(child),
Err(err) => eprintln!("Couldn't xdg-open: {}", err)
}
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::{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 = axum::serve(
TcpListener::bind(
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST),60000)
).await.unwrap(),
router.into_make_service()
);
tokio::task::spawn(server.into_future())
};
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 response: Result<GrantResponse, IndieauthError> = 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()
.and_then(|res| async move {
if res.status().is_success() {
Ok(Ok(res.json::<GrantResponse>().await?))
} else {
Ok(Err(res.json::<IndieauthError>().await?))
}
})
.await?;
if let GrantResponse::AccessToken {
me,
profile,
access_token,
expires_in,
refresh_token,
scope,
..
} = response? {
eprintln!("Congratulations, {}, access token is ready! {}",
profile.as_ref().and_then(|p| p.name.as_deref()).unwrap_or(me.as_str()),
if let Some(exp) = expires_in {
Cow::Owned(format!("It expires in {exp} seconds."))
} else {
Cow::Borrowed("It seems to have unlimited duration.")
}
);
if let Some(scope) = scope {
eprintln!("The following scopes were granted: {}", scope.to_string());
}
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 {
unreachable!("Token endpoint did not return the token grant, but a different grant.")
}
}