use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use foundation_core::url::Query;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
pub client_id: String,
pub client_secret: Option<String>,
pub authorization_url: String,
pub token_url: String,
pub redirect_uri: String,
pub scopes: Vec<String>,
pub pkce_enabled: bool,
pub response_type: String,
pub grant_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
impl Default for OAuthConfig {
fn default() -> Self {
Self {
client_id: String::new(),
client_secret: None,
authorization_url: String::new(),
token_url: String::new(),
redirect_uri: String::new(),
scopes: Vec::new(),
pkce_enabled: true,
response_type: "code".to_string(),
grant_type: "authorization_code".to_string(),
nonce: None,
}
}
}
impl OAuthConfig {
#[must_use]
pub fn builder() -> OAuthConfigBuilder {
OAuthConfigBuilder::new()
}
pub fn validate(&self) -> Result<(), OAuthError> {
if self.client_id.is_empty() {
return Err(OAuthError::MissingClientId);
}
if self.authorization_url.is_empty() {
return Err(OAuthError::MissingAuthorizationUrl);
}
if self.token_url.is_empty() {
return Err(OAuthError::MissingTokenUrl);
}
if self.redirect_uri.is_empty() {
return Err(OAuthError::MissingRedirectUri);
}
Ok(())
}
}
pub struct OAuthConfigBuilder {
config: OAuthConfig,
}
impl OAuthConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: OAuthConfig::default(),
}
}
#[must_use]
pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
self.config.client_id = client_id.into();
self
}
#[must_use]
pub fn client_secret(mut self, client_secret: impl Into<String>) -> Self {
self.config.client_secret = Some(client_secret.into());
self
}
#[must_use]
pub fn authorization_url(mut self, url: impl Into<String>) -> Self {
self.config.authorization_url = url.into();
self
}
#[must_use]
pub fn token_url(mut self, url: impl Into<String>) -> Self {
self.config.token_url = url.into();
self
}
#[must_use]
pub fn redirect_uri(mut self, uri: impl Into<String>) -> Self {
self.config.redirect_uri = uri.into();
self
}
#[must_use]
pub fn scopes(mut self, scopes: Vec<String>) -> Self {
self.config.scopes = scopes;
self
}
#[must_use]
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.config.scopes.push(scope.into());
self
}
#[must_use]
pub fn pkce_enabled(mut self, enabled: bool) -> Self {
self.config.pkce_enabled = enabled;
self
}
#[must_use]
pub fn response_type(mut self, response_type: impl Into<String>) -> Self {
self.config.response_type = response_type.into();
self
}
#[must_use]
pub fn nonce(mut self, nonce: String) -> Self {
self.config.nonce = Some(nonce);
self
}
#[must_use]
pub fn build(self) -> OAuthConfig {
self.config
}
}
impl Default for OAuthConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct PkceChallenge {
pub code_verifier: String,
pub code_challenge: String,
pub challenge_method: String,
}
impl PkceChallenge {
#[must_use]
pub fn generate() -> Self {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let code_verifier = URL_SAFE_NO_PAD.encode(bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let hash = hasher.finalize();
let code_challenge = URL_SAFE_NO_PAD.encode(hash);
Self {
code_verifier,
code_challenge,
challenge_method: "S256".to_string(),
}
}
}
#[derive(Clone)]
pub struct OAuthManager {
pub(crate) config: OAuthConfig,
}
impl OAuthManager {
#[must_use]
pub fn new(config: OAuthConfig) -> Self {
Self { config }
}
#[must_use]
pub fn config(&self) -> &OAuthConfig {
&self.config
}
#[must_use]
pub fn generate_state() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
#[must_use]
pub fn generate_nonce() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn get_authorization_url(
&self,
state: &str,
) -> Result<(String, Option<PkceChallenge>), OAuthError> {
self.config.validate()?;
let mut query = Query::new();
query.append("response_type", &self.config.response_type);
query.append("client_id", &self.config.client_id);
query.append("redirect_uri", &self.config.redirect_uri);
query.append("state", state);
if !self.config.scopes.is_empty() {
let scopes_joined = self.config.scopes.join(" ");
query.append("scope", &scopes_joined);
}
let pkce = if self.config.pkce_enabled {
let challenge = PkceChallenge::generate();
query.append("code_challenge", &challenge.code_challenge);
query.append("code_challenge_method", &challenge.challenge_method);
Some(challenge)
} else {
None
};
if let Some(ref nonce) = self.config.nonce {
query.append("nonce", nonce);
}
let query_string = query.to_string();
let base = &self.config.authorization_url;
let url = if query_string.is_empty() {
base.clone()
} else if base.contains('?') {
format!("{base}&{query_string}")
} else {
format!("{base}?{query_string}")
};
Ok((url, pkce))
}
#[must_use]
pub fn validate_state(expected: &str, actual: &str) -> bool {
expected.as_bytes() == actual.as_bytes()
}
}
#[derive(derive_more::From, Debug)]
pub enum OAuthError {
MissingClientId,
MissingClientSecret,
MissingAuthorizationUrl,
MissingTokenUrl,
MissingRedirectUri,
#[from(ignore)]
InvalidUrl(String),
InvalidState,
#[from(ignore)]
TokenRequestFailed(String),
TokenEndpointError { status: u16, message: String },
#[from(ignore)]
TokenParseError(String),
PkceFailed,
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: Option<u64>,
pub refresh_token: Option<String>,
pub scope: Option<String>,
pub id_token: Option<String>,
}
impl core::fmt::Display for OAuthError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
OAuthError::MissingClientId => write!(f, "Missing client ID"),
OAuthError::MissingClientSecret => write!(f, "Missing client secret"),
OAuthError::MissingAuthorizationUrl => write!(f, "Missing authorization URL"),
OAuthError::MissingTokenUrl => write!(f, "Missing token URL"),
OAuthError::MissingRedirectUri => write!(f, "Missing redirect URI"),
OAuthError::InvalidUrl(s) => write!(f, "Invalid URL: {s}"),
OAuthError::InvalidState => write!(f, "Invalid state parameter"),
OAuthError::TokenRequestFailed(s) => write!(f, "Token request failed: {s}"),
OAuthError::TokenEndpointError { status, message } => {
write!(f, "Token endpoint error ({status}): {message}")
}
OAuthError::TokenParseError(s) => write!(f, "Failed to parse token response: {s}"),
OAuthError::PkceFailed => write!(f, "PKCE generation failed"),
}
}
}
impl std::error::Error for OAuthError {}