Skip to main content

light_openid/
client.rs

1//! # Open ID client implementation
2
3use crate::code_challenge::CodeChallenge;
4use crate::errors::{OpenIdError, Res};
5use crate::primitives::{OpenIDConfig, OpenIDTokenResponse, OpenIDUserInfo};
6use crate::utils::http_client;
7use jsonwebtoken::{DecodingKey, TokenData, Validation};
8use serde::de::DeserializeOwned;
9use std::collections::{HashMap, HashSet};
10use std::fmt::Display;
11use zeroize::Zeroizing;
12
13/// OpenID client options
14#[derive(Debug, Clone)]
15pub struct OpenIDClientOpts {
16    /// This client ID
17    pub client_id: String,
18    /// This client secret
19    pub client_secret: Option<Zeroizing<String>>,
20    /// Redirect URI to use when performing authentication requests
21    pub redirect_uri: String,
22    /// Disable id token signature & content check
23    pub validate_id_token_sig: bool,
24    /// Accepted audiences in JWT
25    pub accepted_audiences: Vec<String>,
26    /// Whether audience field of jwt should be validated or not
27    pub validate_aud: bool,
28    /// Whether expiration field of jwt should be validated or not
29    pub validate_exp: bool,
30    /// Whether not before field of jwt should be validated or not
31    pub validate_nbf: bool,
32}
33
34impl OpenIDClientOpts {
35    pub fn new(
36        client_id: impl Display,
37        client_secret: Option<&str>,
38        redirect_uri: impl Display,
39    ) -> Self {
40        Self {
41            client_id: client_id.to_string(),
42            client_secret: client_secret.map(|s| Zeroizing::new(s.to_string())),
43            redirect_uri: redirect_uri.to_string(),
44            accepted_audiences: vec![client_id.to_string()],
45            validate_id_token_sig: true,
46            validate_aud: true,
47            validate_exp: true,
48            validate_nbf: false,
49        }
50    }
51}
52
53enum TokenEndpointAuth<'a> {
54    AuthorizationCode(&'a str),
55    RefreshToken(&'a str),
56}
57
58/// OpenID client
59#[derive(Clone)]
60pub struct OpenIDClient {
61    pub config: OpenIDConfig,
62    pub jwks: Option<jsonwebtoken::jwk::JwkSet>,
63    pub opts: OpenIDClientOpts,
64}
65
66impl OpenIDClient {
67    /// Construct an OpenID client by give hard-coded configuration values
68    pub async fn new(
69        config: OpenIDConfig,
70        jwks: Option<jsonwebtoken::jwk::JwkSet>,
71        opts: &OpenIDClientOpts,
72    ) -> Self {
73        Self {
74            config,
75            jwks,
76            opts: opts.clone(),
77        }
78    }
79
80    /// Construct an OpenID client by loading configuration from a given
81    /// .well-known/openid-configuration URL
82    #[tracing::instrument(skip(opts))]
83    pub async fn new_from_url(url: &str, opts: &OpenIDClientOpts) -> Res<Self> {
84        let config = http_client::get_json_request(url).await?;
85
86        let mut client = Self {
87            config,
88            jwks: None,
89            opts: opts.clone(),
90        };
91
92        if opts.validate_id_token_sig {
93            client.jwks = Some(http_client::get_json_request(&client.config.jwks_uri).await?)
94        }
95
96        Ok(client)
97    }
98
99    /// Get the authorization URL where a user should be redirect to perform authentication
100    #[tracing::instrument(skip(self, state))]
101    pub fn gen_authorization_url(
102        &self,
103        state: &str,
104        code_challenge: Option<CodeChallenge>,
105    ) -> String {
106        let client_id = urlencoding::encode(self.opts.client_id.as_str());
107        let state = urlencoding::encode(state);
108        let redirect_uri = urlencoding::encode(self.opts.redirect_uri.as_str());
109
110        let mut url = format!(
111            "{}?response_type=code&scope=openid%20profile%20email&client_id={client_id}&state={state}&redirect_uri={redirect_uri}",
112            self.config.authorization_endpoint
113        );
114
115        if let Some(chlg) = code_challenge {
116            let code_challenge = urlencoding::encode(&chlg.code_challenge);
117            let code_challenge_method = urlencoding::encode(&chlg.code_challenge_method);
118
119            url.push_str(&format!(
120                "&code_challenge={code_challenge}&code_challenge_method={code_challenge_method}"
121            ))
122        }
123
124        url
125    }
126
127    /// Query the token endpoint using an authorization code
128    #[tracing::instrument(skip(self, code, code_verifier))]
129    pub async fn request_token_from_code(
130        &self,
131        code: &str,
132        code_verifier: Option<&str>,
133    ) -> Res<(OpenIDTokenResponse, String)> {
134        self.request_token(&TokenEndpointAuth::AuthorizationCode(code), code_verifier)
135            .await
136    }
137
138    /// Query the token endpoint using a refresh token
139    #[tracing::instrument(skip(self, refresh_token))]
140    pub async fn request_token_from_refresh_token(
141        &self,
142        refresh_token: &str,
143    ) -> Res<(OpenIDTokenResponse, String)> {
144        self.request_token(&TokenEndpointAuth::RefreshToken(refresh_token), None)
145            .await
146    }
147
148    /// Query the token endpoint
149    ///
150    /// This endpoint returns both the parsed and the raw response, to allow handling
151    /// of bonus fields
152    async fn request_token(
153        &self,
154        auth: &TokenEndpointAuth<'_>,
155        code_verifier: Option<&str>,
156    ) -> Res<(OpenIDTokenResponse, String)> {
157        let mut params = HashMap::new();
158        match auth {
159            TokenEndpointAuth::AuthorizationCode(code) => {
160                params.insert("grant_type", "authorization_code");
161                params.insert("code", code);
162            }
163            TokenEndpointAuth::RefreshToken(token) => {
164                params.insert("grant_type", "refresh_token");
165                params.insert("refresh_token", token);
166            }
167        }
168        if let Some(verifier) = code_verifier {
169            params.insert("code_verifier", verifier);
170        }
171        params.insert("redirect_uri", self.opts.redirect_uri.as_str());
172
173        let response = http_client::send_request(
174            reqwest::Client::new()
175                .post(&self.config.token_endpoint)
176                .basic_auth(&self.opts.client_id, self.opts.client_secret.as_deref())
177                .form(&params),
178        )
179        .await?
180        .text()
181        .await?;
182
183        let token: OpenIDTokenResponse = serde_json::from_str(&response)?;
184
185        // Check id token signature
186        if self.opts.validate_id_token_sig
187            && self.jwks.is_some()
188            && let Some(id_token) = &token.id_token
189        {
190            self.verify_jwt::<serde_json::Value>(id_token)
191                .map_err(|e| OpenIdError::ValidateIdToken(Box::new(e)))?;
192        }
193
194        Ok((token, response))
195    }
196
197    /// Decode & verify a JWT signature
198    pub fn verify_jwt<T: DeserializeOwned>(&self, jwt: &str) -> Res<TokenData<T>> {
199        let header = jsonwebtoken::decode_header(jwt).map_err(OpenIdError::DecodeJWTHeader)?;
200
201        let Some(kid) = header.kid else {
202            return Err(OpenIdError::KidRequiredForSignatureVerification);
203        };
204
205        let Some(jwks) = &self.jwks else {
206            return Err(OpenIdError::MissingJWKs);
207        };
208
209        let Some(jwk) = jwks.find(&kid) else {
210            return Err(OpenIdError::UnknownKid(kid));
211        };
212
213        let decoding_key = DecodingKey::from_jwk(jwk).map_err(OpenIdError::DecodeJWK)?;
214
215        let mut validation = Validation::new(header.alg);
216        validation.validate_aud = true;
217        validation.aud = Some(HashSet::from_iter(
218            self.opts.accepted_audiences.iter().cloned(),
219        ));
220        validation.validate_aud = self.opts.validate_aud;
221        validation.validate_exp = self.opts.validate_exp;
222        validation.validate_nbf = self.opts.validate_nbf;
223
224        jsonwebtoken::decode(jwt, &decoding_key, &validation).map_err(OpenIdError::ValidateJWT)
225    }
226
227    /// Query the UserInfo endpoint.
228    ///
229    /// This endpoint should be used after having successfully retrieved the token
230    ///
231    /// This endpoint returns both the parsed value and the raw response, in case of presence
232    /// of additional fields
233    #[tracing::instrument(skip(self, token))]
234    pub async fn request_user_info(
235        &self,
236        token: &OpenIDTokenResponse,
237    ) -> Res<(OpenIDUserInfo, String)> {
238        let response = http_client::send_request(
239            reqwest::Client::new()
240                .get(self.config.userinfo_endpoint.as_ref().expect(
241                    "This client only support information retrieval through userinfo endpoint!",
242                ))
243                .header("Authorization", format!("Bearer {}", token.access_token)),
244        )
245        .await?
246        .text()
247        .await?;
248
249        Ok((serde_json::from_str(&response)?, response))
250    }
251}