diff --git a/Cargo.lock b/Cargo.lock index 5895889b..d5276021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 407a5e5b..73ea8719 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/api/client_server/mod.rs b/src/api/client_server/mod.rs index e5d0a5d5..e6f834a5 100644 --- a/src/api/client_server/mod.rs +++ b/src/api/client_server/mod.rs @@ -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; diff --git a/src/api/client_server/oidc/authorize.rs b/src/api/client_server/oidc/authorize.rs new file mode 100644 index 00000000..91a7caf5 --- /dev/null +++ b/src/api/client_server/oidc/authorize.rs @@ -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, + oauth: OAuthRequest, +) -> Result { + 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, +} + +/// # `POST /_matrix/client/unstable/org.matrix.msc2964/authorize?allow=[Option]` +/// +/// 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, + oauth: OAuthRequest, +) -> Result { + 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#" + + + + + + + +
+

{hostname} login

+
+ + + + + + + + + + + +
+
+ + + "# + ) +} + +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#" + + + + + + +
+

{hostname} login

+ '{client_id}' (at {redirect_uri}) is requesting permission for '{scope}' +
+ + +
+
+ + + "#, + ) +} diff --git a/src/api/client_server/oidc/discovery.rs b/src/api/client_server/oidc/discovery.rs new file mode 100644 index 00000000..202e1862 --- /dev/null +++ b/src/api/client_server/oidc/discovery.rs @@ -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, +) -> Result> { + 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))) +} diff --git a/src/api/client_server/oidc/login.rs b/src/api/client_server/oidc/login.rs new file mode 100644 index 00000000..80fe230f --- /dev/null +++ b/src/api/client_server/oidc/login.rs @@ -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 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, +) -> Result { + // 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 = 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") + ) +} + diff --git a/src/api/client_server/oidc/mod.rs b/src/api/client_server/oidc/mod.rs new file mode 100644 index 00000000..50fd403e --- /dev/null +++ b/src/api/client_server/oidc/mod.rs @@ -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; diff --git a/src/api/client_server/oidc/register.rs b/src/api/client_server/oidc/register.rs new file mode 100644 index 00000000..b2c0e7ac --- /dev/null +++ b/src/api/client_server/oidc/register.rs @@ -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, + /// Must be ["code"]. + response_types: Vec, + /// Must include "authorization_type" and "refresh_token". + grant_types: Vec, + //contacts: Vec, + /// Can be "none". + token_endpoint_auth_method: String, + /// Link to the logo. + logo_uri: Option, + /// Link to the client's policy. + policy_uri: Option, + /// Link to the terms of service. + tos_uri: Option, + /// Defaults to "web" if not present. + application_type: Option, +} + +/// 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, + tos_uri: Option, + policy_uri: Option, + redirect_uris: Vec, + token_endpoint_auth_method: String, + response_types: Vec, + grant_types: Vec, + application_type: Option, +} + +/// # `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, +) -> Result> { + 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(), + })) +} diff --git a/src/api/client_server/oidc/token.rs b/src/api/client_server/oidc/token.rs new file mode 100644 index 00000000..ec541c65 --- /dev/null +++ b/src/api/client_server/oidc/token.rs @@ -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 { + 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 = " +This page should be accessed via an oauth token from the client in the example. Click + +here to begin the authorization process. + +"; + 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") + )), + } +} + + diff --git a/src/api/client_server/well_known.rs b/src/api/client_server/well_known.rs index e7bc2a4a..961785cd 100644 --- a/src/api/client_server/well_known.rs +++ b/src/api/client_server/well_known.rs @@ -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, ) -> Result { 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") + ), + ) + ) }) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 7ed875ed..9ace0018 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, #[serde(default = "default_trusted_servers")] pub trusted_servers: Vec, @@ -115,6 +117,14 @@ pub struct WellKnownConfig { pub server: Option, } +#[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(); diff --git a/src/main.rs b/src/main.rs index 6ce5f822..4ee4fcf7 100644 --- a/src/main.rs +++ b/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}; @@ -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), diff --git a/src/service/mod.rs b/src/service/mod.rs index c328bf7e..4075bd7d 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -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, + pub oidc: Arc, } 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 { diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs new file mode 100644 index 00000000..a6a4ecf6 --- /dev/null +++ b/src/service/oidc/mod.rs @@ -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, + authorizer: Mutex>, + issuer: Mutex>, +} + +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 { + 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, + } + } +} +