use base64::Engine;
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth2Config {
pub client_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub redirect_uri: String,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub token_endpoint_auth_method: TokenEndpointAuthMethod,
#[serde(default)]
pub use_basic_auth: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_info_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub introspection_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revocation_endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TokenEndpointAuthMethod {
#[default]
ClientSecretPost,
ClientSecretBasic,
None,
}
impl OAuth2Config {
pub fn new() -> Self {
Self {
client_id: String::new(),
client_secret: None,
authorization_endpoint: String::new(),
token_endpoint: String::new(),
redirect_uri: String::new(),
scopes: Vec::new(),
token_endpoint_auth_method: TokenEndpointAuthMethod::default(),
use_basic_auth: false,
user_info_endpoint: None,
introspection_endpoint: None,
revocation_endpoint: None,
}
}
pub fn client_id(mut self, id: impl Into<String>) -> Self {
self.client_id = id.into();
self
}
pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
self.client_secret = Some(secret.into());
self
}
pub fn authorization_endpoint(mut self, url: impl Into<String>) -> Self {
self.authorization_endpoint = url.into();
self
}
pub fn token_endpoint(mut self, url: impl Into<String>) -> Self {
self.token_endpoint = url.into();
self
}
pub fn redirect_uri(mut self, uri: impl Into<String>) -> Self {
self.redirect_uri = uri.into();
self
}
pub fn add_scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
pub fn scopes(mut self, scopes: Vec<String>) -> Self {
self.scopes = scopes;
self
}
pub fn token_endpoint_auth_method(mut self, method: TokenEndpointAuthMethod) -> Self {
self.token_endpoint_auth_method = method;
self
}
pub fn use_basic_auth(mut self, use_basic: bool) -> Self {
self.use_basic_auth = use_basic;
self
}
pub fn user_info_endpoint(mut self, url: impl Into<String>) -> Self {
self.user_info_endpoint = Some(url.into());
self
}
pub fn introspection_endpoint(mut self, url: impl Into<String>) -> Self {
self.introspection_endpoint = Some(url.into());
self
}
pub fn revocation_endpoint(mut self, url: impl Into<String>) -> Self {
self.revocation_endpoint = Some(url.into());
self
}
}
impl Default for OAuth2Config {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
#[serde(default = "default_token_type")]
pub token_type: String,
#[serde(default)]
pub expires_in: Option<u64>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(rename = "id_token")]
#[serde(default)]
pub id_token: Option<String>,
}
fn default_token_type() -> String {
"Bearer".to_string()
}
impl TokenResponse {
pub fn is_expired(&self) -> bool {
if let Some(expires_in) = self.expires_in {
expires_in == 0
} else {
false
}
}
pub fn authorization_header(&self) -> String {
format!("{} {}", self.token_type, self.access_token)
}
}
#[derive(Debug, Clone)]
pub struct TokenResponseWithTimestamp {
pub token: TokenResponse,
pub created_at: Instant,
}
impl TokenResponseWithTimestamp {
pub fn new(token: TokenResponse) -> Self {
Self {
token,
created_at: Instant::now(),
}
}
pub fn is_expired(&self) -> bool {
if let Some(expires_in) = self.token.expires_in {
let elapsed = self.created_at.elapsed().as_secs();
elapsed >= expires_in
} else {
false
}
}
pub fn remaining_seconds(&self) -> Option<u64> {
self.token
.expires_in
.map(|exp| exp.saturating_sub(self.created_at.elapsed().as_secs()))
}
pub fn authorization_header(&self) -> String {
self.token.authorization_header()
}
}
#[derive(Debug, Clone)]
pub struct PkceParams {
pub code_verifier: String,
pub code_challenge: String,
pub code_challenge_method: String,
}
impl PkceParams {
pub fn generate() -> Self {
let code_verifier = Self::generate_code_verifier();
let code_challenge = Self::generate_code_challenge(&code_verifier);
Self {
code_verifier,
code_challenge,
code_challenge_method: "S256".to_string(),
}
}
pub fn generate_code_verifier() -> String {
let mut rng = rand::rng();
let bytes: [u8; 32] = rng.random();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
pub fn generate_code_challenge(code_verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let hash = hasher.finalize();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
}
}
#[derive(Debug, Clone)]
pub struct StateManager {
state: String,
}
impl StateManager {
pub fn generate() -> Self {
let mut rng = rand::rng();
let bytes: [u8; 32] = rng.random();
Self {
state: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes),
}
}
pub fn from_value(value: impl Into<String>) -> Self {
Self {
state: value.into(),
}
}
pub fn value(&self) -> &str {
&self.state
}
pub fn validate(&self, received_state: &str) -> bool {
subtle::ConstantTimeEq::ct_eq(self.state.as_bytes(), received_state.as_bytes()).into()
}
}
pub struct OAuth2Client {
config: Arc<OAuth2Config>,
#[cfg(feature = "http-client")]
http_client: Arc<reqwest::Client>,
}
impl OAuth2Client {
pub fn new(config: OAuth2Config) -> Self {
Self {
config: Arc::new(config),
#[cfg(feature = "http-client")]
http_client: Arc::new(reqwest::Client::new()),
}
}
pub fn config(&self) -> &OAuth2Config {
&self.config
}
pub fn get_authorization_url_with_state(&self, scopes: &str) -> (String, StateManager) {
let state_mgr = StateManager::generate();
let url = self.build_authorization_url(scopes, state_mgr.value(), None);
(url, state_mgr)
}
pub fn get_authorization_url_with_pkce(
&self,
scopes: &str,
) -> (String, StateManager, PkceParams) {
let state_mgr = StateManager::generate();
let pkce = PkceParams::generate();
let url =
self.build_authorization_url(scopes, state_mgr.value(), Some(&pkce.code_challenge));
(url, state_mgr, pkce)
}
fn build_authorization_url(
&self,
scopes: &str,
state: &str,
code_challenge: Option<&str>,
) -> String {
let scopes_joined = if self.config.scopes.is_empty() {
scopes.to_string()
} else {
self.config.scopes.join(" ")
};
let mut url = format!(
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}",
self.config.authorization_endpoint,
urlencoding::encode(&self.config.client_id),
urlencoding::encode(&self.config.redirect_uri),
urlencoding::encode(&scopes_joined),
urlencoding::encode(state),
);
if let Some(challenge) = code_challenge {
url.push_str("&code_challenge=");
url.push_str(&urlencoding::encode(challenge));
url.push_str("&code_challenge_method=S256");
}
url
}
pub fn get_authorization_url(&self, scopes: &str) -> String {
let state = Self::generate_state();
self.build_authorization_url(scopes, &state, None)
}
fn generate_state() -> String {
let mut rng = rand::rng();
let bytes: [u8; 16] = rng.random();
hex::encode(bytes)
}
#[cfg(feature = "http-client")]
pub async fn exchange_code(
&self,
code: &str,
redirect_uri: &str,
) -> SecurityResult<TokenResponse> {
self.exchange_token(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
])
.await
}
#[cfg(feature = "http-client")]
pub async fn exchange_code_with_pkce(
&self,
code: &str,
redirect_uri: &str,
pkce: &PkceParams,
) -> SecurityResult<TokenResponse> {
self.exchange_token(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &pkce.code_verifier),
])
.await
}
#[cfg(feature = "http-client")]
pub async fn exchange_client_credentials(&self) -> SecurityResult<TokenResponse> {
self.exchange_token(&[("grant_type", "client_credentials")])
.await
}
#[cfg(feature = "http-client")]
pub async fn exchange_password(
&self,
username: &str,
password: &str,
) -> SecurityResult<TokenResponse> {
self.exchange_token(&[
("grant_type", "password"),
("username", username),
("password", password),
])
.await
}
#[cfg(feature = "http-client")]
pub async fn refresh_token(&self, refresh_token: &str) -> SecurityResult<TokenResponse> {
self.exchange_token(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
])
.await
}
#[cfg(feature = "http-client")]
pub async fn get_user_info(&self, access_token: &str) -> SecurityResult<UserInfo> {
let endpoint = self.config.user_info_endpoint.as_deref().ok_or_else(|| {
SecurityError::authentication_error(
"user_info_endpoint is not configured in OAuth2Config",
)
})?;
let response = self
.http_client
.get(endpoint)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| SecurityError::io_error(format!("Failed to fetch user info: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(SecurityError::authentication_error(format!(
"User info request failed: {} - {}",
status, error_text
)));
}
let user_info: UserInfo = response.json().await.map_err(|e| {
SecurityError::io_error(format!("Failed to parse user info response: {}", e))
})?;
Ok(user_info)
}
#[cfg(feature = "http-client")]
pub async fn validate_token(
&self,
access_token: &str,
) -> SecurityResult<IntrospectionResponse> {
let endpoint = self
.config
.introspection_endpoint
.as_deref()
.ok_or_else(|| {
SecurityError::authentication_error(
"introspection_endpoint is not configured in OAuth2Config",
)
})?;
let mut request = self.http_client.post(endpoint);
match self.config.token_endpoint_auth_method {
TokenEndpointAuthMethod::ClientSecretBasic => {
if let Some(ref secret) = self.config.client_secret {
let auth = format!("{}:{}", self.config.client_id, secret);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request = request.header("Authorization", format!("Basic {}", encoded));
}
},
TokenEndpointAuthMethod::ClientSecretPost | TokenEndpointAuthMethod::None => {},
}
let mut params = vec![("token", access_token.to_string())];
if self.config.token_endpoint_auth_method == TokenEndpointAuthMethod::ClientSecretPost {
params.push(("client_id", self.config.client_id.clone()));
if let Some(ref secret) = self.config.client_secret {
params.push(("client_secret", secret.clone()));
}
}
let response = request
.form(¶ms)
.send()
.await
.map_err(|e| SecurityError::io_error(format!("Failed to validate token: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(SecurityError::authentication_error(format!(
"Token introspection failed: {} - {}",
status, error_text
)));
}
let introspection: IntrospectionResponse = response.json().await.map_err(|e| {
SecurityError::io_error(format!("Failed to parse introspection response: {}", e))
})?;
Ok(introspection)
}
#[cfg(feature = "http-client")]
pub async fn revoke_token(
&self,
token: &str,
token_type_hint: Option<&str>,
) -> SecurityResult<()> {
let endpoint = self.config.revocation_endpoint.as_deref().ok_or_else(|| {
SecurityError::authentication_error(
"revocation_endpoint is not configured in OAuth2Config",
)
})?;
let mut request = self.http_client.post(endpoint);
match self.config.token_endpoint_auth_method {
TokenEndpointAuthMethod::ClientSecretBasic => {
if let Some(ref secret) = self.config.client_secret {
let auth = format!("{}:{}", self.config.client_id, secret);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request = request.header("Authorization", format!("Basic {}", encoded));
}
},
TokenEndpointAuthMethod::ClientSecretPost | TokenEndpointAuthMethod::None => {},
}
let mut params = vec![("token", token.to_string())];
if let Some(hint) = token_type_hint {
params.push(("token_type_hint", hint.to_string()));
}
if self.config.token_endpoint_auth_method == TokenEndpointAuthMethod::ClientSecretPost {
params.push(("client_id", self.config.client_id.clone()));
if let Some(ref secret) = self.config.client_secret {
params.push(("client_secret", secret.clone()));
}
}
let response = request
.form(¶ms)
.send()
.await
.map_err(|e| SecurityError::io_error(format!("Failed to revoke token: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(SecurityError::authentication_error(format!(
"Token revocation failed: {} - {}",
status, error_text
)));
}
Ok(())
}
#[cfg(feature = "http-client")]
async fn exchange_token(&self, params: &[(&str, &str)]) -> SecurityResult<TokenResponse> {
let mut request = self.http_client.post(&self.config.token_endpoint);
match self.config.token_endpoint_auth_method {
TokenEndpointAuthMethod::ClientSecretBasic => {
if let Some(ref secret) = self.config.client_secret {
let auth = format!("{}:{}", self.config.client_id, secret);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request = request.header("Authorization", format!("Basic {}", encoded));
}
},
TokenEndpointAuthMethod::ClientSecretPost => {
},
TokenEndpointAuthMethod::None => {
},
}
let mut form: Vec<(&str, String)> =
params.iter().map(|(k, v)| (*k, (*v).to_string())).collect();
if self.config.token_endpoint_auth_method == TokenEndpointAuthMethod::ClientSecretPost {
if let Some(ref secret) = self.config.client_secret {
form.push(("client_id", self.config.client_id.clone()));
form.push(("client_secret", secret.clone()));
}
}
let form_refs: Vec<(&str, &str)> = form.iter().map(|(k, v)| (*k, v.as_str())).collect();
let response = request
.form(&form_refs)
.send()
.await
.map_err(|e| SecurityError::io_error(format!("Failed to exchange token: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(SecurityError::authentication_error(format!(
"Token exchange failed: {} - {}",
status, error_text
)));
}
let token_response: TokenResponse = response.json().await.map_err(|e| {
SecurityError::io_error(format!("Failed to parse token response: {}", e))
})?;
Ok(token_response)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntrospectionResponse {
pub active: bool,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub token_type: Option<String>,
#[serde(default)]
pub exp: Option<i64>,
#[serde(default)]
pub iat: Option<i64>,
#[serde(default)]
pub nbf: Option<i64>,
#[serde(default)]
pub sub: Option<String>,
#[serde(default)]
pub aud: Option<String>,
#[serde(default)]
pub iss: Option<String>,
#[serde(default)]
pub jti: Option<String>,
}
impl IntrospectionResponse {
pub fn is_active(&self) -> bool {
self.active
}
pub fn is_expired(&self) -> bool {
if let Some(exp) = self.exp {
let now = chrono::Utc::now().timestamp();
now >= exp
} else {
false
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OIDCDiscoveryDocument {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
#[serde(rename = "userinfo_endpoint")]
pub user_info_endpoint: Option<String>,
#[serde(rename = "jwks_uri")]
pub jwks_uri: Option<String>,
#[serde(rename = "end_session_endpoint")]
pub end_session_endpoint: Option<String>,
#[serde(rename = "response_types_supported")]
pub response_types_supported: Vec<String>,
#[serde(rename = "subject_types_supported")]
pub subject_types_supported: Vec<String>,
#[serde(rename = "id_token_signing_alg_values_supported")]
pub id_token_signing_alg_values_supported: Vec<String>,
#[serde(rename = "scopes_supported")]
pub scopes_supported: Vec<String>,
#[serde(rename = "introspection_endpoint")]
#[serde(default)]
pub introspection_endpoint: Option<String>,
#[serde(rename = "revocation_endpoint")]
#[serde(default)]
pub revocation_endpoint: Option<String>,
}
impl OIDCDiscoveryDocument {
pub fn to_oauth2_config(
&self,
client_id: String,
client_secret: Option<String>,
redirect_uri: String,
) -> OAuth2Config {
OAuth2Config {
client_id,
client_secret,
authorization_endpoint: self.authorization_endpoint.clone(),
token_endpoint: self.token_endpoint.clone(),
redirect_uri,
scopes: self.scopes_supported.clone(),
token_endpoint_auth_method: TokenEndpointAuthMethod::ClientSecretPost,
use_basic_auth: false,
user_info_endpoint: self.user_info_endpoint.clone(),
introspection_endpoint: self.introspection_endpoint.clone(),
revocation_endpoint: self.revocation_endpoint.clone(),
}
}
}
pub struct OIDCDiscovery {
issuer_url: String,
#[cfg(feature = "http-client")]
http_client: Arc<reqwest::Client>,
}
impl OIDCDiscovery {
pub fn new(issuer_url: impl Into<String>) -> Self {
Self {
issuer_url: issuer_url.into(),
#[cfg(feature = "http-client")]
http_client: Arc::new(reqwest::Client::new()),
}
}
#[cfg(feature = "http-client")]
pub async fn fetch(&self) -> SecurityResult<OIDCDiscoveryDocument> {
let discovery_url =
format!("{}/.well-known/openid-configuration", self.issuer_url.trim_end_matches('/'));
let response = self
.http_client
.get(&discovery_url)
.send()
.await
.map_err(|e| {
SecurityError::io_error(format!("Failed to fetch discovery document: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(SecurityError::io_error(format!(
"Discovery request failed: {} - {}",
status, error_text
)));
}
let doc: OIDCDiscoveryDocument = response.json().await.map_err(|e| {
SecurityError::io_error(format!("Failed to parse discovery document: {}", e))
})?;
Ok(doc)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub sub: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub given_name: Option<String>,
#[serde(default)]
pub family_name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub email_verified: Option<bool>,
#[serde(default)]
pub picture: Option<String>,
#[serde(default)]
pub locale: Option<String>,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_oauth2_config_builder() {
let config = OAuth2Config::new()
.client_id("test-client")
.client_secret("test-secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://app.example.com/callback")
.user_info_endpoint("https://auth.example.com/userinfo")
.introspection_endpoint("https://auth.example.com/introspect")
.revocation_endpoint("https://auth.example.com/revoke")
.add_scope("read")
.add_scope("write");
assert_eq!(config.client_id, "test-client");
assert_eq!(config.client_secret, Some("test-secret".to_string()));
assert_eq!(config.scopes.len(), 2);
assert_eq!(
config.user_info_endpoint,
Some("https://auth.example.com/userinfo".to_string())
);
assert_eq!(
config.introspection_endpoint,
Some("https://auth.example.com/introspect".to_string())
);
assert_eq!(config.revocation_endpoint, Some("https://auth.example.com/revoke".to_string()));
}
#[test]
fn test_token_response_authorization_header() {
let token = TokenResponse {
access_token: "my-access-token".to_string(),
token_type: "Bearer".to_string(),
expires_in: Some(3600),
refresh_token: Some("my-refresh-token".to_string()),
scope: Some("read write".to_string()),
id_token: None,
};
assert_eq!(token.authorization_header(), "Bearer my-access-token");
}
#[test]
fn test_token_response_not_expired() {
let token = TokenResponse {
access_token: "my-access-token".to_string(),
token_type: "Bearer".to_string(),
expires_in: Some(3600),
refresh_token: None,
scope: None,
id_token: None,
};
assert!(!token.is_expired());
}
#[test]
fn test_token_response_with_timestamp() {
let token = TokenResponse {
access_token: "my-token".to_string(),
token_type: "Bearer".to_string(),
expires_in: Some(3600),
refresh_token: None,
scope: None,
id_token: None,
};
let wrapped = TokenResponseWithTimestamp::new(token);
assert!(!wrapped.is_expired());
let remaining = wrapped.remaining_seconds().unwrap();
assert!(remaining <= 3600);
assert!(remaining > 3595);
assert_eq!(wrapped.authorization_header(), "Bearer my-token");
}
#[test]
fn test_authorization_url_generation() {
let config = OAuth2Config::new()
.client_id("test-client")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://app.example.com/callback")
.scopes(vec!["openid".to_string(), "profile".to_string()]);
let client = OAuth2Client::new(config);
let auth_url = client.get_authorization_url("custom_scope");
assert!(auth_url.contains("client_id=test-client"));
assert!(auth_url.contains("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback"));
assert!(auth_url.contains("response_type=code"));
assert!(auth_url.contains("state="));
}
#[test]
fn test_authorization_url_with_state() {
let config = OAuth2Config::new()
.client_id("test-client")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://app.example.com/callback");
let client = OAuth2Client::new(config);
let (auth_url, state_mgr) = client.get_authorization_url_with_state("openid profile");
assert!(auth_url.contains("state="));
assert!(!state_mgr.value().is_empty());
}
#[test]
fn test_authorization_url_with_pkce() {
let config = OAuth2Config::new()
.client_id("test-client")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://app.example.com/callback");
let client = OAuth2Client::new(config);
let (auth_url, state_mgr, pkce) = client.get_authorization_url_with_pkce("openid");
assert!(auth_url.contains("code_challenge="));
assert!(auth_url.contains("code_challenge_method=S256"));
assert!(!pkce.code_verifier.is_empty());
assert!(!pkce.code_challenge.is_empty());
assert_eq!(pkce.code_challenge_method, "S256");
assert!(auth_url.contains(state_mgr.value()));
}
#[test]
fn test_pkce_params_generation() {
let pkce = PkceParams::generate();
assert_eq!(pkce.code_verifier.len(), 43);
assert_eq!(pkce.code_challenge_method, "S256");
assert_eq!(pkce.code_challenge.len(), 43);
let recomputed = PkceParams::generate_code_challenge(&pkce.code_verifier);
assert_eq!(pkce.code_challenge, recomputed);
}
#[test]
fn test_pkce_verifier_uniqueness() {
let a = PkceParams::generate();
let b = PkceParams::generate();
assert_ne!(a.code_verifier, b.code_verifier);
}
#[test]
fn test_pkce_challenge_deterministic() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = PkceParams::generate_code_challenge(verifier);
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
#[test]
fn test_state_manager_generate_and_validate() {
let mgr = StateManager::generate();
let value = mgr.value().to_string();
assert!(mgr.validate(&value));
assert!(!mgr.validate("wrong-state"));
assert!(!mgr.validate(""));
}
#[test]
fn test_state_manager_from_value() {
let mgr = StateManager::from_value("known-state-123");
assert_eq!(mgr.value(), "known-state-123");
assert!(mgr.validate("known-state-123"));
assert!(!mgr.validate("known-state-456"));
}
#[test]
fn test_state_manager_uniqueness() {
let a = StateManager::generate();
let b = StateManager::generate();
assert_ne!(a.value(), b.value());
}
#[test]
fn test_introspection_response_active() {
let resp = IntrospectionResponse {
active: true,
scope: Some("read write".to_string()),
client_id: Some("my-client".to_string()),
username: Some("alice".to_string()),
token_type: Some("Bearer".to_string()),
exp: Some(chrono::Utc::now().timestamp() + 3600),
iat: Some(chrono::Utc::now().timestamp() - 60),
nbf: None,
sub: Some("user-123".to_string()),
aud: Some("my-api".to_string()),
iss: Some("https://auth.example.com".to_string()),
jti: None,
};
assert!(resp.is_active());
assert!(!resp.is_expired());
}
#[test]
fn test_introspection_response_expired() {
let resp = IntrospectionResponse {
active: false,
scope: None,
client_id: None,
username: None,
token_type: None,
exp: Some(chrono::Utc::now().timestamp() - 100),
iat: None,
nbf: None,
sub: None,
aud: None,
iss: None,
jti: None,
};
assert!(!resp.is_active());
assert!(resp.is_expired());
}
#[test]
fn test_introspection_response_serialization() {
let json = r#"{
"active": true,
"scope": "read write",
"client_id": "my-client",
"username": "alice",
"exp": 1234567890,
"sub": "user-123"
}"#;
let resp: IntrospectionResponse = serde_json::from_str(json).unwrap();
assert!(resp.active);
assert_eq!(resp.scope, Some("read write".to_string()));
assert_eq!(resp.client_id, Some("my-client".to_string()));
assert_eq!(resp.username, Some("alice".to_string()));
assert_eq!(resp.sub, Some("user-123".to_string()));
}
#[test]
fn test_user_info_serialization() {
let json = r#"{
"sub": "user-123",
"name": "Alice Smith",
"given_name": "Alice",
"family_name": "Smith",
"email": "alice@example.com",
"email_verified": true,
"picture": "https://example.com/avatar.jpg",
"locale": "en",
"custom_claim": "custom_value"
}"#;
let user_info: UserInfo = serde_json::from_str(json).unwrap();
assert_eq!(user_info.sub, "user-123");
assert_eq!(user_info.name, Some("Alice Smith".to_string()));
assert_eq!(user_info.email, Some("alice@example.com".to_string()));
assert_eq!(user_info.email_verified, Some(true));
assert_eq!(
user_info.additional.get("custom_claim").unwrap(),
&serde_json::Value::String("custom_value".to_string())
);
}
#[test]
fn test_oidc_discovery_document_serialization() {
let json = r#"{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"end_session_endpoint": "https://auth.example.com/logout",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"introspection_endpoint": "https://auth.example.com/introspect",
"revocation_endpoint": "https://auth.example.com/revoke"
}"#;
let doc: OIDCDiscoveryDocument = serde_json::from_str(json).unwrap();
assert_eq!(doc.issuer, "https://auth.example.com");
assert_eq!(
doc.introspection_endpoint,
Some("https://auth.example.com/introspect".to_string())
);
assert_eq!(doc.revocation_endpoint, Some("https://auth.example.com/revoke".to_string()));
let config = doc.to_oauth2_config(
"my-client".to_string(),
Some("my-secret".to_string()),
"https://myapp.example.com/callback".to_string(),
);
assert_eq!(config.client_id, "my-client");
assert_eq!(
config.user_info_endpoint,
Some("https://auth.example.com/userinfo".to_string())
);
assert_eq!(
config.introspection_endpoint,
Some("https://auth.example.com/introspect".to_string())
);
}
#[test]
fn test_token_endpoint_auth_method_default() {
assert_eq!(TokenEndpointAuthMethod::default(), TokenEndpointAuthMethod::ClientSecretPost);
}
}