mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 15:13:37 +00:00
Add oauth.device_code_grant_enabled configuration option
This commit is contained in:
1 parent
0f4ce9eccb
commit
53e6d05f40
16 files changed
+360
-20
No files matched your search
@@ -154,6 +154,7 @@ impl Options {
|
||||
&config.passwords,
|
||||
&config.account,
|
||||
&config.captcha,
|
||||
&config.oauth,
|
||||
)?;
|
||||
|
||||
// Load and compile the templates
|
||||
|
||||
@@ -13,7 +13,7 @@ use clap::Parser;
|
||||
use figment::Figment;
|
||||
use mas_config::{
|
||||
AccountConfig, BrandingConfig, CaptchaConfig, ConfigurationSection, ConfigurationSectionExt,
|
||||
ExperimentalConfig, MatrixConfig, PasswordsConfig, TemplatesConfig,
|
||||
ExperimentalConfig, MatrixConfig, OAuthConfig, PasswordsConfig, TemplatesConfig,
|
||||
};
|
||||
use mas_data_model::{Clock, SystemClock};
|
||||
use rand::SeedableRng;
|
||||
@@ -65,6 +65,8 @@ impl Options {
|
||||
.map_err(anyhow::Error::from_boxed)?;
|
||||
let captcha_config = CaptchaConfig::extract_or_default(figment)
|
||||
.map_err(anyhow::Error::from_boxed)?;
|
||||
let oauth_config =
|
||||
OAuthConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?;
|
||||
|
||||
let now = if stabilise {
|
||||
DateTime::from_timestamp_secs(1_446_823_992).unwrap()
|
||||
@@ -86,6 +88,7 @@ impl Options {
|
||||
&password_config,
|
||||
&account_config,
|
||||
&captcha_config,
|
||||
&oauth_config,
|
||||
)?;
|
||||
let templates = templates_from_config(
|
||||
&template_config,
|
||||
|
||||
@@ -49,6 +49,7 @@ impl Options {
|
||||
&config.passwords,
|
||||
&config.account,
|
||||
&config.captcha,
|
||||
&config.oauth,
|
||||
)?;
|
||||
|
||||
// Load and compile the templates
|
||||
|
||||
@@ -9,8 +9,8 @@ use std::{sync::Arc, time::Duration};
|
||||
use anyhow::Context;
|
||||
use mas_config::{
|
||||
AccountConfig, BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode,
|
||||
EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, PasswordsConfig,
|
||||
PolicyConfig, TemplatesConfig,
|
||||
EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, OAuthConfig,
|
||||
PasswordsConfig, PolicyConfig, TemplatesConfig,
|
||||
};
|
||||
use mas_context::LogContext;
|
||||
use mas_data_model::{SessionExpirationConfig, SessionLimitConfig, SiteConfig};
|
||||
@@ -201,6 +201,7 @@ pub fn site_config_from_config(
|
||||
password_config: &PasswordsConfig,
|
||||
account_config: &AccountConfig,
|
||||
captcha_config: &CaptchaConfig,
|
||||
oauth_config: &OAuthConfig,
|
||||
) -> Result<SiteConfig, anyhow::Error> {
|
||||
let captcha = captcha_config_from_config(captcha_config)?;
|
||||
let session_expiration = experimental_config
|
||||
@@ -243,6 +244,7 @@ pub fn site_config_from_config(
|
||||
soft_limit: c.soft_limit,
|
||||
hard_limit: c.hard_limit,
|
||||
}),
|
||||
device_code_grant_enabled: oauth_config.device_code_grant_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ mod email;
|
||||
mod experimental;
|
||||
mod http;
|
||||
mod matrix;
|
||||
mod oauth;
|
||||
mod passwords;
|
||||
mod policy;
|
||||
mod rate_limiting;
|
||||
@@ -40,6 +41,7 @@ pub use self::{
|
||||
Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp,
|
||||
},
|
||||
matrix::{HomeserverKind, MatrixConfig},
|
||||
oauth::OAuthConfig,
|
||||
passwords::{
|
||||
Algorithm as PasswordAlgorithm, HashingScheme as PasswordHashingScheme, PasswordsConfig,
|
||||
},
|
||||
@@ -126,6 +128,10 @@ pub struct RootConfig {
|
||||
#[serde(default, skip_serializing_if = "AccountConfig::is_default")]
|
||||
pub account: AccountConfig,
|
||||
|
||||
/// Configuration section for OAuth 2.0 protocol options
|
||||
#[serde(default, skip_serializing_if = "OAuthConfig::is_default")]
|
||||
pub oauth: OAuthConfig,
|
||||
|
||||
/// Experimental configuration options
|
||||
#[serde(default, skip_serializing_if = "ExperimentalConfig::is_default")]
|
||||
pub experimental: ExperimentalConfig,
|
||||
@@ -151,6 +157,7 @@ impl ConfigurationSection for RootConfig {
|
||||
self.branding.validate(figment)?;
|
||||
self.captcha.validate(figment)?;
|
||||
self.account.validate(figment)?;
|
||||
self.oauth.validate(figment)?;
|
||||
self.experimental.validate(figment)?;
|
||||
|
||||
Ok(())
|
||||
@@ -183,6 +190,7 @@ impl RootConfig {
|
||||
branding: BrandingConfig::default(),
|
||||
captcha: CaptchaConfig::default(),
|
||||
account: AccountConfig::default(),
|
||||
oauth: OAuthConfig::default(),
|
||||
experimental: ExperimentalConfig::default(),
|
||||
})
|
||||
}
|
||||
@@ -206,6 +214,7 @@ impl RootConfig {
|
||||
branding: BrandingConfig::default(),
|
||||
captcha: CaptchaConfig::default(),
|
||||
account: AccountConfig::default(),
|
||||
oauth: OAuthConfig::default(),
|
||||
experimental: ExperimentalConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -249,6 +258,9 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub account: AccountConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub oauth: OAuthConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub experimental: ExperimentalConfig,
|
||||
}
|
||||
@@ -270,6 +282,7 @@ impl ConfigurationSection for AppConfig {
|
||||
self.branding.validate(figment)?;
|
||||
self.captcha.validate(figment)?;
|
||||
self.account.validate(figment)?;
|
||||
self.oauth.validate(figment)?;
|
||||
self.experimental.validate(figment)?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ConfigurationSection;
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
const fn is_default_true(value: &bool) -> bool {
|
||||
*value == default_true()
|
||||
}
|
||||
|
||||
/// Configuration section for OAuth 2.0 protocol options
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct OAuthConfig {
|
||||
/// Whether the Device Authorization Grant (RFC 8628) is enabled. Defaults
|
||||
/// to `true`.
|
||||
///
|
||||
/// When disabled, the device authorization endpoint will reject requests,
|
||||
/// the discovery metadata will not advertise the device authorization
|
||||
/// endpoint, and dynamic client registrations requesting the
|
||||
/// `urn:ietf:params:oauth:grant-type:device_code` grant type will be
|
||||
/// rejected.
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
|
||||
pub device_code_grant_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_code_grant_enabled: default_true(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthConfig {
|
||||
/// Returns true if the configuration is the default one
|
||||
pub(crate) fn is_default(&self) -> bool {
|
||||
is_default_true(&self.device_code_grant_enabled)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigurationSection for OAuthConfig {
|
||||
const PATH: Option<&'static str> = Some("oauth");
|
||||
}
|
||||
@@ -111,4 +111,7 @@ pub struct SiteConfig {
|
||||
|
||||
/// Limits on the number of application sessions that each user can have
|
||||
pub session_limit: Option<SessionLimitConfig>,
|
||||
|
||||
/// Whether the Device Authorization Grant (RFC 8628) is enabled.
|
||||
pub device_code_grant_enabled: bool,
|
||||
}
|
||||
@@ -26,7 +26,7 @@ use rand::distributions::{Alphanumeric, DistString};
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{BoundActivityTracker, impl_from_error_for_route};
|
||||
use crate::{BoundActivityTracker, SiteConfig, impl_from_error_for_route};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum RouteError {
|
||||
@@ -39,6 +39,9 @@ pub(crate) enum RouteError {
|
||||
#[error("client {0} is not allowed to use the device code grant")]
|
||||
ClientNotAllowed(Ulid),
|
||||
|
||||
#[error("the Device Authorization Grant is disabled")]
|
||||
DeviceCodeGrantDisabled,
|
||||
|
||||
#[error("invalid client credentials for client {client_id}")]
|
||||
InvalidClientCredentials {
|
||||
client_id: Ulid,
|
||||
@@ -73,6 +76,14 @@ impl IntoResponse for RouteError {
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ClientError::from(ClientErrorCode::UnauthorizedClient)),
|
||||
),
|
||||
Self::DeviceCodeGrantDisabled => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
ClientError::from(ClientErrorCode::InvalidRequest).with_description(
|
||||
"The Device Authorization Grant is disabled on this server".to_owned(),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
(sentry_event_id, response).into_response()
|
||||
@@ -93,8 +104,13 @@ pub(crate) async fn post(
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(encrypter): State<Encrypter>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
client_authorization: ClientAuthorization<DeviceAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, RouteError> {
|
||||
if !site_config.device_code_grant_enabled {
|
||||
return Err(RouteError::DeviceCodeGrantDisabled);
|
||||
}
|
||||
|
||||
let client = client_authorization
|
||||
.credentials
|
||||
.fetch(&mut repo)
|
||||
@@ -182,13 +198,16 @@ pub(crate) async fn post(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hyper::{Request, StatusCode};
|
||||
use mas_data_model::SiteConfig;
|
||||
use mas_router::SimpleRoute;
|
||||
use oauth2_types::{
|
||||
registration::ClientRegistrationResponse, requests::DeviceAuthorizationResponse,
|
||||
errors::{ClientError, ClientErrorCode},
|
||||
registration::ClientRegistrationResponse,
|
||||
requests::DeviceAuthorizationResponse,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config};
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_device_code_request(pool: PgPool) {
|
||||
@@ -224,4 +243,37 @@ mod tests {
|
||||
assert_eq!(response.device_code.len(), 32);
|
||||
assert_eq!(response.user_code.len(), 6);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_device_code_request_disabled(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool_with_site_config(
|
||||
pool,
|
||||
SiteConfig {
|
||||
device_code_grant_enabled: false,
|
||||
..test_site_config()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The endpoint rejects the request before looking up the client
|
||||
let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
|
||||
serde_json::json!({
|
||||
"client_id": "some-client-id",
|
||||
"scope": "openid",
|
||||
}),
|
||||
);
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
let error: ClientError = response.json();
|
||||
assert_eq!(error.error, ClientErrorCode::InvalidRequest);
|
||||
assert!(
|
||||
error
|
||||
.error_description
|
||||
.unwrap()
|
||||
.contains("Device Authorization Grant is disabled")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ use tracing::warn;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{
|
||||
BoundActivityTracker, PreferredLanguage,
|
||||
BoundActivityTracker, PreferredLanguage, SiteConfig,
|
||||
session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback},
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(crate) async fn get(
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
mut repo: BoxRepository,
|
||||
mut policy: Policy,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
@@ -60,6 +61,11 @@ pub(crate) async fn get(
|
||||
cookie_jar: CookieJar,
|
||||
Path(grant_id): Path<Ulid>,
|
||||
) -> Result<Response, InternalError> {
|
||||
if !site_config.device_code_grant_enabled {
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"The Device Authorization Grant is disabled"
|
||||
)));
|
||||
}
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
@@ -191,6 +197,7 @@ pub(crate) async fn post(
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
mut repo: BoxRepository,
|
||||
mut policy: Policy,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
@@ -199,6 +206,11 @@ pub(crate) async fn post(
|
||||
Path(grant_id): Path<Ulid>,
|
||||
Form(form): Form<ProtectedForm<ConsentForm>>,
|
||||
) -> Result<Response, InternalError> {
|
||||
if !site_config.device_code_grant_enabled {
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"The Device Authorization Grant is disabled"
|
||||
)));
|
||||
}
|
||||
let form = cookie_jar.verify_form(&clock, form)?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
|
||||
@@ -18,7 +18,7 @@ use mas_templates::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::PreferredLanguage;
|
||||
use crate::{PreferredLanguage, SiteConfig};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
@@ -33,9 +33,15 @@ pub(crate) async fn get(
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
cookie_jar: CookieJar,
|
||||
Query(query): Query<Params>,
|
||||
) -> Result<impl IntoResponse, InternalError> {
|
||||
if !site_config.device_code_grant_enabled {
|
||||
return Err(InternalError::from_anyhow(anyhow::anyhow!(
|
||||
"The Device Authorization Grant is disabled"
|
||||
)));
|
||||
}
|
||||
let mut form_state = FormState::from_form(&query);
|
||||
|
||||
// If we have a code in query, find it in the database
|
||||
|
||||
@@ -59,7 +59,11 @@ pub(crate) async fn get(
|
||||
let issuer = Some(url_builder.oidc_issuer().into());
|
||||
let authorization_endpoint = Some(url_builder.oauth_authorization_endpoint());
|
||||
let token_endpoint = Some(url_builder.oauth_token_endpoint());
|
||||
let device_authorization_endpoint = Some(url_builder.oauth_device_authorization_endpoint());
|
||||
let device_authorization_endpoint = if site_config.device_code_grant_enabled {
|
||||
Some(url_builder.oauth_device_authorization_endpoint())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let jwks_uri = Some(url_builder.jwks_uri());
|
||||
let introspection_endpoint = Some(url_builder.oauth_introspection_endpoint());
|
||||
let revocation_endpoint = Some(url_builder.oauth_revocation_endpoint());
|
||||
@@ -80,12 +84,17 @@ pub(crate) async fn get(
|
||||
ResponseMode::Fragment,
|
||||
]);
|
||||
|
||||
let grant_types_supported = Some(vec![
|
||||
GrantType::AuthorizationCode,
|
||||
GrantType::RefreshToken,
|
||||
GrantType::ClientCredentials,
|
||||
GrantType::DeviceCode,
|
||||
]);
|
||||
let grant_types_supported = {
|
||||
let mut types = vec![
|
||||
GrantType::AuthorizationCode,
|
||||
GrantType::RefreshToken,
|
||||
GrantType::ClientCredentials,
|
||||
];
|
||||
if site_config.device_code_grant_enabled {
|
||||
types.push(GrantType::DeviceCode);
|
||||
}
|
||||
Some(types)
|
||||
};
|
||||
|
||||
let token_endpoint_auth_methods_supported = client_auth_methods_supported.clone();
|
||||
let token_endpoint_auth_signing_alg_values_supported =
|
||||
@@ -199,10 +208,11 @@ pub(crate) async fn get(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hyper::{Request, StatusCode};
|
||||
use oauth2_types::oidc::ProviderMetadata;
|
||||
use mas_data_model::SiteConfig;
|
||||
use oauth2_types::{oidc::ProviderMetadata, requests::GrantType};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config};
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_valid_discovery_metadata(pool: PgPool) {
|
||||
@@ -218,4 +228,35 @@ mod tests {
|
||||
.validate(state.url_builder.oidc_issuer().as_str())
|
||||
.expect("Invalid metadata");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_discovery_device_code_grant_disabled(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool_with_site_config(
|
||||
pool,
|
||||
SiteConfig {
|
||||
device_code_grant_enabled: false,
|
||||
..test_site_config()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request = Request::get("/.well-known/openid-configuration").empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
|
||||
let metadata: ProviderMetadata = response.json();
|
||||
|
||||
// The device_authorization_endpoint should not be present
|
||||
assert!(metadata.device_authorization_endpoint.is_none());
|
||||
|
||||
// The grant_types_supported should not include device_code
|
||||
let grant_types = metadata.grant_types_supported.as_ref().unwrap();
|
||||
assert!(!grant_types.contains(&GrantType::DeviceCode));
|
||||
|
||||
metadata
|
||||
.validate(state.url_builder.oidc_issuer().as_str())
|
||||
.expect("Invalid metadata");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use oauth2_types::{
|
||||
ClientMetadata, ClientMetadataVerificationError, ClientRegistrationResponse, Localized,
|
||||
VerifiedClientMetadata,
|
||||
},
|
||||
requests::GrantType,
|
||||
};
|
||||
use opentelemetry::{Key, KeyValue, metrics::Counter};
|
||||
use psl::Psl;
|
||||
@@ -31,7 +32,7 @@ use thiserror::Error;
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
|
||||
use crate::{BoundActivityTracker, METER, impl_from_error_for_route};
|
||||
use crate::{BoundActivityTracker, METER, SiteConfig, impl_from_error_for_route};
|
||||
|
||||
static REGISTRATION_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
|
||||
METER
|
||||
@@ -58,6 +59,9 @@ pub(crate) enum RouteError {
|
||||
|
||||
#[error("client registration denied by the policy: {0}")]
|
||||
PolicyDenied(EvaluationResult),
|
||||
|
||||
#[error("the Device Authorization Grant is disabled")]
|
||||
DeviceCodeGrantDisabled,
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
@@ -169,6 +173,16 @@ impl IntoResponse for RouteError {
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
Self::DeviceCodeGrantDisabled => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
ClientError::from(ClientErrorCode::InvalidClientMetadata).with_description(
|
||||
"The Device Authorization Grant is disabled on this server".to_owned(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
|
||||
(sentry_event_id, response).into_response()
|
||||
@@ -222,6 +236,7 @@ pub(crate) async fn post(
|
||||
activity_tracker: BoundActivityTracker,
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
State(encrypter): State<Encrypter>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
body: Result<Json<ClientMetadata>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<impl IntoResponse, RouteError> {
|
||||
// Propagate any JSON extraction error
|
||||
@@ -240,6 +255,13 @@ pub(crate) async fn post(
|
||||
// Validate the body
|
||||
let metadata = body.validate()?;
|
||||
|
||||
// Reject if the client requests the device_code grant type and it's disabled
|
||||
if !site_config.device_code_grant_enabled
|
||||
&& metadata.grant_types().contains(&GrantType::DeviceCode)
|
||||
{
|
||||
return Err(RouteError::DeviceCodeGrantDisabled);
|
||||
}
|
||||
|
||||
// Some extra validation that is hard to do in OPA and not done by the
|
||||
// `validate` method either
|
||||
if let Some(client_uri) = &metadata.client_uri
|
||||
@@ -383,6 +405,7 @@ pub(crate) async fn post(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hyper::{Request, StatusCode};
|
||||
use mas_data_model::SiteConfig;
|
||||
use mas_router::SimpleRoute;
|
||||
use oauth2_types::{
|
||||
errors::{ClientError, ClientErrorCode},
|
||||
@@ -393,7 +416,7 @@ mod tests {
|
||||
|
||||
use crate::{
|
||||
oauth2::registration::host_is_public_suffix,
|
||||
test_utils::{RequestBuilderExt, ResponseExt, TestState, setup},
|
||||
test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config},
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -616,4 +639,51 @@ mod tests {
|
||||
let response: ClientRegistrationResponse = response.json();
|
||||
assert_ne!(response.client_id, client_id);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_registration_device_code_grant_disabled(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool_with_site_config(
|
||||
pool,
|
||||
SiteConfig {
|
||||
device_code_grant_enabled: false,
|
||||
..test_site_config()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Registering a client that requests device_code grant should be rejected
|
||||
let request =
|
||||
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
||||
"client_uri": "https://example.com/",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code"],
|
||||
"response_types": [],
|
||||
}));
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::BAD_REQUEST);
|
||||
let error: ClientError = response.json();
|
||||
assert_eq!(error.error, ClientErrorCode::InvalidClientMetadata);
|
||||
assert!(
|
||||
error
|
||||
.error_description
|
||||
.unwrap()
|
||||
.contains("Device Authorization Grant is disabled")
|
||||
);
|
||||
|
||||
// Registering a client without device_code grant should still work
|
||||
let request =
|
||||
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
||||
"client_uri": "https://example.com/",
|
||||
"redirect_uris": ["https://example.com/"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"response_types": ["code"],
|
||||
"grant_types": ["authorization_code"],
|
||||
}));
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
}
|
||||
}
|
||||
@@ -861,6 +861,11 @@ async fn device_code_grant(
|
||||
homeserver: &Arc<dyn HomeserverConnection>,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
||||
// Check that the Device Authorization Grant is enabled on this server
|
||||
if !site_config.device_code_grant_enabled {
|
||||
return Err(RouteError::UnsupportedGrantType);
|
||||
}
|
||||
|
||||
// Check that the client is allowed to use this grant type
|
||||
if !client.grant_types.contains(&GrantType::DeviceCode) {
|
||||
return Err(RouteError::UnauthorizedClient(client.id));
|
||||
@@ -1007,7 +1012,7 @@ mod tests {
|
||||
use sqlx::PgPool;
|
||||
|
||||
use super::*;
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config};
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_auth_code_grant(pool: PgPool) {
|
||||
@@ -1916,4 +1921,47 @@ mod tests {
|
||||
let ClientError { error, .. } = response.json();
|
||||
assert_eq!(error, ClientErrorCode::UnsupportedGrantType);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_device_code_grant_disabled(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool_with_site_config(
|
||||
pool,
|
||||
SiteConfig {
|
||||
device_code_grant_enabled: false,
|
||||
..test_site_config()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Provision a client (without device_code grant, since registration rejects it)
|
||||
let request =
|
||||
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
||||
"client_uri": "https://example.com/",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"response_types": ["code"],
|
||||
"grant_types": ["authorization_code"],
|
||||
}));
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
|
||||
let response: ClientRegistrationResponse = response.json();
|
||||
let client_id = response.client_id;
|
||||
|
||||
// Attempt to use the device_code grant type at the token endpoint
|
||||
let request =
|
||||
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": "fake-device-code",
|
||||
"client_id": client_id,
|
||||
}));
|
||||
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::BAD_REQUEST);
|
||||
let ClientError { error, .. } = response.json();
|
||||
assert_eq!(error, ClientErrorCode::UnsupportedGrantType);
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ pub fn test_site_config() -> SiteConfig {
|
||||
login_with_email_allowed: true,
|
||||
plan_management_iframe_uri: None,
|
||||
session_limit: None,
|
||||
device_code_grant_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"oauth": {
|
||||
"description": "Configuration section for OAuth 2.0 protocol options",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/OAuthConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"experimental": {
|
||||
"description": "Experimental configuration options",
|
||||
"allOf": [
|
||||
@@ -2813,6 +2821,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OAuthConfig": {
|
||||
"description": "Configuration section for OAuth 2.0 protocol options",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"device_code_grant_enabled": {
|
||||
"description": "Whether the Device Authorization Grant (RFC 8628) is enabled. Defaults\n to `true`.\n\n When disabled, the device authorization endpoint will reject requests,\n the discovery metadata will not advertise the device authorization\n endpoint, and dynamic client registrations requesting the\n `urn:ietf:params:oauth:grant-type:device_code` grant type will be\n rejected.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExperimentalConfig": {
|
||||
"description": "Configuration sections for experimental options\n\n Do not change these options unless you know what you are doing.",
|
||||
"type": "object",
|
||||
|
||||
@@ -856,6 +856,23 @@ branding:
|
||||
#logo_uri:
|
||||
```
|
||||
|
||||
## `oauth`
|
||||
|
||||
Configuration section for OAuth 2.0 protocol options.
|
||||
|
||||
```yaml
|
||||
oauth:
|
||||
# Whether the Device Authorization Grant (RFC 8628) is enabled. Defaults
|
||||
# to `true`.
|
||||
#
|
||||
# When disabled, the device authorization endpoint will reject requests,
|
||||
# the discovery metadata will not advertise the device authorization
|
||||
# endpoint, and dynamic client registrations requesting the
|
||||
# `urn:ietf:params:oauth:grant-type:device_code` grant type will be
|
||||
# rejected.
|
||||
device_code_grant_enabled: true
|
||||
```
|
||||
|
||||
## `experimental`
|
||||
|
||||
Settings that may change or be removed in future versions.
|
||||
|
||||
Reference in new issue
Block a user