Skip to content

feat: add support for mandatory upstream pushed authorization requests #4847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions crates/handlers/src/oauth2/authorization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -44,6 +45,9 @@ pub enum RouteError {
#[error("invalid response mode")]
InvalidResponseMode,

#[error("invalid scope")]
InvalidScope,

#[error("invalid parameters")]
IntoCallbackDestination(#[from] self::callback::IntoCallbackDestinationError),

Expand All @@ -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()
Expand Down Expand Up @@ -132,7 +137,11 @@ pub(crate) async fn get(
let redirect_uri = client
.resolve_redirect_uri(&params.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
Expand Down Expand Up @@ -246,14 +255,20 @@ 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(
&mut rng,
&clock,
&client,
redirect_uri.clone(),
params.auth.scope,
scope,
code,
params.auth.state.clone(),
params.auth.nonce,
Expand Down
62 changes: 57 additions & 5 deletions crates/handlers/src/upstream_oauth2/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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::<PushedAuthorizationResponse>()
.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
{
Expand Down
16 changes: 16 additions & 0 deletions crates/handlers/src/upstream_oauth2/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, DiscoveryError> {
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<Option<&Url>, DiscoveryError> {
Ok(self
.load()
.await?
.pushed_authorization_request_endpoint
.as_ref())
}
}

/// A simple OIDC metadata cache
Expand Down
10 changes: 5 additions & 5 deletions crates/oauth2-types/src/requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResponseType>,

/// OAuth 2.0 Client Identifier valid at the Authorization Server.
pub client_id: String,
Expand All @@ -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<Scope>,

/// Opaque value used to maintain state between the request and the
/// callback.
Expand Down Expand Up @@ -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,
Expand Down
106 changes: 96 additions & 10 deletions crates/oidc-client/src/requests/authorization_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ struct FullAuthorizationRequest {
pkce: Option<pkce::AuthorizationRequest>,
}

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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Url, AuthorizationError> {
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.
Expand Down
Loading