use crate::code_challenge::CodeChallenge;
use crate::errors::{OpenIdError, Res};
use crate::primitives::{OpenIDConfig, OpenIDTokenResponse, OpenIDUserInfo};
use crate::utils::http_client;
use jsonwebtoken::{DecodingKey, TokenData, Validation};
use serde::de::DeserializeOwned;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use zeroize::Zeroizing;
#[derive(Debug, Clone)]
pub struct OpenIDClientOpts {
pub client_id: String,
pub client_secret: Option<Zeroizing<String>>,
pub redirect_uri: String,
pub validate_id_token_sig: bool,
pub accepted_audiences: Vec<String>,
pub validate_aud: bool,
pub validate_exp: bool,
pub validate_nbf: bool,
}
impl OpenIDClientOpts {
pub fn new(
client_id: impl Display,
client_secret: Option<&str>,
redirect_uri: impl Display,
) -> Self {
Self {
client_id: client_id.to_string(),
client_secret: client_secret.map(|s| Zeroizing::new(s.to_string())),
redirect_uri: redirect_uri.to_string(),
accepted_audiences: vec![client_id.to_string()],
validate_id_token_sig: true,
validate_aud: true,
validate_exp: true,
validate_nbf: false,
}
}
}
enum TokenEndpointAuth<'a> {
AuthorizationCode(&'a str),
RefreshToken(&'a str),
}
#[derive(Clone)]
pub struct OpenIDClient {
pub config: OpenIDConfig,
pub jwks: Option<jsonwebtoken::jwk::JwkSet>,
pub opts: OpenIDClientOpts,
}
impl OpenIDClient {
pub async fn new(
config: OpenIDConfig,
jwks: Option<jsonwebtoken::jwk::JwkSet>,
opts: &OpenIDClientOpts,
) -> Self {
Self {
config,
jwks,
opts: opts.clone(),
}
}
#[tracing::instrument(skip(opts))]
pub async fn new_from_url(url: &str, opts: &OpenIDClientOpts) -> Res<Self> {
let config = http_client::get_json_request(url).await?;
let mut client = Self {
config,
jwks: None,
opts: opts.clone(),
};
if opts.validate_id_token_sig {
client.jwks = Some(http_client::get_json_request(&client.config.jwks_uri).await?)
}
Ok(client)
}
#[tracing::instrument(skip(self, state))]
pub fn gen_authorization_url(
&self,
state: &str,
code_challenge: Option<CodeChallenge>,
) -> String {
let client_id = urlencoding::encode(self.opts.client_id.as_str());
let state = urlencoding::encode(state);
let redirect_uri = urlencoding::encode(self.opts.redirect_uri.as_str());
let mut url = format!(
"{}?response_type=code&scope=openid%20profile%20email&client_id={client_id}&state={state}&redirect_uri={redirect_uri}",
self.config.authorization_endpoint
);
if let Some(chlg) = code_challenge {
let code_challenge = urlencoding::encode(&chlg.code_challenge);
let code_challenge_method = urlencoding::encode(&chlg.code_challenge_method);
url.push_str(&format!(
"&code_challenge={code_challenge}&code_challenge_method={code_challenge_method}"
))
}
url
}
#[tracing::instrument(skip(self, code, code_verifier))]
pub async fn request_token_from_code(
&self,
code: &str,
code_verifier: Option<&str>,
) -> Res<(OpenIDTokenResponse, String)> {
self.request_token(&TokenEndpointAuth::AuthorizationCode(code), code_verifier)
.await
}
#[tracing::instrument(skip(self, refresh_token))]
pub async fn request_token_from_refresh_token(
&self,
refresh_token: &str,
) -> Res<(OpenIDTokenResponse, String)> {
self.request_token(&TokenEndpointAuth::RefreshToken(refresh_token), None)
.await
}
async fn request_token(
&self,
auth: &TokenEndpointAuth<'_>,
code_verifier: Option<&str>,
) -> Res<(OpenIDTokenResponse, String)> {
let mut params = HashMap::new();
match auth {
TokenEndpointAuth::AuthorizationCode(code) => {
params.insert("grant_type", "authorization_code");
params.insert("code", code);
}
TokenEndpointAuth::RefreshToken(token) => {
params.insert("grant_type", "refresh_token");
params.insert("refresh_token", token);
}
}
if let Some(verifier) = code_verifier {
params.insert("code_verifier", verifier);
}
params.insert("redirect_uri", self.opts.redirect_uri.as_str());
let response = http_client::send_request(
reqwest::Client::new()
.post(&self.config.token_endpoint)
.basic_auth(&self.opts.client_id, self.opts.client_secret.as_deref())
.form(¶ms),
)
.await?
.text()
.await?;
let token: OpenIDTokenResponse = serde_json::from_str(&response)?;
if self.opts.validate_id_token_sig
&& self.jwks.is_some()
&& let Some(id_token) = &token.id_token
{
self.verify_jwt::<serde_json::Value>(id_token)
.map_err(|e| OpenIdError::ValidateIdToken(Box::new(e)))?;
}
Ok((token, response))
}
pub fn verify_jwt<T: DeserializeOwned>(&self, jwt: &str) -> Res<TokenData<T>> {
let header = jsonwebtoken::decode_header(jwt).map_err(OpenIdError::DecodeJWTHeader)?;
let Some(kid) = header.kid else {
return Err(OpenIdError::KidRequiredForSignatureVerification);
};
let Some(jwks) = &self.jwks else {
return Err(OpenIdError::MissingJWKs);
};
let Some(jwk) = jwks.find(&kid) else {
return Err(OpenIdError::UnknownKid(kid));
};
let decoding_key = DecodingKey::from_jwk(jwk).map_err(OpenIdError::DecodeJWK)?;
let mut validation = Validation::new(header.alg);
validation.validate_aud = true;
validation.aud = Some(HashSet::from_iter(
self.opts.accepted_audiences.iter().cloned(),
));
validation.validate_aud = self.opts.validate_aud;
validation.validate_exp = self.opts.validate_exp;
validation.validate_nbf = self.opts.validate_nbf;
jsonwebtoken::decode(jwt, &decoding_key, &validation).map_err(OpenIdError::ValidateJWT)
}
#[tracing::instrument(skip(self, token))]
pub async fn request_user_info(
&self,
token: &OpenIDTokenResponse,
) -> Res<(OpenIDUserInfo, String)> {
let response = http_client::send_request(
reqwest::Client::new()
.get(self.config.userinfo_endpoint.as_ref().expect(
"This client only support information retrieval through userinfo endpoint!",
))
.header("Authorization", format!("Bearer {}", token.access_token)),
)
.await?
.text()
.await?;
Ok((serde_json::from_str(&response)?, response))
}
}