Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions crates/channels/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,29 @@ pub trait ChannelPlugin: Send + Sync {
) -> Option<Box<dyn crate::channel_webhook_middleware::ChannelWebhookVerifier>> {
None
}

/// Start an OAuth/OIDC login flow. Returns auth URL and CSRF state.
async fn oidc_start(
&self,
_account_id: &str,
_config: serde_json::Value,
_redirect_uri: &str,
) -> Result<serde_json::Value> {
Err(Error::unavailable(
"OIDC login not supported for this channel",
))
}

/// Complete an OAuth/OIDC login after browser redirect.
async fn oidc_complete(
&self,
_csrf_state: &str,
_callback_url: &str,
) -> Result<serde_json::Value> {
Err(Error::unavailable(
"OIDC login not supported for this channel",
))
}
}

/// OTP challenge provider for channels that support self-approval.
Expand Down
22 changes: 12 additions & 10 deletions crates/config/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -950,18 +950,20 @@ reset_on_exit = true # Reset serve/funnel when gateway shuts down
# edit_throttle_ms = 500 # Min ms between streaming edits
# thread_replies = true # Reply in threads

# Matrix bots / appservices using access tokens or password login
# NOTE: Matrix encrypted rooms require password auth. Access tokens can connect
# for plain Matrix traffic, but they reuse an existing Matrix session without
# that device's private E2EE keys, so Moltis cannot reliably decrypt encrypted
# chats from token auth alone. Use password auth so Moltis creates and persists
# its own Matrix device keys, then finish Element verification in the chat with
# `verify yes`, `verify no`, `verify show`, or `verify cancel`.
# Matrix bots / appservices using OIDC, access tokens, or password login
# Three authentication modes:
# oidc - OAuth 2.0/OIDC via Matrix Authentication Service (MSC3861).
# Recommended for modern homeservers (e.g. matrix.org since April 2025).
# Authenticates via browser; no password or token needed.
# password - UIAA password login. Required for encrypted Matrix chats on
# older homeservers. Moltis creates and persists its own device keys.
# access_token - Reuses an existing Matrix session. Plain traffic only (no E2EE).
# [channels.matrix.my-bot]
# homeserver = "https://matrix.example.com"
# access_token = "syt_..." # Plain/unencrypted Matrix traffic only
# password = "..." # Required for encrypted Matrix chats
# user_id = "@bot:example.com" # Required for password login, auto-detected for token auth
# auth_mode = "oidc" # "oidc", "password", or "access_token" (auto-detected if omitted)
# access_token = "syt_..." # Required for access_token mode
# password = "..." # Required for password mode
# user_id = "@bot:example.com" # Required for password login, auto-detected for token/OIDC auth
# device_id = "MOLTISBOT" # Optional device ID for session restore
# device_display_name = "Moltis Matrix Bot" # Optional display name for password logins
# ownership_mode = "moltis_owned" # "moltis_owned" or "user_managed"
Expand Down
1 change: 1 addition & 0 deletions crates/config/src/validate/schema_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ pub(super) fn build_schema_map() -> KnownKeys {
("msteams", Map(Box::new(channel_account()))),
("discord", Map(Box::new(channel_account()))),
("slack", Map(Box::new(channel_account()))),
("matrix", Map(Box::new(channel_account()))),
("nostr", Map(Box::new(channel_account()))),
]),
}
Expand Down
105 changes: 105 additions & 0 deletions crates/gateway/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,111 @@ impl ChannelService for LiveChannelService {
"type": channel_type.to_string()
}))
}

#[tracing::instrument(skip(self, params))]
async fn oauth_start(&self, params: Value) -> ServiceResult {
let account_id = params
.get("account_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let homeserver = params
.get("homeserver")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let redirect_uri = params
.get("redirect_uri")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();

if account_id.is_empty() {
return Err("account_id is required".into());
}
if homeserver.is_empty() {
return Err("homeserver is required".into());
}
if redirect_uri.is_empty() {
return Err("redirect_uri is required".into());
}

let config = serde_json::json!({
"homeserver": homeserver,
"auth_mode": "oidc",
"access_token": "",
});

let plugin_lock = self
.registry
.get("matrix")
.ok_or_else(|| ServiceError::from("Matrix channel plugin is not available"))?;
let plugin = plugin_lock.read().await;
let result = plugin
.oidc_start(&account_id, config, &redirect_uri)
.await
.map_err(|e| e.to_string())?;
Ok(result)
}

#[tracing::instrument(skip(self, params))]
async fn oauth_complete(&self, params: Value) -> ServiceResult {
let state = params
.get("state")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let code = params
.get("code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();

if state.is_empty() {
return Err("state parameter is required".into());
}
if code.is_empty() {
return Err("code parameter is required".into());
}

let callback_url = {
let mut url = url::Url::parse("http://localhost/callback")
.map_err(|e| ServiceError::from(e.to_string()))?;
url.query_pairs_mut()
.append_pair("code", &code)
.append_pair("state", &state);
url.to_string()
};

let plugin_lock = self
.registry
.get("matrix")
.ok_or_else(|| ServiceError::from("Matrix channel plugin is not available"))?;
let plugin = plugin_lock.read().await;
let result = plugin
.oidc_complete(&state, &callback_url)
.await
.map_err(|e| e.to_string())?;

// Persist the new channel to the store.
if let Some(account_id) = result.get("account_id").and_then(Value::as_str)
&& let Some(config_json) = plugin.account_config_json(account_id)
&& let Err(e) = self
.store
.upsert(StoredChannel {
account_id: account_id.to_string(),
channel_type: "matrix".to_string(),
config: config_json,
created_at: unix_now(),
updated_at: unix_now(),
})
.await
{
warn!(error = %e, account_id, "failed to persist OIDC channel to store");
}

Ok(result)
}
}

#[cfg(test)]
Expand Down
26 changes: 26 additions & 0 deletions crates/gateway/src/methods/services/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,30 @@ pub(super) fn register(reg: &mut MethodRegistry) {
})
}),
);
reg.register(
"channels.oauth_start",
Box::new(|ctx| {
Box::pin(async move {
ctx.state
.services
.channel
.oauth_start(ctx.params.clone())
.await
.map_err(ErrorShape::from)
})
}),
);
reg.register(
"channels.oauth_complete",
Box::new(|ctx| {
Box::pin(async move {
ctx.state
.services
.channel
.oauth_complete(ctx.params.clone())
.await
.map_err(ErrorShape::from)
})
}),
);
}
4 changes: 3 additions & 1 deletion crates/matrix/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ time = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

[features]
default = []
metrics = ["dep:moltis-metrics"]

[dev-dependencies]
tokio = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true }

[lints]
workspace = true
99 changes: 95 additions & 4 deletions crates/matrix/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ use {
use moltis_channels::{Error as ChannelError, Result as ChannelResult};

use crate::{
config::{MatrixAccountConfig, MatrixOwnershipMode},
handler,
config::{MatrixAccountConfig, MatrixAuthMode, MatrixOwnershipMode},
handler, oidc,
state::AccountStateMap,
verification,
};
Expand All @@ -34,6 +34,7 @@ use crate::{
pub(crate) enum AuthMode {
AccessToken,
Password,
Oidc,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -67,10 +68,16 @@ pub(crate) async fn build_client(
config: &MatrixAccountConfig,
) -> ChannelResult<Client> {
let store_path = ensure_store_path(account_id)?;
Client::builder()
let mut builder = Client::builder()
.homeserver_url(&config.homeserver)
.with_encryption_settings(encryption_settings())
.sqlite_store(&store_path, None)
.sqlite_store(&store_path, None);

if matches!(auth_mode(config), Ok(AuthMode::Oidc)) {
builder = builder.handle_refresh_tokens();
}

builder
.build()
.await
.map_err(|error| ChannelError::external("matrix client build", error))
Expand Down Expand Up @@ -122,6 +129,49 @@ fn should_rebuild_store_after_auth_error(
}

pub(crate) fn auth_mode(config: &MatrixAccountConfig) -> ChannelResult<AuthMode> {
// Explicit auth_mode takes precedence when present.
if let Some(ref explicit) = config.auth_mode {
return match explicit {
MatrixAuthMode::Oidc => {
if config.homeserver.trim().is_empty() {
return Err(ChannelError::invalid_input(
"homeserver is required when using OIDC authentication",
));
}
Ok(AuthMode::Oidc)
},
MatrixAuthMode::Password => {
if config.user_id.as_deref().is_none_or(str::is_empty) {
return Err(ChannelError::invalid_input(
"user_id is required when using password authentication",
));
}
let password = config
.password
.as_ref()
.map(|secret| secret.expose_secret().trim())
.unwrap_or_default();
if password.is_empty() || password == moltis_common::secret_serde::REDACTED {
return Err(ChannelError::invalid_input(
"password is required when auth_mode is \"password\"",
));
}
Ok(AuthMode::Password)
},
MatrixAuthMode::AccessToken => {
let access_token = config.access_token.expose_secret().trim();
if access_token.is_empty() || access_token == moltis_common::secret_serde::REDACTED
{
return Err(ChannelError::invalid_input(
"access_token is required when auth_mode is \"access_token\"",
));
}
Ok(AuthMode::AccessToken)
},
};
}

// Backward-compatible auto-detection from credentials.
let access_token = config.access_token.expose_secret().trim();
if !access_token.is_empty() && access_token != moltis_common::secret_serde::REDACTED {
return Ok(AuthMode::AccessToken);
Expand Down Expand Up @@ -188,6 +238,7 @@ pub(crate) async fn authenticate_client(
ownership_startup_error: None,
})
},
AuthMode::Oidc => oidc::restore_oidc_session(client, account_id).await,
}
}

Expand Down Expand Up @@ -1122,4 +1173,44 @@ mod tests {
RecoveryState::Unknown
));
}

#[test]
fn explicit_oidc_auth_mode_returns_oidc() {
let cfg = MatrixAccountConfig {
auth_mode: Some(MatrixAuthMode::Oidc),
..config()
};
assert!(matches!(auth_mode(&cfg), Ok(AuthMode::Oidc)));
}

#[test]
fn explicit_oidc_auth_mode_requires_homeserver() {
let cfg = MatrixAccountConfig {
auth_mode: Some(MatrixAuthMode::Oidc),
homeserver: String::new(),
..Default::default()
};
let error = match auth_mode(&cfg) {
Ok(mode) => panic!("OIDC with empty homeserver should fail, got {mode:?}"),
Err(error) => error.to_string(),
};
assert!(error.contains("homeserver is required"));
}

#[test]
fn backward_compat_auto_detection_ignores_absent_auth_mode() {
// No auth_mode field — should auto-detect from credentials.
let token_cfg = MatrixAccountConfig {
access_token: Secret::new("syt_test".into()),
..config()
};
assert!(matches!(auth_mode(&token_cfg), Ok(AuthMode::AccessToken)));

let password_cfg = MatrixAccountConfig {
password: Some(Secret::new("wordpass".into())),
user_id: Some("@bot:example.com".into()),
..config()
};
assert!(matches!(auth_mode(&password_cfg), Ok(AuthMode::Password)));
}
}
Loading
Loading