mas_oidc_client/requests/
authorization_code.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2022-2024 Kévin Commaille.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7//! Requests for the [Authorization Code flow].
8//!
9//! [Authorization Code flow]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
10
11use std::{collections::HashSet, num::NonZeroU32};
12
13use base64ct::{Base64UrlUnpadded, Encoding};
14use chrono::{DateTime, Utc};
15use language_tags::LanguageTag;
16use mas_iana::oauth::{OAuthAuthorizationEndpointResponseType, PkceCodeChallengeMethod};
17use mas_jose::claims::{self, TokenHash};
18use oauth2_types::{
19    pkce,
20    prelude::CodeChallengeMethodExt,
21    requests::{
22        AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, AuthorizationRequest,
23        Display, Prompt, ResponseMode,
24    },
25    scope::{OPENID, Scope},
26};
27use rand::{
28    Rng,
29    distributions::{Alphanumeric, DistString},
30};
31use serde::Serialize;
32use url::Url;
33
34use super::jose::JwtVerificationData;
35use crate::{
36    error::{AuthorizationError, IdTokenError, TokenAuthorizationCodeError},
37    requests::{jose::verify_id_token, token::request_access_token},
38    types::{IdToken, client_credentials::ClientCredentials},
39};
40
41/// The data necessary to build an authorization request.
42#[derive(Debug, Clone)]
43pub struct AuthorizationRequestData {
44    /// The ID obtained when registering the client.
45    pub client_id: String,
46
47    /// The scope to authorize.
48    ///
49    /// If the OpenID Connect scope token (`openid`) is not included, it will be
50    /// added.
51    pub scope: Scope,
52
53    /// The URI to redirect the end-user to after the authorization.
54    ///
55    /// It must be one of the redirect URIs provided during registration.
56    pub redirect_uri: Url,
57
58    /// The PKCE methods supported by the issuer.
59    ///
60    /// This field should be cloned from the provider metadata. If it is not
61    /// set, this security measure will not be used.
62    pub code_challenge_methods_supported: Option<Vec<PkceCodeChallengeMethod>>,
63
64    /// How the Authorization Server should display the authentication and
65    /// consent user interface pages to the End-User.
66    pub display: Option<Display>,
67
68    /// Whether the Authorization Server should prompt the End-User for
69    /// reauthentication and consent.
70    ///
71    /// If [`Prompt::None`] is used, it must be the only value.
72    pub prompt: Option<Vec<Prompt>>,
73
74    /// The allowable elapsed time in seconds since the last time the End-User
75    /// was actively authenticated by the OpenID Provider.
76    pub max_age: Option<NonZeroU32>,
77
78    /// End-User's preferred languages and scripts for the user interface.
79    pub ui_locales: Option<Vec<LanguageTag>>,
80
81    /// ID Token previously issued by the Authorization Server being passed as a
82    /// hint about the End-User's current or past authenticated session with the
83    /// Client.
84    pub id_token_hint: Option<String>,
85
86    /// Hint to the Authorization Server about the login identifier the End-User
87    /// might use to log in.
88    pub login_hint: Option<String>,
89
90    /// Requested Authentication Context Class Reference values.
91    pub acr_values: Option<HashSet<String>>,
92
93    /// Requested response mode.
94    pub response_mode: Option<ResponseMode>,
95}
96
97impl AuthorizationRequestData {
98    /// Constructs a new `AuthorizationRequestData` with all the required
99    /// fields.
100    #[must_use]
101    pub fn new(client_id: String, scope: Scope, redirect_uri: Url) -> Self {
102        Self {
103            client_id,
104            scope,
105            redirect_uri,
106            code_challenge_methods_supported: None,
107            display: None,
108            prompt: None,
109            max_age: None,
110            ui_locales: None,
111            id_token_hint: None,
112            login_hint: None,
113            acr_values: None,
114            response_mode: None,
115        }
116    }
117
118    /// Set the `code_challenge_methods_supported` field of this
119    /// `AuthorizationRequestData`.
120    #[must_use]
121    pub fn with_code_challenge_methods_supported(
122        mut self,
123        code_challenge_methods_supported: Vec<PkceCodeChallengeMethod>,
124    ) -> Self {
125        self.code_challenge_methods_supported = Some(code_challenge_methods_supported);
126        self
127    }
128
129    /// Set the `display` field of this `AuthorizationRequestData`.
130    #[must_use]
131    pub fn with_display(mut self, display: Display) -> Self {
132        self.display = Some(display);
133        self
134    }
135
136    /// Set the `prompt` field of this `AuthorizationRequestData`.
137    #[must_use]
138    pub fn with_prompt(mut self, prompt: Vec<Prompt>) -> Self {
139        self.prompt = Some(prompt);
140        self
141    }
142
143    /// Set the `max_age` field of this `AuthorizationRequestData`.
144    #[must_use]
145    pub fn with_max_age(mut self, max_age: NonZeroU32) -> Self {
146        self.max_age = Some(max_age);
147        self
148    }
149
150    /// Set the `ui_locales` field of this `AuthorizationRequestData`.
151    #[must_use]
152    pub fn with_ui_locales(mut self, ui_locales: Vec<LanguageTag>) -> Self {
153        self.ui_locales = Some(ui_locales);
154        self
155    }
156
157    /// Set the `id_token_hint` field of this `AuthorizationRequestData`.
158    #[must_use]
159    pub fn with_id_token_hint(mut self, id_token_hint: String) -> Self {
160        self.id_token_hint = Some(id_token_hint);
161        self
162    }
163
164    /// Set the `login_hint` field of this `AuthorizationRequestData`.
165    #[must_use]
166    pub fn with_login_hint(mut self, login_hint: String) -> Self {
167        self.login_hint = Some(login_hint);
168        self
169    }
170
171    /// Set the `acr_values` field of this `AuthorizationRequestData`.
172    #[must_use]
173    pub fn with_acr_values(mut self, acr_values: HashSet<String>) -> Self {
174        self.acr_values = Some(acr_values);
175        self
176    }
177
178    /// Set the `response_mode` field of this `AuthorizationRequestData`.
179    #[must_use]
180    pub fn with_response_mode(mut self, response_mode: ResponseMode) -> Self {
181        self.response_mode = Some(response_mode);
182        self
183    }
184}
185
186/// The data necessary to validate a response from the Token endpoint in the
187/// Authorization Code flow.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct AuthorizationValidationData {
190    /// A unique identifier for the request.
191    pub state: String,
192
193    /// A string to mitigate replay attacks.
194    /// Used when the `openid` scope is set (and therefore we are using OpenID
195    /// Connect).
196    pub nonce: Option<String>,
197
198    /// The URI where the end-user will be redirected after authorization.
199    pub redirect_uri: Url,
200
201    /// A string to correlate the authorization request to the token request.
202    pub code_challenge_verifier: Option<String>,
203}
204
205#[derive(Clone, Serialize)]
206struct FullAuthorizationRequest {
207    #[serde(flatten)]
208    inner: AuthorizationRequest,
209
210    #[serde(flatten, skip_serializing_if = "Option::is_none")]
211    pkce: Option<pkce::AuthorizationRequest>,
212}
213
214/// Build the authorization request.
215fn build_authorization_request(
216    authorization_data: AuthorizationRequestData,
217    rng: &mut impl Rng,
218) -> Result<(FullAuthorizationRequest, AuthorizationValidationData), AuthorizationError> {
219    let AuthorizationRequestData {
220        client_id,
221        scope,
222        redirect_uri,
223        code_challenge_methods_supported,
224        display,
225        prompt,
226        max_age,
227        ui_locales,
228        id_token_hint,
229        login_hint,
230        acr_values,
231        response_mode,
232    } = authorization_data;
233
234    let is_openid = scope.contains(&OPENID);
235
236    // Generate a random CSRF "state" token and a nonce.
237    let state = Alphanumeric.sample_string(rng, 16);
238
239    // Generate a random nonce if we're in 'OpenID Connect' mode
240    let nonce = is_openid.then(|| Alphanumeric.sample_string(rng, 16));
241
242    // Use PKCE, whenever possible.
243    let (pkce, code_challenge_verifier) = if code_challenge_methods_supported
244        .iter()
245        .any(|methods| methods.contains(&PkceCodeChallengeMethod::S256))
246    {
247        let mut verifier = [0u8; 32];
248        rng.fill(&mut verifier);
249
250        let method = PkceCodeChallengeMethod::S256;
251        let verifier = Base64UrlUnpadded::encode_string(&verifier);
252        let code_challenge = method.compute_challenge(&verifier)?.into();
253
254        let pkce = pkce::AuthorizationRequest {
255            code_challenge_method: method,
256            code_challenge,
257        };
258
259        (Some(pkce), Some(verifier))
260    } else {
261        (None, None)
262    };
263
264    let auth_request = FullAuthorizationRequest {
265        inner: AuthorizationRequest {
266            response_type: OAuthAuthorizationEndpointResponseType::Code.into(),
267            client_id,
268            redirect_uri: Some(redirect_uri.clone()),
269            scope,
270            state: Some(state.clone()),
271            response_mode,
272            nonce: nonce.clone(),
273            display,
274            prompt,
275            max_age,
276            ui_locales,
277            id_token_hint,
278            login_hint,
279            acr_values,
280            request: None,
281            request_uri: None,
282            registration: None,
283        },
284        pkce,
285    };
286
287    let auth_data = AuthorizationValidationData {
288        state,
289        nonce,
290        redirect_uri,
291        code_challenge_verifier,
292    };
293
294    Ok((auth_request, auth_data))
295}
296
297/// Build the URL for authenticating at the Authorization endpoint.
298///
299/// # Arguments
300///
301/// * `authorization_endpoint` - The URL of the issuer's authorization endpoint.
302///
303/// * `authorization_data` - The data necessary to build the authorization
304///   request.
305///
306/// * `rng` - A random number generator.
307///
308/// # Returns
309///
310/// A URL to be opened in a web browser where the end-user will be able to
311/// authorize the given scope, and the [`AuthorizationValidationData`] to
312/// validate this request.
313///
314/// The redirect URI will receive parameters in its query:
315///
316/// * A successful response will receive a `code` and a `state`.
317///
318/// * If the authorization fails, it should receive an `error` parameter with a
319///   [`ClientErrorCode`] and optionally an `error_description`.
320///
321/// # Errors
322///
323/// Returns an error if preparing the URL fails.
324///
325/// [`VerifiedClientMetadata`]: oauth2_types::registration::VerifiedClientMetadata
326/// [`ClientErrorCode`]: oauth2_types::errors::ClientErrorCode
327pub fn build_authorization_url(
328    authorization_endpoint: Url,
329    authorization_data: AuthorizationRequestData,
330    rng: &mut impl Rng,
331) -> Result<(Url, AuthorizationValidationData), AuthorizationError> {
332    tracing::debug!(
333        scope = ?authorization_data.scope,
334        "Authorizing..."
335    );
336
337    let (authorization_request, validation_data) =
338        build_authorization_request(authorization_data, rng)?;
339
340    let authorization_query = serde_urlencoded::to_string(authorization_request)?;
341
342    let mut authorization_url = authorization_endpoint;
343
344    // Add our parameters to the query, because the URL might already have one.
345    let mut full_query = authorization_url
346        .query()
347        .map(ToOwned::to_owned)
348        .unwrap_or_default();
349    if !full_query.is_empty() {
350        full_query.push('&');
351    }
352    full_query.push_str(&authorization_query);
353
354    authorization_url.set_query(Some(&full_query));
355
356    Ok((authorization_url, validation_data))
357}
358
359/// Exchange an authorization code for an access token.
360///
361/// This should be used as the first step for logging in, and to request a
362/// token with a new scope.
363///
364/// # Arguments
365///
366/// * `http_client` - The reqwest client to use for making HTTP requests.
367///
368/// * `client_credentials` - The credentials obtained when registering the
369///   client.
370///
371/// * `token_endpoint` - The URL of the issuer's Token endpoint.
372///
373/// * `code` - The authorization code returned at the Authorization endpoint.
374///
375/// * `validation_data` - The validation data that was returned when building
376///   the Authorization URL, for the state returned at the Authorization
377///   endpoint.
378///
379/// * `id_token_verification_data` - The data required to verify the ID Token in
380///   the response.
381///
382///   The signing algorithm corresponds to the `id_token_signed_response_alg`
383///   field in the client metadata.
384///
385///   If it is not provided, the ID Token won't be verified. Note that in the
386///   OpenID Connect specification, this verification is required.
387///
388/// * `now` - The current time.
389///
390/// * `rng` - A random number generator.
391///
392/// # Errors
393///
394/// Returns an error if the request fails, the response is invalid or the
395/// verification of the ID Token fails.
396#[allow(clippy::too_many_arguments)]
397#[tracing::instrument(skip_all, fields(token_endpoint))]
398pub async fn access_token_with_authorization_code(
399    http_client: &reqwest::Client,
400    client_credentials: ClientCredentials,
401    token_endpoint: &Url,
402    code: String,
403    validation_data: AuthorizationValidationData,
404    id_token_verification_data: Option<JwtVerificationData<'_>>,
405    now: DateTime<Utc>,
406    rng: &mut impl Rng,
407) -> Result<(AccessTokenResponse, Option<IdToken<'static>>), TokenAuthorizationCodeError> {
408    tracing::debug!("Exchanging authorization code for access token...");
409
410    let token_response = request_access_token(
411        http_client,
412        client_credentials,
413        token_endpoint,
414        AccessTokenRequest::AuthorizationCode(AuthorizationCodeGrant {
415            code: code.clone(),
416            redirect_uri: Some(validation_data.redirect_uri),
417            code_verifier: validation_data.code_challenge_verifier,
418        }),
419        now,
420        rng,
421    )
422    .await?;
423
424    let id_token = if let Some(verification_data) = id_token_verification_data {
425        let signing_alg = verification_data.signing_algorithm;
426
427        let id_token = token_response
428            .id_token
429            .as_deref()
430            .ok_or(IdTokenError::MissingIdToken)?;
431
432        let id_token = verify_id_token(id_token, verification_data, None, now)?;
433
434        let mut claims = id_token.payload().clone();
435
436        // Access token hash must match.
437        claims::AT_HASH
438            .extract_optional_with_options(
439                &mut claims,
440                TokenHash::new(signing_alg, &token_response.access_token),
441            )
442            .map_err(IdTokenError::from)?;
443
444        // Code hash must match.
445        claims::C_HASH
446            .extract_optional_with_options(&mut claims, TokenHash::new(signing_alg, &code))
447            .map_err(IdTokenError::from)?;
448
449        // Nonce must match if we have one.
450        if let Some(nonce) = validation_data.nonce.as_deref() {
451            claims::NONCE
452                .extract_required_with_options(&mut claims, nonce)
453                .map_err(IdTokenError::from)?;
454        }
455
456        Some(id_token.into_owned())
457    } else {
458        None
459    };
460
461    Ok((token_response, id_token))
462}