diff --git a/src/api/client/oidc/authorize.rs b/src/api/client/oidc/authorize.rs index a0743fc18..6352d3843 100644 --- a/src/api/client/oidc/authorize.rs +++ b/src/api/client/oidc/authorize.rs @@ -8,6 +8,7 @@ use oxide_auth::{ frontends::simple::endpoint::FnSolicitor, }; use percent_encoding::percent_decode_str; +use service::oidc::{SCOPE_PREFIX_API, SCOPE_PREFIX_DEVICE}; /// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize` /// @@ -27,11 +28,10 @@ pub(crate) async fn authorize( let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else { return Err(err!(Request(Unknown("the scope could not be percent-decoded")))); }; - //if ! scope.contains("urn:matrix:api:*") { - if !scope.contains("urn:matrix:org.matrix.msc2967.client:api:*") { + if !scope.contains(&format!("{SCOPE_PREFIX_API}*")) { return Err(err!(Request(Unknown("the scope does not include the client API")))); } - if !scope.contains("urn:matrix:org.matrix.msc2967.client:device:") { + if !scope.contains(SCOPE_PREFIX_DEVICE) { return Err(err!(Request(Unknown("the scope does not include a device ID")))); } if query.code_challenge_method != "S256" { diff --git a/src/api/client/oidc/login.rs b/src/api/client/oidc/login.rs index 880e39675..dfd72210b 100644 --- a/src/api/client/oidc/login.rs +++ b/src/api/client/oidc/login.rs @@ -1,5 +1,5 @@ use axum::extract::State; -use conduwuit::{Result, err, utils::hash::verify_password}; +use conduwuit::{Result, err, utils::{hash::verify_password, ReadyExt}}; use conduwuit_web::oidc::{ LoginError, LoginQuery, OidcRequest, OidcResponse, oidc_consent_form, }; @@ -36,9 +36,26 @@ pub(crate) async fn oidc_login( if verify_password(&query.password, &valid_hash).is_err() { return Err(err!(Request(InvalidParam("password does not match")))); } + // TODO check if user disabled, etc. See /src/api/client/session.rs let hostname = services.config.server_name.host(); tracing::info!("logging in {user_id:?}"); + let Some(mc) = services.oidc.client_from_client_id(&query.client_id) else { + return Err(err!(Request(Unknown("no client has registered client_id {:?}", query.client_id)))); + }; + let device_id = services.oidc.device_id_from_scope(mc.client.default_scope)?; + let device_exists = services + .users + .all_device_ids(&user_id) + .ready_any(|v| v == device_id) + .await; + // TODO register the client's name at registration time. + //let name = services.oidc.endpoint().registrar.lock().unwrap(); + if ! device_exists { + // TODO get the client's IP from the request. + let client_ip = None; + services.oidc.register_device((&user_id, &device_id), mc.name.as_deref(), client_ip)?; + } services .oidc diff --git a/src/api/client/oidc/register.rs b/src/api/client/oidc/register.rs index 754612875..354ac42f3 100644 --- a/src/api/client/oidc/register.rs +++ b/src/api/client/oidc/register.rs @@ -3,7 +3,7 @@ use conduwuit::{Result, err}; use oxide_auth::primitives::prelude::Client; use reqwest::Url; use ruma::{DeviceId, identifiers_validation}; -use conduwuit_service::oidc::registrar::normalize_redirect; +use conduwuit_service::oidc::{registrar::normalize_redirect, SCOPE_PREFIX_DEVICE, SCOPE_PREFIX_API}; /// The required parameters to register a new client for OAuth2 application. /// See the required metadata in OAuth2 authorization grant flow in [MSC2966]. @@ -87,10 +87,8 @@ pub(crate) async fn register_client( .map(|url| normalize_redirect(url)) .collect(); let device_id = DeviceId::new(); - let scope = format!( - "urn:matrix:org.matrix.msc2967.client:api:* \ - urn:matrix:org.matrix.msc2967.client:device:{device_id}" - ).parse().expect("parseable default Matrix scope"); + // Only provide a default scope, we'll test the client's proposed scope for consent anyway. + let scope = format!("{SCOPE_PREFIX_DEVICE}{device_id} {SCOPE_PREFIX_API}*").parse().expect("parseable default scope"); // TODO check if the users service needs an update. //services.users.update_device_metadata(); @@ -116,7 +114,7 @@ pub(crate) async fn register_client( ).with_additional_redirect_uris(remaining_uris) }; tracing::trace!("registering OIDC device : {registerable:#?}"); - services.oidc.register_client(®isterable)?; + services.oidc.register_client(Some(client.client_name.clone()), ®isterable)?; let client_response = ClientResponse { client_id: device_id.to_string(), diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 14b8ac0e7..c67b75eef 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -13,30 +13,50 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use conduwuit::{Result, err}; +use conduwuit_core::utils; use oxide_auth::{ + endpoint::Scope, frontends::simple::endpoint::{Generic, Vacant}, primitives::{ - grant::Grant, prelude::{ AuthMap, Authorizer, Client, Issuer, RandomGenerator, Registrar, TokenMap, }, - registrar::RegisteredUrl, - }, + registrar::{EncodedClient, RegisteredUrl}, + grant::Grant, + } }; -use registrar::ClientMap; -use ruma::{OwnedDeviceId, OwnedUserId, UserId}; +use registrar::MatrixClientMap; +use ruma::{ + api::client::device::Device, + MilliSecondsSinceUnixEpoch, + OwnedDeviceId, + OwnedUserId, + UserId, +}; +use database::{Json, Map}; + +use crate::{globals, oidc::registrar::MatrixClient, Dep}; + + +pub const SCOPE_PREFIX_DEVICE: &str = "urn:matrix:org.matrix.msc2967.client:device:"; +pub const SCOPE_PREFIX_API : &str = "urn:matrix:org.matrix.msc2967.client:api:"; -use crate::{globals, Dep}; struct Services { globals: Dep, } +struct Data { + userid_devicelistversion: Arc, + userdeviceid_metadata: Arc, +} + pub struct Service { - registrar: Mutex, + registrar: Mutex, authorizer: Mutex>, issuer: Mutex>, services: Services, + db: Data, } #[async_trait] @@ -47,11 +67,34 @@ impl crate::Service for Service { } impl Service { - pub fn register_client(&self, client: &Client) -> Result<()> { + /// Register an OAuth client in the registrar (a ClientMap), so it's known by oxide-auth + /// for future client authentication flows. + pub fn register_client(&self, name: Option, client: &Client) -> Result<()> { self.registrar .lock() .expect("lockable registrar") - .register_client(client.clone()); + .register_client(name.clone(), client.clone()); + + Ok(()) + } + + /// Register a device in the main continuwuity database. Should only be called once the client + /// and the user both have authenticated. + pub fn register_device( + &self, + (user_id, device_id): (&OwnedUserId, &OwnedDeviceId), + display_name: Option<&str>, + client_ip: Option, + ) -> Result<()> { + let key = (user_id, device_id); + let val = Device { + device_id: device_id.into(), + display_name: display_name.map(|n| n.to_string()), + last_seen_ip: client_ip, + last_seen_ts: Some(MilliSecondsSinceUnixEpoch::now()), + }; + increment(&self.db.userid_devicelistversion, user_id.as_bytes()); + self.db.userdeviceid_metadata.put(key, Json(val)); Ok(()) } @@ -82,6 +125,10 @@ impl Service { services: Services { globals: args.depend::("globals"), }, + db: Data { + userid_devicelistversion: args.db["userid_devicelistversion"].clone(), + userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(), + } } } @@ -91,8 +138,36 @@ impl Service { issuer.recover_token(token).expect("infallible recover_token implementation") } + pub fn client_from_client_id(&self, client_id: &str) -> Option { + let registrar = self.registrar.lock().expect("lockable registrar"); + + //registrar.get_client(client_id).map(|mc| mc.client.clone()) + registrar.get_client(client_id).cloned() + } + + pub fn client_from_device_id(&self, device_id: OwnedDeviceId) -> Option { + let registrar = self.registrar.lock().expect("lockable registrar"); + + registrar.find_device(device_id.as_str()).cloned() + } + + pub fn device_id_from_scope(&self, scope: Scope) -> Result { + let Some(device_id) = scope + .iter() + .find(|s| s.starts_with(SCOPE_PREFIX_DEVICE)) else { + tracing::warn!("device_id not found in scope {scope:?}"); + return Err(err!(Request(InvalidParam("something went wrong with the scope")))); + }; + let device_id = device_id.replace(SCOPE_PREFIX_DEVICE, ""); + + OwnedDeviceId::try_from(device_id.clone()) + .map_err(|err| + err!(Request(InvalidParam("invalid device_id {device_id:?}: {err}"))) + ) + } + pub fn user_and_device_from_token(&self, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> { - let Some(Grant { owner_id, client_id, .. }) = self.grant_from_token(token) else { + let Some(Grant { owner_id, scope, .. }) = self.grant_from_token(token) else { return Err(err!(Request(MissingToken("unknown token: {token:?}")))); }; let server_name = self.services.globals.server_name(); @@ -100,10 +175,7 @@ impl Service { .map_err(|err| err!(Request(InvalidUsername("invalid username {owner_id:?}: {err}"))) )?; - let device_id = OwnedDeviceId::try_from(client_id.clone()) - .map_err(|err| - err!(Request(InvalidParam("invalid client_id {client_id:?}: {err}"))) - )?; + let device_id = self.device_id_from_scope(scope)?; Ok((owner_id, device_id)) } @@ -124,3 +196,9 @@ impl Service { } } } + +fn increment(db: &Arc, key: &[u8]) { + let old = db.get_blocking(key); + let new = utils::increment(old.ok().as_deref()); + db.insert(key, new); +} diff --git a/src/service/oidc/registrar.rs b/src/service/oidc/registrar.rs index 6b70bed40..8272571fc 100644 --- a/src/service/oidc/registrar.rs +++ b/src/service/oidc/registrar.rs @@ -3,6 +3,7 @@ use url::Url; use std::collections::HashMap; use std::iter::{Extend, FromIterator}; +use crate::oidc::SCOPE_PREFIX_DEVICE; use oxide_auth::endpoint::{PreGrant, Scope}; use oxide_auth::primitives::prelude::{Client, ClientUrl}; use oxide_auth::primitives::registrar::{Argon2, BoundClient, EncodedClient, PasswordPolicy, RegisteredClient, RegisteredUrl, Registrar, RegistrarError}; @@ -35,26 +36,50 @@ pub fn normalize_redirect(url: Url) -> RegisteredUrl { static DEFAULT_PASSWORD_POLICY: Lazy = Lazy::new(Argon2::default); -/// A very simple, in-memory hash map of client ids to Client entries. +#[derive(Clone)] +pub struct MatrixClient { + pub name: Option, + // scope option ?, + pub client: EncodedClient, +} + +/// A very simple, in-memory hash map of client ids to MatrixClient entries. #[derive(Default)] -pub struct ClientMap { - clients: HashMap, +pub struct MatrixClientMap { + clients: HashMap, password_policy: Option>, } -impl ClientMap { +impl MatrixClientMap { /// Create an empty map without any clients in it. - pub fn new() -> ClientMap { - ClientMap::default() + pub fn new() -> MatrixClientMap { + MatrixClientMap::default() } - /// Insert or update the client record. - pub fn register_client(&mut self, client: Client) { + /// Insert or update the client record with an oxide-auth Client. It will be wrapped in a + /// MatrixClient. + pub fn register_client(&mut self, name: Option, client: Client) { let password_policy = Self::current_policy(&self.password_policy); let client = client.encode(password_policy); - self.clients.insert(client.client_id.clone(), client); + let matrix_client = MatrixClient { name, client: client.clone() }; + self.clients.insert(client.client_id.clone(), matrix_client); } + /// Returns a MatrixClient, containing an oxide-auth EncodedClient, and some metadata, + /// like a public name. + pub fn get_client(&self, client_id: &str) -> Option<&MatrixClient> { + self.clients.get(client_id) + } + + pub fn find_device(&self, device_id: &str) -> Option<&EncodedClient> { + let scope = format!("{SCOPE_PREFIX_DEVICE}:{device_id}"); + + self.clients + .values() + .find(|c| c.client.default_scope.iter().any(|s| s == scope)) + .map(|c| &c.client) + } + /// Change how passwords are encoded while stored. pub fn set_password_policy(&mut self, new_policy: P) { self.password_policy = Some(Box::new(new_policy)) @@ -76,34 +101,34 @@ impl ClientMap { } } -impl Extend for ClientMap { +impl Extend for MatrixClientMap { fn extend(&mut self, iter: I) where I: IntoIterator, { - iter.into_iter().for_each(|client| self.register_client(client)) + iter.into_iter().for_each(|client| self.register_client(None, client)) } } -impl FromIterator for ClientMap { +impl FromIterator for MatrixClientMap { fn from_iter(iter: I) -> Self where I: IntoIterator, { - let mut into = ClientMap::new(); + let mut into = MatrixClientMap::new(); into.extend(iter); into } } -impl Registrar for ClientMap { +impl Registrar for MatrixClientMap { fn bound_redirect<'a>(&self, bound: ClientUrl<'a>) -> Result, RegistrarError> { let client = match self.clients.get(bound.client_id.as_ref()) { None => { tracing::debug!("this client was not registered: {}", bound.client_id); return Err(RegistrarError::Unspecified); }, - Some(stored) => stored, + Some(stored) => &stored.client, }; // Perform exact matching as motivated in the rfc, but substitute "127.0.0.1" and @@ -112,7 +137,7 @@ impl Registrar for ClientMap { let normalized_uri = redirect_uri .clone() .map(|u| normalize_redirect(u.to_url())); - let registered_url = match normalized_uri { + let redirect_uri = match normalized_uri { None => client.redirect_uri.clone(), Some(url) => { let original = std::iter::once(&client.redirect_uri); @@ -132,16 +157,17 @@ impl Registrar for ClientMap { Ok(BoundClient { client_id: bound.client_id, - redirect_uri: Cow::Owned(registered_url), + redirect_uri: Cow::Owned(redirect_uri), }) } - /// Always overrides the scope with a default scope. fn negotiate(&self, bound: BoundClient<'_>, _scope: Option) -> Result { - let client = self + let client = &self .clients .get(bound.client_id.as_ref()) - .expect("Bound client appears to not have been constructed with this registrar"); + .expect("Bound client appears to not have been constructed with this registrar") + .client; + // Always take the client's scope. Ok(PreGrant { client_id: bound.client_id.into_owned(), redirect_uri: bound.redirect_uri.into_owned(), @@ -157,8 +183,8 @@ impl Registrar for ClientMap { .ok_or_else(|| { tracing::debug!("this client is not registered yet: {client_id:?}."); RegistrarError::Unspecified - }).and_then(|client| - RegisteredClient::new(client, password_policy).check_authentication(passphrase) + }).and_then(|mc| + RegisteredClient::new(&mc.client, password_policy).check_authentication(passphrase) )?; Ok(()) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 96c8945f8..b28873920 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -541,12 +541,22 @@ impl Service { // Only existing devices should be able to call this, but we shouldn't assert // either... let key = (user_id, device_id); - if self.db.userdeviceid_metadata.qry(&key).await.is_err() { - return Err!(Database(error!( - ?user_id, - ?device_id, - "User does not exist or device has no metadata." - ))); + if self.services.server.config.auth.as_ref().is_some_and(|auth| auth.enable_oidc_login) { + if self.services.oidc.client_from_device_id(device_id.into()).is_none() { + return Err!(Database(error!( + ?user_id, + ?device_id, + "Device has no metadata." + ))); + } + } else { + if self.db.userdeviceid_metadata.qry(&key).await.is_err() { + return Err!(Database(error!( + ?user_id, + ?device_id, + "User does not exist or device has no metadata." + ))); + } } let mut key = user_id.as_bytes().to_vec();