light-openid 2.0.1

Lightweight OpenID primitives & client
Documentation
//! # Open ID client implementation

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;

/// OpenID client options
#[derive(Debug, Clone)]
pub struct OpenIDClientOpts {
    /// This client ID
    pub client_id: String,
    /// This client secret
    pub client_secret: Option<Zeroizing<String>>,
    /// Redirect URI to use when performing authentication requests
    pub redirect_uri: String,
    /// Disable id token signature & content check
    pub validate_id_token_sig: bool,
    /// Accepted audiences in JWT
    pub accepted_audiences: Vec<String>,
    /// Whether audience field of jwt should be validated or not
    pub validate_aud: bool,
    /// Whether expiration field of jwt should be validated or not
    pub validate_exp: bool,
    /// Whether not before field of jwt should be validated or not
    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),
}

/// OpenID client
pub struct OpenIDClient {
    pub config: OpenIDConfig,
    pub jwks: Option<jsonwebtoken::jwk::JwkSet>,
    pub opts: OpenIDClientOpts,
}

impl OpenIDClient {
    /// Construct an OpenID client by give hard-coded configuration values
    pub async fn new(
        config: OpenIDConfig,
        jwks: Option<jsonwebtoken::jwk::JwkSet>,
        opts: &OpenIDClientOpts,
    ) -> Self {
        Self {
            config,
            jwks,
            opts: opts.clone(),
        }
    }

    /// Construct an OpenID client by loading configuration from a given
    /// .well-known/openid-configuration URL
    #[tracing::instrument]
    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)
    }

    /// Get the authorization URL where a user should be redirect to perform authentication
    #[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
    }

    /// Query the token endpoint using an authorization code
    #[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
    }

    /// Query the token endpoint using a refresh token
    #[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
    }

    /// Query the token endpoint
    ///
    /// This endpoint returns both the parsed and the raw response, to allow handling
    /// of bonus fields
    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(&params),
        )
        .await?
        .text()
        .await?;

        let token: OpenIDTokenResponse = serde_json::from_str(&response)?;

        // Check id token signature
        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))
    }

    /// Decode & verify a JWT signature
    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)
    }

    /// Query the UserInfo endpoint.
    ///
    /// This endpoint should be used after having successfully retrieved the token
    ///
    /// This endpoint returns both the parsed value and the raw response, in case of presence
    /// of additional fields
    #[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))
    }
}