1
0
Fork 0
mirror of https://gitlab.com/famedly/conduit.git synced 2025-06-27 16:35:59 +00:00

Merge branch 'as-oidc-provider' into 'next'

s oidc provider

See merge request famedly/conduit!742
This commit is contained in:
la Fleur 2025-04-18 11:20:32 +00:00
commit 1f5f6a95e6
14 changed files with 975 additions and 3 deletions

172
Cargo.lock generated
View file

@ -38,6 +38,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstyle"
version = "1.0.7"
@ -430,6 +445,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chrono"
version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@ -513,7 +540,10 @@ dependencies = [
"opentelemetry-jaeger-propagator",
"opentelemetry-otlp",
"opentelemetry_sdk",
"oxide-auth",
"oxide-auth-axum",
"parking_lot",
"percent-encoding",
"rand",
"regex",
"reqwest",
@ -1281,6 +1311,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "idna"
version = "0.4.0"
@ -1816,6 +1870,37 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "oxide-auth"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c136ac668d12ba0b5b8ce159b95c7600fda826dc599a1e1916f8461c0d16a84"
dependencies = [
"base64 0.21.7",
"chrono",
"hmac",
"once_cell",
"rand",
"rmp-serde",
"rust-argon2",
"serde",
"serde_derive",
"serde_json",
"sha2",
"subtle",
"url",
]
[[package]]
name = "oxide-auth-axum"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a65f0303e212596cb24ba33f4a7ae8f69e84596bea5fc65d089de65b09bfbcb8"
dependencies = [
"axum 0.7.5",
"oxide-auth",
]
[[package]]
name = "parking_lot"
version = "0.12.3"
@ -1839,6 +1924,12 @@ dependencies = [
"windows-targets 0.52.5",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pear"
version = "0.2.9"
@ -2187,6 +2278,28 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
dependencies = [
"byteorder",
"num-traits",
"paste",
]
[[package]]
name = "rmp-serde"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
dependencies = [
"byteorder",
"rmp",
"serde",
]
[[package]]
name = "ruma"
version = "0.12.1"
@ -3543,6 +3656,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-result"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"

View file

@ -34,6 +34,7 @@ axum = { version = "0.7", default-features = false, features = [
"http2",
"json",
"matched-path",
"query",
], optional = true }
axum-extra = { version = "0.9", features = ["typed-header"] }
axum-server = { version = "0.6", features = ["tls-rustls"] }
@ -142,6 +143,11 @@ tikv-jemallocator = { version = "0.5.0", features = [
sd-notify = { version = "0.4.1", optional = true }
# Oidc authentication utilities.
oxide-auth = { version = "0.6.1" }
oxide-auth-axum = { version = "0.5.0" }
percent-encoding = { version = "2.3.1" }
# Used for matrix spec type definitions and helpers
[dependencies.ruma]
features = [
@ -161,6 +167,7 @@ features = [
"ring-compat",
"state-res",
"unstable-msc2448",
"unstable-msc2965",
"unstable-msc3575",
]
git = "https://github.com/ruma/ruma.git"

View file

@ -13,6 +13,7 @@ mod media;
mod membership;
mod message;
mod openid;
mod oidc;
mod presence;
mod profile;
mod push;
@ -73,6 +74,7 @@ pub use unversioned::*;
pub use user_directory::*;
pub use voip::*;
pub use well_known::*;
pub use oidc::*;
pub const DEVICE_ID_LENGTH: usize = 10;
pub const TOKEN_LENGTH: usize = 32;

View file

@ -0,0 +1,227 @@
use crate::{Result, Error, services};
use oxide_auth_axum::{OAuthResponse, OAuthRequest};
use oxide_auth::{
endpoint::{OwnerConsent, Solicitation},
frontends::simple::endpoint::FnSolicitor,
primitives::registrar::PreGrant,
};
use ruma::api::client::error::ErrorKind as ClientErrorKind;
use axum::extract::Query;
use serde_html_form;
use reqwest::Url;
use percent_encoding::percent_decode_str;
/// The set of query parameters a client needs to get authorization.
#[derive(serde::Deserialize, Debug)]
pub struct OAuthQuery {
client_id: String,
redirect_uri: Url,
scope: String,
state: String,
code_challenge: String,
code_challenge_method: String,
response_type: String,
response_mode: String,
}
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize`
///
/// Authenticate a user and device, and solicit the user's consent.
///
/// Redirects to the login page if no token or token not belonging to any user.
/// [super::login::oidc_login] takes it up at the same point, so it's either
/// the client has a token, or the user does user password. Then the user gets
/// access to stage two, [authorize_consent].
pub async fn authorize(
Query(query): Query<OAuthQuery>,
oauth: OAuthRequest,
) -> Result<OAuthResponse> {
tracing::trace!("processing OAuth request: {query:?}");
// Enforce MSC2964's restrictions on OAuth2 flow.
let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else {
return Err(Error::BadRequest(
ClientErrorKind::Unknown,
"the scope could not be percent-decoded"
));
};
//if ! scope.contains("urn:matrix:api:*") {
if ! scope.contains("urn:matrix:org.matrix.msc2967.client:api:*") {
return Err(Error::BadRequest(
ClientErrorKind::Unknown,
"the scope does not include the client API"
));
}
if ! scope.contains("urn:matrix:org.matrix.msc2967.client:device:") {
return Err(Error::BadRequest(
ClientErrorKind::Unknown,
"the scope does not include a device ID"
));
}
if query.code_challenge_method != "S256" {
return Err(Error::BadRequest(
ClientErrorKind::Unknown,
"unsupported code challenge method"
));
}
// Redirect to the login page if no token or token not known.
let hostname = services()
.globals
.config
.well_known
.client
.as_ref()
.map(|s| s.domain().expect("well-known client should be a domain"));
let login_redirect = OAuthResponse::default()
.body(&login_form(hostname, &query))
.content_type("text/html")
.expect("should set Content-Type on OAuth response");
match oauth.authorization_header() {
| None => {
return Ok(login_redirect);
},
| Some(token) => if services().users.find_from_token(token).is_err() {
return Ok(login_redirect);
}
}
// TODO register the device ID ?
services()
.oidc
.endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>|
OwnerConsent::InProgress(
OAuthResponse::default()
.body(&consent_page_html(
"/_matrix/client/unstable/org.matrix.msc2964/authorize",
solicitation,
hostname.unwrap_or("conduwuit"),
))
.content_type("text/html")
.expect("set content type on consent form")
)
))
.authorization_flow()
.execute(oauth)
.map_err(|_| Error::BadRequest(ClientErrorKind::Unknown, "authorization failed"))
}
/// Wether a user allows their device to access this homeserver's resources.
#[derive(serde::Deserialize)]
pub struct Allowance {
allow: Option<bool>,
}
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/authorize?allow=[Option<bool>]`
///
/// Authorize the device based on the user's consent. If the user allows
/// it to access their data, the client may request a token at the
/// [super::token::token] endpoint.
pub async fn authorize_consent(
Query(Allowance { allow }): Query<Allowance>,
oauth: OAuthRequest,
) -> Result<OAuthResponse> {
let allowed = allow.unwrap_or(false);
tracing::debug!("processing user's consent: {:?} - {:?}", allowed, oauth);
services()
.oidc
.endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>|
match allowed {
| false => OwnerConsent::Denied,
| true => OwnerConsent::Authorized(solicitation.pre_grant().client_id.clone())
}
))
.authorization_flow()
.execute(oauth)
.map_err(|_| Error::BadRequest(ClientErrorKind::Unknown, "consent request failed"))
}
fn login_form(
hostname: Option<&str>,
OAuthQuery {
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
response_type,
response_mode,
}: &OAuthQuery,
) -> String {
let hostname = hostname.unwrap_or("");
format!(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<center>
<h1>{hostname} login</h1>
<form action="/_matrix/client/unstable/org.matrix.msc2964/login" method="post">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
<input type="hidden" name="response_type" value="{response_type}">
<input type="hidden" name="response_mode" value="{response_mode}">
<button type="submit">Login</button>
</form>
</center>
</body>
</html>
"#
)
}
pub(crate) fn consent_page_html(
route: &str,
solicitation: Solicitation<'_>,
hostname: &str,
) -> String {
let state = solicitation.state();
let grant = solicitation.pre_grant();
let PreGrant { client_id, redirect_uri, scope } = grant;
let mut args = vec![
("response_type", "code"),
("client_id", client_id.as_str()),
("redirect_uri", redirect_uri.as_str()),
];
if let Some(state) = state {
args.push(("state", state));
}
let args = serde_html_form::to_string(args).unwrap();
format!(
r#"
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
<body>
<center>
<h1>{hostname} login</h1>
'{client_id}' (at {redirect_uri}) is requesting permission for '{scope}'
<form method="post">
<input type="submit" value="Accept" formaction="{route}?{args}&allow=true">
<input type="submit" value="Deny" formaction="{route}?{args}&deny=true">
</form>
</center>
</body>
</html>
"#,
)
}

View file

@ -0,0 +1,76 @@
/// Manual implementation of [MSC2965]'s OIDC server discovery.
///
/// [MSC2965]: https://github.com/matrix-org/matrix-spec-proposals/pull/2965
use crate::{services, Error, Result, Ruma, RumaResponse};
use ruma::serde::Raw;
use ruma::api::client::{
error::ErrorKind as ClientErrorKind,
discovery::get_authorization_server_metadata::{
self,
msc2965::{
AccountManagementAction,
AuthorizationServerMetadata,
CodeChallengeMethod,
GrantType,
Prompt,
ResponseMode,
ResponseType,
Response,
},
},
};
/// # `GET /_matrix/client/unstable/org.matrix.msc2965/auth_metadata`
/// # `GET /_matrix/client/auth_metadata`
///
/// If `globals.auth.enable_oidc_login` is set, advertise this homeserver's OAuth2 endpoints.
/// Otherwise, MSC2965 requires that the homeserver responds with 404/M_UNRECOGNIZED.
pub async fn get_auth_metadata(
_body: Ruma<get_authorization_server_metadata::msc2965::Request>,
) -> Result<RumaResponse<Response>> {
let authentication = &services().globals.config.authentication;
if ! authentication.enable_oidc_login {
return Err(Error::BadRequest(
ClientErrorKind::Unknown,
"This server doesn't do OIDC authentication."
));
};
// Advertise this homeserver's access URL as the issuer URL.
// Unwrap all Url::parse() calls because the issuer URL is validated at startup.
let issuer = services().globals.config.well_known.client.as_ref().unwrap();
let account_management_uri = authentication
.enable_oidc_account_management
.then_some(issuer.join("/_matrix/client/unstable/org.matrix.msc2964/account").unwrap());
let metadata = AuthorizationServerMetadata {
issuer: issuer.clone(),
authorization_endpoint:
issuer.join("/_matrix/client/unstable/org.matrix.msc2964/authorize").unwrap(),
device_authorization_endpoint:
Some(issuer.join("/_matrix/client/unstable/org.matrix.msc2964/device").unwrap()),
token_endpoint:
issuer.join("/_matrix/client/unstable/org.matrix.msc2964/token").unwrap(),
registration_endpoint:
Some(issuer.join("/_matrix/client/unstable/org.matrix.msc2964/device/register").unwrap()),
revocation_endpoint:
issuer.join("_matrix/client/unstable/org.matrix.msc2964/revoke").unwrap(),
response_types_supported: [ResponseType::Code].into(),
grant_types_supported: [GrantType::AuthorizationCode, GrantType::RefreshToken].into(),
response_modes_supported: [ResponseMode::Fragment, ResponseMode::Query].into(),
code_challenge_methods_supported: [CodeChallengeMethod::S256].into(),
account_management_uri,
account_management_actions_supported: [
AccountManagementAction::Profile,
AccountManagementAction::SessionView,
AccountManagementAction::SessionEnd,
].into(),
prompt_values_supported: match services().globals.config.allow_registration {
| true => vec![Prompt::Create],
| false => vec![]
}
};
let metadata = Raw::new(&metadata).expect("authorization server metadata should serialize");
Ok(RumaResponse(Response::new(metadata)))
}

View file

@ -0,0 +1,148 @@
use super::authorize::consent_page_html;
use oxide_auth_axum::{OAuthRequest, OAuthResponse};
use oxide_auth::{
endpoint::{OwnerConsent, Solicitation},
frontends::simple::endpoint::FnSolicitor,
};
use axum::extract::{Form, FromRequest};
use crate::{
services,
Error,
Result,
};
use ruma::{
user_id::UserId,
api::client::error::ErrorKind as ClientErrorKind,
};
use reqwest::Url;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
/// The set of query parameters a client needs to get authorization.
#[derive(serde::Deserialize, Debug)]
pub struct LoginForm {
username: String,
password: String,
client_id: String,
redirect_uri: Url,
scope: String,
state: String,
code_challenge: String,
code_challenge_method: String,
response_type: String,
response_mode: String,
}
impl From<LoginForm> for String {
/// Turn the OAuth parameters from a Form into a GET query, suitable for
/// then turning it into oxide-auth's OAuthRequest. Strips the unneeded
/// username and password.
fn from(value: LoginForm) -> Self {
let encode = |text: &str| -> String {
utf8_percent_encode(text, NON_ALPHANUMERIC).to_string()
};
format!(
"?client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method={}&response_type={}&response_mode={}",
encode(&value.client_id),
encode(&value.redirect_uri.to_string()),
encode(&value.scope),
encode(&value.state),
encode(&value.code_challenge),
encode(&value.code_challenge_method),
encode(&value.response_type),
encode(&value.response_mode),
)
}
}
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/login`
///
/// Display a login UI to the user and return an authorization code on success.
/// We presume that the OAuth2 query parameters are provided in the form.
/// With the code, the client may then access stage two,
/// [super::authorize::authorize_consent].
pub async fn oidc_login(
Form(login_query): Form<LoginForm>,
) -> Result<OAuthResponse> {
// Only accept local usernames. Mostly to simplify things at first.
let Ok(user_id) = UserId::parse_with_server_name(
login_query.username.clone(),
&services().globals.config.server_name,
) else {
return Err(Error::BadRequest(ClientErrorKind::InvalidUsername, "username is invalid"));
};
if ! services().users.exists(&user_id).expect("searchable users db") {
return Err(Error::BadRequest(ClientErrorKind::Unknown, "unknown username"));
}
tracing::info!("logging in: {user_id:?}");
let Some(valid_hash) = services()
.users
.password_hash(&user_id)
.expect("searchable users hashes") else {
return Err(Error::BadRequest(ClientErrorKind::forbidden(), "Wrong username or password."));
};
if valid_hash.is_empty() {
return Err(Error::BadRequest(
ClientErrorKind::UserDeactivated,
"The user has been deactivated.",
));
}
let hash_matches = argon2::verify_encoded(
&valid_hash,
login_query.password.as_bytes()
).unwrap_or(false);
if ! hash_matches {
return Err(Error::BadRequest(
ClientErrorKind::forbidden(),
"Wrong username or password.",
));
}
// Build up a GET query and parse it as an OAuthRequest.
let login_query: String = login_query.into();
let login_url = http::Uri::builder()
.scheme("https")
.authority(services().globals.config.server_name.as_str())
.path_and_query(login_query)
.build()
.expect("should be parseable");
let req: http::Request<axum::body::Body> = http::Request::builder()
.method("GET")
.uri(&login_url)
.body(axum::body::Body::empty())
.expect("login form OAuth parameters parseable as a query");
let oauth = OAuthRequest::from_request(req, &"")
.await
.expect("request parseable as an OAuth query");
let hostname = services()
.globals
.config
.well_known
.client
.as_ref()
.map(|s| s.domain().expect("well-known client should be a domain"));
services()
.oidc
.endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>|
OwnerConsent::InProgress(
OAuthResponse::default()
.body(&consent_page_html(
"/_matrix/client/unstable/org.matrix.msc2964/authorize",
solicitation,
hostname.unwrap_or("conduwuit"),
))
.content_type("text/html")
.expect("set content type on consent form")
)
))
.authorization_flow()
.execute(oauth)
.map_err(|_|
Error::BadRequest(ClientErrorKind::InvalidParam, "authorization failed")
)
}

View file

@ -0,0 +1,11 @@
mod discovery;
mod authorize;
mod login;
mod register;
mod token;
pub use discovery::get_auth_metadata;
pub use authorize::{authorize, authorize_consent};
pub use login::oidc_login;
pub use register::register_client;
pub use token::token;

View file

@ -0,0 +1,93 @@
use oxide_auth::primitives::prelude::Client;
use axum::Json;
use crate::{services, Error, Result};
use ruma::{
DeviceId,
api::client::error::ErrorKind as ClientErrorKind,
};
use reqwest::Url;
/// The required parameters to register a new client for OAuth2 application.
#[derive(serde::Deserialize, Clone)]
pub struct ClientQuery {
/// Human-readable name.
client_name: String,
/// A public page that tells more about the client. All other links must be within.
client_uri: Url,
/// Redirect URIs declared by the client. At least one.
redirect_uris: Vec<Url>,
/// Must be ["code"].
response_types: Vec<String>,
/// Must include "authorization_type" and "refresh_token".
grant_types: Vec<String>,
//contacts: Vec<String>,
/// Can be "none".
token_endpoint_auth_method: String,
/// Link to the logo.
logo_uri: Option<Url>,
/// Link to the client's policy.
policy_uri: Option<Url>,
/// Link to the terms of service.
tos_uri: Option<Url>,
/// Defaults to "web" if not present.
application_type: Option<String>,
}
/// A successful response that the client was registered.
#[derive(serde::Serialize)]
pub struct ClientResponse {
client_id: String,
client_name: String,
client_uri: Url,
logo_uri: Option<Url>,
tos_uri: Option<Url>,
policy_uri: Option<Url>,
redirect_uris: Vec<Url>,
token_endpoint_auth_method: String,
response_types: Vec<String>,
grant_types: Vec<String>,
application_type: Option<String>,
}
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/device/register`
///
/// Register a client, as specified in [MSC2966]. This client, "device" in Oidc parlance,
/// will have the right to submit [super::authorize::authorize] requests.
///
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966
pub async fn register_client(
Json(client): Json<ClientQuery>,
) -> Result<Json<ClientResponse>> {
let Some(redirect_uri) = client.redirect_uris.first().cloned() else {
return Err(Error::BadRequest(
ClientErrorKind::Unknown,
"register request should contain at least a redirect_uri"
));
};
let device_id = DeviceId::new();
let scope = format!(
"urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:{}",
device_id
);
// TODO check if the users service needs an update.
//services().users.update_device_metadata();
services().oidc.register_client(&Client::public(
&device_id.to_string(),
redirect_uri.into(),
scope.parse().expect("device ID should parse in Matrix scope"),
))?;
Ok(Json(ClientResponse {
client_id: device_id.to_string(),
client_name: client.client_name.clone(),
client_uri: client.client_uri.clone(),
redirect_uris: client.redirect_uris.clone(),
logo_uri: client.logo_uri.clone(),
policy_uri: client.policy_uri.clone(),
tos_uri: client.tos_uri.clone(),
token_endpoint_auth_method: client.token_endpoint_auth_method.clone(),
response_types: client.response_types.clone(),
grant_types: client.grant_types.clone(),
application_type: client.application_type.clone(),
}))
}

View file

@ -0,0 +1,83 @@
/// Implementation of [MSC2964]'s OAuth2 restricted flow using the [oxide-auth]
/// crate. See the MSC for restrictions that apply to this flow.
///
/// [MSC2965]: https://github.com/matrix-org/matrix-spec-proposals/pull/2965
/// [oxide-auth]: https://docs.rs/oxide-auth
use crate::{Result, services, Error};
use oxide_auth_axum::{OAuthResponse, OAuthRequest};
use oxide_auth::endpoint::QueryParameter;
use axum::response::IntoResponse;
use ruma::api::client::error::ErrorKind as ClientErrorKind;
/// # `POST /_matrix/client/unstable/org.matrix.msc2964/token`
///
/// Depending on `grant_type`, either deliver a new token to a device, and store
/// it in the server's ring, or refresh the token.
pub async fn token(
oauth: OAuthRequest,
) -> Result<OAuthResponse> {
let Some(body) = oauth.body() else {
return Err(
Error::BadRequest(ClientErrorKind::Unknown, "OAuth request had an empty body")
);
};
let grant_type = body
.unique_value("grant_type")
.map(|value| value.to_string());
let endpoint = services().oidc.endpoint();
match grant_type.as_deref() {
| Some("authorization_code") =>
endpoint
.access_token_flow()
.execute(oauth)
.map_err(|_|
Error::BadRequest(ClientErrorKind::Unknown, "token grant failed")),
| Some("refresh_token") =>
endpoint
.refresh_flow()
.execute(oauth)
.map_err(|_|
Error::BadRequest(ClientErrorKind::Unknown, "token refresh failed")),
| _ => Err(Error::BadRequest(
ClientErrorKind::Unknown,
"The request's grant type is unsupported"
)),
}
}
/// Sample protected content. TODO check that resources are available with the returned token.
pub(crate) async fn _protected_resource(
oauth: OAuthRequest,
) -> impl IntoResponse {
const DENY_TEXT: &str = "<html>
This page should be accessed via an oauth token from the client in the example. Click
<a href=\"/authorize?response_type=code&client_id=LocalClient\">
here</a> to begin the authorization process.
</html>
";
let protect = services()
.oidc
.endpoint()
.with_scopes(vec!["default-scope".parse().unwrap()])
.resource_flow()
.execute(oauth);
match protect {
Ok(_grant) => Ok("Hello, world"),
Err(Ok(response)) => {
let error: OAuthResponse = response
//.header(ContentType::HTML)
.body(DENY_TEXT)
//.finalize()
.into();
Err(Ok(error))
}
Err(Err(_)) => Err(Err(
Error::BadRequest(ClientErrorKind::Unknown, "authentication failed")
)),
}
}

View file

@ -1,5 +1,8 @@
use ruma::api::client::discovery::discover_homeserver::{
self, HomeserverInfo, SlidingSyncProxyInfo,
self,
AuthenticationServerInfo,
HomeserverInfo,
SlidingSyncProxyInfo,
};
use crate::{services, Result, Ruma};
@ -11,12 +14,21 @@ pub async fn well_known_client(
_body: Ruma<discover_homeserver::Request>,
) -> Result<discover_homeserver::Response> {
let client_url = services().globals.well_known_client();
let authentication = &services().globals.config.authentication;
Ok(discover_homeserver::Response {
homeserver: HomeserverInfo {
base_url: client_url.clone(),
},
identity_server: None,
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url }),
sliding_sync_proxy: Some(SlidingSyncProxyInfo { url: client_url.clone() }),
authentication: authentication.enable_oidc_login.then_some(
AuthenticationServerInfo::new(
client_url.clone(),
authentication.enable_oidc_account_management.then_some(
format!("{client_url}/account")
),
)
)
})
}

View file

@ -67,6 +67,8 @@ pub struct Config {
pub tracing_flame: bool,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub authentication: AuthenticationConfig,
pub jwt_secret: Option<String>,
#[serde(default = "default_trusted_servers")]
pub trusted_servers: Vec<OwnedServerName>,
@ -115,6 +117,14 @@ pub struct WellKnownConfig {
pub server: Option<OwnedServerName>,
}
#[derive(Clone, Debug, Deserialize, Default)]
pub struct AuthenticationConfig {
#[serde(default = "false_fn")]
pub enable_oidc_login: bool,
#[serde(default = "false_fn")]
pub enable_oidc_account_management: bool,
}
const DEPRECATED_KEYS: &[&str] = &[
"cache_capacity",
"turn_username",
@ -260,6 +270,20 @@ impl fmt::Display for Config {
}),
("Well-known server name", well_known_server.as_str()),
("Well-known client URL", &self.well_known_client()),
("OIDC authentication",
if self.authentication.enable_oidc_login {
"enabled"
} else {
"disabled"
}
),
("OIDC account management enabled",
if self.authentication.enable_oidc_account_management {
"enabled"
} else {
"disabled"
}
),
];
let mut msg: String = "Active config values:\n\n".to_owned();

View file

@ -5,7 +5,7 @@ use axum::{
extract::{DefaultBodyLimit, FromRequestParts, MatchedPath},
middleware::map_response,
response::{IntoResponse, Response},
routing::{any, get, on, MethodFilter},
routing::{any, get, on, post, MethodFilter},
Router,
};
use axum_server::{bind, bind_rustls, tls_rustls::RustlsConfig, Handle as ServerHandle};
@ -426,6 +426,33 @@ fn routes(config: &Config) -> Router {
.ruma_route(client_server::get_relating_events_route)
.ruma_route(client_server::get_hierarchy_route)
.ruma_route(client_server::well_known_client)
.route(
"/_matrix/client/unstable/org.matrix.msc2965/auth_metadata",
get(client_server::get_auth_metadata),
)
.route(
"/_matrix/client/auth_metadata",
get(client_server::get_auth_metadata),
)
.route(
"/_matrix/client/unstable/org.matrix.msc2964/authorize",
get(client_server::authorize)
)
.route(
"/_matrix/client/unstable/org.matrix.msc2964/authorize",
post(client_server::authorize_consent)
)
.route(
"/_matrix/client/unstable/org.matrix.msc2964/login",
post(client_server::oidc_login)
)
.route(
"/_matrix/client/unstable/org.matrix.msc2964/token",
post(client_server::token)
)
.route("/_matrix/client/unstable/org.matrix.msc2964/device/register",
post(client_server::register_client)
)
.route(
"/_matrix/client/r0/rooms/:room_id/initialSync",
get(initial_sync),

View file

@ -15,6 +15,7 @@ pub mod appservice;
pub mod globals;
pub mod key_backups;
pub mod media;
pub mod oidc;
pub mod pdu;
pub mod pusher;
pub mod rooms;
@ -36,6 +37,7 @@ pub struct Services {
pub key_backups: key_backups::Service,
pub media: media::Service,
pub sending: Arc<sending::Service>,
pub oidc: Arc<oidc::Service>,
}
impl Services {
@ -123,6 +125,7 @@ impl Services {
sending: sending::Service::build(db, &config),
globals: globals::Service::load(db, config)?,
oidc: Arc::new(oidc::Service::preconfigured()),
})
}
async fn memory_usage(&self) -> String {

87
src/service/oidc/mod.rs Normal file
View file

@ -0,0 +1,87 @@
/// OIDC service.
///
/// Provides the registrar, authorizer and issuer needed by [api::client::oidc].
/// The whole OAuth2 flow is taken care of by [oxide-auth].
///
/// TODO At the moment this service provides no method to dynamically add a
/// client. That would need a dedicated space in the database.
///
/// [oxide-auth]: https://docs.rs/oxide-auth
use crate::Result;
use oxide_auth::{
frontends::simple::endpoint::{Generic, Vacant},
primitives::{
prelude::{
AuthMap,
Authorizer,
Client,
ClientMap,
Issuer,
RandomGenerator,
Registrar,
TokenMap,
},
registrar::RegisteredUrl,
},
};
use std::sync::Mutex;
pub struct Service {
registrar: Mutex<ClientMap>,
authorizer: Mutex<AuthMap<RandomGenerator>>,
issuer: Mutex<TokenMap<RandomGenerator>>,
}
impl Service {
pub fn register_client(&self, client: &Client) -> Result<()> {
self
.registrar
.lock()
.expect("lockable registrar")
.register_client(client.clone());
Ok(())
}
pub fn preconfigured() -> Self {
Service {
registrar: Mutex::new(
vec![Client::public(
"LocalClient",
RegisteredUrl::Semantic(
"http://localhost/clientside/endpoint".parse().unwrap(),
),
"default-scope".parse().unwrap(),
)]
.into_iter()
.collect(),
),
// Authorization tokens are 16 byte random keys to a memory hash map.
authorizer: Mutex::new(AuthMap::new(RandomGenerator::new(16))),
// Bearer tokens are also random generated but 256-bit tokens, since they live longer
// and this example is somewhat paranoid.
//
// We could also use a `TokenSigner::ephemeral` here to create signed tokens which can
// be read and parsed by anyone, but not maliciously created. However, they can not be
// revoked and thus don't offer even longer lived refresh tokens.
issuer: Mutex::new(TokenMap::new(RandomGenerator::new(16))),
}
}
/// The oxide-auth carry-all endpoint.
pub fn endpoint(&self) -> Generic<impl Registrar + '_, impl Authorizer + '_, impl Issuer + '_> {
Generic {
registrar: self.registrar.lock().unwrap(),
authorizer: self.authorizer.lock().unwrap(),
issuer: self.issuer.lock().unwrap(),
// Solicitor configured later.
solicitor: Vacant,
// Scope configured later.
scopes: Vacant,
// `rocket::Response` is `Default`, so we don't need more configuration.
response: Vacant,
}
}
}