use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct OAuthConfig {
pub client_id: String,
pub client_secret: String,
pub authorization_url: String,
pub token_url: String,
pub callback_url: String,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default = "default_refresh_buffer")]
pub refresh_buffer_seconds: u64,
}
fn default_refresh_buffer() -> u64 {
300
}
impl OAuthConfig {
pub fn new(
client_id: String,
client_secret: String,
authorization_url: String,
token_url: String,
callback_url: String,
scopes: Vec<String>,
) -> Self {
Self {
client_id,
client_secret,
authorization_url,
token_url,
callback_url,
scopes,
state: None,
refresh_buffer_seconds: default_refresh_buffer(),
}
}
pub fn validate(&self) -> Result<(), String> {
if self.client_id.trim().is_empty() {
return Err("OAuth client_id cannot be empty".to_string());
}
let auth_url = Url::parse(&self.authorization_url).map_err(|e| {
format!(
"OAuth authorization_url is invalid: {}. Must be a valid URL (e.g., https://example.com/oauth/authorize)",
e
)
})?;
if auth_url.scheme() != "https"
&& auth_url.host_str() != Some("localhost")
&& auth_url.host_str() != Some("127.0.0.1")
{
return Err(
"OAuth authorization_url must use HTTPS or http://localhost/http://127.0.0.1 for development"
.to_string(),
);
}
let token_url = Url::parse(&self.token_url).map_err(|e| {
format!(
"OAuth token_url is invalid: {}. Must be a valid URL (e.g., https://example.com/oauth/token)",
e
)
})?;
if token_url.scheme() != "https"
&& token_url.host_str() != Some("localhost")
&& token_url.host_str() != Some("127.0.0.1")
{
return Err(
"OAuth token_url must use HTTPS or http://localhost/http://127.0.0.1 for development"
.to_string(),
);
}
let callback_url = Url::parse(&self.callback_url).map_err(|e| {
format!(
"OAuth callback_url is invalid: {}. Must be a valid URL (e.g., http://localhost:34567/oauth/callback)",
e
)
})?;
if callback_url.scheme() != "http" && callback_url.scheme() != "https" {
return Err("OAuth callback_url must use HTTP or HTTPS".to_string());
}
for scope in &self.scopes {
if scope.trim().is_empty() {
return Err("OAuth scopes cannot contain empty strings".to_string());
}
}
if self.refresh_buffer_seconds < 60 {
return Err("OAuth refresh_buffer_seconds must be at least 60 seconds".to_string());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_oauth_config_validation_empty_client_id() {
let config = OAuthConfig::new(
String::new(), "secret".to_string(),
"https://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
assert!(config.validate().is_err());
assert_eq!(
config.validate().unwrap_err(),
"OAuth client_id cannot be empty"
);
}
#[test]
fn test_oauth_config_validation_empty_client_secret() {
let config = OAuthConfig::new(
"client_id".to_string(),
String::new(), "https://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
assert!(config.validate().is_ok());
}
#[test]
fn test_oauth_config_validation_empty_scopes() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"https://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec![], );
assert!(config.validate().is_ok());
}
#[test]
fn test_oauth_config_validation_invalid_authorization_url() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"not-a-valid-url".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
assert!(config.validate().is_err());
assert!(config
.validate()
.unwrap_err()
.contains("authorization_url is invalid"));
}
#[test]
fn test_oauth_config_validation_http_authorization_url_not_localhost() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"http://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
assert!(config.validate().is_err());
assert!(config
.validate()
.unwrap_err()
.contains("authorization_url must use HTTPS"));
}
#[test]
fn test_oauth_config_validation_valid_config() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"https://github.com/login/oauth/authorize".to_string(),
"https://github.com/login/oauth/access_token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string(), "read:org".to_string()],
);
assert!(config.validate().is_ok());
}
#[test]
fn test_oauth_config_validation_localhost_allowed() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"http://localhost:8080/oauth/authorize".to_string(),
"http://localhost:8080/oauth/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
assert!(config.validate().is_ok());
}
#[test]
fn test_oauth_config_refresh_buffer_default() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"https://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
assert_eq!(config.refresh_buffer_seconds, 300);
}
#[test]
fn test_oauth_config_refresh_buffer_minimum() {
let mut config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"https://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"http://localhost:34567/oauth/callback".to_string(),
vec!["repo".to_string()],
);
config.refresh_buffer_seconds = 30;
assert!(config.validate().is_err());
assert!(config
.validate()
.unwrap_err()
.contains("refresh_buffer_seconds must be at least 60"));
}
#[test]
fn test_oauth_config_validation_invalid_callback_url() {
let config = OAuthConfig::new(
"client_id".to_string(),
"secret".to_string(),
"https://example.com/authorize".to_string(),
"https://example.com/token".to_string(),
"not-a-valid-url".to_string(),
vec!["repo".to_string()],
);
assert!(config.validate().is_err());
assert!(config
.validate()
.unwrap_err()
.contains("callback_url is invalid"));
}
}