Skip to content

Commit

Permalink
New authentication flow to resolve #515 (#517)
Browse files Browse the repository at this point in the history
  • Loading branch information
jacksongoode committed Sep 3, 2024
1 parent 3095c33 commit 0cd9904
Show file tree
Hide file tree
Showing 11 changed files with 632 additions and 102 deletions.
434 changes: 429 additions & 5 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions psst-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ socks = { version = "0.3.4" }
tempfile = { version = "3.12.0" }
ureq = { version = "2.10.1", features = ["json"] }
url = { version = "2.5.2" }
oauth2 = { version = "4.4.2" }

# Cryptography
aes = { version = "0.8.4" }
Expand Down
28 changes: 14 additions & 14 deletions psst-core/src/connection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,27 @@ const AP_FALLBACK: &str = "ap.spotify.com:443";
#[serde(from = "SerializedCredentials")]
#[serde(into = "SerializedCredentials")]
pub struct Credentials {
pub username: String,
pub username: Option<String>,
pub auth_data: Vec<u8>,
pub auth_type: AuthenticationType,
}

impl Credentials {
pub fn from_username_and_password(username: String, password: String) -> Self {
Self {
username,
username: Some(username),
auth_type: AuthenticationType::AUTHENTICATION_USER_PASS,
auth_data: password.into_bytes(),
}
}

pub fn from_access_token(token: String) -> Self {
Self {
username: None,
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
auth_data: token.into_bytes(),
}
}
}

#[derive(Serialize, Deserialize)]
Expand All @@ -66,7 +74,7 @@ struct SerializedCredentials {
impl From<SerializedCredentials> for Credentials {
fn from(value: SerializedCredentials) -> Self {
Self {
username: value.username,
username: Some(value.username),
auth_data: value.auth_data.into_bytes(),
auth_type: value.auth_type.into(),
}
Expand All @@ -76,7 +84,7 @@ impl From<SerializedCredentials> for Credentials {
impl From<Credentials> for SerializedCredentials {
fn from(value: Credentials) -> Self {
Self {
username: value.username,
username: value.username.unwrap_or_default(),
auth_data: String::from_utf8(value.auth_data)
.expect("Invalid UTF-8 in serialized credentials"),
auth_type: value.auth_type as _,
Expand Down Expand Up @@ -231,14 +239,6 @@ impl Transport {
pub fn authenticate(&mut self, credentials: Credentials) -> Result<Credentials, Error> {
use crate::protocol::{authentication::APWelcome, keyexchange::APLoginFailed};

// Having an empty username or auth_data causes an unclear error message, so replace it with invalid credentials.
if credentials.username.is_empty() || credentials.auth_data.is_empty() {
return Err(Error::AuthFailed {
// code 12 = bad credentials
code: 12,
});
}

// Send a login request with the client credentials.
let request = client_response_encrypted(credentials);
self.encoder.encode(request)?;
Expand All @@ -251,7 +251,7 @@ impl Transport {
let welcome_data: APWelcome =
deserialize_protobuf(&response.payload).expect("Missing data");
Ok(Credentials {
username: welcome_data.canonical_username,
username: Some(welcome_data.canonical_username),
auth_data: welcome_data.reusable_auth_credentials,
auth_type: welcome_data.reusable_auth_credentials_type,
})
Expand Down Expand Up @@ -362,7 +362,7 @@ fn client_response_encrypted(credentials: Credentials) -> ShannonMsg {

let response = ClientResponseEncrypted {
login_credentials: LoginCredentials {
username: Some(credentials.username),
username: credentials.username,
auth_data: Some(credentials.auth_data),
typ: credentials.auth_type,
},
Expand Down
1 change: 1 addition & 0 deletions psst-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ pub mod metadata;
pub mod player;
pub mod session;
pub mod util;
pub mod oauth;

pub use psst_protocol as protocol;
119 changes: 119 additions & 0 deletions psst-core/src/oauth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use oauth2::{
basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use std::{
io::{BufRead, BufReader, Write},
net::TcpStream,
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
sync::mpsc,
time::Duration,
};
use url::Url;

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");

let (tx, rx) = mpsc::channel();

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())?;

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();

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()));
}
}
}

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()))
})
}

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());
}

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
17 changes: 12 additions & 5 deletions psst-gui/src/data/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,21 @@ pub enum PreferencesTab {
pub struct Authentication {
pub username: String,
pub password: String,
pub access_token: String,
pub result: Promise<(), (), String>,
}

impl Authentication {
pub fn session_config(&self) -> SessionConfig {
SessionConfig {
login_creds: Credentials::from_username_and_password(
self.username.to_owned(),
self.password.to_owned(),
),
login_creds: if !self.access_token.is_empty() {
Credentials::from_access_token(self.access_token.clone())
} else {
Credentials::from_username_and_password(
self.username.clone(),
self.password.clone(),
)
},
proxy_url: Config::proxy(),
}
}
Expand Down Expand Up @@ -184,7 +189,9 @@ impl Config {
}

pub fn username(&self) -> Option<&str> {
self.credentials.as_ref().map(|c| c.username.as_str())
self.credentials
.as_ref()
.and_then(|c| c.username.as_deref())
}

pub fn session(&self) -> SessionConfig {
Expand Down
1 change: 1 addition & 0 deletions psst-gui/src/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ impl AppState {
auth: Authentication {
username: String::new(),
password: String::new(),
access_token: String::new(),
result: Promise::Empty,
},
cache_size: Promise::Empty,
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
Loading

0 comments on commit 0cd9904

Please sign in to comment.