Skip to content

Commit 3bf4368

Browse files
committed
GraphQL API changes
1 parent 32b24e7 commit 3bf4368

File tree

18 files changed

+1846
-340
lines changed

18 files changed

+1846
-340
lines changed

Cargo.lock

Lines changed: 637 additions & 328 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
@@ -12,7 +12,7 @@ use mas_context::LogContext;
1212
use mas_data_model::SiteConfig;
1313
use mas_handlers::{
1414
ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter,
15-
MetadataCache, RequesterFingerprint, passwords::PasswordManager,
15+
MetadataCache, RequesterFingerprint, passwords::PasswordManager, webauthn::Webauthn,
1616
};
1717
use mas_i18n::Translator;
1818
use mas_keystore::{Encrypter, Keystore};
@@ -49,6 +49,7 @@ pub struct AppState {
4949
pub activity_tracker: ActivityTracker,
5050
pub trusted_proxies: Vec<IpNetwork>,
5151
pub limiter: Limiter,
52+
pub webauthn: Webauthn,
5253
}
5354

5455
impl AppState {
@@ -216,6 +217,12 @@ impl FromRef<AppState> for Arc<dyn HomeserverConnection> {
216217
}
217218
}
218219

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

crates/cli/src/commands/server.rs

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

@@ -187,6 +187,8 @@ impl Options {
187187

188188
let password_manager = password_manager_from_config(&config.passwords).await?;
189189

190+
let webauthn = webauthn_from_config(&config.http)?;
191+
190192
// The upstream OIDC metadata cache
191193
let metadata_cache = MetadataCache::new();
192194

@@ -222,6 +224,7 @@ impl Options {
222224
password_manager.clone(),
223225
url_builder.clone(),
224226
limiter.clone(),
227+
webauthn.clone(),
225228
);
226229

227230
let state = {
@@ -242,6 +245,7 @@ impl Options {
242245
activity_tracker,
243246
trusted_proxies,
244247
limiter,
248+
webauthn,
245249
};
246250
s.init_metrics();
247251
s.init_metadata_cache();

crates/cli/src/util.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ 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_context::LogContext;
1616
use mas_data_model::{SessionExpirationConfig, SiteConfig};
1717
use mas_email::{MailTransport, Mailer};
18-
use mas_handlers::passwords::PasswordManager;
18+
use mas_handlers::{passwords::PasswordManager, webauthn::Webauthn};
1919
use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection};
2020
use mas_matrix_synapse::SynapseConnection;
2121
use mas_policy::PolicyFactory;
@@ -487,6 +487,10 @@ pub fn homeserver_connection_from_config(
487487
}
488488
}
489489

490+
pub fn webauthn_from_config(config: &HttpConfig) -> Result<Webauthn, anyhow::Error> {
491+
Webauthn::new(&config.public_base)
492+
}
493+
490494
#[cfg(test)]
491495
mod tests {
492496
use rand::SeedableRng;

crates/handlers/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ rand.workspace = true
8888
rand_chacha.workspace = true
8989
headers.workspace = true
9090
ulid.workspace = true
91+
webauthn_rp = { version = "0.3.0", features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] }
9192

9293
mas-axum-utils.workspace = true
9394
mas-config.workspace = true

crates/handlers/src/graphql/mod.rs

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

6161
#[cfg(test)]
@@ -76,6 +76,7 @@ struct GraphQLState {
7676
password_manager: PasswordManager,
7777
url_builder: UrlBuilder,
7878
limiter: Limiter,
79+
webauthn: Webauthn,
7980
}
8081

8182
#[async_trait::async_trait]
@@ -108,6 +109,10 @@ impl state::State for GraphQLState {
108109
&self.limiter
109110
}
110111

112+
fn webauthn(&self) -> &Webauthn {
113+
&self.webauthn
114+
}
115+
111116
fn clock(&self) -> BoxClock {
112117
let clock = SystemClock::default();
113118
Box::new(clock)
@@ -131,6 +136,7 @@ pub fn schema(
131136
password_manager: PasswordManager,
132137
url_builder: UrlBuilder,
133138
limiter: Limiter,
139+
webauthn: Webauthn,
134140
) -> Schema {
135141
let state = GraphQLState {
136142
repository_factory,
@@ -140,6 +146,7 @@ pub fn schema(
140146
password_manager,
141147
url_builder,
142148
limiter,
149+
webauthn,
143150
};
144151
let state: BoxState = Box::new(state);
145152

@@ -519,6 +526,12 @@ impl OwnerId for mas_data_model::UpstreamOAuthLink {
519526
}
520527
}
521528

529+
impl OwnerId for mas_data_model::UserPasskey {
530+
fn owner_id(&self) -> Option<Ulid> {
531+
Some(self.user_id)
532+
}
533+
}
534+
522535
/// A dumb wrapper around a `Ulid` to implement `OwnerId` for it.
523536
pub struct UserId(Ulid);
524537

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)