nu_plugin_twitch 0.1.1

A Nu Shell plugin to interact with Twitch IRC and API, making scripting easier.
Documentation
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;

use chrono::{Duration, Utc};
use console::Term;
use dialoguer::{Confirm, Input, Password};

use nu_plugin::EvaluatedCall;
use nu_plugin::{EngineInterface, PluginCommand};
use nu_protocol::{LabeledError, PipelineData, Signature, Type};
use oauth2::basic::BasicClient;
use oauth2::reqwest;
use oauth2::url::Url;
use oauth2::{
    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
    Scope, TokenUrl,
};
use twitch_irc::login::UserAccessToken;

use crate::TwitchPlugin;
use crate::auth::{ApplicationCredentials, ProfileTokenProvider};

pub struct TwitchAuth;

impl PluginCommand for TwitchAuth {
    type Plugin = TwitchPlugin;

    fn name(&self) -> &str {
        "twitch auth"
    }

    fn description(&self) -> &str {
        "get credentials from dev.twitch.tv"
    }

    fn signature(&self) -> Signature {
        Signature::build(PluginCommand::name(self)).input_output_type(Type::Nothing, Type::Nothing)
    }

    fn run(
        &self,
        plugin: &TwitchPlugin,
        engine: &EngineInterface,
        _call: &EvaluatedCall,
        _input: PipelineData,
    ) -> Result<PipelineData, LabeledError> {
        plugin.setup_tracing();

        // Get terminal ownership
        let _term_handle = engine.enter_foreground();
        let term = Term::stdout();

        let app = if let Ok(app) = ApplicationCredentials::load() {
            app
        } else {
            term.write_line("For nu_plugin_twitch to be able to use Twitch APIs you need to")
                .unwrap();
            term.write_line("configure your own application on https://dev.twitch.tv/console")
                .unwrap();
            term.write_line("and redirect to https://localhost:5000/auth.")
                .unwrap();

            let client_id: String = Input::new()
                .with_prompt("Your CLIENT_ID")
                .interact_text()
                .unwrap();
            let client_secret: String = Password::new()
                .with_prompt("Your CLIENT_SECRET")
                .interact()
                .unwrap();

            let app = ApplicationCredentials {
                client_id,
                client_secret,
            };
            app.save()?;
            app
        };

        if !Confirm::new()
            .with_prompt(format!(
                "Do you want to continue with the application `{}`",
                app.client_id
            ))
            .default(true)
            .interact()
            .unwrap()
        {
            return Ok(PipelineData::Empty);
        };

        let profile = Input::new()
            .with_prompt("Name of the profile to edit (leave empty for default)")
            .default("default".to_string())
            .interact()
            .unwrap();

        let profile = match profile.as_str() {
            "default" | "" => None,
            profile => Some(profile.to_string()),
        };

        let token_storage = ProfileTokenProvider { profile };
        let token_path = token_storage.profile_path();
        if token_path.exists() {
            let confirm_delete = Confirm::new()
                .with_prompt("A token for that profile already exists, do you want to replace it?")
                .default(false)
                .interact()
                .unwrap();
            if !confirm_delete {
                term.write_line("Aborting").unwrap();
                return Ok(PipelineData::Empty);
            }
            std::fs::remove_file(&token_path).map_err(|e| {
                LabeledError::new("Failed to delete token file")
                    .with_inner(LabeledError::new(e.to_string()))
            })?;
        }

        // Create an OAuth2 client by specifying the client ID, client secret, authorization URL and
        // token URL.
        let client = BasicClient::new(ClientId::new(app.client_id.clone()))
            .set_client_secret(ClientSecret::new(app.client_secret.clone()))
            .set_auth_uri(
                AuthUrl::new("https://id.twitch.tv/oauth2/authorize".to_string()).unwrap(),
            )
            .set_token_uri(TokenUrl::new("https://id.twitch.tv/oauth2/token".to_string()).unwrap())
            // Set the URL the user will be redirected to after the authorization process.
            .set_redirect_uri(
                RedirectUrl::new("http://localhost:8080/redirect".to_string()).unwrap(),
            );

        // Generate a PKCE challenge.
        let (pkce_challenge, _pkce_verifier) = PkceCodeChallenge::new_random_sha256();

        // Generate the full authorization URL.
        let (auth_url, csrf_token) = client
            .authorize_url(CsrfToken::new_random)
            // Set the desired scopes.
            .add_scope(Scope::new("chat:read".to_string()))
            .add_scope(Scope::new("chat:edit".to_string()))
            // Set the PKCE code challenge.
            .set_pkce_challenge(pkce_challenge)
            .url();

        // This is the URL you should redirect the user to, in order to trigger the authorization
        // process.
        term.write_line(&format!(
            "Opening URL in your browser of choise ({auth_url})."
        ))
        .unwrap();
        open::that(auth_url.to_string()).unwrap();

        let (code, state) = {
            // A very naive implementation of the redirect server.
            let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
            term.write_line("- Listening for redirect at http://localhost:8080/redirect")
                .unwrap();

            // The server will terminate itself after collecting the first code.
            let Some(mut stream) = listener.incoming().flatten().next() else {
                return Err(LabeledError::new(
                    "Listener terminated without accepting a connection",
                ));
            };

            let mut reader = BufReader::new(&stream);

            let mut request_line = String::new();
            reader.read_line(&mut request_line).unwrap();

            let redirect_url = request_line.split_whitespace().nth(1).unwrap();
            let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap();

            let code = url
                .query_pairs()
                .find(|(key, _)| key == "code")
                .map(|(_, code)| AuthorizationCode::new(code.into_owned()))
                .unwrap();

            let state = url
                .query_pairs()
                .find(|(key, _)| key == "state")
                .map(|(_, state)| CsrfToken::new(state.into_owned()))
                .unwrap();

            let message = "Go back to your terminal :)";
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
                message.len(),
                message
            );
            stream.write_all(response.as_bytes()).unwrap();

            (code, state)
        };

        // Once the user has been redirected to the redirect URL, you'll have access to the
        // authorization code. For security reasons, your code should verify that the `state`
        // parameter returned by the server matches `csrf_token`.
        if csrf_token.secret() != state.secret() {
            tracing::error!(
                expected_state = csrf_token.secret(),
                state = state.secret(),
                "Failed CSRF challenge."
            );
            return Err(LabeledError::new("Failed CSRF challenge"));
        }

        let http_client = reqwest::blocking::ClientBuilder::new()
            // Following redirects opens the client up to SSRF vulnerabilities.
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .expect("Client should build");

        // Now you can trade it for an access token.
        term.write_line("- Exchanging code for access token.")
            .unwrap();

        let token_result = http_client
            .post(client.token_uri().to_string())
            .form(&[
                ("client_id", app.client_id.clone()),
                ("client_secret", app.client_secret.clone()),
                ("code", code.secret().to_string()),
                ("grant_type", "authorization_code".to_string()),
                ("redirect_uri", client.redirect_uri().unwrap().to_string()),
            ])
            .send()
            .and_then(|res| res.text())
            .map_err(|e| {
                LabeledError::new("Failed to exchange token")
                    .with_inner(LabeledError::new(e.to_string()))
            })?;

        term.write_line("- Parsing Twitch response").unwrap();

        let token_result: HashMap<String, serde_json::Value> = serde_json::from_str(&token_result)
            .map_err(|e| {
                LabeledError::new("Failed to exchange token")
                    .with_inner(LabeledError::new(e.to_string()))
            })?;

        if token_result.contains_key("error") {
            return Err(LabeledError::new("Failed to exchange token")
                .with_inner(LabeledError::new(format!("{token_result:?}"))));
        }

        let access_token = token_result
            .get("access_token")
            .map(|v| v.as_str().expect("access_token is not a string"))
            .expect("missing field access_token")
            .to_string();
        let refresh_token = token_result
            .get("refresh_token")
            .map(|v| v.as_str().expect("refresh_token is not a string"))
            .expect("missing field refresh_token")
            .to_string();
        let expires_in = token_result
            .get("expires_in")
            .map(|v| v.as_i64().expect("expires_in is not a number"));

        let token = UserAccessToken {
            access_token,
            refresh_token,
            created_at: Utc::now(),
            expires_at: expires_in.map(|dt| Utc::now() + Duration::seconds(dt)),
        };

        term.write_line(&format!("- Writing access token to {token_path:?}"))
            .unwrap();

        std::fs::write(
            token_path,
            serde_json::to_string(&token).map_err(|e| LabeledError::new(e.to_string()))?,
        )
        .map_err(|e| LabeledError::new(e.to_string()))?;

        term.write_line(&format!("Done. Token expires in {expires_in:?} seconds"))
            .unwrap();

        Ok(PipelineData::Empty)
    }
}