From 54432c0761de4813f94bebd0a22fad68ffe84cb1 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 28 Sep 2026 12:07:24 -0400 Subject: [PATCH] feat: Add support for OAuth client credentials grant --- changelog.d/+a167c798.feature.md | 1 + conduwuit-example.toml | 42 ++++++++ src/api/client/oauth/server_metadata.rs | 2 +- src/api/router/auth.rs | 26 ++++- src/core/config/check.rs | 9 ++ src/core/config/mod.rs | 52 ++++++++++ src/service/oauth/client_metadata.rs | 7 ++ src/service/oauth/grant.rs | 7 +- src/service/oauth/mod.rs | 122 ++++++++++++++++++++++-- 9 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 changelog.d/+a167c798.feature.md diff --git a/changelog.d/+a167c798.feature.md b/changelog.d/+a167c798.feature.md new file mode 100644 index 000000000..71cf08c1e --- /dev/null +++ b/changelog.d/+a167c798.feature.md @@ -0,0 +1 @@ +Continuwuity now supports the OAuth 2.0 client credentials grant type, allowing secure automated access to the Matrix homeserver admin API and future administrative functionality. diff --git a/conduwuit-example.toml b/conduwuit-example.toml index d0bf4e51f..9838a66f5 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2399,3 +2399,45 @@ # through their Matrix client. # #profile_key_import_mode = "on_registration" + +#[global.oauth.clients."$id"] +# Use this section to add static OAuth clients for bots and automation. +# Clients added here may use the client credentials OAuth grant to authorize +# themselves with Continuwuity. +# +# Make sure to replace `$id` in the header with your client ID of choice! You can +# add more copies of this section with different IDs to add multiple clients. +# +# Because the client credentials grant authorizes the client as itself, without +# an attached user, clients that use it will not be able to access most of the Matrix +# client-server API. Administrative functionality that does not require a user +# will still be available. + + +# The client's client secret. +# +# This must be supplied as the `client_secret` parameter when requesting +# an access token with the OAuth 2.0 token endpoint. Continuwuity does +# not check the `Authorization` header for requests to this endpoint. +# +#client_secret = + +# A list of scopes that the client may request. Continuwuity allows static +# clients to request the following scopes: +# +# - "urn:matrix:client:cc.c10y.msc4484.server_administration" (MSC4484): +# This scope grants access to the Matrix server administration APIs, as +# well as Continuwuity's internal admin API (currently a work in +# progress). +# - "urn:matrix:client:api:*": This scope grants access to the Matrix +# client-server API. Most endpoints require an authenticated Matrix user +# and thus will not be usable by static clients. +# +# The `urn:matrix:client:device:` scope may not be requested by static +# clients. +# +# Scopes listed here are not granted by default. The client must +# explicitly include them in the `scope` parameter of its access token +# request. +# +#scopes = [] diff --git a/src/api/client/oauth/server_metadata.rs b/src/api/client/oauth/server_metadata.rs index 331289af2..4c91c7b07 100644 --- a/src/api/client/oauth/server_metadata.rs +++ b/src/api/client/oauth/server_metadata.rs @@ -61,7 +61,7 @@ pub(crate) async fn authorization_server_metadata(services: &Services) -> Value "authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(), "code_challenge_methods_supported": ["S256"], "device_authorization_endpoint": endpoint_base.join(DEVICE_AUTHORIZATION_PATH).unwrap(), - "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "client_credentials"], "issuer": services.config.get_client_domain(), "jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(), "prompt_values_supported": prompt_values_supported, diff --git a/src/api/router/auth.rs b/src/api/router/auth.rs index cac270ee0..9968de0bc 100644 --- a/src/api/router/auth.rs +++ b/src/api/router/auth.rs @@ -41,6 +41,7 @@ pub(crate) enum ClientIdentity { sender_device: Option, appservice_info: Box, }, + StaticClient, } impl ClientIdentity { @@ -48,6 +49,7 @@ pub(crate) fn sender_user(&self) -> Option<&UserId> { match self { | Self::User { sender_user, .. } | Self::Appservice { sender_user, .. } => Some(sender_user), + | Self::StaticClient => None, } } @@ -55,6 +57,8 @@ pub(crate) fn expect_sender_user(&self) -> Result<&UserId> { match self { | Self::User { sender_user, .. } | Self::Appservice { sender_user, .. } => Ok(sender_user), + | Self::StaticClient => + Err!(Request(Forbidden("Static clients cannot use this endpoint."))), } } @@ -62,6 +66,7 @@ pub(crate) fn sender_device(&self) -> Option<&DeviceId> { match self { | Self::User { sender_device, .. } => Some(sender_device), | Self::Appservice { sender_device, .. } => sender_device.as_deref(), + | Self::StaticClient => None, } } @@ -71,12 +76,14 @@ pub(crate) fn expect_sender_device(&self) -> Result<&DeviceId> { | Self::Appservice { sender_device: Some(sender_device), .. } => Ok(sender_device), | Self::Appservice { sender_device: None, .. } => Err!(Request(Forbidden("Appservices must masquerade to use this endpoint."))), + | Self::StaticClient => + Err!(Request(Forbidden("Static clients cannot use this endpoint."))), } } pub(crate) fn appservice_info(&self) -> Option<&RegistrationInfo> { match self { - | Self::User { .. } => None, + | Self::User { .. } | Self::StaticClient => None, | Self::Appservice { appservice_info, .. } => Some(appservice_info), } } @@ -395,6 +402,23 @@ async fn check_access_token( sender_device, appservice_info: Box::new(appservice_info), }) + } else if let Some(session) = services.oauth.get_client_credentials_session(token).await { + if !required_scopes + .iter() + .any(|scope| session.scopes.contains(scope)) + { + return Err(Error::Request( + ErrorKind::InsufficientUserAuthentication(Box::new( + assign!(InsufficientUserAuthenticationErrorData::new(), { + scope: required_scopes.iter().cloned().collect() + }), + )), + "You do not have permission to access this endpoint.".into(), + StatusCode::UNAUTHORIZED, + )); + } + + Ok(ClientIdentity::StaticClient) } else { Err(Error::Request( ErrorKind::UnknownToken(UnknownTokenErrorData::new()), diff --git a/src/core/config/check.rs b/src/core/config/check.rs index 6990abcff..9f0a4869f 100644 --- a/src/core/config/check.rs +++ b/src/core/config/check.rs @@ -345,6 +345,15 @@ pub fn check(config: &Config) -> Result { } })?; + for (id, client) in &config.oauth.clients { + if client.scopes.is_empty() { + warn!( + "The static OAuth client `{id}` has no scopes configured. It will not be able \ + to access any API endpoints." + ); + } + } + Ok(()) } diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index e59077d78..66dd99489 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -23,6 +23,7 @@ OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedServerSigningKeyId, OwnedUserId, RoomVersionId, ServerName, api::{ + OAuthClientScope, client::{discovery::discover_support::ContactRole, rtc::RtcTransport}, federation::discovery::OldVerifyKey, }, @@ -2725,6 +2726,9 @@ pub struct OauthConfig { /// display: hidden pub oidc: Option, + + /// display: hidden + pub clients: HashMap, } impl OauthConfig { @@ -2938,6 +2942,54 @@ pub fn new(server_name: OwnedServerName) -> Self { } } +#[derive(Clone, Debug, Deserialize)] +#[config_example_generator( + filename = "conduwuit-example.toml", + section = "global.oauth.clients.\"$id\"", + optional = "true", + subheader = "\ +# Use this section to add static OAuth clients for bots and automation. +# Clients added here may use the client credentials OAuth grant to authorize +# themselves with Continuwuity. +# +# Make sure to replace `$id` in the header with your client ID of choice! You can +# add more copies of this section with different IDs to add multiple clients. +# +# Because the client credentials grant authorizes the client as itself, without +# an attached user, clients that use it will not be able to access most of the Matrix +# client-server API. Administrative functionality that does not require a user +# will still be available." +)] +pub struct OAuthClient { + /// The client's client secret. + /// + /// This must be supplied as the `client_secret` parameter when requesting + /// an access token with the OAuth 2.0 token endpoint. Continuwuity does + /// not check the `Authorization` header for requests to this endpoint. + pub client_secret: String, + + /// A list of scopes that the client may request. Continuwuity allows static + /// clients to request the following scopes: + /// + /// - "urn:matrix:client:cc.c10y.msc4484.server_administration" (MSC4484): + /// This scope grants access to the Matrix server administration APIs, as + /// well as Continuwuity's internal admin API (currently a work in + /// progress). + /// - "urn:matrix:client:api:*": This scope grants access to the Matrix + /// client-server API. Most endpoints require an authenticated Matrix user + /// and thus will not be usable by static clients. + /// + /// The `urn:matrix:client:device:` scope may not be requested by static + /// clients. + /// + /// Scopes listed here are not granted by default. The client must + /// explicitly include them in the `scope` parameter of its access token + /// request. + /// + /// default: [] + pub scopes: BTreeSet, +} + const DEPRECATED_KEYS: &[&str] = &[ "cache_capacity", "conduit_cache_capacity_modifier", diff --git a/src/service/oauth/client_metadata.rs b/src/service/oauth/client_metadata.rs index 75d6d15b3..e239842dd 100644 --- a/src/service/oauth/client_metadata.rs +++ b/src/service/oauth/client_metadata.rs @@ -116,6 +116,12 @@ pub(super) fn validate(&self) -> Result<(), &'static str> { } } + if self.grant_types.contains(&GrantType::ClientCredentials) { + return Err( + "Dynamically registered clients may not use the client credentials grant." + ); + } + Ok(()) } } @@ -135,6 +141,7 @@ pub enum GrantType { #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")] DeviceCode, RefreshToken, + ClientCredentials, } #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)] diff --git a/src/service/oauth/grant.rs b/src/service/oauth/grant.rs index 1eccc8f98..ba758a560 100644 --- a/src/service/oauth/grant.rs +++ b/src/service/oauth/grant.rs @@ -225,6 +225,10 @@ pub enum TokenRequestType { RefreshToken { refresh_token: String, }, + ClientCredentials { + client_secret: String, + scopes: RawScopes, + }, } impl TokenRequestType { @@ -234,6 +238,7 @@ pub fn grant_type(&self) -> GrantType { | Self::AuthorizationCode { .. } => GrantType::AuthorizationCode, | Self::DeviceCode { .. } => GrantType::DeviceCode, | Self::RefreshToken { .. } => GrantType::RefreshToken, + | Self::ClientCredentials { .. } => GrantType::ClientCredentials, } } } @@ -243,7 +248,7 @@ pub struct TokenResponse { pub access_token: String, pub token_type: TokenType, pub expires_in: u64, - pub refresh_token: String, + pub refresh_token: Option, pub scope: String, } diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index c3db66a57..c6437c8bb 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -23,8 +23,9 @@ client_metadata::{ApplicationType, ClientMetadata, GrantType, ResponseType}, grant::{ AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, - DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RequestedScopes, - ResponseMode, TokenRequest, TokenRequestType, TokenResponse, TokenType, + DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RawScopes, + RequestedScopes, ResponseMode, TokenRequest, TokenRequestType, TokenResponse, + TokenType, }, }, users::{self, DeviceToken}, @@ -39,6 +40,7 @@ pub struct Service { tickets: Mutex>>, pending_auth_code_grants: tokio::sync::Mutex>, pending_device_code_grants: tokio::sync::Mutex>, + client_credential_sessions: tokio::sync::Mutex>, } struct Data { @@ -78,6 +80,12 @@ struct RefreshTokenInfo { device_id: OwnedDeviceId, } +pub struct ClientCredentialsSession { + pub client_id: String, + pub scopes: BTreeSet, + created_at: SystemTime, +} + struct PendingAuthCodeGrant { authorizing_user: OwnedUserId, requested_scopes: RequestedScopes, @@ -176,6 +184,7 @@ fn build(args: crate::Args<'_>) -> Result> { pending_device_code_grants: tokio::sync::Mutex::new(LruCache::new( Self::MAX_PENDING_GRANTS, )), + client_credential_sessions: tokio::sync::Mutex::default(), })) } @@ -450,12 +459,25 @@ pub async fn validate_device_code( pub async fn issue_token(&self, request: TokenRequest) -> Result { let TokenRequest { client_id, request } = request; - let Some(client_metadata) = self.get_client_metadata(&client_id).await else { - return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Invalid client ID")); - }; + if !matches!(request, TokenRequestType::ClientCredentials { .. }) { + // The client credentials grant can only be used by statically + // registered clients, and the other grant types can + // only be used by dynamically registered clients, so we + // don't look up the dynamic client metadata if the token request is + // for the client credentials grant. - if !client_metadata.grant_types.contains(&request.grant_type()) { - return Err(OAuthError::unauthorized_client("Client cannot request this grant type")); + let Some(client_metadata) = self.get_client_metadata(&client_id).await else { + return Err(OAuthError::new_static( + ErrorCode::InvalidClient, + "Invalid client ID", + )); + }; + + if !client_metadata.grant_types.contains(&request.grant_type()) { + return Err(OAuthError::unauthorized_client( + "Client cannot request this grant type", + )); + } } match request { @@ -521,6 +543,9 @@ pub async fn issue_token(&self, request: TokenRequest) -> Result self.refresh_session(client_id, refresh_token).await, + | TokenRequestType::ClientCredentials { client_secret, scopes } => + self.create_client_credentials_token(&client_id, &client_secret, scopes) + .await, } } @@ -664,7 +689,7 @@ async fn create_session( token_type: TokenType::Bearer, expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(), scope: response_scope, - refresh_token, + refresh_token: Some(refresh_token), }) } @@ -734,10 +759,89 @@ async fn refresh_session( token_type: TokenType::Bearer, expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(), scope, - refresh_token: new_refresh_token, + refresh_token: Some(new_refresh_token), }) } + async fn create_client_credentials_token( + &self, + client_id: &str, + client_secret: &str, + scopes: RawScopes, + ) -> Result { + let now = SystemTime::now(); + + let Some(client) = self.services.config.oauth.clients.get(client_id) else { + return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Unknown client ID")); + }; + + let RequestedScopes { device_id, scopes } = scopes + .to_scopes() + .map_err(|err| OAuthError::new(ErrorCode::InvalidGrant, err))?; + + if device_id.is_some() { + return Err(OAuthError::new_static( + ErrorCode::InvalidGrant, + "Static clients may not request a device ID", + )); + } + + if client_secret != client.client_secret { + return Err(OAuthError::new_static( + ErrorCode::InvalidClient, + "Client secret does not match", + )); + } + + if !scopes.is_subset(&client.scopes) { + return Err(OAuthError::new_static( + ErrorCode::InvalidScope, + "This client may not request those scopes", + )); + } + + let mut sessions = self.client_credential_sessions.lock().await; + + // Clear expired sessions while we're at it + sessions.retain(|_, session| { + now.duration_since(session.created_at) + .is_ok_and(|age| age <= Self::ACCESS_TOKEN_MAX_AGE) + }); + + let access_token = Self::generate_token(); + let scope = scopes.iter().join(" "); + + sessions.insert(access_token.clone(), ClientCredentialsSession { + client_id: client_id.to_owned(), + scopes, + created_at: now, + }); + + Ok(TokenResponse { + access_token, + token_type: TokenType::Bearer, + expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(), + refresh_token: None, + scope, + }) + } + + pub async fn get_client_credentials_session( + &self, + token: &str, + ) -> Option> { + let sessions = self.client_credential_sessions.lock().await; + + tokio::sync::MutexGuard::try_map(sessions, |sessions| { + sessions.get_mut(token).filter(|session| { + SystemTime::now() + .duration_since(session.created_at) + .is_ok_and(|age| age <= Self::ACCESS_TOKEN_MAX_AGE) + }) + }) + .ok() + } + pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) { let session_info = self.get_session_info_for_device(user_id, device_id).await;