mas_oidc_client/requests/
authorization_code.rs1use std::{collections::HashSet, num::NonZeroU32};
13
14use base64ct::{Base64UrlUnpadded, Encoding};
15use chrono::{DateTime, Utc};
16use language_tags::LanguageTag;
17use mas_iana::oauth::{OAuthAuthorizationEndpointResponseType, PkceCodeChallengeMethod};
18use mas_jose::claims::{self, TokenHash};
19use oauth2_types::{
20 pkce,
21 prelude::CodeChallengeMethodExt,
22 requests::{
23 AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, AuthorizationRequest,
24 Display, Prompt, ResponseMode,
25 },
26 scope::{OPENID, Scope},
27};
28use rand::{
29 RngExt,
30 distr::{Alphanumeric, SampleString},
31};
32use serde::Serialize;
33use url::Url;
34
35use super::jose::JwtVerificationData;
36use crate::{
37 error::{AuthorizationError, IdTokenError, TokenAuthorizationCodeError},
38 requests::{jose::verify_id_token, token::request_access_token},
39 types::{IdToken, client_credentials::ClientCredentials},
40};
41
42#[derive(Debug, Clone)]
44pub struct AuthorizationRequestData {
45 pub client_id: String,
47
48 pub scope: Scope,
53
54 pub redirect_uri: Url,
58
59 pub code_challenge_methods_supported: Option<Vec<PkceCodeChallengeMethod>>,
64
65 pub display: Option<Display>,
68
69 pub prompt: Option<Vec<Prompt>>,
74
75 pub max_age: Option<NonZeroU32>,
78
79 pub ui_locales: Option<Vec<LanguageTag>>,
81
82 pub id_token_hint: Option<String>,
86
87 pub login_hint: Option<String>,
90
91 pub acr_values: Option<HashSet<String>>,
93
94 pub response_mode: Option<ResponseMode>,
96}
97
98impl AuthorizationRequestData {
99 #[must_use]
102 pub fn new(client_id: String, scope: Scope, redirect_uri: Url) -> Self {
103 Self {
104 client_id,
105 scope,
106 redirect_uri,
107 code_challenge_methods_supported: None,
108 display: None,
109 prompt: None,
110 max_age: None,
111 ui_locales: None,
112 id_token_hint: None,
113 login_hint: None,
114 acr_values: None,
115 response_mode: None,
116 }
117 }
118
119 #[must_use]
122 pub fn with_code_challenge_methods_supported(
123 mut self,
124 code_challenge_methods_supported: Vec<PkceCodeChallengeMethod>,
125 ) -> Self {
126 self.code_challenge_methods_supported = Some(code_challenge_methods_supported);
127 self
128 }
129
130 #[must_use]
132 pub fn with_display(mut self, display: Display) -> Self {
133 self.display = Some(display);
134 self
135 }
136
137 #[must_use]
139 pub fn with_prompt(mut self, prompt: Vec<Prompt>) -> Self {
140 self.prompt = Some(prompt);
141 self
142 }
143
144 #[must_use]
146 pub fn with_max_age(mut self, max_age: NonZeroU32) -> Self {
147 self.max_age = Some(max_age);
148 self
149 }
150
151 #[must_use]
153 pub fn with_ui_locales(mut self, ui_locales: Vec<LanguageTag>) -> Self {
154 self.ui_locales = Some(ui_locales);
155 self
156 }
157
158 #[must_use]
160 pub fn with_id_token_hint(mut self, id_token_hint: String) -> Self {
161 self.id_token_hint = Some(id_token_hint);
162 self
163 }
164
165 #[must_use]
167 pub fn with_login_hint(mut self, login_hint: String) -> Self {
168 self.login_hint = Some(login_hint);
169 self
170 }
171
172 #[must_use]
174 pub fn with_acr_values(mut self, acr_values: HashSet<String>) -> Self {
175 self.acr_values = Some(acr_values);
176 self
177 }
178
179 #[must_use]
181 pub fn with_response_mode(mut self, response_mode: ResponseMode) -> Self {
182 self.response_mode = Some(response_mode);
183 self
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct AuthorizationValidationData {
191 pub state: String,
193
194 pub nonce: Option<String>,
198
199 pub redirect_uri: Url,
201
202 pub code_challenge_verifier: Option<String>,
204}
205
206#[derive(Clone, Serialize)]
207struct FullAuthorizationRequest {
208 #[serde(flatten)]
209 inner: AuthorizationRequest,
210
211 #[serde(flatten, skip_serializing_if = "Option::is_none")]
212 pkce: Option<pkce::AuthorizationRequest>,
213}
214
215fn build_authorization_request(
217 authorization_data: AuthorizationRequestData,
218 rng: &mut impl RngExt,
219) -> Result<(FullAuthorizationRequest, AuthorizationValidationData), AuthorizationError> {
220 let AuthorizationRequestData {
221 client_id,
222 scope,
223 redirect_uri,
224 code_challenge_methods_supported,
225 display,
226 prompt,
227 max_age,
228 ui_locales,
229 id_token_hint,
230 login_hint,
231 acr_values,
232 response_mode,
233 } = authorization_data;
234
235 let is_openid = scope.contains(&OPENID);
236
237 let state = Alphanumeric.sample_string(rng, 16);
239
240 let nonce = is_openid.then(|| Alphanumeric.sample_string(rng, 16));
242
243 let (pkce, code_challenge_verifier) = if code_challenge_methods_supported
245 .iter()
246 .any(|methods| methods.contains(&PkceCodeChallengeMethod::S256))
247 {
248 let mut verifier = [0u8; 32];
249 rng.fill(&mut verifier);
250
251 let method = PkceCodeChallengeMethod::S256;
252 let verifier = Base64UrlUnpadded::encode_string(&verifier);
253 let code_challenge = method.compute_challenge(&verifier)?.into();
254
255 let pkce = pkce::AuthorizationRequest {
256 code_challenge_method: method,
257 code_challenge,
258 };
259
260 (Some(pkce), Some(verifier))
261 } else {
262 (None, None)
263 };
264
265 let auth_request = FullAuthorizationRequest {
266 inner: AuthorizationRequest {
267 response_type: OAuthAuthorizationEndpointResponseType::Code.into(),
268 client_id,
269 redirect_uri: Some(redirect_uri.clone()),
270 scope,
271 state: Some(state.clone()),
272 response_mode,
273 nonce: nonce.clone(),
274 display,
275 prompt,
276 max_age,
277 ui_locales,
278 id_token_hint,
279 login_hint,
280 acr_values,
281 request: None,
282 request_uri: None,
283 registration: None,
284 },
285 pkce,
286 };
287
288 let auth_data = AuthorizationValidationData {
289 state,
290 nonce,
291 redirect_uri,
292 code_challenge_verifier,
293 };
294
295 Ok((auth_request, auth_data))
296}
297
298pub fn build_authorization_url(
329 authorization_endpoint: Url,
330 authorization_data: AuthorizationRequestData,
331 rng: &mut impl RngExt,
332) -> Result<(Url, AuthorizationValidationData), AuthorizationError> {
333 tracing::debug!(
334 scope = ?authorization_data.scope,
335 "Authorizing..."
336 );
337
338 let (authorization_request, validation_data) =
339 build_authorization_request(authorization_data, rng)?;
340
341 let authorization_query = serde_urlencoded::to_string(authorization_request)?;
342
343 let mut authorization_url = authorization_endpoint;
344
345 let mut full_query = authorization_url
347 .query()
348 .map(ToOwned::to_owned)
349 .unwrap_or_default();
350 if !full_query.is_empty() {
351 full_query.push('&');
352 }
353 full_query.push_str(&authorization_query);
354
355 authorization_url.set_query(Some(&full_query));
356
357 Ok((authorization_url, validation_data))
358}
359
360#[expect(clippy::too_many_arguments)]
398#[tracing::instrument(skip_all, fields(token_endpoint))]
399pub async fn access_token_with_authorization_code(
400 http_client: &reqwest::Client,
401 client_credentials: ClientCredentials,
402 token_endpoint: &Url,
403 code: String,
404 validation_data: AuthorizationValidationData,
405 id_token_verification_data: Option<JwtVerificationData<'_>>,
406 now: DateTime<Utc>,
407 rng: &mut impl RngExt,
408) -> Result<(AccessTokenResponse, Option<IdToken<'static>>), TokenAuthorizationCodeError> {
409 tracing::debug!("Exchanging authorization code for access token...");
410
411 let token_response = request_access_token(
412 http_client,
413 client_credentials,
414 token_endpoint,
415 AccessTokenRequest::AuthorizationCode(AuthorizationCodeGrant {
416 code: code.clone(),
417 redirect_uri: Some(validation_data.redirect_uri),
418 code_verifier: validation_data.code_challenge_verifier,
419 }),
420 now,
421 rng,
422 )
423 .await?;
424
425 let id_token = if let Some(verification_data) = id_token_verification_data {
426 let signing_alg = verification_data.signing_algorithm;
427
428 let id_token = token_response
429 .id_token
430 .as_deref()
431 .ok_or(IdTokenError::MissingIdToken)?;
432
433 let id_token = verify_id_token(id_token, verification_data, None, now)?;
434
435 let mut claims = id_token.payload().clone();
436
437 claims::AT_HASH
439 .extract_optional_with_options(
440 &mut claims,
441 TokenHash::new(signing_alg, &token_response.access_token),
442 )
443 .map_err(IdTokenError::from)?;
444
445 claims::C_HASH
447 .extract_optional_with_options(&mut claims, TokenHash::new(signing_alg, &code))
448 .map_err(IdTokenError::from)?;
449
450 if let Some(nonce) = validation_data.nonce.as_deref() {
452 claims::NONCE
453 .extract_required_with_options(&mut claims, nonce)
454 .map_err(IdTokenError::from)?;
455 }
456
457 Some(id_token.into_owned())
458 } else {
459 None
460 };
461
462 Ok((token_response, id_token))
463}