diff --git a/Cargo.lock b/Cargo.lock index 32fec987..a5c3da16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + [[package]] name = "adler" version = "1.0.2" @@ -224,7 +233,7 @@ dependencies = [ "polling 2.8.0", "rustix 0.37.27", "slab", - "socket2", + "socket2 0.4.10", "waker-fn", ] @@ -403,6 +412,33 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide 0.7.4", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -623,6 +659,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.6", ] @@ -1534,8 +1571,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1548,6 +1587,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + [[package]] name = "gio" version = "0.16.7" @@ -1943,6 +1988,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.4.1" @@ -2016,12 +2080,84 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.14.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.7", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls 0.21.12", + "tokio", + "tokio-rustls", +] + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -2195,6 +2331,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "is-docker" version = "0.2.0" @@ -2534,6 +2676,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2559,6 +2707,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "ndk" version = "0.8.0" @@ -2720,6 +2880,26 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -2763,6 +2943,15 @@ dependencies = [ "objc2", ] +[[package]] +name = "object" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +dependencies = [ + "memchr", +] + [[package]] name = "oboe" version = "0.6.1" @@ -3214,6 +3403,7 @@ dependencies = [ "log", "num-bigint", "num-traits", + "oauth2", "once_cell", "parking_lot", "psst-protocol", @@ -3482,6 +3672,47 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + [[package]] name = "rgb" version = "0.8.50" @@ -3506,6 +3737,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3548,6 +3785,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.12" @@ -3558,17 +3807,36 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.7", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.102.7" @@ -3601,6 +3869,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "self_cell" version = "0.10.3" @@ -3654,6 +3932,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -3674,6 +3962,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -3702,6 +4002,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shannon" version = "0.2.0" @@ -3785,6 +4096,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socks" version = "0.3.4" @@ -3941,6 +4262,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4084,6 +4432,44 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.5.7", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.11" @@ -4138,6 +4524,12 @@ dependencies = [ "winnow 0.6.18", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.40" @@ -4192,6 +4584,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "type-map" version = "0.5.0" @@ -4332,17 +4730,17 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "once_cell", - "rustls", + "rustls 0.23.12", "rustls-pki-types", "serde", "serde_json", "socks", "url", - "webpki-roots", + "webpki-roots 0.26.5", ] [[package]] @@ -4354,6 +4752,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -4407,6 +4806,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -4508,6 +4916,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.5" @@ -4897,6 +5311,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winres" version = "0.1.12" diff --git a/psst-core/Cargo.toml b/psst-core/Cargo.toml index dab84a48..933c82b8 100644 --- a/psst-core/Cargo.toml +++ b/psst-core/Cargo.toml @@ -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" } diff --git a/psst-core/src/connection/mod.rs b/psst-core/src/connection/mod.rs index 10eb6d99..ef1b3fbb 100644 --- a/psst-core/src/connection/mod.rs +++ b/psst-core/src/connection/mod.rs @@ -41,7 +41,7 @@ const AP_FALLBACK: &str = "ap.spotify.com:443"; #[serde(from = "SerializedCredentials")] #[serde(into = "SerializedCredentials")] pub struct Credentials { - pub username: String, + pub username: Option, pub auth_data: Vec, pub auth_type: AuthenticationType, } @@ -49,11 +49,19 @@ pub struct Credentials { 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)] @@ -66,7 +74,7 @@ struct SerializedCredentials { impl From 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(), } @@ -76,7 +84,7 @@ impl From for Credentials { impl From 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 _, @@ -231,14 +239,6 @@ impl Transport { pub fn authenticate(&mut self, credentials: Credentials) -> Result { 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)?; @@ -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, }) @@ -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, }, diff --git a/psst-core/src/lib.rs b/psst-core/src/lib.rs index fcbd491a..2faa3174 100644 --- a/psst-core/src/lib.rs +++ b/psst-core/src/lib.rs @@ -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; diff --git a/psst-core/src/oauth.rs b/psst-core/src/oauth.rs new file mode 100644 index 00000000..69ab3ec8 --- /dev/null +++ b/psst-core/src/oauth.rs @@ -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 { + 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>) { + 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 { + 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\nYou can close this window now."; + 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 { + crate::session::access_token::ACCESS_SCOPES + .split(',') + .map(|s| Scope::new(s.trim().to_string())) + .collect() +} diff --git a/psst-core/src/session/access_token.rs b/psst-core/src/session/access_token.rs index c962fa4d..bd4dee61 100644 --- a/psst-core/src/session/access_token.rs +++ b/psst-core/src/session/access_token.rs @@ -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 diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index 62fcb526..cbb897f4 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -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(), } } @@ -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 { diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index 2c1c33fe..a33d0f27 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -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, diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index f51189dc..0f7b9b6b 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -81,7 +81,7 @@ pub fn preferences_window() -> WindowDesc { pub fn account_setup_window() -> WindowDesc { 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) diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index fd826f49..d9c5d403 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -1,5 +1,15 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::thread::{self, JoinHandle}; +use crate::{ + cmd, + data::{ + AppState, AudioQuality, Authentication, Config, Preferences, PreferencesTab, Promise, + SliderScrollScale, Theme, + }, + webapi::WebApi, + widget::{icons, Async, Border, Checkbox, MyWidgetExt}, +}; use druid::{ commands, text::ParseFormatter, @@ -10,18 +20,7 @@ use druid::{ Color, Data, Env, Event, EventCtx, Insets, LensExt, LifeCycle, LifeCycleCtx, Selector, Widget, WidgetExt, }; -use psst_core::connection::Credentials; - -use crate::{ - cmd, - controller::InputController, - data::{ - AppState, AudioQuality, Authentication, Config, Preferences, PreferencesTab, Promise, - SliderScrollScale, Theme, - }, - webapi::WebApi, - widget::{icons, Async, Border, Checkbox, MyWidgetExt}, -}; +use psst_core::{connection::Credentials, oauth, session::SessionConfig}; use super::{icons::SvgIcon, theme}; @@ -271,47 +270,15 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget { col = col .with_child( - TextBox::new() - .with_placeholder("Username") - .controller(InputController::new()) - .env_scope(|env, _| env.set(theme::WIDE_WIDGET_WIDTH, theme::grid(16.0))) - .lens( - AppState::preferences - .then(Preferences::auth) - .then(Authentication::username), - ), - ) - .with_spacer(theme::grid(1.0)); - - col = col - .with_child( - TextBox::new() - .with_placeholder("Password") - .controller(InputController::new()) - .env_scope(|env, _| env.set(theme::WIDE_WIDGET_WIDTH, theme::grid(16.0))) - .lens( - AppState::preferences - .then(Preferences::auth) - .then(Authentication::password), - ), - ) - .with_spacer(theme::grid(1.0)); - - col = col - .with_child( - Button::new(match &tab { - AccountTab::FirstSetup => "Log In & Continue", - AccountTab::InPreferences => "Change Account", - }) - .on_left_click(|ctx, _, _, _| { + 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) @@ -325,8 +292,6 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget { ), ); - 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); @@ -364,59 +329,71 @@ impl> Controller for Authenticate { ) { match event { Event::Command(cmd) if cmd.is(Self::REQUEST) => { - // Signal that we're authenticating. data.preferences.auth.result.defer_default(); - // Authenticate in another thread. + 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) => { self.thread.take(); - // Store the retrieved credentials into the config. - let result = cmd.get_unchecked(Self::RESPONSE); - let result = result.to_owned().map(|credentials| { - // Load user's local tracks for the WebApi. - WebApi::global().load_local_tracks(&credentials.username); - // Save the credentials into config. - data.config.store_credentials(credentials); - data.config.save(); - }); + let result = cmd + .get_unchecked(Self::RESPONSE) + .to_owned() + .map(|credentials| { + let username = credentials.username.clone().unwrap_or_default(); + WebApi::global().load_local_tracks(&username); + data.config.store_credentials(credentials); + data.config.save(); + }); let is_ok = result.is_ok(); - // Signal the auth result to the preferences UI. data.preferences.auth.result.resolve_or_reject((), result); if is_ok { match &self.tab { AccountTab::FirstSetup => { - // We let the `SessionController` pick up the credentials when the main - // window gets created. Close the account setup window and open the main - // one. ctx.submit_command(cmd::SHOW_MAIN); ctx.submit_command(commands::CLOSE_WINDOW); } AccountTab::InPreferences => { - // Drop the old connection and connect again with the new credentials. ctx.submit_command(cmd::SESSION_CONNECT); } } - // Only clear username if login is successful. - data.preferences.auth.username.clear(); } - // Always clear password after login attempt. - data.preferences.auth.password.clear(); + data.preferences.auth.access_token.clear(); ctx.set_handled(); } diff --git a/psst-gui/src/ui/track.rs b/psst-gui/src/ui/track.rs index 721418e1..cd56ee7f 100644 --- a/psst-gui/src/ui/track.rs +++ b/psst-gui/src/ui/track.rs @@ -319,7 +319,7 @@ pub fn track_menu( menu = menu.entry( MenuItem::new( LocalizedString::new("menu-item-remove-from-playlist") - .with_placeholder("Remove from current Playlist"), + .with_placeholder("Remove from Current Playlist"), ) .command(playlist::REMOVE_TRACK.with(PlaylistRemoveTrack { link: playlist.to_owned(),