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