use std::{
io::{BufRead, BufReader, Write},
net::{SocketAddr, TcpListener, TcpStream},
};
use crate::config;
use anyhow::{Context as _, Result};
use base64::Engine as _;
use librespot_core::{authentication::Credentials, cache::Cache, Session};
use reqwest::Url;
use rspotify::clients::{BaseClient as _, OAuthClient as _};
use sha2::{Digest as _, Sha256};
pub const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
pub const NCSPOT_CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b";
const SPOTIFY_AUTHORIZE_URL: &str = "https://accounts.spotify.com/authorize";
const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token";
pub const OAUTH_SCOPES: &[&str] = &[
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
"app-remote-control",
"streaming",
"playlist-read-private",
"playlist-read-collaborative",
"playlist-modify-private",
"playlist-modify-public",
"user-follow-modify",
"user-follow-read",
"user-read-playback-position",
"user-top-read",
"user-read-recently-played",
"user-library-modify",
"user-library-read",
"user-personalized",
];
#[derive(Clone)]
pub struct AuthConfig {
pub cache: Cache,
pub login_redirect_uri: String,
}
impl Default for AuthConfig {
fn default() -> Self {
AuthConfig {
cache: Cache::new(None::<String>, None, None, None).unwrap(),
login_redirect_uri: "http://127.0.0.1:8989/login".to_string(),
}
}
}
impl AuthConfig {
pub fn session(&self) -> Session {
let session_config = config::get_config().app_config.session_config();
Session::new(session_config, Some(self.cache.clone()))
}
pub fn new(configs: &config::Configs) -> Result<AuthConfig> {
let audio_cache_folder = if configs.app_config.device.audio_cache {
Some(configs.cache_folder.join("audio"))
} else {
None
};
let cache = Cache::new(
Some(configs.cache_folder.clone()),
None,
audio_cache_folder,
None,
)?;
Ok(AuthConfig {
cache,
login_redirect_uri: configs.app_config.login_redirect_uri.clone(),
})
}
}
pub fn get_creds(auth_config: &AuthConfig, reauth: bool, use_cached: bool) -> Result<Credentials> {
let creds = if use_cached {
auth_config.cache.credentials()
} else {
None
};
Ok(match creds {
None => {
let msg = "No cached credentials found, please authenticate the application first.";
if reauth {
eprintln!("{msg}");
let access_token = get_oauth_access_token(
SPOTIFY_CLIENT_ID,
&auth_config.login_redirect_uri,
OAUTH_SCOPES,
)?;
Credentials::with_access_token(access_token)
} else {
anyhow::bail!(msg);
}
}
Some(creds) => {
tracing::info!("Using cached credentials");
creds
}
})
}
pub async fn prompt_for_user_token(
client: &mut crate::client::WebApiClient,
force: bool,
) -> Result<()> {
if !force {
if let Ok(Some(token)) = client.read_token_cache(true).await {
let expired = token.is_expired();
let renewable = token.refresh_token.is_some();
*client.get_token().lock().await.unwrap() = Some(token);
if !expired && renewable {
return Ok(());
}
match client.refetch_token().await {
Ok(Some(refreshed)) => {
*client.get_token().lock().await.unwrap() = Some(refreshed);
client
.write_token_cache()
.await
.context("write refreshed token to cache")?;
return Ok(());
}
Ok(None) => {
tracing::warn!(
"Cached token could not be refreshed (no refresh token available); \
falling back to interactive re-authentication."
);
}
Err(err) => {
tracing::warn!(
"Failed to refresh the cached token (the refresh token may have expired \
per Spotify's 6-month refresh-token expiration policy); \
falling back to interactive re-authentication: {err:#}"
);
}
}
}
}
let url = client
.get_authorize_url(None)
.context("get authorize URL for user-provided client")?;
let code = obtain_auth_code(&url, &client.get_oauth().redirect_uri)?;
client
.request_token(&code)
.await
.context("exchange auth code for token (user-provided client)")?;
Ok(())
}
fn get_oauth_access_token(client_id: &str, redirect_uri: &str, scopes: &[&str]) -> Result<String> {
let pkce = Pkce::new_random();
let state = random_url_safe(16);
let auth_url = build_authorize_url(client_id, redirect_uri, scopes, &pkce.challenge, &state)?;
let code = obtain_auth_code(auth_url.as_str(), redirect_uri)?;
exchange_code_for_token(client_id, redirect_uri, &code, &pkce.verifier)
}
fn obtain_auth_code(auth_url: &str, redirect_uri: &str) -> Result<String> {
open::that_in_background(auth_url);
println!("Browse to: {auth_url}");
match redirect_socket_address(redirect_uri) {
Some(addr) => listen_for_auth_code(addr),
None => read_auth_code_from_stdin(),
}
}
fn listen_for_auth_code(addr: SocketAddr) -> Result<String> {
let listener =
TcpListener::bind(addr).with_context(|| format!("bind OAuth callback server to {addr}"))?;
tracing::info!("OAuth callback server listening on {addr}");
for stream in listener.incoming() {
let mut stream = match stream {
Ok(stream) => stream,
Err(err) => {
tracing::warn!("Failed to accept an OAuth callback connection: {err:#}");
continue;
}
};
let mut request_line = String::new();
if let Err(err) = BufReader::new(&stream).read_line(&mut request_line) {
tracing::warn!("Failed to read an OAuth callback request: {err:#}");
continue;
}
let request_target = request_line.split_whitespace().nth(1).unwrap_or_default();
if let Some(code) = code_from_redirect(request_target) {
respond(
&mut stream,
"200 OK",
"Authentication successful! You can close this tab and go back to your terminal.",
);
return Ok(code);
}
tracing::debug!("Ignoring OAuth callback request without an auth code: {request_target}");
respond(&mut stream, "404 Not Found", "");
}
anyhow::bail!("OAuth callback server stopped before receiving an auth code");
}
fn read_auth_code_from_stdin() -> Result<String> {
println!("Enter the URL you were redirected to: ");
let mut buffer = String::new();
std::io::stdin()
.read_line(&mut buffer)
.context("read redirect URL from stdin")?;
code_from_redirect(buffer.trim()).context("no auth code found in the provided redirect URL")
}
fn respond(stream: &mut TcpStream, status: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
if let Err(err) = stream.write_all(response.as_bytes()) {
tracing::warn!("Failed to write an OAuth callback response: {err:#}");
}
}
fn code_from_redirect(redirect: &str) -> Option<String> {
let url = Url::parse(redirect)
.or_else(|_| Url::parse(&format!("http://localhost{redirect}")))
.ok()?;
url.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, code)| code.into_owned())
}
fn redirect_socket_address(redirect_uri: &str) -> Option<SocketAddr> {
let url = match Url::parse(redirect_uri) {
Ok(url) if url.scheme() == "http" && url.port().is_some() => url,
_ => return None,
};
url.socket_addrs(|| None).ok()?.into_iter().next()
}
fn build_authorize_url(
client_id: &str,
redirect_uri: &str,
scopes: &[&str],
challenge: &str,
state: &str,
) -> Result<Url> {
let scope = scopes.join(" ");
Url::parse_with_params(
SPOTIFY_AUTHORIZE_URL,
&[
("response_type", "code"),
("client_id", client_id),
("redirect_uri", redirect_uri),
("scope", scope.as_str()),
("code_challenge_method", "S256"),
("code_challenge", challenge),
("state", state),
],
)
.context("build Spotify authorize URL")
}
fn exchange_code_for_token(
client_id: &str,
redirect_uri: &str,
code: &str,
verifier: &str,
) -> Result<String> {
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
}
let params = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("code_verifier", verifier),
];
std::thread::scope(|s| {
s.spawn(|| {
let token = reqwest::blocking::Client::new()
.post(SPOTIFY_TOKEN_URL)
.form(¶ms)
.send()
.context("send token exchange request")?
.error_for_status()
.context("token exchange request failed")?
.json::<TokenResponse>()
.context("parse token exchange response")?;
Ok(token.access_token)
})
.join()
.map_err(|_| anyhow::anyhow!("token exchange thread panicked"))?
})
}
struct Pkce {
verifier: String,
challenge: String,
}
impl Pkce {
fn new_random() -> Self {
let verifier = random_url_safe(32);
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(verifier.as_bytes()));
Self {
verifier,
challenge,
}
}
}
fn random_url_safe(n: usize) -> String {
let mut bytes = vec![0u8; n];
rand::fill(bytes.as_mut_slice());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn code_from_redirect_extracts_code() {
assert_eq!(
code_from_redirect("/login?code=abc123&state=xyz").as_deref(),
Some("abc123")
);
assert_eq!(
code_from_redirect("http://127.0.0.1:8989/login?code=abc123&state=xyz").as_deref(),
Some("abc123")
);
}
#[test]
fn code_from_redirect_ignores_stray_requests() {
assert_eq!(
code_from_redirect("/apple-touch-icon-precomposed.png"),
None
);
assert_eq!(code_from_redirect("/favicon.ico"), None);
assert_eq!(code_from_redirect("/login"), None);
}
#[test]
fn redirect_socket_address_requires_http_and_port() {
assert!(redirect_socket_address("http://127.0.0.1:8989/login").is_some());
assert!(redirect_socket_address("http://127.0.0.1/login").is_none());
assert!(redirect_socket_address("https://127.0.0.1:8989/login").is_none());
}
#[test]
fn pkce_challenge_matches_rfc7636_example() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(verifier.as_bytes()));
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
}