Add org.matrix.login.jwt support.

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk
2025-06-18 09:29:06 +00:00
parent b5dc933880
commit 18b9d7bc1f
11 changed files with 434 additions and 15 deletions

View File

@@ -85,6 +85,7 @@ ctor.workspace = true
futures.workspace = true
log.workspace = true
ruma.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
tokio.workspace = true

View File

@@ -2,6 +2,7 @@ use std::{
collections::HashMap,
fmt::Write,
iter::once,
str::FromStr,
time::{Instant, SystemTime},
};
@@ -11,9 +12,10 @@ use ruma::{
OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomVersionId,
api::federation::event::get_room_state, events::AnyStateEvent, serde::Raw,
};
use serde::Serialize;
use tracing_subscriber::EnvFilter;
use tuwunel_core::{
Err, Result, debug_error, err, info,
Err, Result, debug_error, err, info, jwt,
matrix::{
Event,
pdu::{PduEvent, PduId, RawPduId},
@@ -22,6 +24,7 @@ use tuwunel_core::{
utils::{
stream::{IterStream, ReadyExt},
string::EMPTY,
time::now_secs,
},
warn,
};
@@ -975,3 +978,60 @@ pub(super) async fn trim_memory(&self) -> Result {
writeln!(self, "done").await
}
#[admin_command]
pub(super) async fn create_jwt(
&self,
user: String,
exp_from_now: Option<u64>,
nbf_from_now: Option<u64>,
issuer: Option<String>,
audience: Option<String>,
) -> Result {
use jwt::{Algorithm, EncodingKey, Header, encode};
#[derive(Serialize)]
struct Claim {
sub: String,
iss: String,
aud: String,
exp: usize,
nbf: usize,
}
let config = &self.services.config.jwt;
if config.format.as_str() != "HMAC" {
return Err!("This command only supports HMAC key format, not {}.", config.format);
}
let key = EncodingKey::from_secret(config.key.as_ref());
let alg = Algorithm::from_str(config.algorithm.as_str()).map_err(|e| {
err!(Config("jwt.algorithm", "JWT algorithm is not recognized or configured {e}"))
})?;
let header = Header { alg, ..Default::default() };
let claim = Claim {
sub: user,
iss: issuer.unwrap_or_default(),
aud: audience.unwrap_or_default(),
exp: exp_from_now
.and_then(|val| now_secs().checked_add(val))
.map(TryInto::try_into)
.and_then(Result::ok)
.unwrap_or(usize::MAX),
nbf: nbf_from_now
.and_then(|val| now_secs().checked_add(val))
.map(TryInto::try_into)
.and_then(Result::ok)
.unwrap_or(0),
};
encode(&header, &claim, &key)
.map_err(|e| err!("Failed to encode JWT: {e}"))
.map(async |token| self.write_str(&token).await)?
.await
}

View File

@@ -234,6 +234,28 @@ pub(super) enum DebugCommand {
level: Option<i32>,
},
/// - Create a JWT token for login
CreateJwt {
/// Localpart of the user's MXID
user: String,
/// Set expiration time in seconds from now.
#[arg(long)]
exp_from_now: Option<u64>,
/// Set not-before time in seconds from now.
#[arg(long)]
nbf_from_now: Option<u64>,
/// Claim an issuer.
#[arg(long)]
issuer: Option<String>,
/// Claim an audience.
#[arg(long)]
audience: Option<String>,
},
/// - Developer test stubs
#[command(subcommand)]
#[allow(non_snake_case)]

View File

@@ -0,0 +1,117 @@
use std::str::FromStr;
use jwt::{Algorithm, DecodingKey, Validation, decode};
use ruma::{
OwnedUserId, UserId,
api::client::session::login::v3::{Request, Token},
};
use serde::Deserialize;
use tuwunel_core::{Err, Result, at, config::JwtConfig, debug, err, jwt, warn};
use tuwunel_service::Services;
use crate::Ruma;
#[derive(Debug, Deserialize)]
struct Claim {
/// Subject is the localpart of the User MXID
sub: String,
}
pub(super) async fn handle_login(
services: &Services,
_body: &Ruma<Request>,
info: &Token,
) -> Result<OwnedUserId> {
let config = &services.config.jwt;
if !config.enable {
return Err!(Request(Unknown("JWT login is not enabled.")));
}
let claim = validate(config, &info.token)?;
let local = claim.sub.to_lowercase();
let server = &services.server.name;
let user_id = UserId::parse_with_server_name(local, server).map_err(|e| {
err!(Request(InvalidUsername("JWT subject is not a valid user MXID: {e}")))
})?;
if !services.users.exists(&user_id).await {
if !config.register_user {
return Err!(Request(NotFound("User {user_id} is not registered on this server.")));
}
services
.users
.create(&user_id, Some("*"), Some("jwt"))
.await?;
}
Ok(user_id)
}
fn validate(config: &JwtConfig, token: &str) -> Result<Claim> {
let verifier = init_verifier(config)?;
let validator = init_validator(config)?;
decode::<Claim>(token, &verifier, &validator)
.map(|decoded| (decoded.header, decoded.claims))
.inspect(|(head, claim)| debug!(?head, ?claim, "JWT token decoded"))
.map_err(|e| err!(Request(Forbidden("Invalid JWT token: {e}"))))
.map(at!(1))
}
fn init_verifier(config: &JwtConfig) -> Result<DecodingKey> {
let key = &config.key;
let format = config.format.as_str();
Ok(match format {
| "HMAC" => DecodingKey::from_secret(key.as_bytes()),
| "HMACB64" => DecodingKey::from_base64_secret(key.as_str())
.map_err(|e| err!(Config("jwt.key", "JWT key is not valid base64: {e}")))?,
| "ECDSA" => DecodingKey::from_ec_pem(key.as_bytes())
.map_err(|e| err!(Config("jwt.key", "JWT key is not valid PEM: {e}")))?,
| _ => return Err!(Config("jwt.format", "Key format {format:?} is not supported.")),
})
}
fn init_validator(config: &JwtConfig) -> Result<Validation> {
let alg = config.algorithm.as_str();
let alg = Algorithm::from_str(alg).map_err(|e| {
err!(Config("jwt.algorithm", "JWT algorithm is not recognized or configured {e}"))
})?;
let mut validator = Validation::new(alg);
let mut required_spec_claims: Vec<_> = ["sub"].into();
validator.validate_exp = config.validate_exp;
if config.require_exp {
required_spec_claims.push("exp");
}
validator.validate_nbf = config.validate_nbf;
if config.require_nbf {
required_spec_claims.push("nbf");
}
if !config.audience.is_empty() {
required_spec_claims.push("aud");
validator.set_audience(&config.audience);
}
if !config.issuer.is_empty() {
required_spec_claims.push("iss");
validator.set_issuer(&config.issuer);
}
if cfg!(debug_assertions) && !config.validate_signature {
warn!("JWT signature validation is disabled!");
validator.insecure_disable_signature_validation();
}
validator.set_required_spec_claims(&required_spec_claims);
debug!(?validator, "JWT configured");
Ok(validator)
}

View File

@@ -1,4 +1,5 @@
mod appservice;
mod jwt;
mod ldap;
mod logout;
mod password;
@@ -9,7 +10,10 @@ use axum_client_ip::InsecureClientIp;
use ruma::api::client::session::{
get_login_types::{
self,
v3::{ApplicationServiceLoginType, LoginType, PasswordLoginType, TokenLoginType},
v3::{
ApplicationServiceLoginType, JwtLoginType, LoginType, PasswordLoginType,
TokenLoginType,
},
},
login::{
self,
@@ -39,6 +43,7 @@ pub(crate) async fn get_login_types_route(
Ok(get_login_types::v3::Response::new(vec![
LoginType::Password(PasswordLoginType::default()),
LoginType::ApplicationService(ApplicationServiceLoginType::default()),
LoginType::Jwt(JwtLoginType::default()),
LoginType::Token(TokenLoginType {
get_login_token: services.config.login_via_existing_session,
}),
@@ -69,6 +74,7 @@ pub(crate) async fn login_route(
let user_id = match &body.login_info {
| LoginInfo::Password(info) => password::handle_login(&services, &body, info).await?,
| LoginInfo::Token(info) => token::handle_login(&services, &body, info).await?,
| LoginInfo::Jwt(info) => jwt::handle_login(&services, &body, info).await?,
| LoginInfo::ApplicationService(info) =>
appservice::handle_login(&services, &body, info).await?,
| _ => {

View File

@@ -78,6 +78,7 @@ http-body-util.workspace = true
http.workspace = true
ipaddress.workspace = true
itertools.workspace = true
jsonwebtoken.workspace = true
ldap3.workspace = true
libc.workspace = true
libloading.workspace = true

View File

@@ -52,7 +52,7 @@ use crate::{Result, err, error::Error, utils::sys};
### For more information, see:
### https://tuwunel.chat/configuration.html
"#,
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates ldap"
ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates ldap jwt"
)]
pub struct Config {
/// The server_name is the pretty name of this server. It is used as a
@@ -1814,6 +1814,10 @@ pub struct Config {
#[serde(default)]
pub ldap: LdapConfig,
// external structure; separate section
#[serde(default)]
pub jwt: JwtConfig,
#[serde(flatten)]
#[allow(clippy::zero_sized_map_values)]
// this is a catchall, the map shouldn't be zero at runtime
@@ -1991,6 +1995,99 @@ pub struct LdapConfig {
pub admin_filter: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[config_example_generator(filename = "tuwunel-example.toml", section = "global.jwt")]
pub struct JwtConfig {
/// Enable JWT logins
///
/// default: false
#[serde(default)]
pub enable: bool,
/// Validation key, also called 'secret' in Synapse config. The type of key
/// can be configured in 'format', but defaults to the common HMAC which
/// is a plaintext shared-secret, so you should keep this value private.
///
/// display: sensitive
/// default:
#[serde(default, alias = "secret")]
pub key: String,
/// Format of the 'key'. Only HMAC, ECDSA, and B64HMAC are supported
/// Binary keys cannot be pasted into this config, so B64HMAC is an
/// alternative to HMAC for properly random secret strings.
/// - HMAC is a plaintext shared-secret private-key.
/// - B64HMAC is a base64-encoded version of HMAC.
/// - ECDSA is a PEM-encoded public-key.
///
/// default: "HMAC"
#[serde(default = "default_jwt_format")]
pub format: String,
/// Automatically create new user from a valid claim, otherwise access is
/// denied for an unknown even with an authentic token.
///
/// default: true
#[serde(default = "true_fn")]
pub register_user: bool,
/// JWT algorithm
///
/// default: "HS256"
#[serde(default = "default_jwt_algorithm")]
pub algorithm: String,
/// Optional audience claim list. The token must claim one or more values
/// from this list when set.
///
/// default: []
#[serde(default)]
pub audience: Vec<String>,
/// Optional issuer claim list. The token must claim one or more values
/// from this list when set.
///
/// default: []
#[serde(default)]
pub issuer: Vec<String>,
/// Require expiration claim in the token. This defaults to false for
/// synapse migration compatibility.
///
/// default: false
#[serde(default)]
pub require_exp: bool,
/// Require not-before claim in the token. This defaults to false for
/// synapse migration compatibility.
///
/// default: false
#[serde(default)]
pub require_nbf: bool,
/// Validate expiration time of the token when present. Whether or not it is
/// required depends on require_exp, but when present this ensures the token
/// is not used after a time.
///
/// default: true
#[serde(default = "true_fn")]
pub validate_exp: bool,
/// Validate not-before time of the token when present. Whether or not it is
/// required depends on require_nbf, but when present this ensures the token
/// is not used before a time.
///
/// default: true
#[serde(default = "true_fn")]
pub validate_nbf: bool,
/// Bypass validation for diagnostic/debug use only.
///
/// default: true
#[serde(default = "true_fn")]
pub validate_signature: bool,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(transparent)]
struct ListeningPort {
@@ -2392,3 +2489,7 @@ fn default_ldap_uid_attribute() -> String { String::from("uid") }
fn default_ldap_mail_attribute() -> String { String::from("mail") }
fn default_ldap_name_attribute() -> String { String::from("givenName") }
fn default_jwt_algorithm() -> String { "HS256".to_owned() }
fn default_jwt_format() -> String { "HMAC".to_owned() }

View File

@@ -14,6 +14,7 @@ pub mod utils;
pub use ::arrayvec;
pub use ::http;
pub use ::jsonwebtoken as jwt;
pub use ::ruma;
pub use ::smallstr;
pub use ::smallvec;