feat: Add support for account management deeplinks

This commit is contained in:
Ginger
2026-04-30 16:46:19 -04:00
parent f6cdcea4a4
commit b367b74623
6 changed files with 84 additions and 18 deletions
+5 -4
View File
@@ -1,7 +1,3 @@
mod register_client;
mod server_metadata;
mod token;
use axum::{
Json, Router,
extract::State,
@@ -11,12 +7,17 @@
use serde_json::json;
pub(crate) use server_metadata::*;
mod register_client;
mod server_metadata;
mod token;
const BASE_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/oauth2/");
const AUTH_CODE_PATH: &str = "grant/authorization_code";
const JWKS_URI_PATH: &str = "client/keys.json";
const CLIENT_REGISTER_PATH: &str = "client/register";
const TOKEN_REVOKE_PATH: &str = "client/revoke";
const TOKEN_PATH: &str = "grant/token";
const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink");
pub(crate) fn router() -> Router<crate::State> {
Router::new().nest(BASE_PATH, oauth_router())
+17 -2
View File
@@ -1,13 +1,19 @@
use axum::extract::State;
use conduwuit::Result;
use ruma::{api::client::discovery::get_authorization_server_metadata, serde::Raw};
use ruma::{
api::client::discovery::get_authorization_server_metadata::{
self, v1::AccountManagementAction,
},
serde::Raw,
};
use serde_json::{Value, json};
use service::Services;
use crate::{
Ruma,
client::oauth::{
AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH, TOKEN_REVOKE_PATH,
ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH,
TOKEN_REVOKE_PATH,
},
};
@@ -28,6 +34,15 @@ pub(crate) async fn authorization_server_metadata(services: &Services) -> Value
.unwrap();
json!({
"account_management_uri": endpoint_base.join(ACCOUNT_MANAGEMENT_PATH).unwrap(),
"account_management_actions_supported": [
AccountManagementAction::AccountDeactivate,
AccountManagementAction::CrossSigningReset,
AccountManagementAction::DeviceDelete,
AccountManagementAction::DeviceView,
AccountManagementAction::DevicesList,
AccountManagementAction::Profile,
],
"authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(),
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
+4 -2
View File
@@ -314,8 +314,10 @@ pub async fn revoke_token(&self, token: String) -> Result<()> {
.deserialized::<RefreshTokenInfo>()
{
(refresh_token_info.user_id, refresh_token_info.device_id)
} else if let Some(user) = self.services.users.find_from_token(&token).await {
user
} else if let Some((user_id, device_id, _)) =
self.services.users.find_from_token(&token).await
{
(user_id, device_id)
} else {
return Err!("Invalid token");
};
+55 -7
View File
@@ -1,13 +1,25 @@
use axum::{Router, extract::State, response::Response, routing::get};
use axum::{
Router,
extract::{Query, State},
response::Redirect,
routing::get,
};
use conduwuit_core::utils::{IterStream, ReadyExt, stream::TryExpect};
use conduwuit_service::threepid::EmailRequirement;
use futures::StreamExt;
use ruma::{OwnedClientSecret, OwnedSessionId};
use ruma::{
OwnedClientSecret, OwnedDeviceId, OwnedSessionId,
api::client::discovery::get_authorization_server_metadata::v1::AccountManagementAction,
};
use serde::{Deserialize, Serialize};
use crate::{
WebError,
pages::components::{DeviceCard, DeviceCardStyle, UserCard},
extract::Expect,
pages::{
Result,
components::{DeviceCard, DeviceCardStyle, UserCard},
},
response,
session::{LoginTarget, User},
template,
@@ -26,6 +38,7 @@ pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/", get(get_account))
.route("/deeplink", get(get_account_deeplink))
.merge(login::build())
.nest("/password/", password::build())
.nest("/email/", email::build())
@@ -49,10 +62,7 @@ struct Account use "account.html.j2" {
}
}
async fn get_account(
State(services): State<crate::State>,
user: User,
) -> Result<Response, WebError> {
async fn get_account(State(services): State<crate::State>, user: User) -> Result {
let user_id = user.expect(LoginTarget::Account)?;
let email_requirement = services.threepid.email_requirement();
@@ -97,3 +107,41 @@ async fn get_account(
response!(Account::new(&services, user_card, email_requirement, email, device_cards))
}
#[derive(Deserialize)]
struct AccountDeeplinkQuery {
action: Option<AccountManagementAction>,
device_id: Option<OwnedDeviceId>,
}
async fn get_account_deeplink(
Expect(Query(query)): Expect<Query<AccountDeeplinkQuery>>,
) -> Result {
let redirect_target = match query.action.unwrap_or(AccountManagementAction::Profile) {
| AccountManagementAction::AccountDeactivate => "deactivate".to_owned(),
| AccountManagementAction::CrossSigningReset => "cross_signing_reset".to_owned(),
| AccountManagementAction::DeviceDelete => {
let Some(device_id) = query.device_id else {
return response!(WebError::BadRequest(
"A device ID is required for this action".to_owned()
));
};
format!("device/{device_id}/delete")
},
| AccountManagementAction::DeviceView => {
let Some(device_id) = query.device_id else {
return response!(WebError::BadRequest(
"A device ID is required for this action".to_owned()
));
};
format!("device/{device_id}/")
},
| AccountManagementAction::DevicesList => "#devices".to_owned(),
| AccountManagementAction::Profile => String::new(),
| _ => return response!(WebError::BadRequest("Unknown action".to_owned())),
};
response!(Redirect::to(&format!("{}/account/{}", crate::ROUTE_PREFIX, redirect_target)))
}
@@ -2,9 +2,9 @@
{% for scope in scopes %}
{% match scope %}
{% when Scope::ClientApi %}
<li>Interact with Matrix on your behalf</li>
<li>Send messages and interact with chatrooms on your behalf</li>
{% when Scope::Device(_) %}
<li>Connect to your Matrix account</li>
<li>Access your Matrix account</li>
{% endmatch %}
{% endfor %}
</ul>
+1 -1
View File
@@ -31,7 +31,7 @@ Your account
<section>
<details>
<summary>Your devices ({{ devices.len() }})</summary>
<div class="card-list">
<div class="card-list" id="devices"car>
{% for device in devices %}
{{ device }}
{% endfor %}