Skip to content

Passkeys (experimental) #4234

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
965 changes: 637 additions & 328 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion crates/cli/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use mas_context::LogContext;
use mas_data_model::SiteConfig;
use mas_handlers::{
ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter,
MetadataCache, RequesterFingerprint, passwords::PasswordManager,
MetadataCache, RequesterFingerprint, passwords::PasswordManager, webauthn::Webauthn,
};
use mas_i18n::Translator;
use mas_keystore::{Encrypter, Keystore};
Expand Down Expand Up @@ -49,6 +49,7 @@ pub struct AppState {
pub activity_tracker: ActivityTracker,
pub trusted_proxies: Vec<IpNetwork>,
pub limiter: Limiter,
pub webauthn: Webauthn,
}

impl AppState {
Expand Down Expand Up @@ -216,6 +217,12 @@ impl FromRef<AppState> for Arc<dyn HomeserverConnection> {
}
}

impl FromRef<AppState> for Webauthn {
fn from_ref(input: &AppState) -> Self {
input.webauthn.clone()
}
}

impl FromRequestParts<AppState> for BoxClock {
type Rejection = Infallible;

Expand Down
6 changes: 5 additions & 1 deletion crates/cli/src/commands/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use crate::{
database_pool_from_config, homeserver_connection_from_config,
load_policy_factory_dynamic_data_continuously, mailer_from_config,
password_manager_from_config, policy_factory_from_config, site_config_from_config,
templates_from_config, test_mailer_in_background,
templates_from_config, test_mailer_in_background, webauthn_from_config,
},
};

Expand Down Expand Up @@ -187,6 +187,8 @@ impl Options {

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

let webauthn = webauthn_from_config(&config.http)?;

// The upstream OIDC metadata cache
let metadata_cache = MetadataCache::new();

Expand Down Expand Up @@ -222,6 +224,7 @@ impl Options {
password_manager.clone(),
url_builder.clone(),
limiter.clone(),
webauthn.clone(),
);

let state = {
Expand All @@ -242,6 +245,7 @@ impl Options {
activity_tracker,
trusted_proxies,
limiter,
webauthn,
};
s.init_metrics();
s.init_metadata_cache();
Expand Down
10 changes: 7 additions & 3 deletions crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ use std::{sync::Arc, time::Duration};
use anyhow::Context;
use mas_config::{
AccountConfig, BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode,
EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, PasswordsConfig,
PolicyConfig, TemplatesConfig,
EmailTransportKind, ExperimentalConfig, HomeserverKind, HttpConfig, MatrixConfig,
PasswordsConfig, PolicyConfig, TemplatesConfig,
};
use mas_context::LogContext;
use mas_data_model::{SessionExpirationConfig, SiteConfig};
use mas_email::{MailTransport, Mailer};
use mas_handlers::passwords::PasswordManager;
use mas_handlers::{passwords::PasswordManager, webauthn::Webauthn};
use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection};
use mas_matrix_synapse::SynapseConnection;
use mas_policy::PolicyFactory;
Expand Down Expand Up @@ -487,6 +487,10 @@ pub fn homeserver_connection_from_config(
}
}

pub fn webauthn_from_config(config: &HttpConfig) -> Result<Webauthn, anyhow::Error> {
Webauthn::new(&config.public_base)
}

#[cfg(test)]
mod tests {
use rand::SeedableRng;
Expand Down
1 change: 1 addition & 0 deletions crates/handlers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ rand.workspace = true
rand_chacha.workspace = true
headers.workspace = true
ulid.workspace = true
webauthn_rp = { version = "0.3.0", features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally can you put new dependencies at the workspace level?


mas-axum-utils.workspace = true
mas-config.workspace = true
Expand Down
15 changes: 14 additions & 1 deletion crates/handlers/src/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ use self::{
};
use crate::{
BoundActivityTracker, Limiter, RequesterFingerprint, impl_from_error_for_route,
passwords::PasswordManager,
passwords::PasswordManager, webauthn::Webauthn,
};

#[cfg(test)]
Expand All @@ -76,6 +76,7 @@ struct GraphQLState {
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
webauthn: Webauthn,
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -108,6 +109,10 @@ impl state::State for GraphQLState {
&self.limiter
}

fn webauthn(&self) -> &Webauthn {
&self.webauthn
}

fn clock(&self) -> BoxClock {
let clock = SystemClock::default();
Box::new(clock)
Expand All @@ -131,6 +136,7 @@ pub fn schema(
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
webauthn: Webauthn,
) -> Schema {
let state = GraphQLState {
repository_factory,
Expand All @@ -140,6 +146,7 @@ pub fn schema(
password_manager,
url_builder,
limiter,
webauthn,
};
let state: BoxState = Box::new(state);

Expand Down Expand Up @@ -519,6 +526,12 @@ impl OwnerId for mas_data_model::UpstreamOAuthLink {
}
}

impl OwnerId for mas_data_model::UserPasskey {
fn owner_id(&self) -> Option<Ulid> {
Some(self.user_id)
}
}

/// A dumb wrapper around a `Ulid` to implement `OwnerId` for it.
pub struct UserId(Ulid);

Expand Down
4 changes: 3 additions & 1 deletion crates/handlers/src/graphql/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ pub use self::{
oauth::{OAuth2Client, OAuth2Session},
site_config::{SITE_CONFIG_ID, SiteConfig},
upstream_oauth::{UpstreamOAuth2Link, UpstreamOAuth2Provider},
users::{AppSession, User, UserEmail, UserEmailAuthentication, UserRecoveryTicket},
users::{
AppSession, User, UserEmail, UserEmailAuthentication, UserPasskey, UserRecoveryTicket,
},
viewer::{Anonymous, Viewer, ViewerSession},
};

Expand Down
6 changes: 6 additions & 0 deletions crates/handlers/src/graphql/model/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub enum NodeType {
UserEmail,
UserEmailAuthentication,
UserRecoveryTicket,
UserPasskey,
UserPasskeyChallenge,
}

#[derive(Debug, Error)]
Expand All @@ -55,6 +57,8 @@ impl NodeType {
NodeType::UserEmail => "user_email",
NodeType::UserEmailAuthentication => "user_email_authentication",
NodeType::UserRecoveryTicket => "user_recovery_ticket",
NodeType::UserPasskey => "user_passkey",
NodeType::UserPasskeyChallenge => "user_passkey_challenge",
}
}

Expand All @@ -72,6 +76,8 @@ impl NodeType {
"user_email" => Some(NodeType::UserEmail),
"user_email_authentication" => Some(NodeType::UserEmailAuthentication),
"user_recovery_ticket" => Some(NodeType::UserRecoveryTicket),
"user_passkey" => Some(NodeType::UserPasskey),
"user_passkey_challenge" => Some(NodeType::UserPasskeyChallenge),
_ => None,
}
}
Expand Down
92 changes: 91 additions & 1 deletion crates/handlers/src/graphql/model/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ use mas_storage::{
compat::{CompatSessionFilter, CompatSsoLoginFilter, CompatSsoLoginRepository},
oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository},
user::{BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository},
user::{
BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository,
UserPasskeyFilter,
},
};

use super::{
Expand Down Expand Up @@ -706,6 +709,66 @@ impl User {
.await
}

/// Get the list of passkeys, chronologically sorted
async fn passkeys(
&self,
ctx: &Context<'_>,

#[graphql(desc = "Returns the elements in the list that come after the cursor.")]
after: Option<String>,
#[graphql(desc = "Returns the elements in the list that come before the cursor.")]
before: Option<String>,
#[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
#[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
) -> Result<Connection<Cursor, UserPasskey, PreloadedTotalCount>, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;

query(
after,
before,
first,
last,
async |after, before, first, last| {
let after_id = after
.map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::UserPasskey))
.transpose()?;
let before_id = before
.map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::UserPasskey))
.transpose()?;
let pagination = Pagination::try_new(before_id, after_id, first, last)?;

let filter = UserPasskeyFilter::new().for_user(&self.0);

let page = repo.user_passkey().list(filter, pagination).await?;

// Preload the total count if requested
let count = if ctx.look_ahead().field("totalCount").exists() {
Some(repo.user_passkey().count(filter).await?)
} else {
None
};

repo.cancel().await?;

let mut connection = Connection::with_additional_fields(
page.has_previous_page,
page.has_next_page,
PreloadedTotalCount(count),
);
connection.edges.extend(page.edges.into_iter().map(|u| {
Edge::new(
OpaqueCursor(NodeCursor(NodeType::UserPasskey, u.id)),
UserPasskey(u),
)
}));

Ok::<_, async_graphql::Error>(connection)
},
)
.await
}

/// Check if the user has a password set.
async fn has_password(&self, ctx: &Context<'_>) -> Result<bool, async_graphql::Error> {
let state = ctx.state();
Expand Down Expand Up @@ -887,3 +950,30 @@ impl UserEmailAuthentication {
&self.0.email
}
}

/// A passkey
#[derive(Description)]
pub struct UserPasskey(pub mas_data_model::UserPasskey);

#[Object(use_type_description)]
impl UserPasskey {
/// ID of the object
pub async fn id(&self) -> ID {
NodeType::UserPasskey.id(self.0.id)
}

/// Name of the passkey
pub async fn name(&self) -> &str {
&self.0.name
}

/// When the object was created.
pub async fn created_at(&self) -> DateTime<Utc> {
self.0.created_at
}

/// When the passkey was last used
pub async fn last_used_at(&self) -> Option<DateTime<Utc>> {
self.0.last_used_at
}
}
2 changes: 2 additions & 0 deletions crates/handlers/src/graphql/mutations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod matrix;
mod oauth2_session;
mod user;
mod user_email;
mod user_passkey;

use anyhow::Context as _;
use async_graphql::MergedObject;
Expand All @@ -24,6 +25,7 @@ use crate::passwords::PasswordManager;
#[derive(Default, MergedObject)]
pub struct Mutation(
user_email::UserEmailMutations,
user_passkey::UserPasskeyMutations,
user::UserMutations,
oauth2_session::OAuth2SessionMutations,
compat_session::CompatSessionMutations,
Expand Down
Loading