Skip to content

Commit

Permalink
Move to GUI
Browse files Browse the repository at this point in the history
  • Loading branch information
jacksongoode committed Aug 25, 2024
1 parent cfd1f83 commit d49fc58
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 77 deletions.
163 changes: 109 additions & 54 deletions psst-core/src/oauth.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
use log::{debug, error, info, trace};
use oauth2::reqwest::http_client;
use log::{debug, error, trace};
use oauth2::{
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge,
RedirectUrl, Scope, TokenResponse, TokenUrl,
basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use std::io;
use std::{
io::{BufRead, BufReader, Write},
net::TcpStream,
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
process::exit,
sync::mpsc,
time::Duration,
};
use url::Url;

Expand All @@ -31,40 +32,70 @@ fn get_authcode_stdin() -> AuthorizationCode {
get_code(buffer.trim())
}

fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode {
let listener = TcpListener::bind(socket_address).unwrap();
pub fn get_authcode_listener(
socket_address: SocketAddr,
timeout: Duration,
) -> Result<AuthorizationCode, String> {
log::info!("Starting OAuth listener on {:?}", socket_address);
let listener = TcpListener::bind(socket_address)
.map_err(|e| format!("Failed to bind to address: {}", e))?;
log::info!("Listener bound successfully");

info!("OAuth server listening on {:?}", socket_address);
let (tx, rx) = mpsc::channel();

let Some(mut stream) = listener.incoming().flatten().next() else {
panic!("listener terminated without accepting a connection");
};
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
handle_connection(&mut stream, &tx);
}
});

let result = rx
.recv_timeout(timeout)
.map_err(|_| "Timed out waiting for authorization code".to_string())?;

let mut reader = BufReader::new(&stream);
handle
.join()
.map_err(|_| "Failed to join server thread".to_string())?;

result
}

fn handle_connection(stream: &mut TcpStream, tx: &mpsc::Sender<Result<AuthorizationCode, String>>) {
let mut reader = BufReader::new(&mut *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 code = get_code(&("http://localhost".to_string() + redirect_url));
if reader.read_line(&mut request_line).is_ok() {
if let Some(code) = extract_code_from_request(&request_line) {
send_success_response(stream);
let _ = tx.send(Ok(code));
} else {
let _ = tx.send(Err("Failed to extract code from request".to_string()));
}
}
}

let message = "Authenticated! You can return to Psst.";
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();
fn extract_code_from_request(request_line: &str) -> Option<AuthorizationCode> {
request_line.split_whitespace().nth(1).and_then(|path| {
Url::parse(&format!("http://localhost{}", path))
.ok()?
.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, code)| AuthorizationCode::new(code.into_owned()))
})
}

code
fn send_success_response(stream: &mut TcpStream) {
let response =
"HTTP/1.1 200 OK\r\n\r\n<html><body>You can close this window now.</body></html>";
let _ = stream.write_all(response.as_bytes());
}

pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {
pub fn get_access_token(redirect_port: u16) -> String {
let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port);
let redirect_uri = format!("http://{redirect_address}/login");

let client = BasicClient::new(
ClientId::new(client_id.to_string()),
ClientId::new(crate::session::access_token::CLIENT_ID.to_string()),
None,
AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(),
Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()),
Expand All @@ -73,35 +104,10 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {

let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

let scopes = vec![
"app-remote-control",
"playlist-modify",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read",
"playlist-read-collaborative",
"playlist-read-private",
"streaming",
"ugc-image-upload",
"user-follow-modify",
"user-follow-read",
"user-library-modify",
"user-library-read",
"user-modify",
"user-modify-playback-state",
"user-modify-private",
"user-personalized",
"user-read-birthdate",
"user-read-currently-playing",
"user-read-email",
"user-read-play-history",
"user-read-playback-position",
"user-read-playback-state",
"user-read-private",
"user-read-recently-played",
"user-top-read",
];
let scopes: Vec<oauth2::Scope> = scopes.iter().map(|&s| Scope::new(s.into())).collect();
let scopes: Vec<oauth2::Scope> = crate::session::access_token::ACCESS_SCOPES
.split(',')
.map(|s| Scope::new(s.trim().to_string()))
.collect();
let (auth_url, _) = client
.authorize_url(CsrfToken::new_random)
.add_scopes(scopes)
Expand All @@ -111,7 +117,7 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {
println!("Browse to: {}", auth_url);

let code = if redirect_port > 0 {
get_authcode_listener(redirect_address)
get_authcode_listener(redirect_address, Duration::from_secs(300)).unwrap()
} else {
get_authcode_stdin()
};
Expand Down Expand Up @@ -140,3 +146,52 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {

token.access_token().secret().to_string()
}

fn create_spotify_oauth_client(redirect_port: u16) -> BasicClient {
let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port);
let redirect_uri = format!("http://{redirect_address}/login");

BasicClient::new(
ClientId::new(crate::session::access_token::CLIENT_ID.to_string()),
None,
AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(),
Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()),
)
.set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL"))
}

pub fn generate_auth_url(redirect_port: u16) -> (String, PkceCodeVerifier) {
let client = create_spotify_oauth_client(redirect_port);
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

let (auth_url, _) = client
.authorize_url(CsrfToken::new_random)
.add_scopes(get_scopes())
.set_pkce_challenge(pkce_challenge)
.url();

(auth_url.to_string(), pkce_verifier)
}

pub fn exchange_code_for_token(
redirect_port: u16,
code: AuthorizationCode,
pkce_verifier: PkceCodeVerifier,
) -> String {
let client = create_spotify_oauth_client(redirect_port);

let token_response = client
.exchange_code(code)
.set_pkce_verifier(pkce_verifier)
.request(http_client)
.expect("Failed to exchange code for token");

token_response.access_token().secret().to_string()
}

fn get_scopes() -> Vec<Scope> {
crate::session::access_token::ACCESS_SCOPES
.split(',')
.map(|s| Scope::new(s.trim().to_string()))
.collect()
}
4 changes: 2 additions & 2 deletions psst-core/src/session/access_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ use crate::error::Error;
use super::SessionService;

// Client ID of the official Web Spotify front-end.
const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";

// All scopes we could possibly require.
const ACCESS_SCOPES: &str = "streaming,user-read-email,user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played";
pub const ACCESS_SCOPES: &str = "streaming,user-read-email,user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played";

// Consider token expired even before the official expiration time. Spotify
// seems to be reporting excessive token TTLs so let's cut it down by 30
Expand Down
1 change: 0 additions & 1 deletion psst-core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ use self::{
pub struct SessionConfig {
pub login_creds: Credentials,
pub proxy_url: Option<String>,
pub client_id: String,
}

/// Cheap to clone, shareable service handle that holds the active session
Expand Down
5 changes: 0 additions & 5 deletions psst-gui/src/data/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ impl Authentication {
)
},
proxy_url: Config::proxy(),
client_id: Config::default().client_id,
}
}

Expand Down Expand Up @@ -101,7 +100,6 @@ pub struct Config {
pub sort_criteria: SortCriteria,
pub paginated_limit: usize,
pub seek_duration: usize,
pub client_id: String,
}

impl Default for Config {
Expand All @@ -120,8 +118,6 @@ impl Default for Config {
sort_criteria: Default::default(),
paginated_limit: 500,
seek_duration: 10,
// Default Spotify Desktop client ID
client_id: "65b708073fc0480ea92a077233ca87bd".to_string(),
}
}
}
Expand Down Expand Up @@ -202,7 +198,6 @@ impl Config {
SessionConfig {
login_creds: self.credentials.clone().expect("Missing credentials"),
proxy_url: Config::proxy(),
client_id: self.client_id.clone(),
}
}

Expand Down
2 changes: 1 addition & 1 deletion psst-gui/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub fn preferences_window() -> WindowDesc<AppState> {

pub fn account_setup_window() -> WindowDesc<AppState> {
let win = WindowDesc::new(account_setup_widget())
.title("Log In")
.title("Login")
.window_size((theme::grid(50.0), theme::grid(45.0)))
.resizable(false)
.show_title(false)
Expand Down
46 changes: 32 additions & 14 deletions psst-gui/src/ui/preferences.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::thread::{self, JoinHandle};

use crate::{
Expand All @@ -19,8 +20,7 @@ use druid::{
Color, Data, Env, Event, EventCtx, Insets, LensExt, LifeCycle, LifeCycleCtx, Selector, Widget,
WidgetExt,
};
use psst_core::connection::Credentials;
use psst_core::oauth;
use psst_core::{connection::Credentials, oauth, session::SessionConfig};

use super::{icons::SvgIcon, theme};

Expand Down Expand Up @@ -270,17 +270,15 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget<AppState> {

col = col
.with_child(
Button::new("Log In with Spotify").on_click(|ctx, data: &mut AppState, _| {
let token = oauth::get_access_token(&data.config.client_id, 8888);
data.preferences.auth.access_token = token;
Button::new("Log in with Spotify").on_click(|ctx, _data: &mut AppState, _| {
ctx.submit_command(Authenticate::REQUEST);
}),
)
.with_spacer(theme::grid(1.0))
.with_child(
Async::new(
|| Label::new("Logging In...").with_text_size(theme::TEXT_SIZE_SMALL),
|| Label::new("Success.").with_text_size(theme::TEXT_SIZE_SMALL),
|| Label::new("Logging in...").with_text_size(theme::TEXT_SIZE_SMALL),
|| Label::new("").with_text_size(theme::TEXT_SIZE_SMALL),
|| {
Label::dynamic(|err: &String, _| err.to_owned())
.with_text_size(theme::TEXT_SIZE_SMALL)
Expand All @@ -294,8 +292,6 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget<AppState> {
),
);

col = col.with_spacer(theme::grid(3.0));

if matches!(tab, AccountTab::InPreferences) {
col = col.with_child(Button::new("Log Out").on_left_click(|ctx, _, _, _| {
ctx.submit_command(cmd::LOG_OUT);
Expand Down Expand Up @@ -335,17 +331,39 @@ impl<W: Widget<AppState>> Controller<AppState, W> for Authenticate {
Event::Command(cmd) if cmd.is(Self::REQUEST) => {
data.preferences.auth.result.defer_default();

let (auth_url, pkce_verifier) = oauth::generate_auth_url(8888);
if webbrowser::open(&auth_url).is_err() {
data.error_alert("Failed to open browser");
return;
}

let config = data.preferences.auth.session_config();
let widget_id = ctx.widget_id();
let event_sink = ctx.get_external_handle();
let thread = thread::spawn(move || {
let response = Authentication::authenticate_and_get_credentials(config);
event_sink
.submit_command(Self::RESPONSE, response, widget_id)
.unwrap();
match oauth::get_authcode_listener(
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8888),
std::time::Duration::from_secs(300),
) {
Ok(code) => {
let token = oauth::exchange_code_for_token(8888, code, pkce_verifier);
let response =
Authentication::authenticate_and_get_credentials(SessionConfig {
login_creds: Credentials::from_access_token(token),
..config
});
event_sink
.submit_command(Self::RESPONSE, response, widget_id)
.unwrap();
}
Err(e) => {
event_sink
.submit_command(Self::RESPONSE, Err(e), widget_id)
.unwrap();
}
}
});
self.thread.replace(thread);

ctx.set_handled();
}
Event::Command(cmd) if cmd.is(Self::RESPONSE) => {
Expand Down

0 comments on commit d49fc58

Please sign in to comment.