mirror of
https://forgejo.ellis.link/continuwuation/continuwuity/
synced 2026-10-22 22:31:21 +00:00
feat: Add support for OAuth client credentials grant
This commit is contained in:
9 files changed
+256
-12
No files matched your search
@@ -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.
|
||||
@@ -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 = []
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user