Add support for access tokens using MAS (#83)
* Add support for access tokens using MAS * use 1.13.0 * Update dependencies * Harden auth credential selection in matrix link init Use the same non-empty access-token criterion for auth mode selection and bind the token directly from the branch condition. Return explicit configuration errors for missing or empty `device_id`/`password` instead of panicking, so invalid auth config fails gracefully. * Centralize and harden user auth config handling Move authentication-mode resolution into typed config parsing with ConfigUserAuth, so downstream login setup consumes validated credentials instead of re-checking raw optional fields. Enforce explicit password-vs-token selection, validate token/device/user-id requirements in one place, and normalize empty auth env overrides to unset values for consistent behavior across YAML and environment input. * Add auth config unit tests Move auth_config tests into a dedicated cfg test module file to keep production config code compact while preserving behavior coverage. The tests cover password/token mode selection, missing/both auth method rejection, missing device_id, and empty-value handling. * Use conventional mxlink version requirement Replace the unconventional wildcard lower-bound expression with a standard semver lower bound for readability and tooling consistency. --------- Co-authored-by: Slavi Pantaleev <slavi@devture.com>
This commit is contained in:
@@ -25,7 +25,7 @@ use crate::agent::Manager as AgentManager;
|
||||
use crate::entity::catch_up_marker::{
|
||||
CatchUpMarker, CatchUpMarkerManager, DelayedCatchUpMarkerManager,
|
||||
};
|
||||
use crate::entity::cfg::{Avatar, Config};
|
||||
use crate::entity::cfg::{Avatar, Config, ConfigUserAuth};
|
||||
use crate::entity::globalconfig::{GlobalConfig, GlobalConfigurationManager};
|
||||
use crate::entity::roomconfig::{RoomConfig, RoomConfigurationManager};
|
||||
|
||||
@@ -395,10 +395,22 @@ async fn create_matrix_link(config: &Config) -> anyhow::Result<MatrixLink> {
|
||||
let session_encryption_key = config.persistence.session_encryption_key()?;
|
||||
let db_dir_path: std::path::PathBuf = config.persistence.db_dir_path()?;
|
||||
|
||||
let login_creds = LoginCredentials::UserPassword(
|
||||
config.user.mxid_localpart.to_owned(),
|
||||
config.user.password.to_owned(),
|
||||
);
|
||||
let user_auth = config.user.auth_config(&config.homeserver.server_name)?;
|
||||
|
||||
let login_creds = match user_auth {
|
||||
ConfigUserAuth::UserPassword { username, password } => {
|
||||
LoginCredentials::UserPassword(username, password)
|
||||
}
|
||||
ConfigUserAuth::AccessToken {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
} => LoginCredentials::AccessToken {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
},
|
||||
};
|
||||
|
||||
let login_encryption = LoginEncryption::new(
|
||||
config.user.encryption.recovery_passphrase.clone(),
|
||||
|
||||
@@ -29,7 +29,15 @@ pub fn load() -> anyhow::Result<Config> {
|
||||
cfg_env::BAIBOT_HOMESERVER_SERVER_NAME => config.homeserver.server_name = value,
|
||||
cfg_env::BAIBOT_HOMESERVER_URL => config.homeserver.url = value,
|
||||
cfg_env::BAIBOT_USER_MXID_LOCALPART => config.user.mxid_localpart = value,
|
||||
cfg_env::BAIBOT_USER_PASSWORD => config.user.password = value,
|
||||
cfg_env::BAIBOT_USER_PASSWORD => {
|
||||
config.user.password = optional_non_empty(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_ACCESS_TOKEN => {
|
||||
config.user.access_token = optional_non_empty(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_DEVICE_ID => {
|
||||
config.user.device_id = optional_non_empty(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE => {
|
||||
config.user.encryption.recovery_passphrase = Some(value);
|
||||
}
|
||||
@@ -120,3 +128,7 @@ pub fn load() -> anyhow::Result<Config> {
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn optional_non_empty(value: String) -> Option<String> {
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mxlink::helpers::encryption::EncryptionKey;
|
||||
use mxlink::matrix_sdk::ruma::{OwnedDeviceId, OwnedUserId};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{
|
||||
@@ -38,7 +39,7 @@ pub struct Config {
|
||||
impl Config {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
self.homeserver.validate()?;
|
||||
self.user.validate()?;
|
||||
self.user.validate(&self.homeserver.server_name)?;
|
||||
self.persistence.validate()?;
|
||||
self.room.validate()?;
|
||||
self.access.validate()?;
|
||||
@@ -57,6 +58,19 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigUserAuth {
|
||||
UserPassword {
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
AccessToken {
|
||||
user_id: OwnedUserId,
|
||||
device_id: OwnedDeviceId,
|
||||
access_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigHomeserver {
|
||||
pub server_name: String,
|
||||
@@ -127,7 +141,15 @@ impl Avatar {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
pub mxid_localpart: String,
|
||||
pub password: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub device_id: Option<String>,
|
||||
|
||||
#[serde(default = "super::defaults::name")]
|
||||
pub name: String,
|
||||
@@ -140,7 +162,7 @@ pub struct ConfigUser {
|
||||
}
|
||||
|
||||
impl ConfigUser {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
pub fn validate(&self, homeserver_server_name: &str) -> anyhow::Result<()> {
|
||||
if self.mxid_localpart.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.mxid_localpart ({}) configuration must be set",
|
||||
@@ -148,12 +170,7 @@ impl ConfigUser {
|
||||
));
|
||||
}
|
||||
|
||||
if self.password.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.password ({}) configuration must be set",
|
||||
super::env::BAIBOT_USER_PASSWORD
|
||||
));
|
||||
}
|
||||
self.auth_config(homeserver_server_name)?;
|
||||
|
||||
if self.name.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -166,6 +183,57 @@ impl ConfigUser {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auth_config(&self, homeserver_server_name: &str) -> anyhow::Result<ConfigUserAuth> {
|
||||
let password = self.password.as_deref().filter(|value| !value.is_empty());
|
||||
let access_token = self
|
||||
.access_token
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
match (password, access_token) {
|
||||
(Some(_), Some(_)) => Err(anyhow::anyhow!(
|
||||
"Set exactly one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})",
|
||||
super::env::BAIBOT_USER_PASSWORD,
|
||||
super::env::BAIBOT_USER_ACCESS_TOKEN,
|
||||
super::env::BAIBOT_USER_DEVICE_ID
|
||||
)),
|
||||
(None, None) => Err(anyhow::anyhow!(
|
||||
"Set one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})",
|
||||
super::env::BAIBOT_USER_PASSWORD,
|
||||
super::env::BAIBOT_USER_ACCESS_TOKEN,
|
||||
super::env::BAIBOT_USER_DEVICE_ID
|
||||
)),
|
||||
(Some(password), None) => Ok(ConfigUserAuth::UserPassword {
|
||||
username: self.mxid_localpart.to_owned(),
|
||||
password: password.to_owned(),
|
||||
}),
|
||||
(None, Some(access_token)) => {
|
||||
let device_id = self
|
||||
.device_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"user.device_id ({}) must be set when using access token authentication",
|
||||
super::env::BAIBOT_USER_DEVICE_ID
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = OwnedUserId::try_from(format!(
|
||||
"@{}:{}",
|
||||
self.mxid_localpart, homeserver_server_name
|
||||
))
|
||||
.map_err(|e| anyhow::anyhow!("Invalid user ID: {e}"))?;
|
||||
|
||||
Ok(ConfigUserAuth::AccessToken {
|
||||
user_id,
|
||||
device_id: OwnedDeviceId::from(device_id),
|
||||
access_token: access_token.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
@@ -468,3 +536,7 @@ impl TryInto<GlobalConfig> for ConfigInitialGlobalConfig {
|
||||
Ok(entity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_tests.rs"]
|
||||
mod config_tests;
|
||||
|
||||
117
src/entity/cfg/config_tests.rs
Normal file
117
src/entity/cfg/config_tests.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use super::{Avatar, ConfigUser, ConfigUserAuth, ConfigUserEncryption};
|
||||
use crate::entity::cfg::env;
|
||||
|
||||
fn base_user() -> ConfigUser {
|
||||
ConfigUser {
|
||||
mxid_localpart: "baibot".to_owned(),
|
||||
password: None,
|
||||
access_token: None,
|
||||
device_id: None,
|
||||
name: "baibot".to_owned(),
|
||||
encryption: ConfigUserEncryption {
|
||||
recovery_passphrase: None,
|
||||
recovery_reset_allowed: false,
|
||||
},
|
||||
avatar: Avatar::Default,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_uses_password_mode() {
|
||||
let mut user = base_user();
|
||||
user.password = Some("secret".to_owned());
|
||||
|
||||
let auth = user
|
||||
.auth_config("example.com")
|
||||
.expect("password auth should be valid");
|
||||
|
||||
match auth {
|
||||
ConfigUserAuth::UserPassword { username, password } => {
|
||||
assert_eq!(username, "baibot");
|
||||
assert_eq!(password, "secret");
|
||||
}
|
||||
ConfigUserAuth::AccessToken { .. } => {
|
||||
panic!("expected password auth mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_uses_access_token_mode() {
|
||||
let mut user = base_user();
|
||||
user.access_token = Some("token123".to_owned());
|
||||
user.device_id = Some("DEVICE1".to_owned());
|
||||
|
||||
let auth = user
|
||||
.auth_config("example.com")
|
||||
.expect("access token auth should be valid");
|
||||
|
||||
match auth {
|
||||
ConfigUserAuth::AccessToken {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
} => {
|
||||
assert_eq!(user_id.as_str(), "@baibot:example.com");
|
||||
assert_eq!(device_id.as_str(), "DEVICE1");
|
||||
assert_eq!(access_token, "token123");
|
||||
}
|
||||
ConfigUserAuth::UserPassword { .. } => {
|
||||
panic!("expected access token auth mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_rejects_both_auth_methods() {
|
||||
let mut user = base_user();
|
||||
user.password = Some("secret".to_owned());
|
||||
user.access_token = Some("token123".to_owned());
|
||||
user.device_id = Some("DEVICE1".to_owned());
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("both auth methods should be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("exactly one authentication method")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_rejects_missing_auth() {
|
||||
let user = base_user();
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("missing auth should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("Set one authentication method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_rejects_access_token_without_device_id() {
|
||||
let mut user = base_user();
|
||||
user.access_token = Some("token123".to_owned());
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("access token mode without device_id should be rejected");
|
||||
|
||||
assert!(err.to_string().contains(env::BAIBOT_USER_DEVICE_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_treats_empty_strings_as_unset() {
|
||||
let mut user = base_user();
|
||||
user.password = Some(String::new());
|
||||
user.access_token = Some(String::new());
|
||||
user.device_id = Some(String::new());
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("empty auth values should be treated as unset");
|
||||
|
||||
assert!(err.to_string().contains("Set one authentication method"));
|
||||
}
|
||||
@@ -5,6 +5,8 @@ pub const BAIBOT_HOMESERVER_URL: &str = "BAIBOT_HOMESERVER_URL";
|
||||
|
||||
pub const BAIBOT_USER_MXID_LOCALPART: &str = "BAIBOT_USER_MXID_LOCALPART";
|
||||
pub const BAIBOT_USER_PASSWORD: &str = "BAIBOT_USER_PASSWORD";
|
||||
pub const BAIBOT_USER_ACCESS_TOKEN: &str = "BAIBOT_USER_ACCESS_TOKEN";
|
||||
pub const BAIBOT_USER_DEVICE_ID: &str = "BAIBOT_USER_DEVICE_ID";
|
||||
pub const BAIBOT_USER_NAME: &str = "BAIBOT_USER_NAME";
|
||||
pub const BAIBOT_USER_AVATAR: &str = "BAIBOT_USER_AVATAR";
|
||||
pub const BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE: &str =
|
||||
|
||||
@@ -2,4 +2,4 @@ mod config;
|
||||
pub mod defaults;
|
||||
pub mod env;
|
||||
|
||||
pub use config::{Avatar, Config};
|
||||
pub use config::{Avatar, Config, ConfigUserAuth};
|
||||
|
||||
Reference in New Issue
Block a user