Skip to content

Commit 53a7da0

Browse files
committed
GraphQL API changes
1 parent d818ead commit 53a7da0

File tree

18 files changed

+1458
-69
lines changed

18 files changed

+1458
-69
lines changed

Cargo.lock

Lines changed: 265 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/src/app_state.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use ipnetwork::IpNetwork;
1111
use mas_data_model::SiteConfig;
1212
use mas_handlers::{
1313
ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter,
14-
MetadataCache, RequesterFingerprint, passwords::PasswordManager,
14+
MetadataCache, RequesterFingerprint, passwords::PasswordManager, webauthn::Webauthn,
1515
};
1616
use mas_i18n::Translator;
1717
use mas_keystore::{Encrypter, Keystore};
@@ -47,6 +47,7 @@ pub struct AppState {
4747
pub trusted_proxies: Vec<IpNetwork>,
4848
pub limiter: Limiter,
4949
pub conn_acquisition_histogram: Option<Histogram<u64>>,
50+
pub webauthn: Webauthn,
5051
}
5152

5253
impl AppState {
@@ -215,6 +216,12 @@ impl FromRef<AppState> for Arc<dyn HomeserverConnection> {
215216
}
216217
}
217218

219+
impl FromRef<AppState> for Webauthn {
220+
fn from_ref(input: &AppState) -> Self {
221+
input.webauthn.clone()
222+
}
223+
}
224+
218225
impl FromRequestParts<AppState> for BoxClock {
219226
type Rejection = Infallible;
220227

crates/cli/src/commands/server.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use crate::{
2828
database_pool_from_config, homeserver_connection_from_config,
2929
load_policy_factory_dynamic_data_continuously, mailer_from_config,
3030
password_manager_from_config, policy_factory_from_config, site_config_from_config,
31-
templates_from_config, test_mailer_in_background,
31+
templates_from_config, test_mailer_in_background, webauthn_from_config,
3232
},
3333
};
3434

@@ -185,6 +185,8 @@ impl Options {
185185

186186
let password_manager = password_manager_from_config(&config.passwords).await?;
187187

188+
let webauthn = webauthn_from_config(&config.http)?;
189+
188190
// The upstream OIDC metadata cache
189191
let metadata_cache = MetadataCache::new();
190192

@@ -220,6 +222,7 @@ impl Options {
220222
password_manager.clone(),
221223
url_builder.clone(),
222224
limiter.clone(),
225+
webauthn.clone(),
223226
);
224227

225228
let state = {
@@ -241,6 +244,7 @@ impl Options {
241244
trusted_proxies,
242245
limiter,
243246
conn_acquisition_histogram: None,
247+
webauthn,
244248
};
245249
s.init_metrics();
246250
s.init_metadata_cache();

crates/cli/src/util.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ use std::{sync::Arc, time::Duration};
99
use anyhow::Context;
1010
use mas_config::{
1111
AccountConfig, BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode,
12-
EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, PasswordsConfig,
13-
PolicyConfig, TemplatesConfig,
12+
EmailTransportKind, ExperimentalConfig, HomeserverKind, HttpConfig, MatrixConfig,
13+
PasswordsConfig, PolicyConfig, TemplatesConfig,
1414
};
1515
use mas_data_model::{SessionExpirationConfig, SiteConfig};
1616
use mas_email::{MailTransport, Mailer};
17-
use mas_handlers::passwords::PasswordManager;
17+
use mas_handlers::{passwords::PasswordManager, webauthn::Webauthn};
1818
use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection};
1919
use mas_matrix_synapse::SynapseConnection;
2020
use mas_policy::PolicyFactory;
@@ -467,6 +467,10 @@ pub fn homeserver_connection_from_config(
467467
}
468468
}
469469

470+
pub fn webauthn_from_config(config: &HttpConfig) -> Result<Webauthn, anyhow::Error> {
471+
Webauthn::new(&config.public_base)
472+
}
473+
470474
#[cfg(test)]
471475
mod tests {
472476
use rand::SeedableRng;

crates/handlers/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ rand.workspace = true
8585
rand_chacha.workspace = true
8686
headers.workspace = true
8787
ulid.workspace = true
88+
webauthn_rp = { version = "0.2.7", features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] }
8889

8990
mas-axum-utils.workspace = true
9091
mas-config.workspace = true

crates/handlers/src/graphql/mod.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ use self::{
5454
};
5555
use crate::{
5656
BoundActivityTracker, Limiter, RequesterFingerprint, impl_from_error_for_route,
57-
passwords::PasswordManager,
57+
passwords::PasswordManager, webauthn::Webauthn,
5858
};
5959

6060
#[cfg(test)]
@@ -75,6 +75,7 @@ struct GraphQLState {
7575
password_manager: PasswordManager,
7676
url_builder: UrlBuilder,
7777
limiter: Limiter,
78+
webauthn: Webauthn,
7879
}
7980

8081
#[async_trait::async_trait]
@@ -111,6 +112,10 @@ impl state::State for GraphQLState {
111112
&self.limiter
112113
}
113114

115+
fn webauthn(&self) -> &Webauthn {
116+
&self.webauthn
117+
}
118+
114119
fn clock(&self) -> BoxClock {
115120
let clock = SystemClock::default();
116121
Box::new(clock)
@@ -134,6 +139,7 @@ pub fn schema(
134139
password_manager: PasswordManager,
135140
url_builder: UrlBuilder,
136141
limiter: Limiter,
142+
webauthn: Webauthn,
137143
) -> Schema {
138144
let state = GraphQLState {
139145
pool: pool.clone(),
@@ -143,6 +149,7 @@ pub fn schema(
143149
password_manager,
144150
url_builder,
145151
limiter,
152+
webauthn,
146153
};
147154
let state: BoxState = Box::new(state);
148155

@@ -512,6 +519,12 @@ impl OwnerId for mas_data_model::UpstreamOAuthLink {
512519
}
513520
}
514521

522+
impl OwnerId for mas_data_model::UserPasskey {
523+
fn owner_id(&self) -> Option<Ulid> {
524+
Some(self.user_id)
525+
}
526+
}
527+
515528
/// A dumb wrapper around a `Ulid` to implement `OwnerId` for it.
516529
pub struct UserId(Ulid);
517530

crates/handlers/src/graphql/model/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ pub use self::{
2626
oauth::{OAuth2Client, OAuth2Session},
2727
site_config::{SITE_CONFIG_ID, SiteConfig},
2828
upstream_oauth::{UpstreamOAuth2Link, UpstreamOAuth2Provider},
29-
users::{AppSession, User, UserEmail, UserEmailAuthentication, UserRecoveryTicket},
29+
users::{
30+
AppSession, User, UserEmail, UserEmailAuthentication, UserPasskey, UserRecoveryTicket,
31+
},
3032
viewer::{Anonymous, Viewer, ViewerSession},
3133
};
3234

crates/handlers/src/graphql/model/node.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub enum NodeType {
2929
UserEmail,
3030
UserEmailAuthentication,
3131
UserRecoveryTicket,
32+
UserPasskey,
33+
UserPasskeyChallenge,
3234
}
3335

3436
#[derive(Debug, Error)]
@@ -55,6 +57,8 @@ impl NodeType {
5557
NodeType::UserEmail => "user_email",
5658
NodeType::UserEmailAuthentication => "user_email_authentication",
5759
NodeType::UserRecoveryTicket => "user_recovery_ticket",
60+
NodeType::UserPasskey => "user_passkey",
61+
NodeType::UserPasskeyChallenge => "user_passkey_challenge",
5862
}
5963
}
6064

@@ -72,6 +76,8 @@ impl NodeType {
7276
"user_email" => Some(NodeType::UserEmail),
7377
"user_email_authentication" => Some(NodeType::UserEmailAuthentication),
7478
"user_recovery_ticket" => Some(NodeType::UserRecoveryTicket),
79+
"user_passkey" => Some(NodeType::UserPasskey),
80+
"user_passkey_challenge" => Some(NodeType::UserPasskeyChallenge),
7581
_ => None,
7682
}
7783
}

crates/handlers/src/graphql/model/users.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ use mas_storage::{
1717
compat::{CompatSessionFilter, CompatSsoLoginFilter, CompatSsoLoginRepository},
1818
oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
1919
upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository},
20-
user::{BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository},
20+
user::{
21+
BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository,
22+
UserPasskeyFilter,
23+
},
2124
};
2225

2326
use super::{
@@ -706,6 +709,66 @@ impl User {
706709
.await
707710
}
708711

712+
/// Get the list of passkeys, chronologically sorted
713+
async fn passkeys(
714+
&self,
715+
ctx: &Context<'_>,
716+
717+
#[graphql(desc = "Returns the elements in the list that come after the cursor.")]
718+
after: Option<String>,
719+
#[graphql(desc = "Returns the elements in the list that come before the cursor.")]
720+
before: Option<String>,
721+
#[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
722+
#[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
723+
) -> Result<Connection<Cursor, UserPasskey, PreloadedTotalCount>, async_graphql::Error> {
724+
let state = ctx.state();
725+
let mut repo = state.repository().await?;
726+
727+
query(
728+
after,
729+
before,
730+
first,
731+
last,
732+
async |after, before, first, last| {
733+
let after_id = after
734+
.map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::UserPasskey))
735+
.transpose()?;
736+
let before_id = before
737+
.map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::UserPasskey))
738+
.transpose()?;
739+
let pagination = Pagination::try_new(before_id, after_id, first, last)?;
740+
741+
let filter = UserPasskeyFilter::new().for_user(&self.0);
742+
743+
let page = repo.user_passkey().list(filter, pagination).await?;
744+
745+
// Preload the total count if requested
746+
let count = if ctx.look_ahead().field("totalCount").exists() {
747+
Some(repo.user_passkey().count(filter).await?)
748+
} else {
749+
None
750+
};
751+
752+
repo.cancel().await?;
753+
754+
let mut connection = Connection::with_additional_fields(
755+
page.has_previous_page,
756+
page.has_next_page,
757+
PreloadedTotalCount(count),
758+
);
759+
connection.edges.extend(page.edges.into_iter().map(|u| {
760+
Edge::new(
761+
OpaqueCursor(NodeCursor(NodeType::UserPasskey, u.id)),
762+
UserPasskey(u),
763+
)
764+
}));
765+
766+
Ok::<_, async_graphql::Error>(connection)
767+
},
768+
)
769+
.await
770+
}
771+
709772
/// Check if the user has a password set.
710773
async fn has_password(&self, ctx: &Context<'_>) -> Result<bool, async_graphql::Error> {
711774
let state = ctx.state();
@@ -887,3 +950,30 @@ impl UserEmailAuthentication {
887950
&self.0.email
888951
}
889952
}
953+
954+
/// A passkey
955+
#[derive(Description)]
956+
pub struct UserPasskey(pub mas_data_model::UserPasskey);
957+
958+
#[Object(use_type_description)]
959+
impl UserPasskey {
960+
/// ID of the object
961+
pub async fn id(&self) -> ID {
962+
NodeType::UserPasskey.id(self.0.id)
963+
}
964+
965+
/// Name of the passkey
966+
pub async fn name(&self) -> &str {
967+
&self.0.name
968+
}
969+
970+
/// When the object was created.
971+
pub async fn created_at(&self) -> DateTime<Utc> {
972+
self.0.created_at
973+
}
974+
975+
/// When the passkey was last used
976+
pub async fn last_used_at(&self) -> Option<DateTime<Utc>> {
977+
self.0.last_used_at
978+
}
979+
}

crates/handlers/src/graphql/mutations/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod matrix;
1010
mod oauth2_session;
1111
mod user;
1212
mod user_email;
13+
mod user_passkey;
1314

1415
use anyhow::Context as _;
1516
use async_graphql::MergedObject;
@@ -24,6 +25,7 @@ use crate::passwords::PasswordManager;
2425
#[derive(Default, MergedObject)]
2526
pub struct Mutation(
2627
user_email::UserEmailMutations,
28+
user_passkey::UserPasskeyMutations,
2729
user::UserMutations,
2830
oauth2_session::OAuth2SessionMutations,
2931
compat_session::CompatSessionMutations,

0 commit comments

Comments
 (0)