Skip to main content

mas_oidc_client/requests/
authorization_code.rs

1// Copyright 2022 Kévin Commaille.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Requests for the [Authorization Code flow].
16//!
17//! [Authorization Code flow]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
18
19use std::{collections::HashSet, num::NonZeroU32};
20
21use base64ct::{Base64UrlUnpadded, Encoding};
22use chrono::{DateTime, Utc};
23use http::header::CONTENT_TYPE;
24use language_tags::LanguageTag;
25use mas_http::{CatchHttpCodesLayer, FormUrlencodedRequestLayer, JsonResponseLayer};
26use mas_iana::oauth::{OAuthAuthorizationEndpointResponseType, PkceCodeChallengeMethod};
27use mas_jose::claims::{self, TokenHash};
28use oauth2_types::{
29    pkce,
30    prelude::CodeChallengeMethodExt,
31    requests::{
32        AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, AuthorizationRequest,
33        Display, Prompt, PushedAuthorizationResponse,
34    },
35    scope::Scope,
36};
37use rand::{
38    distributions::{Alphanumeric, DistString},
39    Rng,
40};
41use serde::Serialize;
42use serde_with::skip_serializing_none;
43use tower::{Layer, Service, ServiceExt};
44use url::Url;
45
46use super::jose::JwtVerificationData;
47use crate::{
48    error::{
49        AuthorizationError, IdTokenError, PushedAuthorizationError, TokenAuthorizationCodeError,
50    },
51    http_service::HttpService,
52    requests::{jose::verify_id_token, token::request_access_token},
53    types::{
54        client_credentials::ClientCredentials,
55        scope::{ScopeExt, ScopeToken},
56        IdToken,
57    },
58    utils::{http_all_error_status_codes, http_error_mapper},
59};
60
61/// The data necessary to build an authorization request.
62#[derive(Debug, Clone)]
63pub struct AuthorizationRequestData {
64    /// The ID obtained when registering the client.
65    pub client_id: String,
66
67    /// The scope to authorize.
68    ///
69    /// If the OpenID Connect scope token (`openid`) is not included, it will be
70    /// added.
71    pub scope: Scope,
72
73    /// The URI to redirect the end-user to after the authorization.
74    ///
75    /// It must be one of the redirect URIs provided during registration.
76    pub redirect_uri: Url,
77
78    /// The PKCE methods supported by the issuer.
79    ///
80    /// This field should be cloned from the provider metadata. If it is not
81    /// set, this security measure will not be used.
82    pub code_challenge_methods_supported: Option<Vec<PkceCodeChallengeMethod>>,
83
84    /// How the Authorization Server should display the authentication and
85    /// consent user interface pages to the End-User.
86    pub display: Option<Display>,
87
88    /// Whether the Authorization Server should prompt the End-User for
89    /// reauthentication and consent.
90    ///
91    /// If [`Prompt::None`] is used, it must be the only value.
92    pub prompt: Option<Vec<Prompt>>,
93
94    /// The allowable elapsed time in seconds since the last time the End-User
95    /// was actively authenticated by the OpenID Provider.
96    pub max_age: Option<NonZeroU32>,
97
98    /// End-User's preferred languages and scripts for the user interface.
99    pub ui_locales: Option<Vec<LanguageTag>>,
100
101    /// ID Token previously issued by the Authorization Server being passed as a
102    /// hint about the End-User's current or past authenticated session with the
103    /// Client.
104    pub id_token_hint: Option<String>,
105
106    /// Hint to the Authorization Server about the login identifier the End-User
107    /// might use to log in.
108    pub login_hint: Option<String>,
109
110    /// Requested Authentication Context Class Reference values.
111    pub acr_values: Option<HashSet<String>>,
112}
113
114impl AuthorizationRequestData {
115    /// Constructs a new `AuthorizationRequestData` with all the required
116    /// fields.
117    #[must_use]
118    pub fn new(client_id: String, scope: Scope, redirect_uri: Url) -> Self {
119        Self {
120            client_id,
121            scope,
122            redirect_uri,
123            code_challenge_methods_supported: None,
124            display: None,
125            prompt: None,
126            max_age: None,
127            ui_locales: None,
128            id_token_hint: None,
129            login_hint: None,
130            acr_values: None,
131        }
132    }
133
134    /// Set the `code_challenge_methods_supported` field of this
135    /// `AuthorizationRequestData`.
136    #[must_use]
137    pub fn with_code_challenge_methods_supported(
138        mut self,
139        code_challenge_methods_supported: Vec<PkceCodeChallengeMethod>,
140    ) -> Self {
141        self.code_challenge_methods_supported = Some(code_challenge_methods_supported);
142        self
143    }
144
145    /// Set the `display` field of this `AuthorizationRequestData`.
146    #[must_use]
147    pub fn with_display(mut self, display: Display) -> Self {
148        self.display = Some(display);
149        self
150    }
151
152    /// Set the `prompt` field of this `AuthorizationRequestData`.
153    #[must_use]
154    pub fn with_prompt(mut self, prompt: Vec<Prompt>) -> Self {
155        self.prompt = Some(prompt);
156        self
157    }
158
159    /// Set the `max_age` field of this `AuthorizationRequestData`.
160    #[must_use]
161    pub fn with_max_age(mut self, max_age: NonZeroU32) -> Self {
162        self.max_age = Some(max_age);
163        self
164    }
165
166    /// Set the `ui_locales` field of this `AuthorizationRequestData`.
167    #[must_use]
168    pub fn with_ui_locales(mut self, ui_locales: Vec<LanguageTag>) -> Self {
169        self.ui_locales = Some(ui_locales);
170        self
171    }
172
173    /// Set the `id_token_hint` field of this `AuthorizationRequestData`.
174    #[must_use]
175    pub fn with_id_token_hint(mut self, id_token_hint: String) -> Self {
176        self.id_token_hint = Some(id_token_hint);
177        self
178    }
179
180    /// Set the `login_hint` field of this `AuthorizationRequestData`.
181    #[must_use]
182    pub fn with_login_hint(mut self, login_hint: String) -> Self {
183        self.login_hint = Some(login_hint);
184        self
185    }
186
187    /// Set the `acr_values` field of this `AuthorizationRequestData`.
188    #[must_use]
189    pub fn with_acr_values(mut self, acr_values: HashSet<String>) -> Self {
190        self.acr_values = Some(acr_values);
191        self
192    }
193}
194
195/// The data necessary to validate a response from the Token endpoint in the
196/// Authorization Code flow.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct AuthorizationValidationData {
199    /// A unique identifier for the request.
200    pub state: String,
201
202    /// A string to mitigate replay attacks.
203    pub nonce: String,
204
205    /// The URI where the end-user will be redirected after authorization.
206    pub redirect_uri: Url,
207
208    /// A string to correlate the authorization request to the token request.
209    pub code_challenge_verifier: Option<String>,
210}
211
212#[skip_serializing_none]
213#[derive(Clone, Serialize)]
214struct FullAuthorizationRequest {
215    #[serde(flatten)]
216    inner: AuthorizationRequest,
217    #[serde(flatten)]
218    pkce: Option<pkce::AuthorizationRequest>,
219}
220
221/// Build the authorization request.
222fn build_authorization_request(
223    authorization_data: AuthorizationRequestData,
224    rng: &mut impl Rng,
225) -> Result<(FullAuthorizationRequest, AuthorizationValidationData), AuthorizationError> {
226    let AuthorizationRequestData {
227        client_id,
228        mut scope,
229        redirect_uri,
230        code_challenge_methods_supported,
231        display,
232        prompt,
233        max_age,
234        ui_locales,
235        id_token_hint,
236        login_hint,
237        acr_values,
238    } = authorization_data;
239
240    // Generate a random CSRF "state" token and a nonce.
241    let state = Alphanumeric.sample_string(rng, 16);
242    let nonce = Alphanumeric.sample_string(rng, 16);
243
244    // Use PKCE, whenever possible.
245    let (pkce, code_challenge_verifier) = if code_challenge_methods_supported
246        .iter()
247        .any(|methods| methods.contains(&PkceCodeChallengeMethod::S256))
248    {
249        let mut verifier = [0u8; 32];
250        rng.fill(&mut verifier);
251
252        let method = PkceCodeChallengeMethod::S256;
253        let verifier = Base64UrlUnpadded::encode_string(&verifier);
254        let code_challenge = method.compute_challenge(&verifier)?.into();
255
256        let pkce = pkce::AuthorizationRequest {
257            code_challenge_method: method,
258            code_challenge,
259        };
260
261        (Some(pkce), Some(verifier))
262    } else {
263        (None, None)
264    };
265
266    scope.insert_token(ScopeToken::Openid);
267
268    let auth_request = FullAuthorizationRequest {
269        inner: AuthorizationRequest {
270            response_type: OAuthAuthorizationEndpointResponseType::Code.into(),
271            client_id,
272            redirect_uri: Some(redirect_uri.clone()),
273            scope,
274            state: Some(state.clone()),
275            response_mode: None,
276            nonce: Some(nonce.clone()),
277            display,
278            prompt,
279            max_age,
280            ui_locales,
281            id_token_hint,
282            login_hint,
283            acr_values,
284            request: None,
285            request_uri: None,
286            registration: None,
287        },
288        pkce,
289    };
290
291    let auth_data = AuthorizationValidationData {
292        state,
293        nonce,
294        redirect_uri,
295        code_challenge_verifier,
296    };
297
298    Ok((auth_request, auth_data))
299}
300
301/// Build the URL for authenticating at the Authorization endpoint.
302///
303/// # Arguments
304///
305/// * `authorization_endpoint` - The URL of the issuer's authorization endpoint.
306///
307/// * `authorization_data` - The data necessary to build the authorization
308///   request.
309///
310/// * `rng` - A random number generator.
311///
312/// # Returns
313///
314/// A URL to be opened in a web browser where the end-user will be able to
315/// authorize the given scope, and the [`AuthorizationValidationData`] to
316/// validate this request.
317///
318/// The redirect URI will receive parameters in its query:
319///
320/// * A successful response will receive a `code` and a `state`.
321///
322/// * If the authorization fails, it should receive an `error` parameter with a
323///   [`ClientErrorCode`] and optionally an `error_description`.
324///
325/// # Errors
326///
327/// Returns an error if preparing the URL fails.
328///
329/// [`VerifiedClientMetadata`]: oauth2_types::registration::VerifiedClientMetadata
330/// [`ClientErrorCode`]: oauth2_types::errors::ClientErrorCode
331#[allow(clippy::too_many_lines)]
332pub fn build_authorization_url(
333    authorization_endpoint: Url,
334    authorization_data: AuthorizationRequestData,
335    rng: &mut impl Rng,
336) -> Result<(Url, AuthorizationValidationData), AuthorizationError> {
337    tracing::debug!(
338        scope = ?authorization_data.scope,
339        "Authorizing..."
340    );
341
342    let (authorization_request, validation_data) =
343        build_authorization_request(authorization_data, rng)?;
344
345    let authorization_query = serde_urlencoded::to_string(authorization_request)?;
346
347    let mut authorization_url = authorization_endpoint;
348
349    // Add our parameters to the query, because the URL might already have one.
350    let mut full_query = authorization_url
351        .query()
352        .map(ToOwned::to_owned)
353        .unwrap_or_default();
354    if !full_query.is_empty() {
355        full_query.push('&');
356    }
357    full_query.push_str(&authorization_query);
358
359    authorization_url.set_query(Some(&full_query));
360
361    Ok((authorization_url, validation_data))
362}
363
364/// Make a [Pushed Authorization Request] and build the URL for authenticating
365/// at the Authorization endpoint.
366///
367/// # Arguments
368///
369/// * `http_service` - The service to use for making HTTP requests.
370///
371/// * `client_credentials` - The credentials obtained when registering the
372///   client.
373///
374/// * `par_endpoint` - The URL of the issuer's Pushed Authorization Request
375///   endpoint.
376///
377/// * `authorization_endpoint` - The URL of the issuer's Authorization endpoint.
378///
379/// * `authorization_data` - The data necessary to build the authorization
380///   request.
381///
382/// * `now` - The current time.
383///
384/// * `rng` - A random number generator.
385///
386/// # Returns
387///
388/// A URL to be opened in a web browser where the end-user will be able to
389/// authorize the given scope, and the [`AuthorizationValidationData`] to
390/// validate this request.
391///
392/// The redirect URI will receive parameters in its query:
393///
394/// * A successful response will receive a `code` and a `state`.
395///
396/// * If the authorization fails, it should receive an `error` parameter with a
397///   [`ClientErrorCode`] and optionally an `error_description`.
398///
399/// # Errors
400///
401/// Returns an error if the request fails, the response is invalid or building
402/// the URL fails.
403///
404/// [Pushed Authorization Request]: https://oauth.net/2/pushed-authorization-requests/
405/// [`ClientErrorCode`]: oauth2_types::errors::ClientErrorCode
406#[tracing::instrument(skip_all, fields(par_endpoint))]
407pub async fn build_par_authorization_url(
408    http_service: &HttpService,
409    client_credentials: ClientCredentials,
410    par_endpoint: &Url,
411    authorization_endpoint: Url,
412    authorization_data: AuthorizationRequestData,
413    now: DateTime<Utc>,
414    rng: &mut impl Rng,
415) -> Result<(Url, AuthorizationValidationData), AuthorizationError> {
416    tracing::debug!(
417        scope = ?authorization_data.scope,
418        "Authorizing with a PAR..."
419    );
420
421    let client_id = client_credentials.client_id().to_owned();
422
423    let (authorization_request, validation_data) =
424        build_authorization_request(authorization_data, rng)?;
425
426    let par_request = http::Request::post(par_endpoint.as_str())
427        .header(CONTENT_TYPE, mime::APPLICATION_WWW_FORM_URLENCODED.as_ref())
428        .body(authorization_request)
429        .map_err(PushedAuthorizationError::from)?;
430
431    let par_request = client_credentials
432        .apply_to_request(par_request, now, rng)
433        .map_err(PushedAuthorizationError::from)?;
434
435    let service = (
436        FormUrlencodedRequestLayer::default(),
437        JsonResponseLayer::<PushedAuthorizationResponse>::default(),
438        CatchHttpCodesLayer::new(http_all_error_status_codes(), http_error_mapper),
439    )
440        .layer(http_service.clone());
441
442    let par_response = service
443        .ready_oneshot()
444        .await
445        .map_err(PushedAuthorizationError::from)?
446        .call(par_request)
447        .await
448        .map_err(PushedAuthorizationError::from)?
449        .into_body();
450
451    let authorization_query = serde_urlencoded::to_string([
452        ("request_uri", par_response.request_uri.as_str()),
453        ("client_id", &client_id),
454    ])?;
455
456    let mut authorization_url = authorization_endpoint;
457
458    // Add our parameters to the query, because the URL might already have one.
459    let mut full_query = authorization_url
460        .query()
461        .map(ToOwned::to_owned)
462        .unwrap_or_default();
463    if !full_query.is_empty() {
464        full_query.push('&');
465    }
466    full_query.push_str(&authorization_query);
467
468    authorization_url.set_query(Some(&full_query));
469
470    Ok((authorization_url, validation_data))
471}
472
473/// Exchange an authorization code for an access token.
474///
475/// This should be used as the first step for logging in, and to request a
476/// token with a new scope.
477///
478/// # Arguments
479///
480/// * `http_service` - The service to use for making HTTP requests.
481///
482/// * `client_credentials` - The credentials obtained when registering the
483///   client.
484///
485/// * `token_endpoint` - The URL of the issuer's Token endpoint.
486///
487/// * `code` - The authorization code returned at the Authorization endpoint.
488///
489/// * `validation_data` - The validation data that was returned when building
490///   the Authorization URL, for the state returned at the Authorization
491///   endpoint.
492///
493/// * `id_token_verification_data` - The data required to verify the ID Token in
494///   the response.
495///
496///   The signing algorithm corresponds to the `id_token_signed_response_alg`
497///   field in the client metadata.
498///
499///   If it is not provided, the ID Token won't be verified. Note that in the
500///   OpenID Connect specification, this verification is required.
501///
502/// * `now` - The current time.
503///
504/// * `rng` - A random number generator.
505///
506/// # Errors
507///
508/// Returns an error if the request fails, the response is invalid or the
509/// verification of the ID Token fails.
510#[allow(clippy::too_many_arguments)]
511#[tracing::instrument(skip_all, fields(token_endpoint))]
512pub async fn access_token_with_authorization_code(
513    http_service: &HttpService,
514    client_credentials: ClientCredentials,
515    token_endpoint: &Url,
516    code: String,
517    validation_data: AuthorizationValidationData,
518    id_token_verification_data: Option<JwtVerificationData<'_>>,
519    now: DateTime<Utc>,
520    rng: &mut impl Rng,
521) -> Result<(AccessTokenResponse, Option<IdToken<'static>>), TokenAuthorizationCodeError> {
522    tracing::debug!("Exchanging authorization code for access token...");
523
524    let token_response = request_access_token(
525        http_service,
526        client_credentials,
527        token_endpoint,
528        AccessTokenRequest::AuthorizationCode(AuthorizationCodeGrant {
529            code: code.clone(),
530            redirect_uri: Some(validation_data.redirect_uri),
531            code_verifier: validation_data.code_challenge_verifier,
532        }),
533        now,
534        rng,
535    )
536    .await?;
537
538    let id_token = if let Some(verification_data) = id_token_verification_data {
539        let signing_alg = verification_data.signing_algorithm;
540
541        let id_token = token_response
542            .id_token
543            .as_deref()
544            .ok_or(IdTokenError::MissingIdToken)?;
545
546        let id_token = verify_id_token(id_token, verification_data, None, now)?;
547
548        let mut claims = id_token.payload().clone();
549
550        // Access token hash must match.
551        claims::AT_HASH
552            .extract_optional_with_options(
553                &mut claims,
554                TokenHash::new(signing_alg, &token_response.access_token),
555            )
556            .map_err(IdTokenError::from)?;
557
558        // Code hash must match.
559        claims::C_HASH
560            .extract_optional_with_options(&mut claims, TokenHash::new(signing_alg, &code))
561            .map_err(IdTokenError::from)?;
562
563        // Nonce must match.
564        claims::NONCE
565            .extract_required_with_options(&mut claims, validation_data.nonce.as_str())
566            .map_err(IdTokenError::from)?;
567
568        Some(id_token.into_owned())
569    } else {
570        None
571    };
572
573    Ok((token_response, id_token))
574}