Skip to main content

light_openid/
client.rs

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