diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index b72d48111..74570b8ef 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -154,6 +154,7 @@ impl Options { &config.passwords, &config.account, &config.captcha, + &config.oauth, )?; // Load and compile the templates diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index 1199e1f9a..875122406 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -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, diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 6dfcd27f1..3cab3f866 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -49,6 +49,7 @@ impl Options { &config.passwords, &config.account, &config.captcha, + &config.oauth, )?; // Load and compile the templates diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 454276150..735a9e482 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -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 { 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, }) } diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index eb4ff2a44..231a63323 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -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(()) diff --git a/crates/config/src/sections/oauth.rs b/crates/config/src/sections/oauth.rs new file mode 100644 index 000000000..09a70b967 --- /dev/null +++ b/crates/config/src/sections/oauth.rs @@ -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"); +} diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index bb92dc3e4..570b58230 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -111,4 +111,7 @@ pub struct SiteConfig { /// Limits on the number of application sessions that each user can have pub session_limit: Option, + + /// Whether the Device Authorization Grant (RFC 8628) is enabled. + pub device_code_grant_enabled: bool, } diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index 415131617..fb2c61913 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -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, State(http_client): State, State(encrypter): State, + State(site_config): State, client_authorization: ClientAuthorization, ) -> Result { + 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") + ); + } } diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 3912d2dc1..d6ec84d68 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -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, State(url_builder): State, State(homeserver): State>, + State(site_config): State, 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, ) -> Result { + 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, State(url_builder): State, State(homeserver): State>, + State(site_config): State, mut repo: BoxRepository, mut policy: Policy, activity_tracker: BoundActivityTracker, @@ -199,6 +206,11 @@ pub(crate) async fn post( Path(grant_id): Path, Form(form): Form>, ) -> Result { + 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, diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index 84d0c5077..8f988a600 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -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, State(url_builder): State, + State(site_config): State, cookie_jar: CookieJar, Query(query): Query, ) -> Result { + 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 diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index b1d000f8f..4530ea820 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -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"); + } } diff --git a/crates/handlers/src/oauth2/registration.rs b/crates/handlers/src/oauth2/registration.rs index 5ad58b386..26dff9021 100644 --- a/crates/handlers/src/oauth2/registration.rs +++ b/crates/handlers/src/oauth2/registration.rs @@ -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> = 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>, State(encrypter): State, + State(site_config): State, body: Result, axum::extract::rejection::JsonRejection>, ) -> Result { // 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); + } } diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 696c7d426..56de038f0 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -861,6 +861,11 @@ async fn device_code_grant( homeserver: &Arc, user_agent: Option, ) -> 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); + } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 521a4848d..48a4a4dec 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -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, } } diff --git a/docs/config.schema.json b/docs/config.schema.json index f6d947e48..88ebaa848 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -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", diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d2d65a0b4..e2d721db7 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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.