Skip to content

Commit

Permalink
Lift and shift OAuth2 flow from librespot so we can open the authoris…
Browse files Browse the repository at this point in the history
…e URL in the default browser
  • Loading branch information
stevenleadbeater committed Oct 27, 2024
1 parent 2422253 commit 919ddfd
Show file tree
Hide file tree
Showing 12 changed files with 411 additions and 8 deletions.
41 changes: 40 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,6 @@ lazy_static = "1.4.0"
log = "0.4.17"
env_logger = "0.10.0"
percent-encoding = "2.2.0"
oauth2 = "4.4"
url = "2.4.1"
open = "5.3.0"
1 change: 1 addition & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod cached_client;
mod client;

pub mod cache;
pub(crate) mod oauth2;

pub use cached_client::{CachedSpotifyClient, SpotifyApiClient, SpotifyResult};
pub use client::SpotifyApiError;
Expand Down
290 changes: 290 additions & 0 deletions src/api/oauth2.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
//! Provides a Spotify access token using the OAuth authorization code flow
//! with PKCE.
//!
//! Assuming sufficient scopes, the returned access token may be used with Spotify's
//! Web API, and/or to establish a new Session with [`librespot_core`].
//!
//! The authorization code flow is an interactive process which requires a web browser
//! to complete. The resulting code must then be provided back from the browser to this
//! library for exchange into an access token. Providing the code can be automatic via
//! a spawned http server (mimicking Spotify's client), or manually via stdin. The latter
//! is appropriate for headless systems.
use log::{error, info, trace};
use oauth2::reqwest::http_client;
use oauth2::{
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge,
RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use std::io;
use std::time::{Duration, Instant};
use std::{
io::{BufRead, BufReader, Write},
net::{SocketAddr, TcpListener},
sync::mpsc,
};
use thiserror::Error;
use url::Url;

#[derive(Debug, Error)]
pub enum OAuthError {
#[error("Unable to parse redirect URI {uri} ({e})")]
AuthCodeBadUri { uri: String, e: url::ParseError },

#[error("Auth code param not found in URI {uri}")]
AuthCodeNotFound { uri: String },

#[error("Failed to read redirect URI from stdin")]
AuthCodeStdinRead,

#[error("Failed to bind server to {addr} ({e})")]
AuthCodeListenerBind { addr: SocketAddr, e: io::Error },

#[error("Listener terminated without accepting a connection")]
AuthCodeListenerTerminated,

#[error("Failed to read redirect URI from HTTP request")]
AuthCodeListenerRead,

#[error("Failed to parse redirect URI from HTTP request")]
AuthCodeListenerParse,

#[error("Failed to write HTTP response")]
AuthCodeListenerWrite,

#[error("Invalid Spotify OAuth URI")]
InvalidSpotifyUri,

#[error("Invalid Redirect URI {uri} ({e})")]
InvalidRedirectUri { uri: String, e: url::ParseError },

#[error("Failed to receive code")]
Recv,

#[error("Failed to exchange code for access token ({e})")]
ExchangeCode { e: String },
}

#[derive(Debug)]
pub struct OAuthToken {
pub access_token: String,
pub refresh_token: String,
pub expires_at: Instant,
pub token_type: String,
pub scopes: Vec<String>,
}

/// Return code query-string parameter from the redirect URI.
fn get_code(redirect_url: &str) -> Result<AuthorizationCode, OAuthError> {
let url = Url::parse(redirect_url).map_err(|e| OAuthError::AuthCodeBadUri {
uri: redirect_url.to_string(),
e,
})?;
let code = url
.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, code)| AuthorizationCode::new(code.into_owned()))
.ok_or(OAuthError::AuthCodeNotFound {
uri: redirect_url.to_string(),
})?;

Ok(code)
}

/// Prompt for redirect URI on stdin and return auth code.
fn get_authcode_stdin() -> Result<AuthorizationCode, OAuthError> {
println!("Provide redirect URL");
let mut buffer = String::new();
let stdin = io::stdin();
stdin
.read_line(&mut buffer)
.map_err(|_| OAuthError::AuthCodeStdinRead)?;

get_code(buffer.trim())
}

/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code.
fn get_authcode_listener(socket_address: SocketAddr) -> Result<AuthorizationCode, OAuthError> {
let listener =
TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind {
addr: socket_address,
e,
})?;
info!("OAuth server listening on {:?}", socket_address);

// The server will terminate itself after collecting the first code.
let mut stream = listener
.incoming()
.flatten()
.next()
.ok_or(OAuthError::AuthCodeListenerTerminated)?;
let mut reader = BufReader::new(&stream);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.map_err(|_| OAuthError::AuthCodeListenerRead)?;

let redirect_url = request_line
.split_whitespace()
.nth(1)
.ok_or(OAuthError::AuthCodeListenerParse)?;
let code = get_code(&("http://localhost".to_string() + redirect_url));

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())
.map_err(|_| OAuthError::AuthCodeListenerWrite)?;

code
}

// If the specified `redirect_uri` is HTTP, loopback, and contains a port,
// then the corresponding socket address is returned.
fn get_socket_address(redirect_uri: &str) -> Option<SocketAddr> {
let url = match Url::parse(redirect_uri) {
Ok(u) if u.scheme() == "http" && u.port().is_some() => u,
_ => return None,
};
let socket_addr = match url.socket_addrs(|| None) {
Ok(mut addrs) => addrs.pop(),
_ => None,
};
if let Some(s) = socket_addr {
if s.ip().is_loopback() {
return socket_addr;
}
}
None
}

/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow.
/// The redirect_uri must match what is registered to the client ID.
pub fn get_access_token(
client_id: &str,
redirect_uri: &str,
scopes: Vec<&str>,
) -> Result<OAuthToken, OAuthError> {
let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string())
.map_err(|_| OAuthError::InvalidSpotifyUri)?;
let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string())
.map_err(|_| OAuthError::InvalidSpotifyUri)?;
let redirect_url =
RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri {
uri: redirect_uri.to_string(),
e,
})?;
let client = BasicClient::new(
ClientId::new(client_id.to_string()),
None,
auth_url,
Some(token_url),
)
.set_redirect_uri(redirect_url);

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

// Generate the full authorization URL.
// Some of these scopes are unavailable for custom client IDs. Which?
let request_scopes: Vec<oauth2::Scope> = scopes
.clone()
.into_iter()
.map(|s| Scope::new(s.into()))
.collect();
let (auth_url, _) = client
.authorize_url(CsrfToken::new_random)
.add_scopes(request_scopes)
.set_pkce_challenge(pkce_challenge)
.url();

println!("Browse to: {}", auth_url);
if let Err(err) = open::that(auth_url.to_string()) {
eprintln!("An error occurred when opening '{}': {}", auth_url.to_string(), err)
}

let code = match get_socket_address(redirect_uri) {
Some(addr) => get_authcode_listener(addr),
_ => get_authcode_stdin(),
}?;
trace!("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();
std::thread::spawn(move || {
let resp = client
.exchange_code(code)
.set_pkce_verifier(pkce_verifier)
.request(http_client);
if let Err(e) = tx.send(resp) {
error!("OAuth channel send error: {e}");
}
});
let token_response = rx.recv().map_err(|_| OAuthError::Recv)?;
let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?;
trace!("Obtained new access token: {token:?}");

let token_scopes: Vec<String> = match token.scopes() {
Some(s) => s.iter().map(|s| s.to_string()).collect(),
_ => scopes.into_iter().map(|s| s.to_string()).collect(),
};
let refresh_token = match token.refresh_token() {
Some(t) => t.secret().to_string(),
_ => "".to_string(), // Spotify always provides a refresh token.
};
Ok(OAuthToken {
access_token: token.access_token().secret().to_string(),
refresh_token,
expires_at: Instant::now()
+ token
.expires_in()
.unwrap_or_else(|| Duration::from_secs(3600)),
token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!?
scopes: token_scopes,
})
}

#[cfg(test)]
mod test {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};

use super::*;

#[test]
fn get_socket_address_none() {
// No port
assert_eq!(get_socket_address("http://127.0.0.1/foo"), None);
assert_eq!(get_socket_address("http://127.0.0.1:/foo"), None);
assert_eq!(get_socket_address("http://[::1]/foo"), None);
// Not localhost
assert_eq!(get_socket_address("http://56.0.0.1:1234/foo"), None);
assert_eq!(
get_socket_address("http://[3ffe:2a00:100:7031::1]:1234/foo"),
None
);
// Not http
assert_eq!(get_socket_address("https://127.0.0.1/foo"), None);
}

#[test]
fn get_socket_address_localhost() {
let localhost_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1234);
let localhost_v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8888);

assert_eq!(
get_socket_address("http://127.0.0.1:1234/foo"),
Some(localhost_v4)
);
assert_eq!(
get_socket_address("http://[0:0:0:0:0:0:0:1]:8888/foo"),
Some(localhost_v6)
);
assert_eq!(
get_socket_address("http://[::1]:8888/foo"),
Some(localhost_v6)
);
}
}
Loading

0 comments on commit 919ddfd

Please sign in to comment.