Skip to main content

mas_oidc_client/requests/
authorization_code.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 Kévin Commaille.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8//! Requests for the [Authorization Code flow].
9//!
10//! [Authorization Code flow]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
11
12use 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/// The data necessary to build an authorization request.
43#[derive(Debug, Clone)]
44pub struct AuthorizationRequestData {
45    /// The ID obtained when registering the client.
46    pub client_id: String,
47
48    /// The scope to authorize.
49    ///
50    /// If the OpenID Connect scope token (`openid`) is not included, it will be
51    /// added.
52    pub scope: Scope,
53
54    /// The URI to redirect the end-user to after the authorization.
55    ///
56    /// It must be one of the redirect URIs provided during registration.
57    pub redirect_uri: Url,
58
59    /// The PKCE methods supported by the issuer.
60    ///
61    /// This field should be cloned from the provider metadata. If it is not
62    /// set, this security measure will not be used.
63    pub code_challenge_methods_supported: Option<Vec<PkceCodeChallengeMethod>>,
64
65    /// How the Authorization Server should display the authentication and
66    /// consent user interface pages to the End-User.
67    pub display: Option<Display>,
68
69    /// Whether the Authorization Server should prompt the End-User for
70    /// reauthentication and consent.
71    ///
72    /// If [`Prompt::None`] is used, it must be the only value.
73    pub prompt: Option<Vec<Prompt>>,
74
75    /// The allowable elapsed time in seconds since the last time the End-User
76    /// was actively authenticated by the OpenID Provider.
77    pub max_age: Option<NonZeroU32>,
78
79    /// End-User's preferred languages and scripts for the user interface.
80    pub ui_locales: Option<Vec<LanguageTag>>,
81
82    /// ID Token previously issued by the Authorization Server being passed as a
83    /// hint about the End-User's current or past authenticated session with the
84    /// Client.
85    pub id_token_hint: Option<String>,
86
87    /// Hint to the Authorization Server about the login identifier the End-User
88    /// might use to log in.
89    pub login_hint: Option<String>,
90
91    /// Requested Authentication Context Class Reference values.
92    pub acr_values: Option<HashSet<String>>,
93
94    /// Requested response mode.
95    pub response_mode: Option<ResponseMode>,
96}
97
98impl AuthorizationRequestData {
99    /// Constructs a new `AuthorizationRequestData` with all the required
100    /// fields.
101    #[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    /// Set the `code_challenge_methods_supported` field of this
120    /// `AuthorizationRequestData`.
121    #[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    /// Set the `display` field of this `AuthorizationRequestData`.
131    #[must_use]
132    pub fn with_display(mut self, display: Display) -> Self {
133        self.display = Some(display);
134        self
135    }
136
137    /// Set the `prompt` field of this `AuthorizationRequestData`.
138    #[must_use]
139    pub fn with_prompt(mut self, prompt: Vec<Prompt>) -> Self {
140        self.prompt = Some(prompt);
141        self
142    }
143
144    /// Set the `max_age` field of this `AuthorizationRequestData`.
145    #[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    /// Set the `ui_locales` field of this `AuthorizationRequestData`.
152    #[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    /// Set the `id_token_hint` field of this `AuthorizationRequestData`.
159    #[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    /// Set the `login_hint` field of this `AuthorizationRequestData`.
166    #[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    /// Set the `acr_values` field of this `AuthorizationRequestData`.
173    #[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    /// Set the `response_mode` field of this `AuthorizationRequestData`.
180    #[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/// The data necessary to validate a response from the Token endpoint in the
188/// Authorization Code flow.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct AuthorizationValidationData {
191    /// A unique identifier for the request.
192    pub state: String,
193
194    /// A string to mitigate replay attacks.
195    /// Used when the `openid` scope is set (and therefore we are using OpenID
196    /// Connect).
197    pub nonce: Option<String>,
198
199    /// The URI where the end-user will be redirected after authorization.
200    pub redirect_uri: Url,
201
202    /// A string to correlate the authorization request to the token request.
203    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
215/// Build the authorization request.
216fn 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    // Generate a random CSRF "state" token and a nonce.
238    let state = Alphanumeric.sample_string(rng, 16);
239
240    // Generate a random nonce if we're in 'OpenID Connect' mode
241    let nonce = is_openid.then(|| Alphanumeric.sample_string(rng, 16));
242
243    // Use PKCE, whenever possible.
244    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
298/// Build the URL for authenticating at the Authorization endpoint.
299///
300/// # Arguments
301///
302/// * `authorization_endpoint` - The URL of the issuer's authorization endpoint.
303///
304/// * `authorization_data` - The data necessary to build the authorization
305///   request.
306///
307/// * `rng` - A random number generator.
308///
309/// # Returns
310///
311/// A URL to be opened in a web browser where the end-user will be able to
312/// authorize the given scope, and the [`AuthorizationValidationData`] to
313/// validate this request.
314///
315/// The redirect URI will receive parameters in its query:
316///
317/// * A successful response will receive a `code` and a `state`.
318///
319/// * If the authorization fails, it should receive an `error` parameter with a
320///   [`ClientErrorCode`] and optionally an `error_description`.
321///
322/// # Errors
323///
324/// Returns an error if preparing the URL fails.
325///
326/// [`VerifiedClientMetadata`]: oauth2_types::registration::VerifiedClientMetadata
327/// [`ClientErrorCode`]: oauth2_types::errors::ClientErrorCode
328pub 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    // Add our parameters to the query, because the URL might already have one.
346    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/// Exchange an authorization code for an access token.
361///
362/// This should be used as the first step for logging in, and to request a
363/// token with a new scope.
364///
365/// # Arguments
366///
367/// * `http_client` - The reqwest client to use for making HTTP requests.
368///
369/// * `client_credentials` - The credentials obtained when registering the
370///   client.
371///
372/// * `token_endpoint` - The URL of the issuer's Token endpoint.
373///
374/// * `code` - The authorization code returned at the Authorization endpoint.
375///
376/// * `validation_data` - The validation data that was returned when building
377///   the Authorization URL, for the state returned at the Authorization
378///   endpoint.
379///
380/// * `id_token_verification_data` - The data required to verify the ID Token in
381///   the response.
382///
383///   The signing algorithm corresponds to the `id_token_signed_response_alg`
384///   field in the client metadata.
385///
386///   If it is not provided, the ID Token won't be verified. Note that in the
387///   OpenID Connect specification, this verification is required.
388///
389/// * `now` - The current time.
390///
391/// * `rng` - A random number generator.
392///
393/// # Errors
394///
395/// Returns an error if the request fails, the response is invalid or the
396/// verification of the ID Token fails.
397#[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        // Access token hash must match.
438        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        // Code hash must match.
446        claims::C_HASH
447            .extract_optional_with_options(&mut claims, TokenHash::new(signing_alg, &code))
448            .map_err(IdTokenError::from)?;
449
450        // Nonce must match if we have one.
451        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}