Skip to content

Commit 4f9151c

Browse files
authored
Credentials with access token (oauth) (#1309)
* core: Create credentials from access token via OAuth2 * core: Credentials.username is optional: not required for token auth. * core: store auth data within session. We might need this later if need to re-auth and original creds are no longer valid/available. * bin: New --token arg for using Spotify access token. Specify 0 to manually enter the auth code (headless). * bin: Added --enable-oauth / -j option. Using --password / -p option will error and exit. * core: reconnect session if using token authentication Token authenticated sessions cannot use keymaster. So reconnect using the reusable credentials we just obtained. Can perhaps remove this workaround once keymaster is replaced with login5. * examples: replace password login with token login
1 parent f647331 commit 4f9151c

17 files changed

Lines changed: 629 additions & 88 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ https://github.com/librespot-org/librespot
5555
- [core] Cache resolved access points during runtime (breaking)
5656
- [core] `FileId` is moved out of `SpotifyId`. For now it will be re-exported.
5757
- [core] Report actual platform data on login
58+
- [core] Support `Session` authentication with a Spotify access token
59+
- [core] `Credentials.username` is now an `Option` (breaking)
5860
- [main] `autoplay {on|off}` now acts as an override. If unspecified, `librespot`
5961
now follows the setting in the Connect client that controls it. (breaking)
6062
- [metadata] Most metadata is now retrieved with the `spclient` (breaking)
@@ -95,6 +97,7 @@ https://github.com/librespot-org/librespot
9597
- [main] Add an event worker thread that runs async to the main thread(s) but
9698
sync to itself to prevent potential data races for event consumers
9799
- [metadata] All metadata fields in the protobufs are now exposed (breaking)
100+
- [oauth] Standalone module to obtain Spotify access token using OAuth authorization code flow.
98101
- [playback] Explicit tracks are skipped if the controlling Connect client has
99102
disabled such content. Applications that use librespot as a library without
100103
Connect should use the 'filter-explicit-content' user attribute in the session.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ version = "0.5.0-dev"
4949
path = "protocol"
5050
version = "0.5.0-dev"
5151

52+
[dependencies.librespot-oauth]
53+
path = "oauth"
54+
version = "0.5.0-dev"
55+
5256
[dependencies]
5357
data-encoding = "2.5"
5458
env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] }

core/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ license = "MIT"
99
repository = "https://github.com/librespot-org/librespot"
1010
edition = "2021"
1111

12+
[dependencies.librespot-oauth]
13+
path = "../oauth"
14+
version = "0.5.0-dev"
15+
1216
[dependencies.librespot-protocol]
1317
path = "../protocol"
1418
version = "0.5.0-dev"

core/src/authentication.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ impl From<AuthenticationError> for Error {
2929
/// The credentials are used to log into the Spotify API.
3030
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
3131
pub struct Credentials {
32-
pub username: String,
32+
pub username: Option<String>,
3333

3434
#[serde(serialize_with = "serialize_protobuf_enum")]
3535
#[serde(deserialize_with = "deserialize_protobuf_enum")]
@@ -50,19 +50,27 @@ impl Credentials {
5050
///
5151
/// let creds = Credentials::with_password("my account", "my password");
5252
/// ```
53-
pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> Credentials {
54-
Credentials {
55-
username: username.into(),
53+
pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> Self {
54+
Self {
55+
username: Some(username.into()),
5656
auth_type: AuthenticationType::AUTHENTICATION_USER_PASS,
5757
auth_data: password.into().into_bytes(),
5858
}
5959
}
6060

61+
pub fn with_access_token(token: impl Into<String>) -> Self {
62+
Self {
63+
username: None,
64+
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
65+
auth_data: token.into().into_bytes(),
66+
}
67+
}
68+
6169
pub fn with_blob(
6270
username: impl Into<String>,
6371
encrypted_blob: impl AsRef<[u8]>,
6472
device_id: impl AsRef<[u8]>,
65-
) -> Result<Credentials, Error> {
73+
) -> Result<Self, Error> {
6674
fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> {
6775
let mut data = [0u8];
6876
stream.read_exact(&mut data)?;
@@ -136,8 +144,8 @@ impl Credentials {
136144
read_u8(&mut cursor)?;
137145
let auth_data = read_bytes(&mut cursor)?;
138146

139-
Ok(Credentials {
140-
username,
147+
Ok(Self {
148+
username: Some(username),
141149
auth_type,
142150
auth_data,
143151
})

core/src/connection/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,12 @@ pub async fn authenticate(
9999
};
100100

101101
let mut packet = ClientResponseEncrypted::new();
102-
packet
103-
.login_credentials
104-
.mut_or_insert_default()
105-
.set_username(credentials.username);
102+
if let Some(username) = credentials.username {
103+
packet
104+
.login_credentials
105+
.mut_or_insert_default()
106+
.set_username(username);
107+
}
106108
packet
107109
.login_credentials
108110
.mut_or_insert_default()
@@ -133,6 +135,7 @@ pub async fn authenticate(
133135
let cmd = PacketType::Login;
134136
let data = packet.write_to_bytes()?;
135137

138+
debug!("Authenticating with AP using {:?}", credentials.auth_type);
136139
transport.send((cmd as u8, data)).await?;
137140
let (cmd, data) = transport
138141
.next()
@@ -144,7 +147,7 @@ pub async fn authenticate(
144147
let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?;
145148

146149
let reusable_credentials = Credentials {
147-
username: welcome_data.canonical_username().to_owned(),
150+
username: Some(welcome_data.canonical_username().to_owned()),
148151
auth_type: welcome_data.reusable_auth_credentials_type(),
149152
auth_data: welcome_data.reusable_auth_credentials().to_owned(),
150153
};

core/src/error.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use tokio::sync::{
1919
};
2020
use url::ParseError;
2121

22+
use librespot_oauth::OAuthError;
23+
2224
#[cfg(feature = "with-dns-sd")]
2325
use dns_sd::DNSError;
2426

@@ -287,6 +289,25 @@ impl fmt::Display for Error {
287289
}
288290
}
289291

292+
impl From<OAuthError> for Error {
293+
fn from(err: OAuthError) -> Self {
294+
use OAuthError::*;
295+
match err {
296+
AuthCodeBadUri { .. }
297+
| AuthCodeNotFound { .. }
298+
| AuthCodeListenerRead
299+
| AuthCodeListenerParse => Error::unavailable(err),
300+
AuthCodeStdinRead
301+
| AuthCodeListenerBind { .. }
302+
| AuthCodeListenerTerminated
303+
| AuthCodeListenerWrite
304+
| Recv
305+
| ExchangeCode { .. } => Error::internal(err),
306+
_ => Error::failed_precondition(err),
307+
}
308+
}
309+
}
310+
290311
impl From<DecodeError> for Error {
291312
fn from(err: DecodeError) -> Self {
292313
Self::new(ErrorKind::FailedPrecondition, err)

core/src/session.rs

100755100644
Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use byteorder::{BigEndian, ByteOrder};
1313
use bytes::Bytes;
1414
use futures_core::TryStream;
1515
use futures_util::{future, ready, StreamExt, TryStreamExt};
16+
use librespot_protocol::authentication::AuthenticationType;
1617
use num_traits::FromPrimitive;
1718
use once_cell::sync::OnceCell;
1819
use parking_lot::RwLock;
@@ -22,13 +23,13 @@ use tokio::{sync::mpsc, time::Instant};
2223
use tokio_stream::wrappers::UnboundedReceiverStream;
2324

2425
use crate::{
25-
apresolve::ApResolver,
26+
apresolve::{ApResolver, SocketAddress},
2627
audio_key::AudioKeyManager,
2728
authentication::Credentials,
2829
cache::Cache,
2930
channel::ChannelManager,
3031
config::SessionConfig,
31-
connection::{self, AuthenticationError},
32+
connection::{self, AuthenticationError, Transport},
3233
http_client::HttpClient,
3334
mercury::MercuryManager,
3435
packet::PacketType,
@@ -77,6 +78,7 @@ struct SessionData {
7778
client_brand_name: String,
7879
client_model_name: String,
7980
connection_id: String,
81+
auth_data: Vec<u8>,
8082
time_delta: i64,
8183
invalid: bool,
8284
user_data: UserData,
@@ -140,6 +142,46 @@ impl Session {
140142
}))
141143
}
142144

145+
async fn connect_inner(
146+
&self,
147+
access_point: SocketAddress,
148+
credentials: Credentials,
149+
) -> Result<(Credentials, Transport), Error> {
150+
let mut transport = connection::connect(
151+
&access_point.0,
152+
access_point.1,
153+
self.config().proxy.as_ref(),
154+
)
155+
.await?;
156+
let mut reusable_credentials = connection::authenticate(
157+
&mut transport,
158+
credentials.clone(),
159+
&self.config().device_id,
160+
)
161+
.await?;
162+
163+
// Might be able to remove this once keymaster is replaced with login5.
164+
if credentials.auth_type == AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN {
165+
trace!(
166+
"Reconnect using stored credentials as token authed sessions cannot use keymaster."
167+
);
168+
transport = connection::connect(
169+
&access_point.0,
170+
access_point.1,
171+
self.config().proxy.as_ref(),
172+
)
173+
.await?;
174+
reusable_credentials = connection::authenticate(
175+
&mut transport,
176+
reusable_credentials.clone(),
177+
&self.config().device_id,
178+
)
179+
.await?;
180+
}
181+
182+
Ok((reusable_credentials, transport))
183+
}
184+
143185
pub async fn connect(
144186
&self,
145187
credentials: Credentials,
@@ -148,17 +190,8 @@ impl Session {
148190
let (reusable_credentials, transport) = loop {
149191
let ap = self.apresolver().resolve("accesspoint").await?;
150192
info!("Connecting to AP \"{}:{}\"", ap.0, ap.1);
151-
let mut transport =
152-
connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?;
153-
154-
match connection::authenticate(
155-
&mut transport,
156-
credentials.clone(),
157-
&self.config().device_id,
158-
)
159-
.await
160-
{
161-
Ok(creds) => break (creds, transport),
193+
match self.connect_inner(ap, credentials.clone()).await {
194+
Ok(ct) => break ct,
162195
Err(e) => {
163196
if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) =
164197
e.error.downcast_ref::<AuthenticationError>()
@@ -172,8 +205,13 @@ impl Session {
172205
}
173206
};
174207

175-
info!("Authenticated as \"{}\" !", reusable_credentials.username);
176-
self.set_username(&reusable_credentials.username);
208+
let username = reusable_credentials
209+
.username
210+
.as_ref()
211+
.map_or("UNKNOWN", |s| s.as_str());
212+
info!("Authenticated as '{username}' !");
213+
self.set_username(username);
214+
self.set_auth_data(&reusable_credentials.auth_data);
177215
if let Some(cache) = self.cache() {
178216
if store_credentials {
179217
let cred_changed = cache
@@ -471,6 +509,14 @@ impl Session {
471509
username.clone_into(&mut self.0.data.write().user_data.canonical_username);
472510
}
473511

512+
pub fn auth_data(&self) -> Vec<u8> {
513+
self.0.data.read().auth_data.clone()
514+
}
515+
516+
pub fn set_auth_data(&self, auth_data: &[u8]) {
517+
self.0.data.write().auth_data = auth_data.to_owned();
518+
}
519+
474520
pub fn country(&self) -> String {
475521
self.0.data.read().user_data.country.clone()
476522
}

examples/get_token.rs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,34 @@ const SCOPES: &str =
77

88
#[tokio::main]
99
async fn main() {
10-
let session_config = SessionConfig::default();
10+
let mut builder = env_logger::Builder::new();
11+
builder.parse_filters("librespot=trace");
12+
builder.init();
13+
14+
let mut session_config = SessionConfig::default();
1115

1216
let args: Vec<_> = env::args().collect();
13-
if args.len() != 3 {
14-
eprintln!("Usage: {} USERNAME PASSWORD", args[0]);
17+
if args.len() == 3 {
18+
// Only special client IDs have sufficient privileges e.g. Spotify's.
19+
session_config.client_id = args[2].clone()
20+
} else if args.len() != 2 {
21+
eprintln!("Usage: {} ACCESS_TOKEN [CLIENT_ID]", args[0]);
1522
return;
1623
}
24+
let access_token = &args[1];
1725

18-
println!("Connecting...");
19-
let credentials = Credentials::with_password(&args[1], &args[2]);
20-
let session = Session::new(session_config, None);
21-
26+
// Now create a new session with that token.
27+
let session = Session::new(session_config.clone(), None);
28+
let credentials = Credentials::with_access_token(access_token);
29+
println!("Connecting with token..");
2230
match session.connect(credentials, false).await {
23-
Ok(()) => println!(
24-
"Token: {:#?}",
25-
session.token_provider().get_token(SCOPES).await.unwrap()
26-
),
27-
Err(e) => println!("Error connecting: {}", e),
28-
}
31+
Ok(()) => println!("Session username: {:#?}", session.username()),
32+
Err(e) => {
33+
println!("Error connecting: {e}");
34+
return;
35+
}
36+
};
37+
38+
let token = session.token_provider().get_token(SCOPES).await.unwrap();
39+
println!("Got me a token: {token:#?}");
2940
}

examples/play.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ async fn main() {
2222
let audio_format = AudioFormat::default();
2323

2424
let args: Vec<_> = env::args().collect();
25-
if args.len() != 4 {
26-
eprintln!("Usage: {} USERNAME PASSWORD TRACK", args[0]);
25+
if args.len() != 3 {
26+
eprintln!("Usage: {} ACCESS_TOKEN TRACK", args[0]);
2727
return;
2828
}
29-
let credentials = Credentials::with_password(&args[1], &args[2]);
29+
let credentials = Credentials::with_access_token(&args[1]);
3030

31-
let mut track = SpotifyId::from_base62(&args[3]).unwrap();
31+
let mut track = SpotifyId::from_base62(&args[2]).unwrap();
3232
track.item_type = SpotifyItemType::Track;
3333

3434
let backend = audio_backend::find(None).unwrap();

examples/play_connect.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@ async fn main() {
2828
let connect_config = ConnectConfig::default();
2929

3030
let mut args: Vec<_> = env::args().collect();
31-
let context_uri = if args.len() == 4 {
31+
let context_uri = if args.len() == 3 {
3232
args.pop().unwrap()
33-
} else if args.len() == 3 {
33+
} else if args.len() == 2 {
3434
String::from("spotify:album:79dL7FLiJFOO0EoehUHQBv")
3535
} else {
36-
eprintln!("Usage: {} USERNAME PASSWORD (ALBUM URI)", args[0]);
36+
eprintln!("Usage: {} ACCESS_TOKEN (ALBUM URI)", args[0]);
3737
return;
3838
};
3939

40-
let credentials = Credentials::with_password(&args[1], &args[2]);
40+
let credentials = Credentials::with_access_token(&args[1]);
4141
let backend = audio_backend::find(None).unwrap();
4242

4343
println!("Connecting...");

0 commit comments

Comments
 (0)