Skip to content
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

Passkeys (experimental) #4234

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
322 changes: 265 additions & 57 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 @@ -11,7 +11,7 @@ use ipnetwork::IpNetwork;
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 @@ -47,6 +47,7 @@ pub struct AppState {
pub trusted_proxies: Vec<IpNetwork>,
pub limiter: Limiter,
pub conn_acquisition_histogram: Option<Histogram<u64>>,
pub webauthn: Webauthn,
}

impl AppState {
Expand Down Expand Up @@ -215,6 +216,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 @@ -28,7 +28,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 @@ -185,6 +185,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 @@ -220,6 +222,7 @@ impl Options {
password_manager.clone(),
url_builder.clone(),
limiter.clone(),
webauthn.clone(),
);

let state = {
Expand All @@ -241,6 +244,7 @@ impl Options {
trusted_proxies,
limiter,
conn_acquisition_histogram: None,
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,12 +9,12 @@ 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_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 @@ -214,6 +214,7 @@ pub fn site_config_from_config(
captcha,
minimum_password_complexity: password_config.minimum_complexity(),
session_expiration,
passkeys_enabled: experimental_config.passkeys,
})
}

Expand Down Expand Up @@ -466,6 +467,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 @@ -87,4 +87,7 @@ pub struct SiteConfig {
pub minimum_password_complexity: u8,

pub session_expiration: Option<SessionExpirationConfig>,

/// 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 @@ -69,6 +69,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 @@ -217,3 +218,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,
pub name: String,
pub transports: serde_json::Value,
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 @@ -85,6 +85,7 @@ rand.workspace = true
rand_chacha.workspace = true
headers.workspace = true
ulid.workspace = true
webauthn_rp = { version = "0.2.7", features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] }

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 @@ -54,7 +54,7 @@ use self::{
};
use crate::{
BoundActivityTracker, Limiter, RequesterFingerprint, impl_from_error_for_route,
passwords::PasswordManager,
passwords::PasswordManager, webauthn::Webauthn,
};

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

#[async_trait::async_trait]
Expand Down Expand Up @@ -111,6 +112,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 @@ -134,6 +139,7 @@ pub fn schema(
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
webauthn: Webauthn,
) -> Schema {
let state = GraphQLState {
pool: pool.clone(),
Expand All @@ -143,6 +149,7 @@ pub fn schema(
password_manager,
url_builder,
limiter,
webauthn,
};
let state: BoxState = Box::new(state);

Expand Down Expand Up @@ -512,6 +519,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 @@ -53,6 +53,9 @@ pub struct SiteConfig {
/// The exact scorer (including dictionaries and other data tables)
/// in use is <https://crates.io/crates/zxcvbn>.
minimum_password_complexity: u8,

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

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