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 all commits
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
11 changes: 8 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 @@ -222,6 +222,7 @@ pub fn site_config_from_config(
minimum_password_complexity: password_config.minimum_complexity(),
session_expiration,
login_with_email_allowed: account_config.login_with_email_allowed,
passkeys_enabled: experimental_config.passkeys,
})
}

Expand Down Expand Up @@ -486,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
8 changes: 8 additions & 0 deletions crates/config/src/sections/experimental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ pub struct ExperimentalConfig {
/// Disabled by default
#[serde(skip_serializing_if = "Option::is_none")]
pub inactive_session_expiration: Option<InactiveSessionExpirationConfig>,

/// Experimental passkey support
///
/// Disabled by default
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub passkeys: bool,
}

impl Default for ExperimentalConfig {
Expand All @@ -83,6 +89,7 @@ impl Default for ExperimentalConfig {
access_token_ttl: default_token_ttl(),
compat_token_ttl: default_token_ttl(),
inactive_session_expiration: None,
passkeys: false,
}
}
}
Expand All @@ -92,6 +99,7 @@ impl ExperimentalConfig {
is_default_token_ttl(&self.access_token_ttl)
&& is_default_token_ttl(&self.compat_token_ttl)
&& self.inactive_session_expiration.is_none()
&& !self.passkeys
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/data-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub use self::{
user_agent::{DeviceType, UserAgent},
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
UserEmailAuthentication, UserEmailAuthenticationCode, UserPasskey, UserPasskeyChallenge,
UserRecoverySession, UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
},
};
3 changes: 3 additions & 0 deletions crates/data-model/src/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,7 @@ pub struct SiteConfig {

/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,

/// Whether passkeys are enabled
pub passkeys_enabled: bool,
}
24 changes: 24 additions & 0 deletions crates/data-model/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub struct Authentication {
pub enum AuthenticationMethod {
Password { user_password_id: Ulid },
UpstreamOAuth2 { upstream_oauth2_session_id: Ulid },
Passkey { user_passkey_id: Ulid },
Unknown,
}

Expand Down Expand Up @@ -215,3 +216,26 @@ pub struct UserRegistration {
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserPasskey {
pub id: Ulid,
pub user_id: Ulid,
pub credential_id: String,
Copy link
Member

Choose a reason for hiding this comment

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

This is supposed to be a 'buffersource', so raw bytes? Shouldn't that be a Vec<u8> / BYTEA?

Copy link
Contributor Author

@tonkku107 tonkku107 Jun 4, 2025

Choose a reason for hiding this comment

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

Whichever works, the webauthn api provides both a base64 string and an ArrayBuffer (though both get sent as base64 thanks to toJSON). credential_id needs to be queried so a string format probably works better and that reminds me that it probably needs an index as well...

pub name: String,
pub transports: serde_json::Value,
Copy link
Member

Choose a reason for hiding this comment

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

I think I would rather have that the exact type, even if that means making mas-data-model depend on webauthn-rp? This way, potential errors comes at the repository level, and there is no need for post-processing those later

pub static_state: Vec<u8>,
pub dynamic_state: Vec<u8>,
pub metadata: Vec<u8>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserPasskeyChallenge {
pub id: Ulid,
pub user_session_id: Option<Ulid>,
pub state: Vec<u8>,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
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
4 changes: 4 additions & 0 deletions crates/handlers/src/graphql/model/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ pub struct SiteConfig {

/// Whether users can log in with their email address.
login_with_email_allowed: bool,

/// Whether passkeys are enabled
passkeys_enabled: bool,
}

#[derive(SimpleObject)]
Expand Down Expand Up @@ -102,6 +105,7 @@ impl SiteConfig {
account_deactivation_allowed: data_model.account_deactivation_allowed,
minimum_password_complexity: data_model.minimum_password_complexity,
login_with_email_allowed: data_model.login_with_email_allowed,
passkeys_enabled: data_model.passkeys_enabled,
}
}
}
Expand Down
Loading
Loading