feat: Add support for OAuth client credentials grant

This commit is contained in:
Ginger committed 2026-10-05 10:23:52 -04:00
1 parent 4d7341c32d
commit 54432c0761
9 files changed
+256 -12

No files matched your search

+1
View File
@@ -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.
+42
View File
@@ -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:<id>` 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 = []
+1 -1
View File
@@ -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,
+25 -1
View File
@@ -41,6 +41,7 @@ pub(crate) enum ClientIdentity {
sender_device: Option<OwnedDeviceId>,
appservice_info: Box<RegistrationInfo>,
},
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()),
+9
View File
@@ -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(())
}
+52
View File
@@ -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<OidcConfig>,
/// display: hidden
pub clients: HashMap<String, OAuthClient>,
}
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:<id>` 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<OAuthClientScope>,
}
const DEPRECATED_KEYS: &[&str] = &[
"cache_capacity",
"conduit_cache_capacity_modifier",
+7
View File
@@ -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)]
+6 -1
View File
@@ -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<String>,
pub scope: String,
}
+113 -9
View File
@@ -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<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_auth_code_grants: tokio::sync::Mutex<LruCache<String, PendingAuthCodeGrant>>,
pending_device_code_grants: tokio::sync::Mutex<LruCache<String, PendingDeviceCodeGrant>>,
client_credential_sessions: tokio::sync::Mutex<HashMap<String, ClientCredentialsSession>>,
}
struct Data {
@@ -78,6 +80,12 @@ struct RefreshTokenInfo {
device_id: OwnedDeviceId,
}
pub struct ClientCredentialsSession {
pub client_id: String,
pub scopes: BTreeSet<OAuthClientScope>,
created_at: SystemTime,
}
struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: RequestedScopes,
@@ -176,6 +184,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
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<TokenResponse, OAuthError> {
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<TokenResponse,
},
| TokenRequestType::RefreshToken { refresh_token } =>
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<TokenResponse, OAuthError> {
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<tokio::sync::MappedMutexGuard<'_, ClientCredentialsSession>> {
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;