1
0
Fork 0
mirror of https://gitlab.com/famedly/conduit.git synced 2025-08-06 17:40:59 +00:00

impl MSC2964: OIDC authorization flow with oxide-auth

This commit is contained in:
lafleur 2025-04-17 19:54:58 +02:00
parent c322cbcb79
commit 43458b64f8
10 changed files with 742 additions and 1 deletions

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,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,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;

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")
)),
}
}