use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcConfig {
pub provider_name: String,
pub issuer_url: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
#[serde(default = "default_scopes")]
pub scopes: Vec<String>,
}
fn default_scopes() -> Vec<String> {
vec!["openid".to_owned(), "email".to_owned(), "profile".to_owned()]
}
#[derive(Debug, Clone)]
pub struct OidcUser {
pub sub: String,
pub email: Option<String>,
pub email_verified: bool,
pub name: Option<String>,
pub provider: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SsoError {
#[error("OIDC configuration error: {0}")]
Config(String),
#[error("OIDC discovery failed: {0}")]
Discovery(String),
#[error("OIDC token exchange failed: {0}")]
TokenExchange(String),
#[error("OIDC userinfo failed: {0}")]
UserInfo(String),
#[error("OIDC provider not configured: {0}")]
NotConfigured(String),
}
pub fn authorization_url(config: &OidcConfig, state: &str, nonce: &str) -> String {
let scope = config.scopes.join(" ");
format!(
"{}/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}",
config.issuer_url.trim_end_matches('/'),
urlencoding::encode(&config.client_id),
urlencoding::encode(&config.redirect_uri),
urlencoding::encode(&scope),
urlencoding::encode(state),
urlencoding::encode(nonce),
)
}
pub fn generate_state() -> String {
use rand::RngCore;
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn generate_nonce() -> String {
generate_state()
}
mod urlencoding {
pub fn encode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for byte in s.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(*byte as char);
}
_ => {
result.push_str(&format!("%{:02X}", byte));
}
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_authorization_url_construction() {
let config = OidcConfig {
provider_name: "Test".to_owned(),
issuer_url: "https://example.com".to_owned(),
client_id: "test-client".to_owned(),
client_secret: "test-secret".to_owned(),
redirect_uri: "https://hub.example.com/auth/callback".to_owned(),
scopes: vec!["openid".to_owned(), "email".to_owned()],
};
let url = authorization_url(&config, "test-state", "test-nonce");
assert!(url.starts_with("https://example.com/authorize?"));
assert!(url.contains("client_id=test-client"));
assert!(url.contains("state=test-state"));
assert!(url.contains("nonce=test-nonce"));
assert!(url.contains("scope=openid"));
}
#[test]
fn test_generate_state_is_unique() {
let s1 = generate_state();
let s2 = generate_state();
assert_eq!(s1.len(), 64); assert_ne!(s1, s2);
}
#[test]
fn test_default_scopes() {
let scopes = default_scopes();
assert_eq!(scopes, vec!["openid", "email", "profile"]);
}
}