use std::net::{SocketAddr, TcpListener};
use std::path::PathBuf;
use sha2::{Digest, Sha256};
use url::Url;
use crate::error::{Error, Result};
use crate::paths::cache_dir;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthFlow {
Auto,
AuthorizationCode,
ClientCredentials,
}
impl OAuthFlow {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::AuthorizationCode => "authorization_code",
Self::ClientCredentials => "client_credentials",
}
}
}
pub fn parse_oauth_flow(s: &str) -> Result<OAuthFlow> {
match s {
"auto" => Ok(OAuthFlow::Auto),
"authorization_code" => Ok(OAuthFlow::AuthorizationCode),
"client_credentials" => Ok(OAuthFlow::ClientCredentials),
other => Err(Error::usage(format!(
"invalid --oauth-flow {other:?}; expected auto|authorization_code|client_credentials"
))),
}
}
#[derive(Debug, Clone)]
pub struct OAuthOptions {
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub client_name: String,
pub scope: Option<String>,
pub redirect_uri: Option<String>,
pub flow: OAuthFlow,
}
impl OAuthOptions {
pub fn use_client_credentials(&self) -> bool {
match self.flow {
OAuthFlow::ClientCredentials => true,
OAuthFlow::Auto => self.client_id.is_some() && self.client_secret.is_some(),
OAuthFlow::AuthorizationCode => false,
}
}
}
pub fn validate_redirect_uri(uri: &str) -> Result<Url> {
let parsed = Url::parse(uri).map_err(|e| Error::usage(format!("invalid redirect URI: {e}")))?;
if parsed.scheme() != "http" {
return Err(Error::usage(
"oauth redirect URI must use http:// (local callback server)",
));
}
let host = parsed.host_str().unwrap_or("");
if !matches!(host, "localhost" | "127.0.0.1" | "::1") {
return Err(Error::usage(format!(
"oauth redirect URI host must be loopback (localhost/127.0.0.1/::1), got {host:?}"
)));
}
if parsed.port().is_none() {
return Err(Error::usage(
"oauth redirect URI must include an explicit port",
));
}
Ok(parsed)
}
pub fn find_free_port() -> Result<u16> {
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| Error::runtime(format!("cannot bind free port: {e}")))?;
Ok(listener
.local_addr()
.map_err(|e| Error::runtime(e.to_string()))?
.port())
}
pub fn port_available(host: &str, port: u16) -> bool {
let addr = match format!("{host}:{port}").parse::<SocketAddr>() {
Ok(a) => a,
Err(_) => {
if host == "::1" {
SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], port))
} else {
return false;
}
}
};
TcpListener::bind(addr).is_ok()
}
pub fn server_hash(server_url: &str) -> String {
let digest = Sha256::digest(server_url.as_bytes());
hex::encode(&digest[..8])
}
pub fn oauth_dir_for_server(server_url: &str) -> PathBuf {
cache_dir().join("oauth").join(server_hash(server_url))
}
pub fn resolve_redirect_uri(explicit: Option<&str>, sticky_uri: Option<&str>) -> Result<String> {
if let Some(uri) = explicit {
validate_redirect_uri(uri)?;
return Ok(uri.to_string());
}
if let Some(uri) = sticky_uri {
if let Ok(parsed) = validate_redirect_uri(uri) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
if let Some(port) = parsed.port() {
if port_available(host, port) {
return Ok(uri.to_string());
}
}
}
}
let port = find_free_port()?;
Ok(format!("http://127.0.0.1:{port}/callback"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flow_parse() {
assert_eq!(parse_oauth_flow("auto").unwrap(), OAuthFlow::Auto);
assert_eq!(
parse_oauth_flow("authorization_code").unwrap(),
OAuthFlow::AuthorizationCode
);
assert!(parse_oauth_flow("bogus").is_err());
}
#[test]
fn redirect_validation() {
assert!(validate_redirect_uri("http://127.0.0.1:18080/callback").is_ok());
assert!(validate_redirect_uri("http://localhost:9999/cb").is_ok());
assert!(validate_redirect_uri("https://127.0.0.1:18080/callback").is_err());
assert!(validate_redirect_uri("http://127.0.0.1/callback").is_err());
assert!(validate_redirect_uri("http://example.com:8080/callback").is_err());
}
#[test]
fn auto_picks_cc_when_both_creds() {
let opts = OAuthOptions {
client_id: Some("id".into()),
client_secret: Some("sec".into()),
client_name: "skiff".into(),
scope: None,
redirect_uri: None,
flow: OAuthFlow::Auto,
};
assert!(opts.use_client_credentials());
let opts2 = OAuthOptions {
client_secret: None,
flow: OAuthFlow::Auto,
..opts.clone()
};
assert!(!opts2.use_client_credentials());
let opts3 = OAuthOptions {
flow: OAuthFlow::AuthorizationCode,
..opts
};
assert!(!opts3.use_client_credentials());
}
#[test]
fn server_hash_stable() {
assert_eq!(server_hash("http://x"), server_hash("http://x"));
assert_ne!(server_hash("http://x"), server_hash("http://y"));
assert_eq!(server_hash("http://x").len(), 16);
}
#[test]
fn sticky_redirect_reused_when_port_free() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let sticky = format!("http://127.0.0.1:{port}/callback");
let resolved = resolve_redirect_uri(None, Some(&sticky)).unwrap();
assert_eq!(resolved, sticky);
}
#[test]
fn sticky_non_loopback_is_ignored() {
let sticky = "http://0.0.0.0:19999/callback";
let resolved = resolve_redirect_uri(None, Some(sticky)).unwrap();
assert!(resolved.starts_with("http://127.0.0.1:"));
assert!(resolved.ends_with("/callback"));
}
#[test]
fn explicit_redirect_wins_over_sticky() {
let explicit = "http://127.0.0.1:18080/callback";
let sticky = "http://127.0.0.1:18081/callback";
assert_eq!(
resolve_redirect_uri(Some(explicit), Some(sticky)).unwrap(),
explicit
);
}
}