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();
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()))
})?;
}
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_redirect_uri(
RedirectUrl::new("http://localhost:8080/redirect".to_string()).unwrap(),
);
let (pkce_challenge, _pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("chat:read".to_string()))
.add_scope(Scope::new("chat:edit".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
term.write_line(&format!(
"Opening URL in your browser of choise ({auth_url})."
))
.unwrap();
open::that(auth_url.to_string()).unwrap();
let (code, state) = {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
term.write_line("- Listening for redirect at http://localhost:8080/redirect")
.unwrap();
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)
};
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()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Client should build");
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)
}
}