Skip to content

Commit f4b3090

Browse files
committed
oauth: obtain Spotify access token via OAuth2
1 parent f52696f commit f4b3090

File tree

3 files changed

+206
-0
lines changed

3 files changed

+206
-0
lines changed

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"] }

oauth/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "librespot-oauth"
3+
version = "0.5.0-dev"
4+
rust-version = "1.73"
5+
authors = ["Paul Lietar <[email protected]>"]
6+
description = "Spotify OAuth"
7+
license = "MIT"
8+
repository = "https://github.com/librespot-org/librespot"
9+
edition = "2021"
10+
11+
[dependencies]
12+
log = "0.4"
13+
oauth2 = "4.4"
14+
serde = { version = "1.0", features = ["derive"] }
15+
url = "2.2"
16+
17+
[dependencies.librespot-core]
18+
path = "../core"
19+
version = "0.5.0-dev"

oauth/src/lib.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use log::{debug, error, info, trace};
2+
use oauth2::basic::{
3+
BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse,
4+
BasicTokenType,
5+
};
6+
use oauth2::reqwest::http_client;
7+
use oauth2::{
8+
AuthUrl, AuthorizationCode, Client, ClientId, CsrfToken, ExtraTokenFields, PkceCodeChallenge,
9+
RedirectUrl, Scope, StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl,
10+
};
11+
use serde::{Deserialize, Serialize};
12+
use std::io;
13+
use std::{
14+
io::{BufRead, BufReader, Write},
15+
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
16+
process::exit,
17+
sync::mpsc,
18+
};
19+
use url::Url;
20+
21+
// Define extra fields to get the username too.
22+
// TODO: Maybe don't bother and use simpler BasicClient instead?
23+
24+
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
25+
pub struct SpotifyFields {
26+
#[serde(rename = "username")]
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
pub username: Option<String>,
29+
}
30+
impl SpotifyFields {
31+
pub fn username(&self) -> Option<&String> {
32+
self.username.as_ref()
33+
}
34+
}
35+
impl ExtraTokenFields for SpotifyFields {}
36+
37+
type SpotifyTokenResponse = StandardTokenResponse<SpotifyFields, BasicTokenType>;
38+
39+
type SpotifyClient = Client<
40+
BasicErrorResponse,
41+
SpotifyTokenResponse,
42+
BasicTokenType,
43+
BasicTokenIntrospectionResponse,
44+
StandardRevocableToken,
45+
BasicRevocationErrorResponse,
46+
>;
47+
48+
fn get_authcode_stdin() -> AuthorizationCode {
49+
println!("Provide code");
50+
let mut buffer = String::new();
51+
let stdin = io::stdin(); // We get `Stdin` here.
52+
stdin.read_line(&mut buffer).unwrap();
53+
54+
AuthorizationCode::new(buffer.trim().into())
55+
}
56+
57+
fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode {
58+
// A very naive implementation of the redirect server.
59+
let listener = TcpListener::bind(socket_address).unwrap();
60+
61+
info!("OAuth server listening on {:?}", socket_address);
62+
63+
// The server will terminate itself after collecting the first code.
64+
let Some(mut stream) = listener.incoming().flatten().next() else {
65+
panic!("listener terminated without accepting a connection");
66+
};
67+
68+
let mut reader = BufReader::new(&stream);
69+
70+
let mut request_line = String::new();
71+
reader.read_line(&mut request_line).unwrap();
72+
73+
let redirect_url = request_line.split_whitespace().nth(1).unwrap();
74+
let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap();
75+
76+
let code = url
77+
.query_pairs()
78+
.find(|(key, _)| key == "code")
79+
.map(|(_, code)| AuthorizationCode::new(code.into_owned()))
80+
.unwrap();
81+
82+
let message = "Go back to your terminal :)";
83+
let response = format!(
84+
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
85+
message.len(),
86+
message
87+
);
88+
stream.write_all(response.as_bytes()).unwrap();
89+
90+
code
91+
}
92+
93+
// TODO: Return a Result
94+
// TODO: Pass in redirect_address instead since the redirect host depends on client ID?
95+
// TODO: Should also return username, for fun?
96+
pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {
97+
// Must use host 127.0.0.1 with Spotify Desktop client ID.
98+
let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port);
99+
let redirect_uri = format!("http://{}/login", redirect_address.to_string());
100+
101+
let client = SpotifyClient::new(
102+
ClientId::new(client_id.to_string()),
103+
None,
104+
AuthUrl::new("https://accounts.spotify.com/authorize".to_string())
105+
.expect("Invalid authorization endpoint URL"),
106+
Some(
107+
TokenUrl::new("https://accounts.spotify.com/api/token".to_string())
108+
.expect("Invalid token endpoint URL"),
109+
),
110+
)
111+
.set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL"));
112+
113+
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
114+
115+
// Generate the full authorization URL.
116+
let scopes = vec![
117+
"app-remote-control",
118+
"playlist-modify",
119+
"playlist-modify-private",
120+
"playlist-modify-public",
121+
"playlist-read",
122+
"playlist-read-collaborative",
123+
"playlist-read-private",
124+
"streaming",
125+
"ugc-image-upload",
126+
"user-follow-modify",
127+
"user-follow-read",
128+
"user-library-modify",
129+
"user-library-read",
130+
"user-modify",
131+
"user-modify-playback-state",
132+
"user-modify-private",
133+
"user-personalized",
134+
"user-read-birthdate",
135+
"user-read-currently-playing",
136+
"user-read-email",
137+
"user-read-play-history",
138+
"user-read-playback-position",
139+
"user-read-playback-state",
140+
"user-read-private",
141+
"user-read-recently-played",
142+
"user-top-read",
143+
];
144+
let scopes: Vec<oauth2::Scope> = scopes.into_iter().map(|s| Scope::new(s.into())).collect();
145+
let (auth_url, _) = client
146+
.authorize_url(CsrfToken::new_random)
147+
.add_scopes(scopes)
148+
.set_pkce_challenge(pkce_challenge)
149+
.url();
150+
151+
println!("Browse to: {}", auth_url);
152+
153+
let code = if redirect_port > 0 {
154+
get_authcode_listener(redirect_address)
155+
} else {
156+
get_authcode_stdin()
157+
};
158+
debug!("Exchange {code:?} for access token");
159+
160+
// Do this sync in another thread because I am too stupid to make the async version work.
161+
let (tx, rx) = mpsc::channel();
162+
let client_clone = client.clone();
163+
std::thread::spawn(move || {
164+
let resp = client_clone
165+
.exchange_code(code)
166+
.set_pkce_verifier(pkce_verifier)
167+
.request(http_client);
168+
tx.send(resp).unwrap();
169+
});
170+
let token_response = rx.recv().unwrap();
171+
let token = match token_response {
172+
Ok(tok) => tok,
173+
Err(e) => {
174+
error!("Failed to exchange code for access token: {e:?}");
175+
exit(1);
176+
}
177+
};
178+
let username = token.extra_fields().username().unwrap().to_string();
179+
let access_token = token.access_token().secret().to_string();
180+
trace!("Obtained new access token for {username}: {token:?}");
181+
182+
access_token
183+
}

0 commit comments

Comments
 (0)