diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index 339dc0408..ee4c1af63 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -4,6 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use anyhow::Context; use axum::{ extract::{Form, State}, response::{IntoResponse, Response}, @@ -44,6 +45,9 @@ pub enum RouteError { #[error("invalid response mode")] InvalidResponseMode, + #[error("invalid scope")] + InvalidScope, + #[error("invalid parameters")] IntoCallbackDestination(#[from] self::callback::IntoCallbackDestinationError), @@ -57,6 +61,7 @@ impl IntoResponse for RouteError { Self::Internal(e) => InternalError::new(e).into_response(), e @ (Self::ClientNotFound | Self::InvalidResponseMode + | Self::InvalidScope | Self::IntoCallbackDestination(_) | Self::UnknownRedirectUri(_)) => { GenericError::new(StatusCode::BAD_REQUEST, e).into_response() @@ -132,7 +137,11 @@ pub(crate) async fn get( let redirect_uri = client .resolve_redirect_uri(¶ms.auth.redirect_uri)? .clone(); - let response_type = params.auth.response_type; + let response_type = params + .auth + .response_type + .context("response_type should not be missing") + .map_err(|_| RouteError::InvalidResponseMode)?; let response_mode = resolve_response_mode(&response_type, params.auth.response_mode)?; // Now we have a proper callback destination to go to on error @@ -246,6 +255,12 @@ pub(crate) async fn get( None }; + let scope = params + .auth + .scope + .context("scope should not be missing") + .map_err(|_| RouteError::InvalidScope)?; + let grant = repo .oauth2_authorization_grant() .add( @@ -253,7 +268,7 @@ pub(crate) async fn get( &clock, &client, redirect_uri.clone(), - params.auth.scope, + scope, code, params.auth.state.clone(), params.auth.nonce, diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index 016dd36d3..44c32972a 100644 --- a/crates/handlers/src/upstream_oauth2/authorize.rs +++ b/crates/handlers/src/upstream_oauth2/authorize.rs @@ -4,21 +4,26 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use anyhow::Context; use axum::{ extract::{Path, Query, State}, + http, response::{IntoResponse, Redirect}, }; use hyper::StatusCode; use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar}; use mas_data_model::UpstreamOAuthProvider; +use mas_http::RequestBuilderExt; use mas_oidc_client::requests::authorization_code::AuthorizationRequestData; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ BoxClock, BoxRepository, BoxRng, upstream_oauth2::{UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository}, }; +use oauth2_types::requests::PushedAuthorizationResponse; use thiserror::Error; use ulid::Ulid; +use url::Url; use super::{UpstreamSessionsCookie, cache::LazyProviderInfos}; use crate::{ @@ -113,11 +118,58 @@ pub(crate) async fn get( }; // Build an authorization request for it - let (mut url, data) = mas_oidc_client::requests::authorization_code::build_authorization_url( - lazy_metadata.authorization_endpoint().await?.clone(), - data, - &mut rng, - )?; + let (mut url, data) = if lazy_metadata + .require_pushed_authorization_requests() + .await? + { + // The upstream provider enforces Pushed Authorization Requests (PAR) + let url = lazy_metadata + .pushed_authorization_request_endpoint() + .await? + .context("provider should have a PAR endpoint") + .map_err(|e| RouteError::Internal(e.into()))? + .clone(); + + // Construct the body for the PAR request + let client_id = data.client_id.clone(); + let (query, validation_data) = + mas_oidc_client::requests::authorization_code::build_par_body(data, &mut rng)?; + + // POST to the PAR endpoint + let response = http_client + .post(url) + .header( + http::header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(query) + .send_traced() + .await + .map_err(|e| RouteError::Internal(e.into()))?; + + // Extract the request_uri from the response + let json = response + .json::() + .await + .map_err(|e| RouteError::Internal(e.into()))?; + let request_uri = + Url::parse(&json.request_uri).map_err(|e| RouteError::Internal(e.into()))?; + + // Build the final authorization URL + let url = mas_oidc_client::requests::authorization_code::build_par_authorization_url( + lazy_metadata.authorization_endpoint().await?.clone(), + client_id, + request_uri, + )?; + + (url, validation_data) + } else { + mas_oidc_client::requests::authorization_code::build_authorization_url( + lazy_metadata.authorization_endpoint().await?.clone(), + data, + &mut rng, + )? + }; // We do that in a block because params borrows url mutably { diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 79a9fe5fb..f752af701 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -140,6 +140,22 @@ impl<'a> LazyProviderInfos<'a> { Ok(methods) } + + /// Check whether the provider accepts authorization requests only via PAR. + pub async fn require_pushed_authorization_requests(&mut self) -> Result { + Ok(self.load().await?.require_pushed_authorization_requests()) + } + + /// Get the provider's pushed authorization request endpoint, if any. + pub async fn pushed_authorization_request_endpoint( + &mut self, + ) -> Result, DiscoveryError> { + Ok(self + .load() + .await? + .pushed_authorization_request_endpoint + .as_ref()) + } } /// A simple OIDC metadata cache diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index ac0770411..b372f6258 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -213,11 +213,11 @@ impl core::str::FromStr for Prompt { /// [Authorization Endpoint]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.1 #[skip_serializing_none] #[serde_as] -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Default)] pub struct AuthorizationRequest { /// OAuth 2.0 Response Type value that determines the authorization /// processing flow to be used. - pub response_type: ResponseType, + pub response_type: Option, /// OAuth 2.0 Client Identifier valid at the Authorization Server. pub client_id: String, @@ -233,7 +233,7 @@ pub struct AuthorizationRequest { /// The scope of the access request. /// /// OpenID Connect requests must contain the `openid` scope value. - pub scope: Scope, + pub scope: Option, /// Opaque value used to maintain state between the request and the /// callback. @@ -313,10 +313,10 @@ impl AuthorizationRequest { #[must_use] pub fn new(response_type: ResponseType, client_id: String, scope: Scope) -> Self { Self { - response_type, + response_type: Some(response_type), client_id, redirect_uri: None, - scope, + scope: Some(scope), state: None, response_mode: None, nonce: None, diff --git a/crates/oidc-client/src/requests/authorization_code.rs b/crates/oidc-client/src/requests/authorization_code.rs index 4965e13d0..cbbf0d666 100644 --- a/crates/oidc-client/src/requests/authorization_code.rs +++ b/crates/oidc-client/src/requests/authorization_code.rs @@ -211,6 +211,19 @@ struct FullAuthorizationRequest { pkce: Option, } +impl FullAuthorizationRequest { + /// Strip the `request_uri` field if it is set. + fn without_request_uri(&self) -> Self { + FullAuthorizationRequest { + inner: AuthorizationRequest { + request_uri: None, + ..self.inner.clone() + }, + pkce: self.pkce.clone(), + } + } +} + /// Build the authorization request. fn build_authorization_request( authorization_data: AuthorizationRequestData, @@ -263,10 +276,10 @@ fn build_authorization_request( let auth_request = FullAuthorizationRequest { inner: AuthorizationRequest { - response_type: OAuthAuthorizationEndpointResponseType::Code.into(), + response_type: Some(OAuthAuthorizationEndpointResponseType::Code.into()), client_id, redirect_uri: Some(redirect_uri.clone()), - scope, + scope: Some(scope), state: Some(state.clone()), response_mode, nonce: nonce.clone(), @@ -338,22 +351,95 @@ pub fn build_authorization_url( build_authorization_request(authorization_data, rng)?; let authorization_query = serde_urlencoded::to_string(authorization_request)?; + let authorization_url = add_query(authorization_endpoint, &authorization_query); - let mut authorization_url = authorization_endpoint; + Ok((authorization_url, validation_data)) +} + +/// Build the body for pushing the authorization request to the PAR endpoint. +/// +/// # Arguments +/// +/// * `authorization_data` - The data necessary to build the authorization +/// request. +/// +/// * `rng` - A random number generator. +/// +/// # Returns +/// +/// A string to be used as the body of a request to the PAR endpoint where it +/// can be exchanged for a request URI and the [`AuthorizationValidationData`] +/// to validate this request. The request URI can then be used to build the +/// authorization URL to be opened in a web browser where the end-user will +/// be able to authorize the given scope. +/// +/// # Errors +/// +/// Returns an error if preparing the body fails. +pub fn build_par_body( + authorization_data: AuthorizationRequestData, + rng: &mut impl Rng, +) -> Result<(String, AuthorizationValidationData), AuthorizationError> { + let (authorization_request, validation_data) = + build_authorization_request(authorization_data, rng)?; + + let authorization_query = + serde_urlencoded::to_string(authorization_request.without_request_uri())?; + + Ok((authorization_query, validation_data)) +} + +/// Build the URL for authenticating at the Authorization endpoint with the PAR +/// request URI. +/// +/// # Arguments +/// +/// * `authorization_endpoint` - The URL of the issuer's authorization endpoint. +/// +/// * `client_id` - The authorizing client's ID. +/// +/// * `request_uri` - The request URI obtained at the PAR endpoint. +/// +/// # Returns +/// +/// A URL to be opened in a web browser where the end-user will be able to +/// authorize the given scope. +/// +/// # Errors +/// +/// Returns an error if preparing the URL fails. +pub fn build_par_authorization_url( + authorization_endpoint: Url, + client_id: String, + request_uri: Url, +) -> Result { + let authorization_request = FullAuthorizationRequest { + inner: AuthorizationRequest { + client_id, + request_uri: Some(request_uri), + ..Default::default() + }, + pkce: None, + }; + let authorization_query = serde_urlencoded::to_string(authorization_request)?; + + Ok(add_query(authorization_endpoint, &authorization_query)) +} + +/// Safely append a query on a URL that might already have one. +fn add_query(url: Url, query: &str) -> Url { + let mut url = url; // Add our parameters to the query, because the URL might already have one. - let mut full_query = authorization_url - .query() - .map(ToOwned::to_owned) - .unwrap_or_default(); + let mut full_query = url.query().map(ToOwned::to_owned).unwrap_or_default(); if !full_query.is_empty() { full_query.push('&'); } - full_query.push_str(&authorization_query); + full_query.push_str(query); - authorization_url.set_query(Some(&full_query)); + url.set_query(Some(&full_query)); - Ok((authorization_url, validation_data)) + url } /// Exchange an authorization code for an access token.