Skip to content

Commit

Permalink
oauth: obtain Spotify access token via OAuth2
Browse files Browse the repository at this point in the history
  • Loading branch information
kingosticks committed Aug 6, 2024
1 parent a902f03 commit 9d3cbab
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 0 deletions.
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ version = "0.5.0-dev"
path = "protocol"
version = "0.5.0-dev"

[dependencies.librespot-oauth]
path = "oauth"
version = "0.5.0-dev"

[dependencies]
data-encoding = "2.5"
env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] }
Expand Down
19 changes: 19 additions & 0 deletions oauth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "librespot-oauth"
version = "0.5.0-dev"
rust-version = "1.73"
authors = ["Paul Lietar <[email protected]>"]
description = "Spotify OAuth"
license = "MIT"
repository = "https://github.com/librespot-org/librespot"
edition = "2021"

[dependencies]
log = "0.4"
oauth2 = "4.4"
serde = { version = "1.0", features = ["derive"] }
url = "2.2"

[dependencies.librespot-core]
path = "../core"
version = "0.5.0-dev"
183 changes: 183 additions & 0 deletions oauth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
use log::{debug, error, info, trace};
use oauth2::basic::{
BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse,
BasicTokenType,
};
use oauth2::reqwest::http_client;
use oauth2::{
AuthUrl, AuthorizationCode, Client, ClientId, CsrfToken, ExtraTokenFields, PkceCodeChallenge,
RedirectUrl, Scope, StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl,
};
use serde::{Deserialize, Serialize};
use std::io;
use std::{
io::{BufRead, BufReader, Write},
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
process::exit,
sync::mpsc,
};
use url::Url;

// Define extra fields to get the username too.
// TODO: Maybe don't bother and use simpler BasicClient instead?

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SpotifyFields {
#[serde(rename = "username")]
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
}
impl SpotifyFields {
pub fn username(&self) -> Option<&String> {
self.username.as_ref()
}
}
impl ExtraTokenFields for SpotifyFields {}

type SpotifyTokenResponse = StandardTokenResponse<SpotifyFields, BasicTokenType>;

type SpotifyClient = Client<
BasicErrorResponse,
SpotifyTokenResponse,
BasicTokenType,
BasicTokenIntrospectionResponse,
StandardRevocableToken,
BasicRevocationErrorResponse,
>;

fn get_authcode_stdin() -> AuthorizationCode {
println!("Provide code");
let mut buffer = String::new();
let stdin = io::stdin(); // We get `Stdin` here.
stdin.read_line(&mut buffer).unwrap();

AuthorizationCode::new(buffer.trim().into())
}

fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode {
// A very naive implementation of the redirect server.
let listener = TcpListener::bind(socket_address).unwrap();

info!("OAuth server listening on {:?}", socket_address);

// The server will terminate itself after collecting the first code.
let Some(mut stream) = listener.incoming().flatten().next() else {
panic!("listener terminated without accepting a connection");
};

let mut reader = BufReader::new(&stream);

let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();

let redirect_url = request_line.split_whitespace().nth(1).unwrap();
let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap();

let code = url
.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, code)| AuthorizationCode::new(code.into_owned()))
.unwrap();

let message = "Go back to your terminal :)";
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
message.len(),
message
);
stream.write_all(response.as_bytes()).unwrap();

code
}

// TODO: Return a Result
// TODO: Pass in redirect_address instead since the redirect host depends on client ID?
// TODO: Should also return username, for fun?
pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {
// Must use host 127.0.0.1 with Spotify Desktop client ID.
let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port);
let redirect_uri = format!("http://{redirect_address}/login");

let client = SpotifyClient::new(
ClientId::new(client_id.to_string()),
None,
AuthUrl::new("https://accounts.spotify.com/authorize".to_string())
.expect("Invalid authorization endpoint URL"),
Some(
TokenUrl::new("https://accounts.spotify.com/api/token".to_string())
.expect("Invalid token endpoint URL"),
),
)
.set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL"));

let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

// Generate the full authorization URL.
let scopes = vec![
"app-remote-control",
"playlist-modify",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read",
"playlist-read-collaborative",
"playlist-read-private",
"streaming",
"ugc-image-upload",
"user-follow-modify",
"user-follow-read",
"user-library-modify",
"user-library-read",
"user-modify",
"user-modify-playback-state",
"user-modify-private",
"user-personalized",
"user-read-birthdate",
"user-read-currently-playing",
"user-read-email",
"user-read-play-history",
"user-read-playback-position",
"user-read-playback-state",
"user-read-private",
"user-read-recently-played",
"user-top-read",
];
let scopes: Vec<oauth2::Scope> = scopes.into_iter().map(|s| Scope::new(s.into())).collect();
let (auth_url, _) = client
.authorize_url(CsrfToken::new_random)
.add_scopes(scopes)
.set_pkce_challenge(pkce_challenge)
.url();

println!("Browse to: {}", auth_url);

let code = if redirect_port > 0 {
get_authcode_listener(redirect_address)
} else {
get_authcode_stdin()
};
debug!("Exchange {code:?} for access token");

// Do this sync in another thread because I am too stupid to make the async version work.
let (tx, rx) = mpsc::channel();
let client_clone = client.clone();
std::thread::spawn(move || {
let resp = client_clone
.exchange_code(code)
.set_pkce_verifier(pkce_verifier)
.request(http_client);
tx.send(resp).unwrap();
});
let token_response = rx.recv().unwrap();
let token = match token_response {
Ok(tok) => tok,
Err(e) => {
error!("Failed to exchange code for access token: {e:?}");
exit(1);
}
};
let username = token.extra_fields().username().unwrap().to_string();
let access_token = token.access_token().secret().to_string();
trace!("Obtained new access token for {username}: {token:?}");

access_token
}

0 comments on commit 9d3cbab

Please sign in to comment.