use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub scope: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcDiscovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: String,
pub jwks_uri: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcTokenResponse {
pub access_token: String,
pub token_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcUserInfo {
pub sub: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum OidcError {
#[error("discovery failed: {0}")]
DiscoveryFailed(String),
#[error("token exchange failed: {0}")]
TokenExchangeFailed(String),
#[error("userinfo fetch failed: {0}")]
UserInfoFailed(String),
#[error("HTTP error: {0}")]
HttpError(String),
}
pub async fn fetch_discovery(
client: &reqwest::Client,
issuer: &str,
) -> Result<OidcDiscovery, OidcError> {
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
let discovery: OidcDiscovery = resp
.json()
.await
.map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
Ok(discovery)
}
pub async fn exchange_code(
client: &reqwest::Client,
token_endpoint: &str,
config: &OidcConfig,
code: &str,
code_verifier: Option<&str>,
) -> Result<OidcTokenResponse, OidcError> {
let mut params = vec![
("grant_type", "authorization_code".to_string()),
("code", code.to_string()),
("redirect_uri", config.redirect_uri.clone()),
("client_id", config.client_id.clone()),
("client_secret", config.client_secret.clone()),
];
if let Some(verifier) = code_verifier {
params.push(("code_verifier", verifier.to_string()));
}
let resp = client
.post(token_endpoint)
.form(¶ms)
.send()
.await
.map_err(|e| OidcError::HttpError(e.to_string()))?;
let token_resp: OidcTokenResponse = resp
.json()
.await
.map_err(|e| OidcError::TokenExchangeFailed(e.to_string()))?;
Ok(token_resp)
}
pub async fn fetch_user_info(
client: &reqwest::Client,
userinfo_endpoint: &str,
access_token: &str,
) -> Result<OidcUserInfo, OidcError> {
let resp = client
.get(userinfo_endpoint)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| OidcError::HttpError(e.to_string()))?;
let user_info: OidcUserInfo = resp
.json()
.await
.map_err(|e| OidcError::UserInfoFailed(e.to_string()))?;
Ok(user_info)
}
pub async fn refresh_token(
client: &reqwest::Client,
token_endpoint: &str,
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> Result<OidcTokenResponse, OidcError> {
let resp = client
.post(token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("client_id", client_id),
("client_secret", client_secret),
("refresh_token", refresh_token),
])
.send()
.await
.map_err(|e| OidcError::HttpError(e.to_string()))?;
let token_resp: OidcTokenResponse = resp
.json()
.await
.map_err(|e| OidcError::TokenExchangeFailed(e.to_string()))?;
Ok(token_resp)
}
pub fn end_session_url(issuer: &str, client_id: &str, post_logout_redirect: &str) -> String {
format!(
"{}/logout?client_id={}&post_logout_redirect_uri={}",
issuer.trim_end_matches('/'),
urlencoding::encode(client_id),
urlencoding::encode(post_logout_redirect),
)
}