use std::fmt;
use crate::crypto::zeroize::Zeroizing;
use crate::util::log::{info, warn};
use crate::util::validation::{is_valid_client_id, is_valid_redirect_uri, is_valid_scope};
#[derive(Debug, Clone, PartialEq, Eq)]
enum OAuthConfigErrorKind {
InvalidClientId,
InvalidRedirectUri,
InvalidScope,
MissingAuthorizationEndpoint,
MissingTokenEndpoint,
MissingRedirectUri,
InsecureAuthorizationEndpoint,
InsecureTokenEndpoint,
EmptyClientSecret,
}
#[doc(alias = "config_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthConfigError {
kind: OAuthConfigErrorKind,
}
impl OAuthConfigError {
const fn new(kind: OAuthConfigErrorKind) -> Self {
Self { kind }
}
}
impl fmt::Display for OAuthConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
OAuthConfigErrorKind::InvalidClientId => {
write!(f, "oauth config: invalid client_id")
}
OAuthConfigErrorKind::InvalidRedirectUri => {
write!(f, "oauth config: invalid redirect_uri")
}
OAuthConfigErrorKind::InvalidScope => {
write!(f, "oauth config: invalid scope")
}
OAuthConfigErrorKind::MissingAuthorizationEndpoint => {
write!(f, "oauth config: missing authorization_endpoint")
}
OAuthConfigErrorKind::MissingTokenEndpoint => {
write!(f, "oauth config: missing token_endpoint")
}
OAuthConfigErrorKind::MissingRedirectUri => {
write!(f, "oauth config: missing redirect_uri")
}
OAuthConfigErrorKind::InsecureAuthorizationEndpoint => {
write!(
f,
"oauth config: insecure authorization_endpoint (HTTPS required)"
)
}
OAuthConfigErrorKind::InsecureTokenEndpoint => {
write!(f, "oauth config: insecure token_endpoint (HTTPS required)")
}
OAuthConfigErrorKind::EmptyClientSecret => {
write!(f, "oauth config: empty client_secret")
}
}
}
}
impl std::error::Error for OAuthConfigError {}
#[doc(alias = "oauth_config")]
pub struct OAuthConfig {
client_id: String,
client_secret: Zeroizing<String>,
authorization_endpoint: String,
token_endpoint: String,
redirect_uri: String,
scopes: Vec<String>,
}
impl OAuthConfig {
#[must_use]
pub fn builder(client_id: &str, client_secret: &str) -> OAuthConfigBuilder {
OAuthConfigBuilder {
client_id: client_id.to_owned(),
client_secret: Zeroizing::new(client_secret.to_owned()),
authorization_endpoint: None,
token_endpoint: None,
redirect_uri: None,
scopes: Vec::new(),
}
}
#[must_use]
#[inline]
pub fn client_id(&self) -> &str {
&self.client_id
}
#[must_use]
#[inline]
pub fn client_secret(&self) -> &str {
&self.client_secret
}
#[must_use]
#[inline]
pub fn authorization_endpoint(&self) -> &str {
&self.authorization_endpoint
}
#[must_use]
#[inline]
pub fn token_endpoint(&self) -> &str {
&self.token_endpoint
}
#[must_use]
#[inline]
pub fn redirect_uri(&self) -> &str {
&self.redirect_uri
}
#[must_use]
#[inline]
pub fn scopes(&self) -> &[String] {
&self.scopes
}
}
impl fmt::Debug for OAuthConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthConfig")
.field("client_id", &self.client_id)
.field("client_secret", &"[REDACTED]")
.field("authorization_endpoint", &self.authorization_endpoint)
.field("token_endpoint", &self.token_endpoint)
.field("redirect_uri", &self.redirect_uri)
.field("scopes", &self.scopes)
.finish()
}
}
#[doc(alias = "config_builder")]
pub struct OAuthConfigBuilder {
client_id: String,
client_secret: Zeroizing<String>,
authorization_endpoint: Option<String>,
token_endpoint: Option<String>,
redirect_uri: Option<String>,
scopes: Vec<String>,
}
impl fmt::Debug for OAuthConfigBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthConfigBuilder")
.field("client_id", &self.client_id)
.field("client_secret", &"[REDACTED]")
.field("authorization_endpoint", &self.authorization_endpoint)
.field("token_endpoint", &self.token_endpoint)
.field("redirect_uri", &self.redirect_uri)
.field("scopes", &self.scopes)
.finish()
}
}
impl OAuthConfigBuilder {
#[must_use]
pub fn authorization_endpoint(mut self, url: &str) -> Self {
self.authorization_endpoint = Some(url.to_owned());
self
}
#[must_use]
pub fn token_endpoint(mut self, url: &str) -> Self {
self.token_endpoint = Some(url.to_owned());
self
}
#[must_use]
pub fn redirect_uri(mut self, uri: &str) -> Self {
self.redirect_uri = Some(uri.to_owned());
self
}
#[must_use]
pub fn scope(mut self, scope: &str) -> Self {
self.scopes.push(scope.to_owned());
self
}
pub fn build(self) -> Result<OAuthConfig, OAuthConfigError> {
if !is_valid_client_id(&self.client_id) {
warn!("oauth: config validation failed: invalid client_id");
return Err(OAuthConfigError::new(OAuthConfigErrorKind::InvalidClientId));
}
if self.client_secret.is_empty() {
warn!("oauth: config validation failed: empty client_secret");
return Err(OAuthConfigError::new(
OAuthConfigErrorKind::EmptyClientSecret,
));
}
let authorization_endpoint = self.authorization_endpoint.ok_or_else(|| {
warn!("oauth: config validation failed: missing authorization_endpoint");
OAuthConfigError::new(OAuthConfigErrorKind::MissingAuthorizationEndpoint)
})?;
let token_endpoint = self.token_endpoint.ok_or_else(|| {
warn!("oauth: config validation failed: missing token_endpoint");
OAuthConfigError::new(OAuthConfigErrorKind::MissingTokenEndpoint)
})?;
if !crate::util::validation::is_https_url(&authorization_endpoint) {
warn!("oauth: config validation failed: insecure authorization_endpoint");
return Err(OAuthConfigError::new(
OAuthConfigErrorKind::InsecureAuthorizationEndpoint,
));
}
if !crate::util::validation::is_https_url(&token_endpoint) {
warn!("oauth: config validation failed: insecure token_endpoint");
return Err(OAuthConfigError::new(
OAuthConfigErrorKind::InsecureTokenEndpoint,
));
}
let redirect_uri = self.redirect_uri.ok_or_else(|| {
warn!("oauth: config validation failed: missing redirect_uri");
OAuthConfigError::new(OAuthConfigErrorKind::MissingRedirectUri)
})?;
if !is_valid_redirect_uri(&redirect_uri) {
warn!("oauth: config validation failed: invalid redirect_uri");
return Err(OAuthConfigError::new(
OAuthConfigErrorKind::InvalidRedirectUri,
));
}
for scope in &self.scopes {
if !is_valid_scope(scope) {
warn!("oauth: config validation failed: invalid scope");
return Err(OAuthConfigError::new(OAuthConfigErrorKind::InvalidScope));
}
}
info!(client_id = %self.client_id, "oauth: provider configured");
Ok(OAuthConfig {
client_id: self.client_id,
client_secret: self.client_secret,
authorization_endpoint,
token_endpoint,
redirect_uri,
scopes: self.scopes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_builder() -> OAuthConfigBuilder {
OAuthConfig::builder("my-client-id", "my-client-secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
}
#[test]
fn build_valid_config() {
let config = valid_builder()
.scope("openid")
.scope("profile")
.build()
.unwrap();
assert_eq!(config.client_id(), "my-client-id");
assert_eq!(config.client_secret(), "my-client-secret");
assert_eq!(
config.authorization_endpoint(),
"https://auth.example.com/authorize",
);
assert_eq!(config.token_endpoint(), "https://auth.example.com/token");
assert_eq!(config.redirect_uri(), "https://myapp.example.com/callback",);
assert_eq!(config.scopes().len(), 2);
assert_eq!(config.scopes()[0], "openid");
assert_eq!(config.scopes()[1], "profile");
}
#[test]
fn build_no_scopes_is_valid() {
let config = valid_builder().build().unwrap();
assert!(config.scopes().is_empty());
}
#[test]
fn build_rejects_empty_client_id() {
let result = OAuthConfig::builder("", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("client_id"));
}
#[test]
fn build_rejects_invalid_client_id() {
let result = OAuthConfig::builder("client\x00id", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("client_id"));
}
#[test]
fn build_rejects_missing_authorization_endpoint() {
let result = OAuthConfig::builder("client", "secret")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("authorization_endpoint"),
);
}
#[test]
fn build_rejects_missing_token_endpoint() {
let result = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("token_endpoint"),);
}
#[test]
fn build_rejects_missing_redirect_uri() {
let result = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("redirect_uri"));
}
#[test]
fn build_rejects_invalid_redirect_uri() {
let result = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("http://evil.com/callback")
.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("redirect_uri"));
}
#[test]
fn build_rejects_invalid_scope() {
let result = valid_builder().scope("open\"id").build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("scope"));
}
#[test]
fn accessors_return_configured_values() {
let config = valid_builder().scope("email").build().unwrap();
assert_eq!(config.client_id(), "my-client-id");
assert_eq!(config.client_secret(), "my-client-secret");
assert_eq!(config.scopes(), &["email"]);
}
#[test]
fn debug_redacts_client_secret() {
let config = valid_builder().build().unwrap();
let debug_output = format!("{config:?}");
assert!(
debug_output.contains("[REDACTED]"),
"debug output should contain [REDACTED]: {debug_output}",
);
assert!(
!debug_output.contains("my-client-secret"),
"debug output must not contain the client secret",
);
}
#[test]
fn error_display_messages() {
let err = OAuthConfigError::new(OAuthConfigErrorKind::InvalidClientId);
assert_eq!(err.to_string(), "oauth config: invalid client_id");
let err = OAuthConfigError::new(OAuthConfigErrorKind::InvalidRedirectUri);
assert_eq!(err.to_string(), "oauth config: invalid redirect_uri");
let err = OAuthConfigError::new(OAuthConfigErrorKind::InvalidScope);
assert_eq!(err.to_string(), "oauth config: invalid scope");
let err = OAuthConfigError::new(OAuthConfigErrorKind::MissingAuthorizationEndpoint);
assert_eq!(
err.to_string(),
"oauth config: missing authorization_endpoint",
);
let err = OAuthConfigError::new(OAuthConfigErrorKind::MissingTokenEndpoint);
assert_eq!(err.to_string(), "oauth config: missing token_endpoint");
let err = OAuthConfigError::new(OAuthConfigErrorKind::MissingRedirectUri);
assert_eq!(err.to_string(), "oauth config: missing redirect_uri");
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(OAuthConfigError::new(OAuthConfigErrorKind::InvalidClientId));
let _ = err.to_string();
}
#[test]
fn build_rejects_http_authorization_endpoint() {
let result = OAuthConfig::builder("client", "secret")
.authorization_endpoint("http://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("insecure authorization_endpoint"),
);
}
#[test]
fn build_rejects_http_token_endpoint() {
let result = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("http://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("insecure token_endpoint"),
);
}
#[test]
fn build_accepts_https_endpoints() {
let result = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_ok());
}
#[test]
fn build_rejects_empty_client_secret() {
let result = OAuthConfig::builder("client", "")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("empty client_secret"),
);
}
#[test]
fn error_display_new_variants() {
let err = OAuthConfigError::new(OAuthConfigErrorKind::InsecureAuthorizationEndpoint);
assert_eq!(
err.to_string(),
"oauth config: insecure authorization_endpoint (HTTPS required)",
);
let err = OAuthConfigError::new(OAuthConfigErrorKind::InsecureTokenEndpoint);
assert_eq!(
err.to_string(),
"oauth config: insecure token_endpoint (HTTPS required)",
);
let err = OAuthConfigError::new(OAuthConfigErrorKind::EmptyClientSecret);
assert_eq!(err.to_string(), "oauth config: empty client_secret");
}
}