mirror of
https://gitlab.com/famedly/conduit.git
synced 2025-06-27 16:35:59 +00:00
impl MSC2964: OIDC authorization flow with oxide-auth
This commit is contained in:
parent
c322cbcb79
commit
43458b64f8
10 changed files with 742 additions and 1 deletions
172
Cargo.lock
generated
172
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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 = [
|
||||
|
|
227
src/api/client_server/oidc/authorize.rs
Normal file
227
src/api/client_server/oidc/authorize.rs
Normal 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>
|
||||
"#,
|
||||
)
|
||||
}
|
148
src/api/client_server/oidc/login.rs
Normal file
148
src/api/client_server/oidc/login.rs
Normal 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")
|
||||
)
|
||||
}
|
||||
|
9
src/api/client_server/oidc/mod.rs
Normal file
9
src/api/client_server/oidc/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
mod discovery;
|
||||
mod authorize;
|
||||
mod login;
|
||||
mod token;
|
||||
|
||||
pub use discovery::get_auth_metadata;
|
||||
pub use authorize::{authorize, authorize_consent};
|
||||
pub use login::oidc_login;
|
||||
pub use token::token;
|
83
src/api/client_server/oidc/token.rs
Normal file
83
src/api/client_server/oidc/token.rs
Normal 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")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
18
src/main.rs
18
src/main.rs
|
@ -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};
|
||||
|
@ -433,6 +433,22 @@ fn routes(config: &Config) -> Router {
|
|||
.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/r0/rooms/:room_id/initialSync",
|
||||
|
|
|
@ -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 {
|
||||
|
|
77
src/service/oidc/mod.rs
Normal file
77
src/service/oidc/mod.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
/// 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 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue