oidc: implement registering devices

This commit is contained in:
lafleur authored and Ginger committed 2025-11-11 11:34:26 -05:00
1 parent 3bbbfcdd46
commit 600b8cb366
6 files changed
+181 -52

No files matched your search

+3 -3
View File
@@ -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" {
+18 -1
View File
@@ -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
+4 -6
View File
@@ -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(&registerable)?;
services.oidc.register_client(Some(client.client_name.clone()), &registerable)?;
let client_response = ClientResponse {
client_id: device_id.to_string(),
+92 -14
View File
@@ -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<globals::Service>,
}
struct Data {
userid_devicelistversion: Arc<Map>,
userdeviceid_metadata: Arc<Map>,
}
pub struct Service {
registrar: Mutex<ClientMap>,
registrar: Mutex<MatrixClientMap>,
authorizer: Mutex<AuthMap<RandomGenerator>>,
issuer: Mutex<TokenMap<RandomGenerator>>,
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<String>, 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<String>,
) -> 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::Service>("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<MatrixClient> {
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<EncodedClient> {
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<OwnedDeviceId> {
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<Map>, key: &[u8]) {
let old = db.get_blocking(key);
let new = utils::increment(old.ok().as_deref());
db.insert(key, new);
}
+48 -22
View File
@@ -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<Argon2> = 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<String>,
// 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<String, EncodedClient>,
pub struct MatrixClientMap {
clients: HashMap<String, MatrixClient>,
password_policy: Option<Box<dyn PasswordPolicy>>,
}
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<String>, 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<P: PasswordPolicy + 'static>(&mut self, new_policy: P) {
self.password_policy = Some(Box::new(new_policy))
@@ -76,34 +101,34 @@ impl ClientMap {
}
}
impl Extend<Client> for ClientMap {
impl Extend<Client> for MatrixClientMap {
fn extend<I>(&mut self, iter: I)
where
I: IntoIterator<Item = Client>,
{
iter.into_iter().for_each(|client| self.register_client(client))
iter.into_iter().for_each(|client| self.register_client(None, client))
}
}
impl FromIterator<Client> for ClientMap {
impl FromIterator<Client> for MatrixClientMap {
fn from_iter<I>(iter: I) -> Self
where
I: IntoIterator<Item = Client>,
{
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<BoundClient<'a>, 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<Scope>) -> Result<PreGrant, RegistrarError> {
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(())
+16 -6
View File
@@ -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();