Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,071 changes: 591 additions & 480 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions psst-core/src/cdn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,14 @@ use std::{
use serde::Deserialize;

use crate::{
error::Error,
item_id::FileId,
session::{access_token::TokenProvider, SessionService},
util::default_ureq_agent_builder,
error::Error, item_id::FileId, session::SessionService, util::default_ureq_agent_builder,
};

pub type CdnHandle = Arc<Cdn>;

pub struct Cdn {
session: SessionService,
agent: ureq::Agent,
token_provider: TokenProvider,
}

impl Cdn {
Expand All @@ -27,7 +23,6 @@ impl Cdn {
Ok(Arc::new(Self {
session,
agent: agent.into(),
token_provider: TokenProvider::new(),
}))
}

Expand All @@ -36,15 +31,20 @@ impl Cdn {
"https://api.spotify.com/v1/storage-resolve/files/audio/interactive/{}",
id.to_base16()
);
let access_token = self.token_provider.get(&self.session)?;
// OAuth-only: requires a browser OAuth bearer; no Keymaster fallback for CDN.
let bearer = self
.session
.oauth_bearer()
.ok_or_else(|| Error::OAuthError("OAuth access token required".to_string()))?;

let response = self
.agent
.get(&locations_uri)
.query("version", "10000000")
.query("product", "9")
.query("platform", "39")
.query("alt", "json")
.header("Authorization", &format!("Bearer {}", access_token.token))
.header("Authorization", &format!("Bearer {}", bearer))
.call()?;

#[derive(Deserialize)]
Expand Down
53 changes: 48 additions & 5 deletions psst-core/src/oauth.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::error::Error;
use oauth2::{
basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
};
use std::{
io::{BufRead, BufReader, Write},
Expand All @@ -17,9 +17,7 @@ pub fn listen_for_callback_parameter(
timeout: Duration,
parameter_name: &'static str,
) -> Result<String, Error> {
log::info!(
"starting callback listener for '{parameter_name}' on {socket_address:?}",
);
log::info!("starting callback listener for '{parameter_name}' on {socket_address:?}",);

// Create a simpler, linear flow
// 1. Bind the listener
Expand Down Expand Up @@ -191,8 +189,53 @@ pub fn exchange_code_for_token(
}

fn get_scopes() -> Vec<Scope> {
crate::session::access_token::ACCESS_SCOPES
// Use a broader OAuth scope set for initial AP login (includes streaming).
const OAUTH_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,app-remote-control";
OAUTH_SCOPES
.split(',')
.map(|s| Scope::new(s.trim().to_string()))
.collect()
}

/// Variant of `exchange_code_for_token` that also returns a refresh token if Spotify provides one.
pub fn exchange_code_for_token_with_refresh(
redirect_port: u16,
code: AuthorizationCode,
pkce_verifier: PkceCodeVerifier,
) -> (String, Option<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");

let access = token_response.access_token().secret().to_string();
let refresh = token_response
.refresh_token()
.map(|t| t.secret().to_string());
(access, refresh)
}

/// Refresh an access token using a stored refresh token. Returns the new access token and
/// an optional new refresh token if Spotify rotates it.
pub fn refresh_access_token(refresh_token: &str) -> Result<(String, Option<String>), Error> {
let client = 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()),
);

let token_response = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request(http_client)
.map_err(|e| Error::OAuthError(format!("Failed to refresh token: {e}")))?;

let access = token_response.access_token().secret().to_string();
let refresh = token_response
.refresh_token()
.map(|t| t.secret().to_string());
Ok((access, refresh))
}
29 changes: 24 additions & 5 deletions psst-core/src/session/access_token.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Keymaster token acquisition is deprecated in Psst.
// OAuth/PKCE is the primary auth path; Keymaster remains only for legacy compatibility.
// See librespot discussion for context (403/code=4, scope restrictions):
// https://github.com/librespot-org/librespot/issues/1532#issuecomment-3188123661
use std::time::{Duration, Instant};

use parking_lot::Mutex;
Expand All @@ -11,12 +15,13 @@ use super::SessionService;
pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";

// All scopes we could possibly require.
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";
pub const ACCESS_SCOPES: &str = "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
// minutes.
const EXPIRATION_TIME_THRESHOLD: Duration = Duration::from_secs(60 * 30);
// Avoid repeatedly hammering keymaster when errors occur.

#[derive(Clone)]
pub struct AccessToken {
Expand All @@ -35,10 +40,10 @@ impl AccessToken {
pub fn request(session: &SessionService) -> Result<Self, Error> {
#[derive(Deserialize)]
struct MercuryAccessToken {
#[serde(rename = "expiresIn")]
expires_in: u64,
#[serde(rename = "accessToken")]
#[serde(alias = "accessToken", alias = "access_token")]
access_token: String,
#[serde(alias = "expiresIn", alias = "expires_in")]
expires_in: u64,
}

let token: MercuryAccessToken = session.connected()?.get_mercury_json(format!(
Expand Down Expand Up @@ -67,10 +72,24 @@ impl TokenProvider {
}
}

pub fn invalidate(&self) {
let mut token = self.token.lock();
*token = AccessToken::expired();
}

pub fn get(&self, session: &SessionService) -> Result<AccessToken, Error> {
// Prefer an OAuth bearer if the session provides one.
if let Some(tok) = session.oauth_bearer() {
return Ok(AccessToken {
token: tok,
// Give the bearer a reasonable lifetime; it will be replaced when refreshed.
expires: Instant::now() + Duration::from_secs(3600),
});
}

let mut token = self.token.lock();
if token.is_expired() {
log::info!("access token expired, requesting");
log::debug!("access token expired, requesting");
*token = AccessToken::request(session)?;
}
Ok(token.clone())
Expand Down
35 changes: 34 additions & 1 deletion psst-core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub struct SessionConfig {
pub struct SessionService {
connected: Arc<Mutex<Option<SessionWorker>>>,
config: Arc<Mutex<Option<SessionConfig>>>,
oauth_bearer: Arc<Mutex<Option<String>>>,
oauth_refresh_token: Arc<Mutex<Option<String>>>,
}

impl SessionService {
Expand All @@ -58,6 +60,8 @@ impl SessionService {
Self {
connected: Arc::default(),
config: Arc::default(),
oauth_bearer: Arc::default(),
oauth_refresh_token: Arc::default(),
}
}

Expand All @@ -66,6 +70,8 @@ impl SessionService {
Self {
connected: Arc::default(),
config: Arc::new(Mutex::new(Some(config))),
oauth_bearer: Arc::default(),
oauth_refresh_token: Arc::default(),
}
}

Expand Down Expand Up @@ -116,6 +122,26 @@ impl SessionService {
worker.join();
}
}

/// Set or clear OAuth bearer used by dependent services.
pub fn set_oauth_bearer(&self, token: Option<String>) {
*self.oauth_bearer.lock() = token;
}

/// Get the currently configured OAuth bearer, if any.
pub fn oauth_bearer(&self) -> Option<String> {
self.oauth_bearer.lock().clone()
}

/// Set or clear the OAuth refresh token.
pub fn set_oauth_refresh_token(&self, token: Option<String>) {
*self.oauth_refresh_token.lock() = token;
}

/// Get the currently configured OAuth refresh token, if any.
pub fn oauth_refresh_token(&self) -> Option<String> {
self.oauth_refresh_token.lock().clone()
}
}

/// Successful connection through the Spotify Shannon-encrypted TCP channel.
Expand All @@ -135,7 +161,14 @@ impl SessionConnection {
let proxy_url = config.proxy_url.as_deref();
let ap_list = Transport::resolve_ap_with_fallback(proxy_url);
let mut transport = Transport::connect(&ap_list, proxy_url)?;
let credentials = transport.authenticate(config.login_creds)?;
let is_token_login = config.login_creds.auth_type
== crate::protocol::authentication::AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN;
let mut credentials = transport.authenticate(config.login_creds)?;
if is_token_login {
// Reconnect using reusable credentials so that keymaster requests succeed.
transport = Transport::connect(&ap_list, proxy_url)?;
credentials = transport.authenticate(credentials)?;
}
Ok(Self {
credentials,
transport,
Expand Down
6 changes: 6 additions & 0 deletions psst-gui/src/controller/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ impl SessionController {
// Update the session configuration, any active session will get shut down.
data.session.update_config(data.config.session());

// Re-apply persisted OAuth bearer to both core session and Web API, if present.
if let Some(tok) = data.config.oauth_bearer.clone() {
data.session.set_oauth_bearer(Some(tok.clone()));
crate::webapi::WebApi::global().set_oauth_bearer(Some(tok));
}

// Reload the global, usually visible data.
ctx.submit_command(playlist::LOAD_LIST);
ctx.submit_command(home::LOAD_MADE_FOR_YOU);
Expand Down
10 changes: 10 additions & 0 deletions psst-gui/src/data/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ const PROXY_ENV_VAR: &str = "SOCKS_PROXY";
pub struct Config {
#[data(ignore)]
credentials: Option<Credentials>,
#[serde(alias = "oauth_token_override")]
pub oauth_bearer: Option<String>,
pub oauth_refresh_token: Option<String>,
pub audio_quality: AudioQuality,
pub theme: Theme,
pub volume: f64,
Expand All @@ -132,6 +135,8 @@ impl Default for Config {
fn default() -> Self {
Self {
credentials: Default::default(),
oauth_bearer: None,
oauth_refresh_token: None,
audio_quality: Default::default(),
theme: Default::default(),
volume: 1.0,
Expand Down Expand Up @@ -218,6 +223,11 @@ impl Config {
self.credentials = Default::default();
}

pub fn clear_oauth_tokens(&mut self) {
self.oauth_bearer = None;
self.oauth_refresh_token = None;
}

pub fn username(&self) -> Option<&str> {
self.credentials
.as_ref()
Expand Down
2 changes: 1 addition & 1 deletion psst-gui/src/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ impl AppState {
.find(|queued| queued.item.id() == item_id)
.cloned()
{
return Some(queued);
Some(queued)
} else {
None
}
Expand Down
27 changes: 26 additions & 1 deletion psst-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use env_logger::{Builder, Env};
use webapi::WebApi;

use psst_core::cache::Cache;
use psst_core::oauth::refresh_access_token;

use crate::{
data::{AppState, Config},
Expand Down Expand Up @@ -51,13 +52,37 @@ fn main() {
}

WebApi::new(
state.session.clone(),
Config::proxy().as_deref(),
Config::cache_dir(),
paginated_limit,
)
.install_as_global();

// Apply persisted OAuth bearer if present; otherwise try refresh once if a refresh token exists.
if let Some(tok) = state.config.oauth_bearer.clone() {
state.session.set_oauth_bearer(Some(tok.clone()));
WebApi::global().set_oauth_bearer(Some(tok));
if let Some(rtok) = state.config.oauth_refresh_token.clone() {
state.session.set_oauth_refresh_token(Some(rtok.clone()));
WebApi::global().set_oauth_refresh_token(Some(rtok));
}
} else if let Some(rtok) = state.config.oauth_refresh_token.clone() {
match refresh_access_token(&rtok) {
Ok((new_access, new_refresh)) => {
state.session.set_oauth_bearer(Some(new_access.clone()));
WebApi::global().set_oauth_bearer(Some(new_access.clone()));
state.config.oauth_bearer = Some(new_access);
if let Some(r) = new_refresh {
state.config.oauth_refresh_token = Some(r);
}
state.config.save();
}
Err(e) => {
log::warn!("Failed to refresh OAuth token: {e}");
}
}
}

let delegate;
let launcher;
if state.config.has_credentials() {
Expand Down
Loading
Loading