about summary refs log blame commit diff
path: root/src/bin/kittybox-indieauth-helper.rs
blob: e7004c044c40be967f606448007ad2efc0bec17a (plain) (tree)
1
2
3
4
5
6
7
8
9
10
                                       

                                                   
                                         
  
                 
                                                      
 
                                                                                     
                                                                     




                                       
                                 

                                         
                                     




















                                                                              

                                                                                         
 









                                                                      






                                                                         

                                

                                                                  
 







                                                                                     
 



                                                              
                                                                             


                                                                 





                                                               
 
                                                                                                    




                                                                                      


                                                                                                   
 

                                                                            
                                                           
                                                                        
 




                                                                               
 
















                                                                                             




                                                                      
 
                                                
      
 











                                                                              
 

                                                                                    
                                   

                                     
                                                                                            






                                                





                                                            





                                       
                      

                   
                                                                   




                                                                                    
          

                                                                                  






















                                                                                 
                                                                                             
     
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.")
    }
}