From 35a1c517314302f3d362fc0657766ebdee24e54d Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Mon, 3 Mar 2025 13:15:11 +0000
Subject: [PATCH 1/7] Experimental configuration toggle for passkeys
---
crates/cli/src/util.rs | 4 +++
crates/config/src/sections/experimental.rs | 17 +++++++++++
crates/data-model/src/site_config.rs | 3 ++
.../handlers/src/graphql/model/site_config.rs | 4 +++
crates/handlers/src/test_utils.rs | 1 +
crates/handlers/src/views/login.rs | 7 +++--
crates/templates/src/context/ext.rs | 1 +
crates/templates/src/context/features.rs | 5 ++++
crates/templates/src/lib.rs | 1 +
docs/config.schema.json | 19 ++++++++++++
frontend/locales/en.json | 3 ++
frontend/schema.graphql | 4 +++
frontend/src/gql/gql.ts | 6 ++--
frontend/src/gql/graphql.ts | 5 +++-
frontend/src/routes/_account.index.tsx | 11 +++++++
templates/pages/login.html | 30 ++++++++++++-------
translations/en.json | 16 ++++++----
17 files changed, 113 insertions(+), 24 deletions(-)
diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs
index 959c8ba0f..e3f2166c1 100644
--- a/crates/cli/src/util.rs
+++ b/crates/cli/src/util.rs
@@ -224,6 +224,10 @@ pub fn site_config_from_config(
session_expiration,
login_with_email_allowed: account_config.login_with_email_allowed,
plan_management_iframe_uri: experimental_config.plan_management_iframe_uri.clone(),
+ passkeys_enabled: experimental_config
+ .passkeys
+ .as_ref()
+ .is_some_and(|c| c.enabled),
})
}
diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs
index c6c50e88d..62ad599cc 100644
--- a/crates/config/src/sections/experimental.rs
+++ b/crates/config/src/sections/experimental.rs
@@ -45,6 +45,15 @@ pub struct InactiveSessionExpirationConfig {
pub expire_user_sessions: bool,
}
+/// Configuration options for passkeys
+#[serde_as]
+#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
+pub struct PasskeysConfig {
+ /// Whether passkeys are enabled or not
+ #[serde(default)]
+ pub enabled: bool,
+}
+
/// Configuration sections for experimental options
///
/// Do not change these options unless you know what you are doing.
@@ -81,6 +90,12 @@ pub struct ExperimentalConfig {
/// validation.
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_management_iframe_uri: Option,
+
+ /// Experimental passkey support
+ ///
+ /// Disabled by default
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub passkeys: Option,
}
impl Default for ExperimentalConfig {
@@ -90,6 +105,7 @@ impl Default for ExperimentalConfig {
compat_token_ttl: default_token_ttl(),
inactive_session_expiration: None,
plan_management_iframe_uri: None,
+ passkeys: None,
}
}
}
@@ -100,6 +116,7 @@ impl ExperimentalConfig {
&& is_default_token_ttl(&self.compat_token_ttl)
&& self.inactive_session_expiration.is_none()
&& self.plan_management_iframe_uri.is_none()
+ && self.passkeys.is_none()
}
}
diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs
index ac0d7e6b8..552d2a20a 100644
--- a/crates/data-model/src/site_config.rs
+++ b/crates/data-model/src/site_config.rs
@@ -96,4 +96,7 @@ pub struct SiteConfig {
/// The iframe URL to show in the plan tab of the UI
pub plan_management_iframe_uri: Option,
+
+ /// Whether passkeys are enabled
+ pub passkeys_enabled: bool,
}
diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs
index d6966907e..5657a22ea 100644
--- a/crates/handlers/src/graphql/model/site_config.rs
+++ b/crates/handlers/src/graphql/model/site_config.rs
@@ -59,6 +59,9 @@ pub struct SiteConfig {
/// Experimental plan management iframe URI.
plan_management_iframe_uri: Option,
+
+ /// Whether passkeys are enabled
+ passkeys_enabled: bool,
}
#[derive(SimpleObject)]
@@ -106,6 +109,7 @@ impl SiteConfig {
minimum_password_complexity: data_model.minimum_password_complexity,
login_with_email_allowed: data_model.login_with_email_allowed,
plan_management_iframe_uri: data_model.plan_management_iframe_uri.clone(),
+ passkeys_enabled: data_model.passkeys_enabled,
}
}
}
diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs
index e43194776..7c4660a34 100644
--- a/crates/handlers/src/test_utils.rs
+++ b/crates/handlers/src/test_utils.rs
@@ -147,6 +147,7 @@ pub fn test_site_config() -> SiteConfig {
session_expiration: None,
login_with_email_allowed: true,
plan_management_iframe_uri: None,
+ passkeys_enabled: false,
}
}
diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs
index 57091e5fc..4c15156f8 100644
--- a/crates/handlers/src/views/login.rs
+++ b/crates/handlers/src/views/login.rs
@@ -99,9 +99,10 @@ pub(crate) async fn get(
let providers = repo.upstream_oauth_provider().all_enabled().await?;
- // If password-based login is disabled, and there is only one upstream provider,
- // we can directly start an authorization flow
- if !site_config.password_login_enabled && providers.len() == 1 {
+ // If password-based login and passkeys are disabled, and there is only one
+ // upstream provider, we can directly start an authorization flow
+ if !site_config.password_login_enabled && !site_config.passkeys_enabled && providers.len() == 1
+ {
let provider = providers.into_iter().next().unwrap();
let mut destination = UpstreamOAuth2Authorize::new(provider.id);
diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs
index e4ae3886c..2c17e7288 100644
--- a/crates/templates/src/context/ext.rs
+++ b/crates/templates/src/context/ext.rs
@@ -48,6 +48,7 @@ impl SiteConfigExt for SiteConfig {
password_login: self.password_login_enabled,
account_recovery: self.account_recovery_allowed,
login_with_email_allowed: self.login_with_email_allowed,
+ passkeys_enabled: self.passkeys_enabled,
}
}
}
diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs
index d514b5c63..ba7cb0cf1 100644
--- a/crates/templates/src/context/features.rs
+++ b/crates/templates/src/context/features.rs
@@ -26,6 +26,9 @@ pub struct SiteFeatures {
/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,
+
+ /// Whether passkeys are enabled
+ pub passkeys_enabled: bool,
}
impl Object for SiteFeatures {
@@ -35,6 +38,7 @@ impl Object for SiteFeatures {
"password_login" => Some(Value::from(self.password_login)),
"account_recovery" => Some(Value::from(self.account_recovery)),
"login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)),
+ "passkeys_enabled" => Some(Value::from(self.passkeys_enabled)),
_ => None,
}
}
@@ -45,6 +49,7 @@ impl Object for SiteFeatures {
"password_login",
"account_recovery",
"login_with_email_allowed",
+ "passkeys_enabled",
])
}
}
diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs
index 1c0bef423..045b8af50 100644
--- a/crates/templates/src/lib.rs
+++ b/crates/templates/src/lib.rs
@@ -511,6 +511,7 @@ mod tests {
password_registration: true,
account_recovery: true,
login_with_email_allowed: true,
+ passkeys_enabled: true,
};
let vite_manifest_path =
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
diff --git a/docs/config.schema.json b/docs/config.schema.json
index 761f1dd62..350c49614 100644
--- a/docs/config.schema.json
+++ b/docs/config.schema.json
@@ -2657,6 +2657,14 @@
"plan_management_iframe_uri": {
"description": "Experimental feature to show a plan management tab and iframe. This value is passed through \"as is\" to the client without any validation.",
"type": "string"
+ },
+ "passkeys": {
+ "description": "Experimental passkey support\n\nDisabled by default",
+ "allOf": [
+ {
+ "$ref": "#/definitions/PasskeysConfig"
+ }
+ ]
}
}
},
@@ -2690,6 +2698,17 @@
"type": "boolean"
}
}
+ },
+ "PasskeysConfig": {
+ "description": "Configuration options for passkeys",
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "description": "Whether passkeys are enabled or not",
+ "default": false,
+ "type": "boolean"
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 929b1a99c..d36d1b793 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -55,6 +55,9 @@
"title": "Edit profile",
"username_label": "Username"
},
+ "passkeys": {
+ "title": "Passkeys"
+ },
"password": {
"change": "Change password",
"change_disabled": "Password changes are disabled by the administrator.",
diff --git a/frontend/schema.graphql b/frontend/schema.graphql
index 99da32010..d2b03b0a0 100644
--- a/frontend/schema.graphql
+++ b/frontend/schema.graphql
@@ -1762,6 +1762,10 @@ type SiteConfig implements Node {
"""
planManagementIframeUri: String
"""
+ Whether passkeys are enabled
+ """
+ passkeysEnabled: Boolean!
+ """
The ID of the site configuration.
"""
id: ID!
diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts
index 635e117ca..34555560b 100644
--- a/frontend/src/gql/gql.ts
+++ b/frontend/src/gql/gql.ts
@@ -49,7 +49,7 @@ type Documents = {
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc,
- "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
+ "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": typeof types.BrowserSessionListDocument,
"\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": typeof types.SessionsOverviewDocument,
@@ -106,7 +106,7 @@ const documents: Documents = {
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc,
- "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument,
+ "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument,
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument,
"\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewDocument,
@@ -268,7 +268,7 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument;
+export function graphql(source: "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
index b6f357170..84b98f8ce 100644
--- a/frontend/src/gql/graphql.ts
+++ b/frontend/src/gql/graphql.ts
@@ -1290,6 +1290,8 @@ export type SiteConfig = Node & {
* in use is .
*/
minimumPasswordComplexity: Scalars['Int']['output'];
+ /** Whether passkeys are enabled */
+ passkeysEnabled: Scalars['Boolean']['output'];
/** Whether passwords are enabled and users can change their own passwords. */
passwordChangeAllowed: Scalars['Boolean']['output'];
/** Whether passwords are enabled for login. */
@@ -1856,7 +1858,7 @@ export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typena
{ __typename?: 'User', hasPassword: boolean, emails: { __typename?: 'UserEmailConnection', totalCount: number } }
& { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment;'AccountDeleteButton_UserFragment': AccountDeleteButton_UserFragment } }
) } | { __typename: 'Oauth2Session' }, siteConfig: (
- { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean, accountDeactivationAllowed: boolean }
+ { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean, passkeysEnabled: boolean, accountDeactivationAllowed: boolean }
& { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment;'AccountDeleteButton_SiteConfigFragment': AccountDeleteButton_SiteConfigFragment } }
) };
@@ -2549,6 +2551,7 @@ export const UserProfileDocument = new TypedDocumentString(`
siteConfig {
emailChangeAllowed
passwordLoginEnabled
+ passkeysEnabled
accountDeactivationAllowed
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx
index 4b21b326c..e49fb3915 100644
--- a/frontend/src/routes/_account.index.tsx
+++ b/frontend/src/routes/_account.index.tsx
@@ -46,6 +46,7 @@ const QUERY = graphql(/* GraphQL */ `
siteConfig {
emailChangeAllowed
passwordLoginEnabled
+ passkeysEnabled
accountDeactivationAllowed
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
@@ -226,6 +227,16 @@ function Index(): React.ReactElement {
>
)}
+ {siteConfig.passkeysEnabled && (
+ <>
+
+ placeholder text
+
+
+
+ >
+ )}
+
{t("frontend.reset_cross_signing.description")}
diff --git a/templates/pages/login.html b/templates/pages/login.html
index 57bccd03e..10802adf8 100644
--- a/templates/pages/login.html
+++ b/templates/pages/login.html
@@ -42,17 +42,17 @@ {{ _("mas.login.headline") }}
- {% if features.login_with_email_allowed %}
- {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %}
-
- {% endcall %}
- {% else %}
- {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
-
- {% endcall %}
- {% endif %}
-
{% if features.password_login %}
+ {% if features.login_with_email_allowed %}
+ {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %}
+
+ {% endcall %}
+ {% else %}
+ {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
+
+ {% endcall %}
+ {% endif %}
+
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
{% endcall %}
@@ -68,7 +68,15 @@ {{ _("mas.login.headline") }}
{{ button.button(text=_("action.continue")) }}
{% endif %}
- {% if features.password_login and providers %}
+ {% if features.password_login and features.passkeys_enabled %}
+ {{ field.separator() }}
+ {% endif %}
+
+ {% if features.passkeys_enabled %}
+ {{ button.link(text=_("mas.login.with_passkey")) }}
+ {% endif %}
+
+ {% if (features.password_login or features.passkeys_enabled) and providers %}
{{ field.separator() }}
{% endif %}
diff --git a/translations/en.json b/translations/en.json
index 0d263fb55..27410e0d5 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -14,7 +14,7 @@
},
"create_account": "Create Account",
"@create_account": {
- "context": "pages/login.html:94:33-59, pages/upstream_oauth2/do_register.html:191:26-52"
+ "context": "pages/login.html:102:33-59, pages/upstream_oauth2/do_register.html:191:26-52"
},
"sign_in": "Sign in",
"@sign_in": {
@@ -99,7 +99,7 @@
},
"username": "Username",
"@username": {
- "context": "pages/login.html:50:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59"
+ "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59"
}
},
"error": {
@@ -419,11 +419,11 @@
"login": {
"call_to_register": "Don't have an account yet?",
"@call_to_register": {
- "context": "pages/login.html:90:13-44"
+ "context": "pages/login.html:98:13-44"
},
"continue_with_provider": "Continue with %(provider)s",
"@continue_with_provider": {
- "context": "pages/login.html:81:15-67, pages/register/index.html:53:15-67",
+ "context": "pages/login.html:89:15-67, pages/register/index.html:53:15-67",
"description": "Button to log in with an upstream provider"
},
"description": "Please sign in to continue:",
@@ -451,11 +451,15 @@
},
"no_login_methods": "No login methods available.",
"@no_login_methods": {
- "context": "pages/login.html:100:11-42"
+ "context": "pages/login.html:108:11-42"
},
"username_or_email": "Username or Email",
"@username_or_email": {
- "context": "pages/login.html:46:37-69"
+ "context": "pages/login.html:47:39-71"
+ },
+ "with_passkey": "Sign in with a Passkey",
+ "@with_passkey": {
+ "context": "pages/login.html:76:28-55"
}
},
"navbar": {
From 79bbd44792aa2026b34c489e22abc48dd79a4b21 Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Mon, 3 Mar 2025 17:08:34 +0000
Subject: [PATCH 2/7] Database model changes
---
Cargo.lock | 130 ++++
Cargo.toml | 4 +
crates/data-model/Cargo.toml | 1 +
crates/data-model/src/lib.rs | 5 +-
crates/data-model/src/users.rs | 25 +
...43b6bdf6a41896d787a13be3399022fe5e43d.json | 46 ++
...90af5ecca2f977b8d74311b4cad401e2650e5.json | 17 +
...02ffb44631d77b14796db971064ef27abb739.json | 16 +
...61e4f55b19405def7e2e51051b6f564f5e4c5.json | 76 ++
...7b0bc4a2f963aadca4264f5e6e250afde4965.json | 22 +
...8c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json | 76 ++
...4d5215d39ea6d1390056c9e3ab0981673c84e.json | 14 +
...ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json | 15 +
...a78edec1b66be5ff6d57ab90ac06e0f1098be.json | 16 +
...b180b0d5c60e877dc6818f60662aa801b49ac.json | 76 ++
...00ece02fb9a5bed134fda19de1e4731bc9911.json | 15 +
...32ef8fc5eaac1176471d2a5f25ee8cf1f849a.json | 17 +
crates/storage-pg/Cargo.toml | 1 +
.../migrations/20250303142622_passkeys.sql | 63 ++
...ion_authentications_user_passkey_id_fk.sql | 9 +
crates/storage-pg/src/iden.rs | 15 +
crates/storage-pg/src/repository.rs | 16 +-
crates/storage-pg/src/user/mod.rs | 6 +-
crates/storage-pg/src/user/passkey.rs | 657 ++++++++++++++++++
crates/storage-pg/src/user/session.rs | 51 +-
crates/storage/Cargo.toml | 1 +
crates/storage/src/repository.rs | 27 +-
crates/storage/src/user/mod.rs | 2 +
crates/storage/src/user/passkey.rs | 328 +++++++++
crates/storage/src/user/session.rs | 29 +
30 files changed, 1759 insertions(+), 17 deletions(-)
create mode 100644 crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json
create mode 100644 crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json
create mode 100644 crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json
create mode 100644 crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json
create mode 100644 crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json
create mode 100644 crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json
create mode 100644 crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json
create mode 100644 crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json
create mode 100644 crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json
create mode 100644 crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json
create mode 100644 crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json
create mode 100644 crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json
create mode 100644 crates/storage-pg/migrations/20250303142622_passkeys.sql
create mode 100644 crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql
create mode 100644 crates/storage-pg/src/user/passkey.rs
create mode 100644 crates/storage/src/user/passkey.rs
diff --git a/Cargo.lock b/Cargo.lock
index b973e1cc7..718a502fe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1439,6 +1439,32 @@ dependencies = [
"cipher",
]
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "darling"
version = "0.20.11"
@@ -1488,6 +1514,12 @@ dependencies = [
"parking_lot_core",
]
+[[package]]
+name = "data-encoding"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
+
[[package]]
name = "deadpool"
version = "0.10.0"
@@ -1656,6 +1688,27 @@ dependencies = [
"spki",
]
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "sha2",
+ "subtle",
+]
+
[[package]]
name = "either"
version = "1.15.0"
@@ -1816,6 +1869,12 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "figment"
version = "0.10.19"
@@ -3292,6 +3351,7 @@ dependencies = [
"thiserror 2.0.16",
"ulid",
"url",
+ "webauthn_rp",
"woothee",
]
@@ -3670,6 +3730,7 @@ dependencies = [
"tracing-opentelemetry",
"ulid",
"url",
+ "webauthn_rp",
]
[[package]]
@@ -3697,6 +3758,7 @@ dependencies = [
"ulid",
"url",
"uuid",
+ "webauthn_rp",
]
[[package]]
@@ -4625,6 +4687,40 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "precis-core"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c2e7b31f132e0c6f8682cfb7bf4a5340dbe925b7986618d0826a56dfe0c8e56"
+dependencies = [
+ "precis-tools",
+ "ucd-parse",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "precis-profiles"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc4f67f78f50388f03494794766ba824a704db16fb5d400fe8d545fa7bc0d3f1"
+dependencies = [
+ "lazy_static",
+ "precis-core",
+ "precis-tools",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "precis-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cc1eb2d5887ac7bfd2c0b745764db89edb84b856e4214e204ef48ef96d10c4a"
+dependencies = [
+ "lazy_static",
+ "regex",
+ "ucd-parse",
+]
+
[[package]]
name = "prettyplease"
version = "0.2.36"
@@ -4988,6 +5084,12 @@ dependencies = [
"regex-syntax 0.8.5",
]
+[[package]]
+name = "regex-lite"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
+
[[package]]
name = "regex-syntax"
version = "0.6.29"
@@ -5079,6 +5181,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
+ "sha2",
"signature",
"spki",
"subtle",
@@ -6697,6 +6800,15 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
+[[package]]
+name = "ucd-parse"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06ff81122fcbf4df4c1660b15f7e3336058e7aec14437c9f85c6b31a0f279b9"
+dependencies = [
+ "regex-lite",
+]
+
[[package]]
name = "ucd-trie"
version = "0.1.7"
@@ -7295,6 +7407,24 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webauthn_rp"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3a3b672b5e6ffc799106fb40c86d5787e331d5491452ffc3eb616b418e04e85"
+dependencies = [
+ "data-encoding",
+ "ed25519-dalek",
+ "p256",
+ "p384",
+ "precis-profiles",
+ "rand 0.9.2",
+ "rsa",
+ "serde",
+ "serde_json",
+ "url",
+]
+
[[package]]
name = "webpki-root-certs"
version = "1.0.2"
diff --git a/Cargo.toml b/Cargo.toml
index 1c90e5707..e618f640f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -727,6 +727,10 @@ features = ["rustc"]
[workspace.dependencies.walkdir]
version = "2.5.0"
+[workspace.dependencies.webauthn_rp]
+version = "0.3.0"
+features = ["bin", "serde_relaxed", "custom", "serializable_server_state"]
+
# HTTP mock server
[workspace.dependencies.wiremock]
version = "0.6.4"
diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml
index da7021b11..3464c308a 100644
--- a/crates/data-model/Cargo.toml
+++ b/crates/data-model/Cargo.toml
@@ -31,6 +31,7 @@ regex.workspace = true
woothee.workspace = true
ruma-common.workspace = true
lettre.workspace = true
+webauthn_rp.workspace = true
mas-iana.workspace = true
mas-jose.workspace = true
diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs
index 6be06b4d9..9c00230d4 100644
--- a/crates/data-model/src/lib.rs
+++ b/crates/data-model/src/lib.rs
@@ -53,8 +53,9 @@ pub use self::{
user_agent::{DeviceType, UserAgent},
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
- UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
- UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken,
+ UserEmailAuthentication, UserEmailAuthenticationCode, UserPasskey, UserPasskeyChallenge,
+ UserRecoverySession, UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
+ UserRegistrationToken,
},
utils::{BoxClock, BoxRng},
};
diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs
index 920726ef8..c6ccefa7a 100644
--- a/crates/data-model/src/users.rs
+++ b/crates/data-model/src/users.rs
@@ -11,6 +11,7 @@ use rand::Rng;
use serde::Serialize;
use ulid::Ulid;
use url::Url;
+use webauthn_rp::response::{AuthTransports, CredentialId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct User {
@@ -67,6 +68,7 @@ pub struct Authentication {
pub enum AuthenticationMethod {
Password { user_password_id: Ulid },
UpstreamOAuth2 { upstream_oauth2_session_id: Ulid },
+ Passkey { user_passkey_id: Ulid },
Unknown,
}
@@ -262,3 +264,26 @@ pub struct UserRegistration {
pub created_at: DateTime,
pub completed_at: Option>,
}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct UserPasskey {
+ pub id: Ulid,
+ pub user_id: Ulid,
+ pub credential_id: CredentialId>,
+ pub name: String,
+ pub transports: AuthTransports,
+ pub static_state: Vec,
+ pub dynamic_state: Vec,
+ pub metadata: Vec,
+ pub last_used_at: Option>,
+ pub created_at: DateTime,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct UserPasskeyChallenge {
+ pub id: Ulid,
+ pub user_session_id: Option,
+ pub state: Vec,
+ pub created_at: DateTime,
+ pub completed_at: Option>,
+}
diff --git a/crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json b/crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json
new file mode 100644
index 000000000..1f3390208
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json
@@ -0,0 +1,46 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT user_passkey_challenge_id\n , user_session_id\n , state\n , created_at\n , completed_at\n FROM user_passkey_challenges\n WHERE user_passkey_challenge_id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_passkey_challenge_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_session_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 3,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "completed_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d"
+}
diff --git a/crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json b/crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json
new file mode 100644
index 000000000..096dafc48
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO user_passkey_challenges\n ( user_passkey_challenge_id\n , user_session_id\n , state\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Uuid",
+ "Bytea",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5"
+}
diff --git a/crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json b/crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json
new file mode 100644
index 000000000..90e65187f
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO user_passkey_challenges\n ( user_passkey_challenge_id\n , state\n , created_at\n )\n VALUES ($1, $2, $3)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Bytea",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739"
+}
diff --git a/crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json b/crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json
new file mode 100644
index 000000000..014e1235c
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json
@@ -0,0 +1,76 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT user_passkey_id\n , user_id\n , credential_id\n , name\n , transports\n , static_state\n , dynamic_state\n , metadata\n , last_used_at\n , created_at\n FROM user_passkeys\n\n WHERE user_id = $1\n\n ORDER BY created_at ASC\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_passkey_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "credential_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "transports",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 5,
+ "name": "static_state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 6,
+ "name": "dynamic_state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 7,
+ "name": "metadata",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 8,
+ "name": "last_used_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5"
+}
diff --git a/crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json b/crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json
new file mode 100644
index 000000000..f47e262c6
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO user_passkeys (user_passkey_id, user_id, credential_id, name, transports, static_state, dynamic_state, metadata, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Uuid",
+ "Text",
+ "Text",
+ "Jsonb",
+ "Bytea",
+ "Bytea",
+ "Bytea",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965"
+}
diff --git a/crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json b/crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json
new file mode 100644
index 000000000..42b3c684f
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json
@@ -0,0 +1,76 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT user_passkey_id\n , user_id\n , credential_id\n , name\n , transports\n , static_state\n , dynamic_state\n , metadata\n , last_used_at\n , created_at\n FROM user_passkeys\n\n WHERE user_passkey_id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_passkey_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "credential_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "transports",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 5,
+ "name": "static_state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 6,
+ "name": "dynamic_state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 7,
+ "name": "metadata",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 8,
+ "name": "last_used_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3"
+}
diff --git a/crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json b/crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json
new file mode 100644
index 000000000..0e5cf52b3
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM user_passkeys\n WHERE user_passkey_id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e"
+}
diff --git a/crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json b/crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json
new file mode 100644
index 000000000..09d0c404c
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE user_passkey_challenges\n SET completed_at = $2\n WHERE user_passkey_challenge_id = $1\n AND completed_at IS NULL\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe"
+}
diff --git a/crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json b/crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json
new file mode 100644
index 000000000..98527fd45
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE user_passkeys\n SET last_used_at = $2, dynamic_state = $3\n WHERE user_passkey_id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Timestamptz",
+ "Bytea"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be"
+}
diff --git a/crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json b/crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json
new file mode 100644
index 000000000..8fc36f528
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json
@@ -0,0 +1,76 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT user_passkey_id\n , user_id\n , credential_id\n , name\n , transports\n , static_state\n , dynamic_state\n , metadata\n , last_used_at\n , created_at\n FROM user_passkeys\n\n WHERE credential_id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_passkey_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "credential_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "transports",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 5,
+ "name": "static_state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 6,
+ "name": "dynamic_state",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 7,
+ "name": "metadata",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 8,
+ "name": "last_used_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac"
+}
diff --git a/crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json b/crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json
new file mode 100644
index 000000000..2ae52acf3
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE user_passkeys\n SET name = $2\n WHERE user_passkey_id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911"
+}
diff --git a/crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json b/crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json
new file mode 100644
index 000000000..749ff96e0
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO user_session_authentications\n (user_session_authentication_id, user_session_id, created_at, user_passkey_id)\n VALUES ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Uuid",
+ "Timestamptz",
+ "Uuid"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a"
+}
diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml
index 149e92fc6..c6a6c287f 100644
--- a/crates/storage-pg/Cargo.toml
+++ b/crates/storage-pg/Cargo.toml
@@ -33,6 +33,7 @@ tracing.workspace = true
ulid.workspace = true
url.workspace = true
uuid.workspace = true
+webauthn_rp.workspace = true
oauth2-types.workspace = true
mas-storage.workspace = true
diff --git a/crates/storage-pg/migrations/20250303142622_passkeys.sql b/crates/storage-pg/migrations/20250303142622_passkeys.sql
new file mode 100644
index 000000000..4095af5fa
--- /dev/null
+++ b/crates/storage-pg/migrations/20250303142622_passkeys.sql
@@ -0,0 +1,63 @@
+-- Copyright 2025 New Vector Ltd.
+--
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Please see LICENSE in the repository root for full details.
+
+CREATE TABLE "user_passkeys" (
+ "user_passkey_id" UUID NOT NULL
+ CONSTRAINT "user_passkey_id_pkey"
+ PRIMARY KEY,
+
+ "user_id" UUID NOT NULL
+ CONSTRAINT "user_passkeys_user_id_fkey"
+ REFERENCES "users" ("user_id")
+ ON DELETE CASCADE,
+
+ "credential_id" TEXT NOT NULL
+ CONSTRAINT "user_passkeys_credential_id_unique"
+ UNIQUE,
+
+ "name" TEXT NOT NULL,
+
+ "transports" JSONB NOT NULL,
+
+ "static_state" BYTEA NOT NULL,
+
+ "dynamic_state" BYTEA NOT NULL,
+
+ "metadata" BYTEA NOT NULL,
+
+ "last_used_at" TIMESTAMP WITH TIME ZONE,
+
+ "created_at" TIMESTAMP WITH TIME ZONE NOT NULL
+);
+
+CREATE INDEX
+ user_passkeys_user_id_fk
+ ON user_passkeys (user_id);
+
+CREATE TABLE "user_passkey_challenges" (
+ "user_passkey_challenge_id" UUID NOT NULL
+ CONSTRAINT "user_passkey_challenge_id_pkey"
+ PRIMARY KEY,
+
+ "user_session_id" UUID
+ REFERENCES "user_sessions" ("user_session_id")
+ ON DELETE SET NULL,
+
+ "state" BYTEA NOT NULL,
+
+ "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ "completed_at" TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX
+ user_passkey_challenges_user_session_id_fk
+ ON user_passkey_challenges (user_session_id);
+
+ALTER TABLE "user_session_authentications"
+ ADD COLUMN "user_passkey_id" UUID
+ REFERENCES "user_passkeys" ("user_passkey_id")
+ ON DELETE SET NULL;
+
diff --git a/crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql b/crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql
new file mode 100644
index 000000000..b1285c80b
--- /dev/null
+++ b/crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql
@@ -0,0 +1,9 @@
+-- no-transaction
+-- Copyright 2025 New Vector Ltd.
+--
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- Please see LICENSE in the repository root for full details.
+
+CREATE INDEX CONCURRENTLY
+ user_session_authentications_user_passkey_id_fk
+ ON user_session_authentications (user_passkey_id);
diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs
index e6c03acc4..a79b9b44b 100644
--- a/crates/storage-pg/src/iden.rs
+++ b/crates/storage-pg/src/iden.rs
@@ -188,3 +188,18 @@ pub enum UserRegistrationTokens {
ExpiresAt,
RevokedAt,
}
+
+#[derive(sea_query::Iden)]
+pub enum UserPasskeys {
+ Table,
+ UserPasskeyId,
+ UserId,
+ CredentialId,
+ Name,
+ Transports,
+ StaticState,
+ DynamicState,
+ Metadata,
+ LastUsedAt,
+ CreatedAt,
+}
diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs
index 7911cd2b6..4d9e11065 100644
--- a/crates/storage-pg/src/repository.rs
+++ b/crates/storage-pg/src/repository.rs
@@ -27,9 +27,9 @@ use mas_storage::{
UpstreamOAuthSessionRepository,
},
user::{
- BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
- UserRecoveryRepository, UserRegistrationRepository, UserRegistrationTokenRepository,
- UserRepository, UserTermsRepository,
+ BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository,
+ UserPasswordRepository, UserRecoveryRepository, UserRegistrationRepository,
+ UserRegistrationTokenRepository, UserRepository, UserTermsRepository,
},
};
use sqlx::{PgConnection, PgPool, Postgres, Transaction};
@@ -58,9 +58,9 @@ use crate::{
PgUpstreamOAuthSessionRepository,
},
user::{
- PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository,
- PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRegistrationTokenRepository,
- PgUserRepository, PgUserTermsRepository,
+ PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasskeyRepository,
+ PgUserPasswordRepository, PgUserRecoveryRepository, PgUserRegistrationRepository,
+ PgUserRegistrationTokenRepository, PgUserRepository, PgUserTermsRepository,
},
};
@@ -228,6 +228,10 @@ where
Box::new(PgUserEmailRepository::new(self.conn.as_mut()))
}
+ fn user_passkey<'c>(&'c mut self) -> Box + 'c> {
+ Box::new(PgUserPasskeyRepository::new(self.conn.as_mut()))
+ }
+
fn user_password<'c>(
&'c mut self,
) -> Box + 'c> {
diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs
index 8af61b61c..6951b1b7b 100644
--- a/crates/storage-pg/src/user/mod.rs
+++ b/crates/storage-pg/src/user/mod.rs
@@ -26,6 +26,7 @@ use crate::{
};
mod email;
+mod passkey;
mod password;
mod recovery;
mod registration;
@@ -37,8 +38,9 @@ mod terms;
mod tests;
pub use self::{
- email::PgUserEmailRepository, password::PgUserPasswordRepository,
- recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository,
+ email::PgUserEmailRepository, passkey::PgUserPasskeyRepository,
+ password::PgUserPasswordRepository, recovery::PgUserRecoveryRepository,
+ registration::PgUserRegistrationRepository,
registration_token::PgUserRegistrationTokenRepository, session::PgBrowserSessionRepository,
terms::PgUserTermsRepository,
};
diff --git a/crates/storage-pg/src/user/passkey.rs b/crates/storage-pg/src/user/passkey.rs
new file mode 100644
index 000000000..e4def890c
--- /dev/null
+++ b/crates/storage-pg/src/user/passkey.rs
@@ -0,0 +1,657 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge};
+use mas_storage::{
+ Page, Pagination,
+ user::{UserPasskeyFilter, UserPasskeyRepository},
+};
+use rand::RngCore;
+use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def};
+use sea_query_binder::SqlxBinder;
+use sqlx::PgConnection;
+use ulid::Ulid;
+use uuid::Uuid;
+use webauthn_rp::response::{AuthTransports, CredentialId};
+
+use crate::{
+ DatabaseError, DatabaseInconsistencyError,
+ filter::{Filter, StatementExt},
+ iden::UserPasskeys,
+ pagination::QueryBuilderExt,
+ tracing::ExecuteExt,
+};
+
+/// An implementation of [`UserPasskeyRepository`] for a PostgreSQL connection
+pub struct PgUserPasskeyRepository<'c> {
+ conn: &'c mut PgConnection,
+}
+
+impl<'c> PgUserPasskeyRepository<'c> {
+ /// Create a new [`PgUserPasskeyRepository`] from an active PostgreSQL
+ /// connection
+ pub fn new(conn: &'c mut PgConnection) -> Self {
+ Self { conn }
+ }
+}
+
+#[derive(Debug, Clone, sqlx::FromRow)]
+#[enum_def]
+struct UserPasskeyLookup {
+ user_passkey_id: Uuid,
+ user_id: Uuid,
+ credential_id: String,
+ name: String,
+ transports: serde_json::Value,
+ static_state: Vec,
+ dynamic_state: Vec,
+ metadata: Vec,
+ last_used_at: Option>,
+ created_at: DateTime,
+}
+
+impl TryFrom for UserPasskey {
+ type Error = DatabaseInconsistencyError;
+
+ fn try_from(value: UserPasskeyLookup) -> Result {
+ Ok(UserPasskey {
+ id: value.user_passkey_id.into(),
+ user_id: value.user_id.into(),
+ credential_id: serde_json::from_str(&value.credential_id).map_err(|e| {
+ DatabaseInconsistencyError::on("user_passkeys")
+ .column("credential_id")
+ .row(value.user_passkey_id.into())
+ .source(e)
+ })?,
+ name: value.name,
+ transports: serde_json::from_value(value.transports).map_err(|e| {
+ DatabaseInconsistencyError::on("user_passkeys")
+ .column("transports")
+ .row(value.user_passkey_id.into())
+ .source(e)
+ })?,
+ static_state: value.static_state,
+ dynamic_state: value.dynamic_state,
+ metadata: value.metadata,
+ last_used_at: value.last_used_at,
+ created_at: value.created_at,
+ })
+ }
+}
+
+struct UserPasskeyChallengeLookup {
+ user_passkey_challenge_id: Uuid,
+ user_session_id: Option,
+ state: Vec,
+ created_at: DateTime,
+ completed_at: Option>,
+}
+
+impl From for UserPasskeyChallenge {
+ fn from(value: UserPasskeyChallengeLookup) -> Self {
+ UserPasskeyChallenge {
+ id: value.user_passkey_challenge_id.into(),
+ user_session_id: value.user_session_id.map(Ulid::from),
+ state: value.state,
+ created_at: value.created_at,
+ completed_at: value.completed_at,
+ }
+ }
+}
+
+impl Filter for UserPasskeyFilter<'_> {
+ fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
+ sea_query::Condition::all().add_option(self.user().map(|user| {
+ Expr::col((UserPasskeys::Table, UserPasskeys::UserId)).eq(Uuid::from(user.id))
+ }))
+ }
+}
+
+#[async_trait]
+impl UserPasskeyRepository for PgUserPasskeyRepository<'_> {
+ type Error = DatabaseError;
+
+ #[tracing::instrument(
+ name = "db.user_passkey.lookup",
+ skip_all,
+ fields(
+ db.query.text,
+ user_passkey.id = %id,
+ ),
+ err,
+ )]
+ async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> {
+ let res = sqlx::query_as!(
+ UserPasskeyLookup,
+ r#"
+ SELECT user_passkey_id
+ , user_id
+ , credential_id
+ , name
+ , transports
+ , static_state
+ , dynamic_state
+ , metadata
+ , last_used_at
+ , created_at
+ FROM user_passkeys
+
+ WHERE user_passkey_id = $1
+ "#,
+ Uuid::from(id),
+ )
+ .traced()
+ .fetch_optional(&mut *self.conn)
+ .await?;
+
+ let Some(user_passkey) = res else {
+ return Ok(None);
+ };
+
+ Ok(Some(user_passkey.try_into()?))
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.find",
+ skip_all,
+ fields(
+ db.query.text,
+ ),
+ err,
+ )]
+ async fn find(
+ &mut self,
+ credential_id: &CredentialId>,
+ ) -> Result, Self::Error> {
+ let res = sqlx::query_as!(
+ UserPasskeyLookup,
+ r#"
+ SELECT user_passkey_id
+ , user_id
+ , credential_id
+ , name
+ , transports
+ , static_state
+ , dynamic_state
+ , metadata
+ , last_used_at
+ , created_at
+ FROM user_passkeys
+
+ WHERE credential_id = $1
+ "#,
+ serde_json::to_string(&credential_id).map_err(DatabaseError::to_invalid_operation)?,
+ )
+ .traced()
+ .fetch_optional(&mut *self.conn)
+ .await?;
+
+ let Some(user_passkey) = res else {
+ return Ok(None);
+ };
+
+ Ok(Some(user_passkey.try_into()?))
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.all",
+ skip_all,
+ fields(
+ db.query.text,
+ %user.id,
+ ),
+ err,
+ )]
+ async fn all(&mut self, user: &User) -> Result, Self::Error> {
+ let res = sqlx::query_as!(
+ UserPasskeyLookup,
+ r#"
+ SELECT user_passkey_id
+ , user_id
+ , credential_id
+ , name
+ , transports
+ , static_state
+ , dynamic_state
+ , metadata
+ , last_used_at
+ , created_at
+ FROM user_passkeys
+
+ WHERE user_id = $1
+
+ ORDER BY created_at ASC
+ "#,
+ Uuid::from(user.id),
+ )
+ .traced()
+ .fetch_all(&mut *self.conn)
+ .await?;
+
+ Ok(res
+ .into_iter()
+ .map(TryInto::try_into)
+ .collect::>()?)
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.list",
+ skip_all,
+ fields(
+ db.query.text,
+ ),
+ err,
+ )]
+ async fn list(
+ &mut self,
+ filter: UserPasskeyFilter<'_>,
+ pagination: Pagination,
+ ) -> Result, DatabaseError> {
+ let (sql, arguments) = Query::select()
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::UserPasskeyId)),
+ UserPasskeyLookupIden::UserPasskeyId,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::UserId)),
+ UserPasskeyLookupIden::UserId,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::CredentialId)),
+ UserPasskeyLookupIden::CredentialId,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::Name)),
+ UserPasskeyLookupIden::Name,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::Transports)),
+ UserPasskeyLookupIden::Transports,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::StaticState)),
+ UserPasskeyLookupIden::StaticState,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::DynamicState)),
+ UserPasskeyLookupIden::DynamicState,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::Metadata)),
+ UserPasskeyLookupIden::Metadata,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::LastUsedAt)),
+ UserPasskeyLookupIden::LastUsedAt,
+ )
+ .expr_as(
+ Expr::col((UserPasskeys::Table, UserPasskeys::CreatedAt)),
+ UserPasskeyLookupIden::CreatedAt,
+ )
+ .from(UserPasskeys::Table)
+ .apply_filter(filter)
+ .generate_pagination(
+ (UserPasskeys::Table, UserPasskeys::UserPasskeyId),
+ pagination,
+ )
+ .build_sqlx(PostgresQueryBuilder);
+
+ let edges: Vec = sqlx::query_as_with(&sql, arguments)
+ .traced()
+ .fetch_all(&mut *self.conn)
+ .await?;
+
+ let page = pagination.process(edges).try_map(TryFrom::try_from)?;
+
+ Ok(page)
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.count",
+ skip_all,
+ fields(
+ db.query.text,
+ ),
+ err,
+ )]
+ async fn count(&mut self, filter: UserPasskeyFilter<'_>) -> Result {
+ let (sql, arguments) = Query::select()
+ .expr(Expr::col((UserPasskeys::Table, UserPasskeys::UserPasskeyId)).count())
+ .from(UserPasskeys::Table)
+ .apply_filter(filter)
+ .build_sqlx(PostgresQueryBuilder);
+
+ let count: i64 = sqlx::query_scalar_with(&sql, arguments)
+ .traced()
+ .fetch_one(&mut *self.conn)
+ .await?;
+
+ count
+ .try_into()
+ .map_err(DatabaseError::to_invalid_operation)
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.add",
+ skip_all,
+ fields(
+ db.query.text,
+ %user.id,
+ user_passkey.id,
+ ),
+ err,
+ )]
+ async fn add(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ user: &User,
+ name: String,
+ credential_id: CredentialId>,
+ transports: AuthTransports,
+ static_state: Vec,
+ dynamic_state: Vec,
+ metadata: Vec,
+ ) -> Result {
+ let created_at = clock.now();
+ let id = Ulid::from_datetime_with_source(created_at.into(), rng);
+ tracing::Span::current().record("user_passkey.id", tracing::field::display(id));
+
+ sqlx::query!(
+ r#"
+ INSERT INTO user_passkeys (user_passkey_id, user_id, credential_id, name, transports, static_state, dynamic_state, metadata, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ "#,
+ Uuid::from(id),
+ Uuid::from(user.id),
+ serde_json::to_string(&credential_id).map_err(DatabaseError::to_invalid_operation)?,
+ &name,
+ serde_json::to_value(transports).map_err(DatabaseError::to_invalid_operation)?,
+ static_state,
+ dynamic_state,
+ metadata,
+ created_at,
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ Ok(UserPasskey {
+ id,
+ user_id: user.id,
+ credential_id,
+ name,
+ transports,
+ static_state,
+ dynamic_state,
+ metadata,
+ last_used_at: None,
+ created_at,
+ })
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.rename",
+ skip_all,
+ fields(
+ db.query.text,
+ %user_passkey.id,
+ ),
+ err,
+ )]
+ async fn rename(
+ &mut self,
+ mut user_passkey: UserPasskey,
+ name: String,
+ ) -> Result {
+ let res = sqlx::query!(
+ r#"
+ UPDATE user_passkeys
+ SET name = $2
+ WHERE user_passkey_id = $1
+ "#,
+ Uuid::from(user_passkey.id),
+ name,
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ DatabaseError::ensure_affected_rows(&res, 1)?;
+
+ user_passkey.name = name;
+ Ok(user_passkey)
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.update",
+ skip_all,
+ fields(
+ db.query.text,
+ %user_passkey.id,
+ ),
+ err,
+ )]
+ async fn update(
+ &mut self,
+ clock: &dyn Clock,
+ mut user_passkey: UserPasskey,
+ dynamic_state: Vec,
+ ) -> Result {
+ let last_used_at = clock.now();
+
+ let res = sqlx::query!(
+ r#"
+ UPDATE user_passkeys
+ SET last_used_at = $2, dynamic_state = $3
+ WHERE user_passkey_id = $1
+ "#,
+ Uuid::from(user_passkey.id),
+ last_used_at,
+ dynamic_state
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ DatabaseError::ensure_affected_rows(&res, 1)?;
+
+ user_passkey.last_used_at = Some(last_used_at);
+ user_passkey.dynamic_state = dynamic_state;
+ Ok(user_passkey)
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.remove",
+ skip_all,
+ fields(
+ db.query.text,
+ user.id = %user_passkey.user_id,
+ %user_passkey.id,
+ ),
+ err,
+ )]
+ async fn remove(&mut self, user_passkey: UserPasskey) -> Result<(), Self::Error> {
+ let res = sqlx::query!(
+ r#"
+ DELETE FROM user_passkeys
+ WHERE user_passkey_id = $1
+ "#,
+ Uuid::from(user_passkey.id),
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ DatabaseError::ensure_affected_rows(&res, 1)?;
+
+ Ok(())
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.add_challenge_for_session",
+ skip_all,
+ fields(
+ db.query.text,
+ %session.id,
+ user_passkey_challenge.id,
+ ),
+ err,
+ )]
+ async fn add_challenge_for_session(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ state: Vec,
+ session: &BrowserSession,
+ ) -> Result {
+ let created_at = clock.now();
+ let id = Ulid::from_datetime_with_source(created_at.into(), rng);
+ tracing::Span::current().record("user_passkey_challenge.id", tracing::field::display(id));
+
+ sqlx::query!(
+ r#"
+ INSERT INTO user_passkey_challenges
+ ( user_passkey_challenge_id
+ , user_session_id
+ , state
+ , created_at
+ )
+ VALUES ($1, $2, $3, $4)
+ "#,
+ Uuid::from(id),
+ Uuid::from(session.id),
+ state,
+ created_at,
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ Ok(UserPasskeyChallenge {
+ id,
+ user_session_id: Some(session.id),
+ state,
+ created_at,
+ completed_at: None,
+ })
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.add_challenge",
+ skip_all,
+ fields(
+ db.query.text,
+ user_passkey_challenge.id,
+ ),
+ err,
+ )]
+ async fn add_challenge(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ state: Vec,
+ ) -> Result {
+ let created_at = clock.now();
+ let id = Ulid::from_datetime_with_source(created_at.into(), rng);
+ tracing::Span::current().record("user_passkey_challenge.id", tracing::field::display(id));
+
+ sqlx::query!(
+ r#"
+ INSERT INTO user_passkey_challenges
+ ( user_passkey_challenge_id
+ , state
+ , created_at
+ )
+ VALUES ($1, $2, $3)
+ "#,
+ Uuid::from(id),
+ state,
+ created_at,
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ Ok(UserPasskeyChallenge {
+ id,
+ user_session_id: None,
+ state,
+ created_at,
+ completed_at: None,
+ })
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.lookup_challenge",
+ skip_all,
+ fields(
+ db.query.text,
+ user_passkey_challenge.id = %id,
+ ),
+ err,
+ )]
+ async fn lookup_challenge(
+ &mut self,
+ id: Ulid,
+ ) -> Result, Self::Error> {
+ let res = sqlx::query_as!(
+ UserPasskeyChallengeLookup,
+ r#"
+ SELECT user_passkey_challenge_id
+ , user_session_id
+ , state
+ , created_at
+ , completed_at
+ FROM user_passkey_challenges
+ WHERE user_passkey_challenge_id = $1
+ "#,
+ Uuid::from(id),
+ )
+ .traced()
+ .fetch_optional(&mut *self.conn)
+ .await?;
+
+ Ok(res.map(UserPasskeyChallenge::from))
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.complete_challenge",
+ skip_all,
+ fields(
+ db.query.text,
+ %user_passkey_challenge.id,
+ ),
+ err,
+ )]
+ async fn complete_challenge(
+ &mut self,
+ clock: &dyn Clock,
+ mut user_passkey_challenge: UserPasskeyChallenge,
+ ) -> Result {
+ let completed_at = clock.now();
+
+ let res = sqlx::query!(
+ r#"
+ UPDATE user_passkey_challenges
+ SET completed_at = $2
+ WHERE user_passkey_challenge_id = $1
+ AND completed_at IS NULL
+ "#,
+ Uuid::from(user_passkey_challenge.id),
+ completed_at,
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ DatabaseError::ensure_affected_rows(&res, 1)?;
+
+ user_passkey_challenge.completed_at = Some(completed_at);
+ Ok(user_passkey_challenge)
+ }
+}
diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs
index a8c5ab458..b42b0e909 100644
--- a/crates/storage-pg/src/user/session.rs
+++ b/crates/storage-pg/src/user/session.rs
@@ -10,7 +10,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
Authentication, AuthenticationMethod, BrowserSession, Clock, Password,
- UpstreamOAuthAuthorizationSession, User,
+ UpstreamOAuthAuthorizationSession, User, UserPasskey,
};
use mas_storage::{
Page, Pagination,
@@ -539,6 +539,55 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> {
})
}
+ #[tracing::instrument(
+ name = "db.browser_session.authenticate_with_passkey",
+ skip_all,
+ fields(
+ db.query.text,
+ %user_session.id,
+ %user_passkey.id,
+ user_session_authentication.id,
+ ),
+ err,
+ )]
+ async fn authenticate_with_passkey(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ user_session: &BrowserSession,
+ user_passkey: &UserPasskey,
+ ) -> Result {
+ let created_at = clock.now();
+ let id = Ulid::from_datetime_with_source(created_at.into(), rng);
+ tracing::Span::current().record(
+ "user_session_authentication.id",
+ tracing::field::display(id),
+ );
+
+ sqlx::query!(
+ r#"
+ INSERT INTO user_session_authentications
+ (user_session_authentication_id, user_session_id, created_at, user_passkey_id)
+ VALUES ($1, $2, $3, $4)
+ "#,
+ Uuid::from(id),
+ Uuid::from(user_session.id),
+ created_at,
+ Uuid::from(user_passkey.id),
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ Ok(Authentication {
+ id,
+ created_at,
+ authentication_method: AuthenticationMethod::Passkey {
+ user_passkey_id: user_passkey.id,
+ },
+ })
+ }
+
#[tracing::instrument(
name = "db.browser_session.get_last_authentication",
skip_all,
diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml
index 07f4330c6..d636806c4 100644
--- a/crates/storage/Cargo.toml
+++ b/crates/storage/Cargo.toml
@@ -29,6 +29,7 @@ tracing-opentelemetry.workspace = true
tracing.workspace = true
ulid.workspace = true
url.workspace = true
+webauthn_rp.workspace = true
oauth2-types.workspace = true
mas-data-model.workspace = true
diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs
index 518769eb1..3c329a77a 100644
--- a/crates/storage/src/repository.rs
+++ b/crates/storage/src/repository.rs
@@ -25,9 +25,9 @@ use crate::{
UpstreamOAuthSessionRepository,
},
user::{
- BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
- UserRecoveryRepository, UserRegistrationRepository, UserRegistrationTokenRepository,
- UserRepository, UserTermsRepository,
+ BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository,
+ UserPasswordRepository, UserRecoveryRepository, UserRegistrationRepository,
+ UserRegistrationTokenRepository, UserRepository, UserTermsRepository,
},
};
@@ -136,6 +136,9 @@ pub trait RepositoryAccess: Send {
/// Get an [`UserEmailRepository`]
fn user_email<'c>(&'c mut self) -> Box + 'c>;
+ /// Get an [`UserPasskeyRepository`]
+ fn user_passkey<'c>(&'c mut self) -> Box + 'c>;
+
/// Get an [`UserPasswordRepository`]
fn user_password<'c>(&'c mut self)
-> Box + 'c>;
@@ -254,9 +257,9 @@ mod impls {
UpstreamOAuthSessionRepository,
},
user::{
- BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
- UserRegistrationRepository, UserRegistrationTokenRepository, UserRepository,
- UserTermsRepository,
+ BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository,
+ UserPasswordRepository, UserRegistrationRepository, UserRegistrationTokenRepository,
+ UserRepository, UserTermsRepository,
},
};
@@ -334,6 +337,12 @@ mod impls {
Box::new(MapErr::new(self.inner.user_email(), &mut self.mapper))
}
+ fn user_passkey<'c>(
+ &'c mut self,
+ ) -> Box + 'c> {
+ Box::new(MapErr::new(self.inner.user_passkey(), &mut self.mapper))
+ }
+
fn user_password<'c>(
&'c mut self,
) -> Box + 'c> {
@@ -510,6 +519,12 @@ mod impls {
(**self).user_email()
}
+ fn user_passkey<'c>(
+ &'c mut self,
+ ) -> Box + 'c> {
+ (**self).user_passkey()
+ }
+
fn user_password<'c>(
&'c mut self,
) -> Box + 'c> {
diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs
index 0294d83b4..1d0077e42 100644
--- a/crates/storage/src/user/mod.rs
+++ b/crates/storage/src/user/mod.rs
@@ -14,6 +14,7 @@ use ulid::Ulid;
use crate::{Page, Pagination, repository_impl};
mod email;
+mod passkey;
mod password;
mod recovery;
mod registration;
@@ -23,6 +24,7 @@ mod terms;
pub use self::{
email::{UserEmailFilter, UserEmailRepository},
+ passkey::{UserPasskeyFilter, UserPasskeyRepository},
password::UserPasswordRepository,
recovery::UserRecoveryRepository,
registration::UserRegistrationRepository,
diff --git a/crates/storage/src/user/passkey.rs b/crates/storage/src/user/passkey.rs
new file mode 100644
index 000000000..40bef966d
--- /dev/null
+++ b/crates/storage/src/user/passkey.rs
@@ -0,0 +1,328 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+use async_trait::async_trait;
+use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge};
+use rand_core::RngCore;
+use ulid::Ulid;
+use webauthn_rp::response::{AuthTransports, CredentialId};
+
+use crate::{Page, Pagination, repository_impl};
+
+/// Filter parameters for listing user passkeys
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub struct UserPasskeyFilter<'a> {
+ user: Option<&'a User>,
+}
+
+impl<'a> UserPasskeyFilter<'a> {
+ /// Create a new [`UserPasskeyFilter`] with default values
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Filter for passkeys of a specific user
+ #[must_use]
+ pub fn for_user(mut self, user: &'a User) -> Self {
+ self.user = Some(user);
+ self
+ }
+
+ /// Get the user filter
+ ///
+ /// Returns [`None`] if no user filter is set
+ #[must_use]
+ pub fn user(&self) -> Option<&User> {
+ self.user
+ }
+}
+
+/// A [`UserPasskeyRepository`] helps interacting with [`UserPasskey`] saved in
+/// the storage backend
+#[allow(clippy::too_many_arguments)]
+#[async_trait]
+pub trait UserPasskeyRepository: Send + Sync {
+ /// The error type returned by the repository
+ type Error;
+
+ /// Lookup an [`UserPasskey`] by its ID
+ ///
+ /// Returns `None` if no [`UserPasskey`] was found
+ ///
+ /// # Parameters
+ ///
+ /// * `id`: The ID of the [`UserPasskey`] to lookup
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>;
+
+ /// Lookup an [`UserPasskey`] by its credential ID
+ ///
+ /// Returns `None` if no matching [`UserPasskey`] was found
+ ///
+ /// # Parameters
+ ///
+ /// * `credential_id`: The credential ID to lookup
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn find(
+ &mut self,
+ credential_id: &CredentialId>,
+ ) -> Result, Self::Error>;
+
+ /// Get all [`UserPasskey`] of a [`User`]
+ ///
+ /// # Parameters
+ ///
+ /// * `user`: The [`User`] for whom to lookup the [`UserPasskey`]
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn all(&mut self, user: &User) -> Result, Self::Error>;
+
+ /// List [`UserPasskey`] with the given filter and pagination
+ ///
+ /// # Parameters
+ ///
+ /// * `filter`: The filter parameters
+ /// * `pagination`: The pagination parameters
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn list(
+ &mut self,
+ filter: UserPasskeyFilter<'_>,
+ pagination: Pagination,
+ ) -> Result, Self::Error>;
+
+ /// Count the [`UserPasskey`] with the given filter
+ ///
+ /// # Parameters
+ ///
+ /// * `filter`: The filter parameters
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn count(&mut self, filter: UserPasskeyFilter<'_>) -> Result;
+
+ /// Create a new [`UserPasskey`] for a [`User`]
+ ///
+ /// Returns the newly created [`UserPasskey`]
+ ///
+ /// # Parameters
+ ///
+ /// * `rng`: The random number generator to use
+ /// * `clock`: The clock to use
+ /// * `user`: The [`User`] for whom to create the [`UserPasskey`]
+ /// * `name`: The name for the [`UserPasskey`]
+ /// * `data`: The passkey data of the [`UserPasskey`]
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn add(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ user: &User,
+ name: String,
+ credential_id: CredentialId>,
+ transports: AuthTransports,
+ static_state: Vec,
+ dynamic_state: Vec,
+ metadata: Vec,
+ ) -> Result;
+
+ /// Rename a [`UserPasskey`]
+ ///
+ /// Returns the modified [`UserPasskey`]
+ ///
+ /// # Parameters
+ ///
+ /// * `user_passkey`: The [`UserPasskey`] to rename
+ /// * `name`: The new name
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying repository fails
+ async fn rename(
+ &mut self,
+ user_passkey: UserPasskey,
+ name: String,
+ ) -> Result;
+
+ /// Update a [`UserPasskey`]
+ ///
+ /// Returns the modified [`UserPasskey`]
+ ///
+ /// # Parameters
+ ///
+ /// * `clock`: The clock to use
+ /// * `user_passkey`: The [`UserPasskey`] to update
+ /// * `data`: The new passkey data
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying repository fails
+ async fn update(
+ &mut self,
+ clock: &dyn Clock,
+ user_passkey: UserPasskey,
+ dynamic_state: Vec,
+ ) -> Result;
+
+ /// Delete a [`UserPasskey`]
+ ///
+ /// # Parameters
+ ///
+ /// * `user_passkey`: The [`UserPasskey`] to delete
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn remove(&mut self, user_passkey: UserPasskey) -> Result<(), Self::Error>;
+
+ /// Add a new [`UserPasskeyChallenge`] for a [`BrowserSession`]
+ ///
+ /// # Parameters
+ ///
+ /// * `rng`: The random number generator to use
+ /// * `clock`: The clock to use
+ /// * `state`: The challenge state to add
+ /// * `session`: The [`BrowserSession`] for which to add the
+ /// [`UserPasskeyChallenge`]
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying repository fails
+ async fn add_challenge_for_session(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ state: Vec,
+ session: &BrowserSession,
+ ) -> Result;
+
+ /// Add a new [`UserPasskeyChallenge`]
+ ///
+ /// # Parameters
+ ///
+ /// * `rng`: The random number generator to use
+ /// * `clock`: The clock to use
+ /// * `state`: The challenge state to add
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying repository fails
+ async fn add_challenge(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ state: Vec,
+ ) -> Result;
+
+ /// Lookup a [`UserPasskeyChallenge`]
+ ///
+ /// # Parameters
+ ///
+ /// * `id`: The ID of the [`UserPasskeyChallenge`] to lookup
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying repository fails
+ async fn lookup_challenge(
+ &mut self,
+ id: Ulid,
+ ) -> Result, Self::Error>;
+
+ /// Complete a [`UserPasskeyChallenge`] by using the given code
+ ///
+ /// Returns the completed [`UserPasskeyChallenge`]
+ ///
+ /// # Parameters
+ ///
+ /// * `clock`: The clock to use to generate timestamps
+ /// * `challenge`: The [`UserPasskeyChallenge`] to complete
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying repository fails
+ async fn complete_challenge(
+ &mut self,
+ clock: &dyn Clock,
+ user_passkey_challenge: UserPasskeyChallenge,
+ ) -> Result;
+}
+
+repository_impl!(UserPasskeyRepository:
+ async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>;
+ async fn find(&mut self, credential_id: &CredentialId>) -> Result, Self::Error>;
+ async fn all(&mut self, user: &User) -> Result, Self::Error>;
+
+ async fn list(
+ &mut self,
+ filter: UserPasskeyFilter<'_>,
+ pagination: Pagination,
+ ) -> Result, Self::Error>;
+ async fn count(&mut self, filter: UserPasskeyFilter<'_>) -> Result;
+
+ async fn add(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ user: &User,
+ name: String,
+ credential_id: CredentialId>,
+ transports: AuthTransports,
+ static_state: Vec,
+ dynamic_state: Vec,
+ metadata: Vec,
+ ) -> Result;
+ async fn rename(
+ &mut self,
+ user_passkey: UserPasskey,
+ name: String,
+ ) -> Result;
+ async fn update(
+ &mut self,
+ clock: &dyn Clock,
+ user_passkey: UserPasskey,
+ dynamic_state: Vec,
+ ) -> Result;
+ async fn remove(&mut self, user_passkey: UserPasskey) -> Result<(), Self::Error>;
+
+ async fn add_challenge_for_session(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ state: Vec,
+ session: &BrowserSession,
+ ) -> Result;
+ async fn add_challenge(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ state: Vec,
+ ) -> Result;
+
+ async fn lookup_challenge(
+ &mut self,
+ id: Ulid,
+ ) -> Result, Self::Error>;
+
+ async fn complete_challenge(
+ &mut self,
+ clock: &dyn Clock,
+ user_passkey_challenge: UserPasskeyChallenge,
+ ) -> Result;
+);
diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs
index 155556454..7bac9a520 100644
--- a/crates/storage/src/user/session.rs
+++ b/crates/storage/src/user/session.rs
@@ -10,6 +10,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
Authentication, BrowserSession, Clock, Password, UpstreamOAuthAuthorizationSession, User,
+ UserPasskey,
};
use rand_core::RngCore;
use ulid::Ulid;
@@ -279,6 +280,26 @@ pub trait BrowserSessionRepository: Send + Sync {
upstream_oauth_session: &UpstreamOAuthAuthorizationSession,
) -> Result;
+ /// Authenticate a [`BrowserSession`] with the given [`UserPasskey`]
+ ///
+ /// # Parameters
+ ///
+ /// * `rng`: The random number generator to use
+ /// * `clock`: The clock used to generate timestamps
+ /// * `user_session`: The session to authenticate
+ /// * `user_passkey`: The passkey which was used to authenticate
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn authenticate_with_passkey(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ user_session: &BrowserSession,
+ user_passkey: &UserPasskey,
+ ) -> Result;
+
/// Get the last successful authentication for a [`BrowserSession`]
///
/// # Params
@@ -354,6 +375,14 @@ repository_impl!(BrowserSessionRepository:
upstream_oauth_session: &UpstreamOAuthAuthorizationSession,
) -> Result;
+ async fn authenticate_with_passkey(
+ &mut self,
+ rng: &mut (dyn RngCore + Send),
+ clock: &dyn Clock,
+ user_session: &BrowserSession,
+ user_passkey: &UserPasskey,
+ ) -> Result;
+
async fn get_last_authentication(
&mut self,
user_session: &BrowserSession,
From 8f1c6de338ee66faf19f11f6da85b7bfa2c61a00 Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Wed, 5 Mar 2025 17:32:07 +0200
Subject: [PATCH 3/7] GraphQL API changes
---
Cargo.lock | 1 +
crates/cli/src/app_state.rs | 9 +-
crates/cli/src/commands/server.rs | 6 +-
crates/cli/src/util.rs | 16 +-
crates/config/src/sections/experimental.rs | 8 +
crates/config/src/sections/mod.rs | 2 +-
crates/handlers/Cargo.toml | 1 +
crates/handlers/src/graphql/mod.rs | 15 +-
crates/handlers/src/graphql/model/mod.rs | 4 +-
crates/handlers/src/graphql/model/node.rs | 6 +
crates/handlers/src/graphql/model/users.rs | 92 ++++-
crates/handlers/src/graphql/mutations/mod.rs | 2 +
.../src/graphql/mutations/user_passkey.rs | 383 ++++++++++++++++++
crates/handlers/src/graphql/query/mod.rs | 8 +-
crates/handlers/src/graphql/state.rs | 3 +-
crates/handlers/src/lib.rs | 1 +
crates/handlers/src/test_utils.rs | 9 +
crates/handlers/src/webauthn.rs | 268 ++++++++++++
docs/config.schema.json | 13 +
frontend/schema.graphql | 251 ++++++++++++
frontend/src/gql/graphql.ts | 161 ++++++++
21 files changed, 1246 insertions(+), 13 deletions(-)
create mode 100644 crates/handlers/src/graphql/mutations/user_passkey.rs
create mode 100644 crates/handlers/src/webauthn.rs
diff --git a/Cargo.lock b/Cargo.lock
index 718a502fe..2bf2dea3e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3441,6 +3441,7 @@ dependencies = [
"tracing-subscriber",
"ulid",
"url",
+ "webauthn_rp",
"wiremock",
"zeroize",
"zxcvbn",
diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs
index f9f761338..a5b1de4d0 100644
--- a/crates/cli/src/app_state.rs
+++ b/crates/cli/src/app_state.rs
@@ -12,7 +12,7 @@ use mas_context::LogContext;
use mas_data_model::{BoxClock, BoxRng, SiteConfig, SystemClock};
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};
@@ -47,6 +47,7 @@ pub struct AppState {
pub activity_tracker: ActivityTracker,
pub trusted_proxies: Vec,
pub limiter: Limiter,
+ pub webauthn: Webauthn,
}
impl AppState {
@@ -214,6 +215,12 @@ impl FromRef for Arc {
}
}
+impl FromRef for Webauthn {
+ fn from_ref(input: &AppState) -> Self {
+ input.webauthn.clone()
+ }
+}
+
impl FromRequestParts for BoxClock {
type Rejection = Infallible;
diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs
index 1367f131e..fb9a46ecb 100644
--- a/crates/cli/src/commands/server.rs
+++ b/crates/cli/src/commands/server.rs
@@ -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,
},
};
@@ -191,6 +191,8 @@ impl Options {
let password_manager = password_manager_from_config(&config.passwords).await?;
+ let webauthn = webauthn_from_config(&config.http, &config.experimental)?;
+
// The upstream OIDC metadata cache
let metadata_cache = MetadataCache::new();
@@ -226,6 +228,7 @@ impl Options {
password_manager.clone(),
url_builder.clone(),
limiter.clone(),
+ webauthn.clone(),
);
let state = {
@@ -246,6 +249,7 @@ impl Options {
activity_tracker,
trusted_proxies,
limiter,
+ webauthn,
};
s.init_metrics();
s.init_metadata_cache();
diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs
index e3f2166c1..8389cbb1b 100644
--- a/crates/cli/src/util.rs
+++ b/crates/cli/src/util.rs
@@ -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::{LegacySynapseConnection, SynapseConnection};
use mas_policy::PolicyFactory;
@@ -500,6 +500,16 @@ pub async fn homeserver_connection_from_config(
})
}
+pub fn webauthn_from_config(
+ http_config: &HttpConfig,
+ experimental_config: &ExperimentalConfig,
+) -> Result {
+ Webauthn::new(
+ &http_config.public_base,
+ experimental_config.passkeys.as_ref(),
+ )
+}
+
#[cfg(test)]
mod tests {
use rand::SeedableRng;
diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs
index 62ad599cc..ab83aeb05 100644
--- a/crates/config/src/sections/experimental.rs
+++ b/crates/config/src/sections/experimental.rs
@@ -52,6 +52,14 @@ pub struct PasskeysConfig {
/// Whether passkeys are enabled or not
#[serde(default)]
pub enabled: bool,
+ /// Relying Party Identifier to use
+ ///
+ /// If not set, the host from `public_base` is used
+ #[serde(default)]
+ pub rpid: Option,
+ /// Additional allowed origins. `rpid` and `public_base` are already allowed
+ #[serde(default)]
+ pub allowed_origins: Option>,
}
/// Configuration sections for experimental options
diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs
index f992d8698..3cc278e35 100644
--- a/crates/config/src/sections/mod.rs
+++ b/crates/config/src/sections/mod.rs
@@ -32,7 +32,7 @@ pub use self::{
clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig},
database::{DatabaseConfig, PgSslMode},
email::{EmailConfig, EmailSmtpMode, EmailTransportKind},
- experimental::ExperimentalConfig,
+ experimental::{ExperimentalConfig, PasskeysConfig},
http::{
BindConfig as HttpBindConfig, HttpConfig, ListenerConfig as HttpListenerConfig,
Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp,
diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml
index 57f391854..52928da9d 100644
--- a/crates/handlers/Cargo.toml
+++ b/crates/handlers/Cargo.toml
@@ -66,6 +66,7 @@ tower.workspace = true
tracing.workspace = true
ulid.workspace = true
url.workspace = true
+webauthn_rp.workspace = true
zeroize.workspace = true
mas-axum-utils.workspace = true
diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs
index 7ccf9e51b..e2a58f635 100644
--- a/crates/handlers/src/graphql/mod.rs
+++ b/crates/handlers/src/graphql/mod.rs
@@ -55,7 +55,7 @@ use self::{
};
use crate::{
BoundActivityTracker, Limiter, RequesterFingerprint, impl_from_error_for_route,
- passwords::PasswordManager,
+ passwords::PasswordManager, webauthn::Webauthn,
};
#[cfg(test)]
@@ -76,6 +76,7 @@ struct GraphQLState {
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
+ webauthn: Webauthn,
}
#[async_trait::async_trait]
@@ -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)
@@ -131,6 +136,7 @@ pub fn schema(
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
+ webauthn: Webauthn,
) -> Schema {
let state = GraphQLState {
repository_factory,
@@ -140,6 +146,7 @@ pub fn schema(
password_manager,
url_builder,
limiter,
+ webauthn,
};
let state: BoxState = Box::new(state);
@@ -518,6 +525,12 @@ impl OwnerId for mas_data_model::UpstreamOAuthLink {
}
}
+impl OwnerId for mas_data_model::UserPasskey {
+ fn owner_id(&self) -> Option {
+ Some(self.user_id)
+ }
+}
+
/// A dumb wrapper around a `Ulid` to implement `OwnerId` for it.
pub struct UserId(Ulid);
diff --git a/crates/handlers/src/graphql/model/mod.rs b/crates/handlers/src/graphql/model/mod.rs
index 063a63fb0..bc7faaaec 100644
--- a/crates/handlers/src/graphql/model/mod.rs
+++ b/crates/handlers/src/graphql/model/mod.rs
@@ -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},
};
diff --git a/crates/handlers/src/graphql/model/node.rs b/crates/handlers/src/graphql/model/node.rs
index e63d2b387..987e4ae19 100644
--- a/crates/handlers/src/graphql/model/node.rs
+++ b/crates/handlers/src/graphql/model/node.rs
@@ -29,6 +29,8 @@ pub enum NodeType {
UserEmail,
UserEmailAuthentication,
UserRecoveryTicket,
+ UserPasskey,
+ UserPasskeyChallenge,
}
#[derive(Debug, Error)]
@@ -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",
}
}
@@ -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,
}
}
diff --git a/crates/handlers/src/graphql/model/users.rs b/crates/handlers/src/graphql/model/users.rs
index 11522c6b4..375ce6d20 100644
--- a/crates/handlers/src/graphql/model/users.rs
+++ b/crates/handlers/src/graphql/model/users.rs
@@ -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::{
@@ -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,
+ #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
+ before: Option,
+ #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option,
+ #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option,
+ ) -> Result, 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| x.extract_for_type(NodeType::UserPasskey))
+ .transpose()?;
+ let before_id = before
+ .map(|x: OpaqueCursor| 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 {
let state = ctx.state();
@@ -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 {
+ self.0.created_at
+ }
+
+ /// When the passkey was last used
+ pub async fn last_used_at(&self) -> Option> {
+ self.0.last_used_at
+ }
+}
diff --git a/crates/handlers/src/graphql/mutations/mod.rs b/crates/handlers/src/graphql/mutations/mod.rs
index af6caab62..c81d414c2 100644
--- a/crates/handlers/src/graphql/mutations/mod.rs
+++ b/crates/handlers/src/graphql/mutations/mod.rs
@@ -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;
@@ -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,
diff --git a/crates/handlers/src/graphql/mutations/user_passkey.rs b/crates/handlers/src/graphql/mutations/user_passkey.rs
new file mode 100644
index 000000000..29eab64a8
--- /dev/null
+++ b/crates/handlers/src/graphql/mutations/user_passkey.rs
@@ -0,0 +1,383 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
+use mas_storage::RepositoryAccess;
+use ulid::Ulid;
+
+use crate::{
+ graphql::{
+ model::{NodeType, UserPasskey},
+ state::ContextExt,
+ },
+ webauthn::WebauthnError,
+};
+
+#[derive(Default)]
+pub struct UserPasskeyMutations {
+ _private: (),
+}
+
+/// The payload of the `startRegisterPasskey` mutation
+#[derive(Description)]
+struct StartRegisterPasskeyPayload {
+ id: Ulid,
+ options: String,
+}
+
+#[Object(use_type_description)]
+impl StartRegisterPasskeyPayload {
+ async fn id(&self) -> ID {
+ NodeType::UserPasskeyChallenge.id(self.id)
+ }
+
+ /// The options to pass to `navigator.credentials.create()` as a JSON string
+ async fn options(&self) -> &str {
+ &self.options
+ }
+}
+
+/// The input for the `completeRegisterPasskey` mutation
+#[derive(InputObject)]
+struct CompleteRegisterPasskeyInput {
+ /// The ID of the passkey challenge to complete
+ id: ID,
+
+ /// Name of the passkey
+ name: String,
+
+ /// The response from `navigator.credentials.create()` as a JSON string
+ response: String,
+}
+
+/// The payload of the `completeRegisterPasskey` mutation
+#[derive(Description)]
+enum CompleteRegisterPasskeyPayload {
+ Added(Box),
+ InvalidChallenge,
+ InvalidResponse(WebauthnError),
+ InvalidName,
+ Exists,
+}
+
+/// The status of the `completeRegisterPasskey` mutation
+#[derive(Enum, Copy, Clone, Eq, PartialEq)]
+enum CompleteRegisterPasskeyStatus {
+ /// The passkey was added
+ Added,
+ /// The challenge was invalid
+ InvalidChallenge,
+ /// The response was invalid
+ InvalidResponse,
+ /// The name for the passkey was invalid
+ InvalidName,
+ /// The passkey credential already exists
+ Exists,
+}
+
+#[Object(use_type_description)]
+impl CompleteRegisterPasskeyPayload {
+ /// Status of the operation
+ async fn status(&self) -> CompleteRegisterPasskeyStatus {
+ match self {
+ Self::Added(_) => CompleteRegisterPasskeyStatus::Added,
+ Self::InvalidChallenge => CompleteRegisterPasskeyStatus::InvalidChallenge,
+ Self::InvalidResponse(_) => CompleteRegisterPasskeyStatus::InvalidResponse,
+ Self::InvalidName => CompleteRegisterPasskeyStatus::InvalidName,
+ Self::Exists => CompleteRegisterPasskeyStatus::Exists,
+ }
+ }
+
+ /// The passkey that was added
+ async fn passkey(&self) -> Option {
+ match self {
+ Self::Added(passkey) => Some(UserPasskey(*passkey.clone())),
+ _ => None,
+ }
+ }
+
+ /// The error when the status is `INVALID_RESPONSE`
+ async fn error(&self) -> Option {
+ match self {
+ Self::InvalidResponse(e) => Some(e.to_string()),
+ _ => None,
+ }
+ }
+}
+
+/// The input for the `renamePasskey` mutation
+#[derive(InputObject)]
+struct RenamePasskeyInput {
+ /// The ID of the passkey to rename
+ id: ID,
+
+ /// new name for the passkey
+ name: String,
+}
+
+/// The payload of the `renamePasskey` mutation
+#[derive(Description)]
+enum RenamePasskeyPayload {
+ Renamed(Box),
+ Invalid,
+ NotFound,
+}
+
+/// The status of the `renamePasskey` mutation
+#[derive(Enum, Copy, Clone, Eq, PartialEq)]
+enum RenamePasskeyStatus {
+ /// The passkey was renamed
+ Renamed,
+ /// The new name was invalid
+ Invalid,
+ /// The passkey was not found
+ NotFound,
+}
+
+#[Object(use_type_description)]
+impl RenamePasskeyPayload {
+ /// Status of the operation
+ async fn status(&self) -> RenamePasskeyStatus {
+ match self {
+ Self::Renamed(_) => RenamePasskeyStatus::Renamed,
+ Self::Invalid => RenamePasskeyStatus::Invalid,
+ Self::NotFound => RenamePasskeyStatus::NotFound,
+ }
+ }
+
+ /// The passkey that was renamed
+ async fn passkey(&self) -> Option {
+ match self {
+ Self::Renamed(passkey) => Some(UserPasskey(*passkey.clone())),
+ _ => None,
+ }
+ }
+}
+
+/// The input for the `removePasskey` mutation
+#[derive(InputObject)]
+struct RemovePasskeyInput {
+ /// The ID of the passkey to remove
+ id: ID,
+}
+
+/// The payload of the `removePasskey` mutation
+#[derive(Description)]
+enum RemovePasskeyPayload {
+ Removed(Box),
+ NotFound,
+}
+
+/// The status of the `removePasskey` mutation
+#[derive(Enum, Copy, Clone, Eq, PartialEq)]
+enum RemovePasskeyStatus {
+ /// The passkey was removed
+ Removed,
+ /// The passkey was not found
+ NotFound,
+}
+
+#[Object(use_type_description)]
+impl RemovePasskeyPayload {
+ /// Status of the operation
+ async fn status(&self) -> RemovePasskeyStatus {
+ match self {
+ Self::Removed(_) => RemovePasskeyStatus::Removed,
+ Self::NotFound => RemovePasskeyStatus::NotFound,
+ }
+ }
+
+ /// The passkey that was removed
+ async fn passkey(&self) -> Option {
+ match self {
+ Self::Removed(passkey) => Some(UserPasskey(*passkey.clone())),
+ Self::NotFound => None,
+ }
+ }
+}
+
+#[Object]
+impl UserPasskeyMutations {
+ /// Start registering a new passkey
+ async fn start_register_passkey(
+ &self,
+ ctx: &Context<'_>,
+ ) -> Result {
+ let state = ctx.state();
+ let mut rng = state.rng();
+ let clock = state.clock();
+ let mut repo = state.repository().await?;
+ let conn = state.homeserver_connection();
+ let requester = ctx.requester();
+
+ // Only allow calling this if the requester is a browser session
+ let Some(browser_session) = requester.browser_session() else {
+ return Err(async_graphql::Error::new("Unauthorized"));
+ };
+
+ let user = &browser_session.user;
+
+ // Allow registering passkeys if the site config allows it
+ if !state.site_config().passkeys_enabled {
+ return Err(async_graphql::Error::new(
+ "Passkeys are not allowed on this server",
+ ));
+ }
+
+ let webauthn = state.webauthn();
+
+ let (options, challenge) = webauthn
+ .start_passkey_registration(&mut repo, &mut rng, &clock, &conn, user, browser_session)
+ .await?;
+
+ repo.save().await?;
+
+ Ok(StartRegisterPasskeyPayload {
+ id: challenge.id,
+ options,
+ })
+ }
+
+ /// Complete registering a new passkey
+ async fn complete_register_passkey(
+ &self,
+ ctx: &Context<'_>,
+ input: CompleteRegisterPasskeyInput,
+ ) -> Result {
+ let state = ctx.state();
+ let mut rng = state.rng();
+ let clock = state.clock();
+ let mut repo = state.repository().await?;
+
+ let id = NodeType::UserPasskeyChallenge.extract_ulid(&input.id)?;
+
+ if input.name.len() > 256 || input.name.is_empty() {
+ return Ok(CompleteRegisterPasskeyPayload::InvalidName);
+ }
+
+ let Some(browser_session) = ctx.requester().browser_session() else {
+ return Err(async_graphql::Error::new("Unauthorized"));
+ };
+
+ // Allow registering passkeys if the site config allows it
+ if !state.site_config().passkeys_enabled {
+ return Err(async_graphql::Error::new(
+ "Passkeys are not allowed on this server",
+ ));
+ }
+
+ let webauthn = state.webauthn();
+
+ let challenge = match webauthn
+ .lookup_challenge(&mut repo, &clock, id, Some(browser_session))
+ .await
+ .map_err(anyhow::Error::downcast)
+ {
+ Ok(c) => c,
+ Err(Ok(WebauthnError::InvalidChallenge)) => {
+ return Ok(CompleteRegisterPasskeyPayload::InvalidChallenge);
+ }
+ Err(Ok(e)) => return Err(e.into()),
+ Err(Err(e)) => return Err(e.into()),
+ };
+
+ let user_passkey = match webauthn
+ .finish_passkey_registration(
+ &mut repo,
+ &mut rng,
+ &clock,
+ &browser_session.user,
+ challenge,
+ input.response,
+ input.name,
+ )
+ .await
+ .map_err(anyhow::Error::downcast)
+ {
+ Ok(p) => p,
+ Err(Ok(WebauthnError::Exists)) => {
+ return Ok(CompleteRegisterPasskeyPayload::Exists);
+ }
+ Err(Ok(e)) => return Ok(CompleteRegisterPasskeyPayload::InvalidResponse(e)),
+ Err(Err(e)) => return Err(e.into()),
+ };
+
+ repo.save().await?;
+
+ Ok(CompleteRegisterPasskeyPayload::Added(Box::new(
+ user_passkey,
+ )))
+ }
+
+ /// Rename a passkey
+ async fn rename_passkey(
+ &self,
+ ctx: &Context<'_>,
+ input: RenamePasskeyInput,
+ ) -> Result {
+ let state = ctx.state();
+ let requester = ctx.requester();
+
+ let id = NodeType::UserPasskey.extract_ulid(&input.id)?;
+
+ if input.name.len() > 256 || input.name.is_empty() {
+ return Ok(RenamePasskeyPayload::Invalid);
+ }
+
+ let mut repo = state.repository().await?;
+ let user_passkey = repo.user_passkey().lookup(id).await?;
+ let Some(user_passkey) = user_passkey else {
+ return Ok(RenamePasskeyPayload::NotFound);
+ };
+
+ if !requester.is_owner_or_admin(&user_passkey) {
+ return Ok(RenamePasskeyPayload::NotFound);
+ }
+
+ // Allow non-admins to rename passkeys if the site config allows it
+ if !requester.is_admin() && !state.site_config().passkeys_enabled {
+ return Err(async_graphql::Error::new("Unauthorized"));
+ }
+
+ let user_passkey = repo.user_passkey().rename(user_passkey, input.name).await?;
+
+ repo.save().await?;
+
+ Ok(RenamePasskeyPayload::Renamed(Box::new(user_passkey)))
+ }
+
+ /// Remove a passkey
+ async fn remove_passkey(
+ &self,
+ ctx: &Context<'_>,
+ input: RemovePasskeyInput,
+ ) -> Result {
+ let state = ctx.state();
+ let requester = ctx.requester();
+
+ let id = NodeType::UserPasskey.extract_ulid(&input.id)?;
+
+ let mut repo = state.repository().await?;
+ let user_passkey = repo.user_passkey().lookup(id).await?;
+ let Some(user_passkey) = user_passkey else {
+ return Ok(RemovePasskeyPayload::NotFound);
+ };
+
+ if !requester.is_owner_or_admin(&user_passkey) {
+ return Ok(RemovePasskeyPayload::NotFound);
+ }
+
+ // Allow non-admins to remove passkeys if the site config allows it
+ if !requester.is_admin() && !state.site_config().passkeys_enabled {
+ return Err(async_graphql::Error::new("Unauthorized"));
+ }
+
+ repo.user_passkey().remove(user_passkey.clone()).await?;
+
+ repo.save().await?;
+
+ Ok(RemovePasskeyPayload::Removed(Box::new(user_passkey)))
+ }
+}
diff --git a/crates/handlers/src/graphql/query/mod.rs b/crates/handlers/src/graphql/query/mod.rs
index 66e6b38bb..c2234a93f 100644
--- a/crates/handlers/src/graphql/query/mod.rs
+++ b/crates/handlers/src/graphql/query/mod.rs
@@ -240,9 +240,11 @@ impl BaseQuery {
let ret = match node_type {
// TODO
- NodeType::Authentication | NodeType::CompatSsoLogin | NodeType::UserRecoveryTicket => {
- None
- }
+ NodeType::Authentication
+ | NodeType::CompatSsoLogin
+ | NodeType::UserRecoveryTicket
+ | NodeType::UserPasskey
+ | NodeType::UserPasskeyChallenge => None,
NodeType::UpstreamOAuth2Provider => UpstreamOAuthQuery
.upstream_oauth2_provider(ctx, id)
diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs
index 7faf76334..2def565aa 100644
--- a/crates/handlers/src/graphql/state.rs
+++ b/crates/handlers/src/graphql/state.rs
@@ -11,7 +11,7 @@ use mas_policy::Policy;
use mas_router::UrlBuilder;
use mas_storage::{BoxRepository, RepositoryError};
-use crate::{Limiter, graphql::Requester, passwords::PasswordManager};
+use crate::{Limiter, graphql::Requester, passwords::PasswordManager, webauthn::Webauthn};
const CLEAR_SESSION_SENTINEL: &str = "__CLEAR_SESSION__";
@@ -26,6 +26,7 @@ pub trait State {
fn site_config(&self) -> &SiteConfig;
fn url_builder(&self) -> &UrlBuilder;
fn limiter(&self) -> &Limiter;
+ fn webauthn(&self) -> &Webauthn;
}
pub type BoxState = Box;
diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs
index 65a75f550..8e2f3ff3d 100644
--- a/crates/handlers/src/lib.rs
+++ b/crates/handlers/src/lib.rs
@@ -59,6 +59,7 @@ mod oauth2;
pub mod passwords;
pub mod upstream_oauth2;
mod views;
+pub mod webauthn;
mod activity_tracker;
mod captcha;
diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs
index 7c4660a34..19d08f187 100644
--- a/crates/handlers/src/test_utils.rs
+++ b/crates/handlers/src/test_utils.rs
@@ -55,6 +55,7 @@ use crate::{
ActivityTracker, BoundActivityTracker, Limiter, RequesterFingerprint, graphql,
passwords::{Hasher, PasswordManager},
upstream_oauth2::cache::MetadataCache,
+ webauthn::Webauthn,
};
/// Setup rustcrypto and tracing for tests.
@@ -203,6 +204,8 @@ impl TestState {
PasswordManager::disabled()
};
+ let webauthn = Webauthn::new(&url_builder.http_base(), None)?;
+
let policy_factory =
policy_factory(&site_config.server_name, serde_json::json!({})).await?;
@@ -224,6 +227,7 @@ impl TestState {
password_manager: password_manager.clone(),
url_builder: url_builder.clone(),
limiter: limiter.clone(),
+ webauthn: webauthn.clone(),
};
let state: crate::graphql::BoxState = Box::new(graphql_state);
@@ -437,6 +441,7 @@ struct TestGraphQLState {
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
+ webauthn: Webauthn,
}
#[async_trait::async_trait]
@@ -473,6 +478,10 @@ impl graphql::State for TestGraphQLState {
&self.limiter
}
+ fn webauthn(&self) -> &Webauthn {
+ &self.webauthn
+ }
+
fn rng(&self) -> BoxRng {
let mut parent_rng = self.rng.lock().expect("Failed to lock RNG");
let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG");
diff --git a/crates/handlers/src/webauthn.rs b/crates/handlers/src/webauthn.rs
new file mode 100644
index 000000000..fb59d512f
--- /dev/null
+++ b/crates/handlers/src/webauthn.rs
@@ -0,0 +1,268 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+use std::sync::Arc;
+
+use anyhow::{Context, Result};
+use chrono::Duration;
+use mas_config::PasskeysConfig;
+use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge};
+use mas_matrix::HomeserverConnection;
+use mas_storage::RepositoryAccess;
+use rand::RngCore;
+use ulid::Ulid;
+use url::Url;
+use webauthn_rp::{
+ PublicKeyCredentialCreationOptions, RegistrationServerState,
+ bin::{Decode, Encode},
+ request::{
+ DomainOrigin, Port, PublicKeyCredentialDescriptor, RpId, Scheme,
+ register::{PublicKeyCredentialUserEntity, RegistrationVerificationOptions, UserHandle},
+ },
+ response::{
+ CredentialId,
+ register::{error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed},
+ },
+};
+
+/// User-facing errors
+#[derive(Debug, thiserror::Error)]
+pub enum WebauthnError {
+ #[error(transparent)]
+ RegistrationCeremonyError(#[from] RegCeremonyErr),
+
+ #[error("The challenge doesn't exist, expired or doesn't belong for this session")]
+ InvalidChallenge,
+
+ #[error("Credential already exists")]
+ Exists,
+
+ #[error("The passkey belongs to a different user")]
+ UserMismatch,
+}
+
+#[derive(Clone, Debug)]
+pub struct Webauthn {
+ rpid: Arc,
+ allowed_origins: Vec,
+}
+
+impl Webauthn {
+ /// Creates a new instance
+ ///
+ /// # Errors
+ /// If the `public_base` has no valid host domain
+ pub fn new(public_base: &Url, passkey_config: Option<&PasskeysConfig>) -> Result {
+ let public_base_host = public_base
+ .host_str()
+ .context("Public base doesn't have a host")?
+ .to_owned();
+
+ let rpid_host = if let Some(rpid) = passkey_config.and_then(|c| c.rpid.clone()) {
+ rpid
+ } else {
+ public_base_host.clone()
+ };
+
+ let rpid = Arc::new(RpId::Domain(rpid_host.clone().try_into()?));
+
+ let mut allowed_origins = vec![rpid_host, public_base_host];
+ allowed_origins.extend(
+ passkey_config
+ .and_then(|c| c.allowed_origins.clone())
+ .unwrap_or_default(),
+ );
+
+ Ok(Self {
+ rpid,
+ allowed_origins,
+ })
+ }
+
+ #[must_use]
+ pub fn get_allowed_origins(&self) -> Vec> {
+ self.allowed_origins
+ .iter()
+ .map(|host| {
+ // Allow any port and http when using localhost for development
+ // (normally no port is allowed and https is required)
+ if host == "localhost" {
+ DomainOrigin {
+ scheme: Scheme::Any,
+ host,
+ port: Port::Any,
+ }
+ } else {
+ DomainOrigin::new(host)
+ }
+ })
+ .collect()
+ }
+
+ /// Finds a challenge and does some checks on it
+ ///
+ /// # Errors
+ /// [`WebauthnError::InvalidChallenge`] if the challenge is not found or is
+ /// invalid.
+ ///
+ /// The rest of the anyhow errors should be treated as internal errors
+ pub async fn lookup_challenge(
+ &self,
+ repo: &mut impl RepositoryAccess,
+ clock: &impl Clock,
+ id: Ulid,
+ browser_session: Option<&BrowserSession>,
+ ) -> Result {
+ let user_passkey_challenge = repo
+ .user_passkey()
+ .lookup_challenge(id)
+ .await?
+ .ok_or(WebauthnError::InvalidChallenge)?;
+
+ // Check that challenge belongs to a browser session if provided or belongs to
+ // no session if not provided. If not tied to a session, challenge should
+ // be tied by a cookie and checked in the handler
+ if user_passkey_challenge.user_session_id != browser_session.map(|s| s.id) {
+ return Err(WebauthnError::InvalidChallenge.into());
+ }
+
+ // Challenge was already completed
+ if user_passkey_challenge.completed_at.is_some() {
+ return Err(WebauthnError::InvalidChallenge.into());
+ }
+
+ // Challenge has expired
+ if clock.now() - user_passkey_challenge.created_at > Duration::hours(1) {
+ return Err(WebauthnError::InvalidChallenge.into());
+ }
+
+ Ok(user_passkey_challenge)
+ }
+
+ /// Creates a passkey registration challenge
+ ///
+ /// # Returns
+ /// 1. The JSON options to `navigator.credentials.create()` on the frontend
+ /// 2. The created [`UserPasskeyChallenge`]
+ ///
+ /// # Errors
+ /// Various anyhow errors that should be treated as internal errors
+ pub async fn start_passkey_registration(
+ &self,
+ repo: &mut impl RepositoryAccess,
+ rng: &mut (dyn RngCore + Send),
+ clock: &impl Clock,
+ conn: &impl HomeserverConnection,
+ user: &User,
+ browser_session: &BrowserSession,
+ ) -> Result<(String, UserPasskeyChallenge)> {
+ let matrix_user = conn.query_user(&user.username).await?;
+ let user_entity = PublicKeyCredentialUserEntity {
+ name: user.username.as_str().try_into()?,
+ id: &UserHandle::decode(user.id.to_bytes())?,
+ display_name: matrix_user.displayname.and_then(|d| d.try_into().ok()),
+ };
+
+ let exclude_credentials = repo
+ .user_passkey()
+ .all(user)
+ .await?
+ .into_iter()
+ .map(|v| PublicKeyCredentialDescriptor {
+ id: v.credential_id,
+ transports: v.transports,
+ })
+ .collect();
+
+ let options = PublicKeyCredentialCreationOptions::passkey(
+ &self.rpid,
+ user_entity,
+ exclude_credentials,
+ );
+
+ let (server_state, client_state) = options.start_ceremony()?;
+
+ let user_passkey_challenge = repo
+ .user_passkey()
+ .add_challenge_for_session(rng, clock, server_state.encode()?, browser_session)
+ .await?;
+
+ Ok((
+ serde_json::to_string(&client_state)?,
+ user_passkey_challenge,
+ ))
+ }
+
+ /// Validates and creates a passkey from a challenge response
+ ///
+ /// # Errors
+ /// [`WebauthnError::Exists`] if the passkey credential the user is trying
+ /// to register already exists.
+ ///
+ /// [`WebauthnError::RegistrationCeremonyError`] if the response from the
+ /// user is invalid.
+ ///
+ /// [`WebauthnError::UserMismatch`] if the user handle in the response
+ /// doesn't match.
+ ///
+ /// The rest of the anyhow errors should be treated as internal errors
+ pub async fn finish_passkey_registration(
+ &self,
+ repo: &mut impl RepositoryAccess,
+ rng: &mut (dyn RngCore + Send),
+ clock: &impl Clock,
+ user: &User,
+ user_passkey_challenge: UserPasskeyChallenge,
+ response: String,
+ name: String,
+ ) -> Result {
+ let server_state = RegistrationServerState::decode(&user_passkey_challenge.state)?;
+
+ let RegistrationRelaxed(response) = serde_json::from_str(&response)?;
+
+ let options = RegistrationVerificationOptions:: {
+ allowed_origins: &self.get_allowed_origins(),
+ client_data_json_relaxed: true,
+ ..Default::default()
+ };
+
+ let credential = server_state
+ .verify(&self.rpid, &response, &options)
+ .map_err(WebauthnError::from)?;
+
+ let user_id = Ulid::from_bytes(credential.user_id().encode()?);
+ if user_id != user.id {
+ return Err(WebauthnError::UserMismatch.into());
+ }
+
+ let cred_id: CredentialId> = credential.id().into();
+
+ // Webauthn requires that credential IDs be unique globally
+ if repo.user_passkey().find(&cred_id).await?.is_some() {
+ return Err(WebauthnError::Exists.into());
+ }
+
+ let user_passkey = repo
+ .user_passkey()
+ .add(
+ rng,
+ clock,
+ user,
+ name,
+ cred_id,
+ credential.transports(),
+ credential.static_state().encode()?,
+ credential.dynamic_state().encode()?.to_vec(),
+ credential.metadata().encode()?,
+ )
+ .await?;
+
+ repo.user_passkey()
+ .complete_challenge(clock, user_passkey_challenge)
+ .await?;
+
+ Ok(user_passkey)
+ }
+}
diff --git a/docs/config.schema.json b/docs/config.schema.json
index 350c49614..40bf3c85d 100644
--- a/docs/config.schema.json
+++ b/docs/config.schema.json
@@ -2707,6 +2707,19 @@
"description": "Whether passkeys are enabled or not",
"default": false,
"type": "boolean"
+ },
+ "rpid": {
+ "description": "Relying Party Identifier to use\n\nIf not set, the host from `public_base` is used",
+ "default": null,
+ "type": "string"
+ },
+ "allowed_origins": {
+ "description": "Additional allowed origins. `rpid` and `public_base` are already allowed",
+ "default": null,
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
}
}
diff --git a/frontend/schema.graphql b/frontend/schema.graphql
index d2b03b0a0..d490effc6 100644
--- a/frontend/schema.graphql
+++ b/frontend/schema.graphql
@@ -538,6 +538,68 @@ enum CompleteEmailAuthenticationStatus {
IN_USE
}
+"""
+The input for the `completeRegisterPasskey` mutation
+"""
+input CompleteRegisterPasskeyInput {
+ """
+ The ID of the passkey challenge to complete
+ """
+ id: ID!
+ """
+ Name of the passkey
+ """
+ name: String!
+ """
+ The response from `navigator.credentials.create()` as a JSON string
+ """
+ response: String!
+}
+
+"""
+The payload of the `completeRegisterPasskey` mutation
+"""
+type CompleteRegisterPasskeyPayload {
+ """
+ Status of the operation
+ """
+ status: CompleteRegisterPasskeyStatus!
+ """
+ The passkey that was added
+ """
+ passkey: UserPasskey
+ """
+ The error when the status is `INVALID_RESPONSE`
+ """
+ error: String
+}
+
+"""
+The status of the `completeRegisterPasskey` mutation
+"""
+enum CompleteRegisterPasskeyStatus {
+ """
+ The passkey was added
+ """
+ ADDED
+ """
+ The challenge was invalid
+ """
+ INVALID_CHALLENGE
+ """
+ The response was invalid
+ """
+ INVALID_RESPONSE
+ """
+ The name for the passkey was invalid
+ """
+ INVALID_NAME
+ """
+ The passkey credential already exists
+ """
+ EXISTS
+}
+
"""
The input of the `createOauth2Session` mutation.
"""
@@ -878,6 +940,24 @@ type Mutation {
input: CompleteEmailAuthenticationInput!
): CompleteEmailAuthenticationPayload!
"""
+ Start registering a new passkey
+ """
+ startRegisterPasskey: StartRegisterPasskeyPayload!
+ """
+ Complete registering a new passkey
+ """
+ completeRegisterPasskey(
+ input: CompleteRegisterPasskeyInput!
+ ): CompleteRegisterPasskeyPayload!
+ """
+ Rename a passkey
+ """
+ renamePasskey(input: RenamePasskeyInput!): RenamePasskeyPayload!
+ """
+ Remove a passkey
+ """
+ removePasskey(input: RemovePasskeyInput!): RemovePasskeyPayload!
+ """
Add a user. This is only available to administrators.
"""
addUser(input: AddUserInput!): AddUserPayload!
@@ -1313,6 +1393,90 @@ enum RemoveEmailStatus {
INCORRECT_PASSWORD
}
+"""
+The input for the `removePasskey` mutation
+"""
+input RemovePasskeyInput {
+ """
+ The ID of the passkey to remove
+ """
+ id: ID!
+}
+
+"""
+The payload of the `removePasskey` mutation
+"""
+type RemovePasskeyPayload {
+ """
+ Status of the operation
+ """
+ status: RemovePasskeyStatus!
+ """
+ The passkey that was removed
+ """
+ passkey: UserPasskey
+}
+
+"""
+The status of the `removePasskey` mutation
+"""
+enum RemovePasskeyStatus {
+ """
+ The passkey was removed
+ """
+ REMOVED
+ """
+ The passkey was not found
+ """
+ NOT_FOUND
+}
+
+"""
+The input for the `renamePasskey` mutation
+"""
+input RenamePasskeyInput {
+ """
+ The ID of the passkey to rename
+ """
+ id: ID!
+ """
+ new name for the passkey
+ """
+ name: String!
+}
+
+"""
+The payload of the `renamePasskey` mutation
+"""
+type RenamePasskeyPayload {
+ """
+ Status of the operation
+ """
+ status: RenamePasskeyStatus!
+ """
+ The passkey that was renamed
+ """
+ passkey: UserPasskey
+}
+
+"""
+The status of the `renamePasskey` mutation
+"""
+enum RenamePasskeyStatus {
+ """
+ The passkey was renamed
+ """
+ RENAMED
+ """
+ The new name was invalid
+ """
+ INVALID
+ """
+ The passkey was not found
+ """
+ NOT_FOUND
+}
+
"""
The input for the `resendEmailAuthenticationCode` mutation
"""
@@ -1838,6 +2002,17 @@ enum StartEmailAuthenticationStatus {
INCORRECT_PASSWORD
}
+"""
+The payload of the `startRegisterPasskey` mutation
+"""
+type StartRegisterPasskeyPayload {
+ id: ID!
+ """
+ The options to pass to `navigator.credentials.create()` as a JSON string
+ """
+ options: String!
+}
+
"""
The input for the `unlockUser` mutation.
"""
@@ -2239,6 +2414,27 @@ type User implements Node {
last: Int
): AppSessionConnection!
"""
+ Get the list of passkeys, chronologically sorted
+ """
+ passkeys(
+ """
+ Returns the elements in the list that come after the cursor.
+ """
+ after: String
+ """
+ Returns the elements in the list that come before the cursor.
+ """
+ before: String
+ """
+ Returns the first *n* elements from the list.
+ """
+ first: Int
+ """
+ Returns the last *n* elements from the list.
+ """
+ last: Int
+ ): UserPasskeyConnection!
+ """
Check if the user has a password set.
"""
hasPassword: Boolean!
@@ -2403,6 +2599,61 @@ enum UserEmailState {
CONFIRMED
}
+"""
+A passkey
+"""
+type UserPasskey {
+ """
+ ID of the object
+ """
+ id: ID!
+ """
+ Name of the passkey
+ """
+ name: String!
+ """
+ When the object was created.
+ """
+ createdAt: DateTime!
+ """
+ When the passkey was last used
+ """
+ lastUsedAt: DateTime
+}
+
+type UserPasskeyConnection {
+ """
+ Information to aid in pagination.
+ """
+ pageInfo: PageInfo!
+ """
+ A list of edges.
+ """
+ edges: [UserPasskeyEdge!]!
+ """
+ A list of nodes.
+ """
+ nodes: [UserPasskey!]!
+ """
+ Identifies the total count of items in the connection.
+ """
+ totalCount: Int!
+}
+
+"""
+An edge in a connection.
+"""
+type UserPasskeyEdge {
+ """
+ The item at the end of the edge
+ """
+ node: UserPasskey!
+ """
+ A cursor for use in pagination
+ """
+ cursor: String!
+}
+
"""
A recovery ticket
"""
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
index 84b98f8ce..7519727e3 100644
--- a/frontend/src/gql/graphql.ts
+++ b/frontend/src/gql/graphql.ts
@@ -356,6 +356,40 @@ export type CompleteEmailAuthenticationStatus =
/** Too many attempts to complete an email authentication */
| 'RATE_LIMITED';
+/** The input for the `completeRegisterPasskey` mutation */
+export type CompleteRegisterPasskeyInput = {
+ /** The ID of the passkey challenge to complete */
+ id: Scalars['ID']['input'];
+ /** Name of the passkey */
+ name: Scalars['String']['input'];
+ /** The response from `navigator.credentials.create()` as a JSON string */
+ response: Scalars['String']['input'];
+};
+
+/** The payload of the `completeRegisterPasskey` mutation */
+export type CompleteRegisterPasskeyPayload = {
+ __typename?: 'CompleteRegisterPasskeyPayload';
+ /** The error when the status is `INVALID_RESPONSE` */
+ error?: Maybe;
+ /** The passkey that was added */
+ passkey?: Maybe;
+ /** Status of the operation */
+ status: CompleteRegisterPasskeyStatus;
+};
+
+/** The status of the `completeRegisterPasskey` mutation */
+export type CompleteRegisterPasskeyStatus =
+ /** The passkey was added */
+ | 'ADDED'
+ /** The passkey credential already exists */
+ | 'EXISTS'
+ /** The challenge was invalid */
+ | 'INVALID_CHALLENGE'
+ /** The name for the passkey was invalid */
+ | 'INVALID_NAME'
+ /** The response was invalid */
+ | 'INVALID_RESPONSE';
+
/** The input of the `createOauth2Session` mutation. */
export type CreateOAuth2SessionInput = {
/** Whether the session should issue a never-expiring access token */
@@ -547,6 +581,8 @@ export type Mutation = {
allowUserCrossSigningReset: AllowUserCrossSigningResetPayload;
/** Complete the email authentication flow */
completeEmailAuthentication: CompleteEmailAuthenticationPayload;
+ /** Complete registering a new passkey */
+ completeRegisterPasskey: CompleteRegisterPasskeyPayload;
/**
* Create a new arbitrary OAuth 2.0 Session.
*
@@ -567,6 +603,10 @@ export type Mutation = {
lockUser: LockUserPayload;
/** Remove an email address */
removeEmail: RemoveEmailPayload;
+ /** Remove a passkey */
+ removePasskey: RemovePasskeyPayload;
+ /** Rename a passkey */
+ renamePasskey: RenamePasskeyPayload;
/** Resend the email authentication code */
resendEmailAuthenticationCode: ResendEmailAuthenticationCodePayload;
/**
@@ -604,6 +644,8 @@ export type Mutation = {
setPrimaryEmail: SetPrimaryEmailPayload;
/** Start a new email authentication flow */
startEmailAuthentication: StartEmailAuthenticationPayload;
+ /** Start registering a new passkey */
+ startRegisterPasskey: StartRegisterPasskeyPayload;
/** Unlock and reactivate a user. This is only available to administrators. */
unlockUser: UnlockUserPayload;
};
@@ -633,6 +675,12 @@ export type MutationCompleteEmailAuthenticationArgs = {
};
+/** The mutations root of the GraphQL interface. */
+export type MutationCompleteRegisterPasskeyArgs = {
+ input: CompleteRegisterPasskeyInput;
+};
+
+
/** The mutations root of the GraphQL interface. */
export type MutationCreateOauth2SessionArgs = {
input: CreateOAuth2SessionInput;
@@ -675,6 +723,18 @@ export type MutationRemoveEmailArgs = {
};
+/** The mutations root of the GraphQL interface. */
+export type MutationRemovePasskeyArgs = {
+ input: RemovePasskeyInput;
+};
+
+
+/** The mutations root of the GraphQL interface. */
+export type MutationRenamePasskeyArgs = {
+ input: RenamePasskeyInput;
+};
+
+
/** The mutations root of the GraphQL interface. */
export type MutationResendEmailAuthenticationCodeArgs = {
input: ResendEmailAuthenticationCodeInput;
@@ -1027,6 +1087,54 @@ export type RemoveEmailStatus =
/** The email address was removed */
| 'REMOVED';
+/** The input for the `removePasskey` mutation */
+export type RemovePasskeyInput = {
+ /** The ID of the passkey to remove */
+ id: Scalars['ID']['input'];
+};
+
+/** The payload of the `removePasskey` mutation */
+export type RemovePasskeyPayload = {
+ __typename?: 'RemovePasskeyPayload';
+ /** The passkey that was removed */
+ passkey?: Maybe;
+ /** Status of the operation */
+ status: RemovePasskeyStatus;
+};
+
+/** The status of the `removePasskey` mutation */
+export type RemovePasskeyStatus =
+ /** The passkey was not found */
+ | 'NOT_FOUND'
+ /** The passkey was removed */
+ | 'REMOVED';
+
+/** The input for the `renamePasskey` mutation */
+export type RenamePasskeyInput = {
+ /** The ID of the passkey to rename */
+ id: Scalars['ID']['input'];
+ /** new name for the passkey */
+ name: Scalars['String']['input'];
+};
+
+/** The payload of the `renamePasskey` mutation */
+export type RenamePasskeyPayload = {
+ __typename?: 'RenamePasskeyPayload';
+ /** The passkey that was renamed */
+ passkey?: Maybe;
+ /** Status of the operation */
+ status: RenamePasskeyStatus;
+};
+
+/** The status of the `renamePasskey` mutation */
+export type RenamePasskeyStatus =
+ /** The new name was invalid */
+ | 'INVALID'
+ /** The passkey was not found */
+ | 'NOT_FOUND'
+ /** The passkey was renamed */
+ | 'RENAMED';
+
/** The input for the `resendEmailAuthenticationCode` mutation */
export type ResendEmailAuthenticationCodeInput = {
/** The ID of the authentication session to resend the code for */
@@ -1347,6 +1455,14 @@ export type StartEmailAuthenticationStatus =
/** The email address was started */
| 'STARTED';
+/** The payload of the `startRegisterPasskey` mutation */
+export type StartRegisterPasskeyPayload = {
+ __typename?: 'StartRegisterPasskeyPayload';
+ id: Scalars['ID']['output'];
+ /** The options to pass to `navigator.credentials.create()` as a JSON string */
+ options: Scalars['String']['output'];
+};
+
/** The input for the `unlockUser` mutation. */
export type UnlockUserInput = {
/** The ID of the user to unlock */
@@ -1479,6 +1595,8 @@ export type User = Node & {
matrix: MatrixUser;
/** Get the list of OAuth 2.0 sessions, chronologically sorted */
oauth2Sessions: Oauth2SessionConnection;
+ /** Get the list of passkeys, chronologically sorted */
+ passkeys: UserPasskeyConnection;
/** Get the list of upstream OAuth 2.0 links */
upstreamOauth2Links: UpstreamOAuth2LinkConnection;
/** Username chosen by the user. */
@@ -1553,6 +1671,15 @@ export type UserOauth2SessionsArgs = {
};
+/** A user is an individual's account. */
+export type UserPasskeysArgs = {
+ after?: InputMaybe;
+ before?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+};
+
+
/** A user is an individual's account. */
export type UserUpstreamOauth2LinksArgs = {
after?: InputMaybe;
@@ -1659,6 +1786,40 @@ export type UserEmailState =
/** The email address is pending confirmation. */
| 'PENDING';
+/** A passkey */
+export type UserPasskey = {
+ __typename?: 'UserPasskey';
+ /** When the object was created. */
+ createdAt: Scalars['DateTime']['output'];
+ /** ID of the object */
+ id: Scalars['ID']['output'];
+ /** When the passkey was last used */
+ lastUsedAt?: Maybe;
+ /** Name of the passkey */
+ name: Scalars['String']['output'];
+};
+
+export type UserPasskeyConnection = {
+ __typename?: 'UserPasskeyConnection';
+ /** A list of edges. */
+ edges: Array;
+ /** A list of nodes. */
+ nodes: Array;
+ /** Information to aid in pagination. */
+ pageInfo: PageInfo;
+ /** Identifies the total count of items in the connection. */
+ totalCount: Scalars['Int']['output'];
+};
+
+/** An edge in a connection. */
+export type UserPasskeyEdge = {
+ __typename?: 'UserPasskeyEdge';
+ /** A cursor for use in pagination */
+ cursor: Scalars['String']['output'];
+ /** The item at the end of the edge */
+ node: UserPasskey;
+};
+
/** A recovery ticket */
export type UserRecoveryTicket = CreationEvent & Node & {
__typename?: 'UserRecoveryTicket';
From 54b4c9d3b1f8f5c25e2fe43e20e629da63df2d00 Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Mon, 10 Mar 2025 15:35:19 +0000
Subject: [PATCH 4/7] Frontend passkey management
---
frontend/locales/en.json | 15 ++
.../UserPasskey/UserPasskey.module.css | 31 +++
.../components/UserPasskey/UserPasskey.tsx | 224 ++++++++++++++++++
frontend/src/components/UserPasskey/index.ts | 6 +
.../components/UserProfile/AddPasskeyForm.tsx | 206 ++++++++++++++++
.../UserProfile/UserPasskeyList.tsx | 104 ++++++++
frontend/src/gql/gql.ts | 36 +++
frontend/src/gql/graphql.ts | 220 +++++++++++++++++
frontend/src/routes/_account.index.tsx | 8 +-
frontend/src/utils/webauthn.ts | 118 +++++++++
10 files changed, 967 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/UserPasskey/UserPasskey.module.css
create mode 100644 frontend/src/components/UserPasskey/UserPasskey.tsx
create mode 100644 frontend/src/components/UserPasskey/index.ts
create mode 100644 frontend/src/components/UserProfile/AddPasskeyForm.tsx
create mode 100644 frontend/src/components/UserProfile/UserPasskeyList.tsx
create mode 100644 frontend/src/utils/webauthn.ts
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index d36d1b793..5a5b294bf 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -56,6 +56,21 @@
"username_label": "Username"
},
"passkeys": {
+ "add": "Add passkey",
+ "challenge_invalid_error": "The request expired, please try again.",
+ "created_at_message": "Created {{date}}",
+ "delete_button_confirmation_modal": {
+ "action": "Delete passkey",
+ "body": "Delete this passkey?"
+ },
+ "delete_button_title": "Remove passkey",
+ "exists_error": "This passkey already exists.",
+ "last_used_message": "Last used {{date}}",
+ "name_field_help": "Give your passkey a name you can identify later",
+ "name_field_label": "Name",
+ "name_invalid_error": "The entered name is invalid",
+ "never_used_message": "Never used",
+ "response_invalid_error": "The response from your passkey was invalid: {{error}}",
"title": "Passkeys"
},
"password": {
diff --git a/frontend/src/components/UserPasskey/UserPasskey.module.css b/frontend/src/components/UserPasskey/UserPasskey.module.css
new file mode 100644
index 000000000..d9539220e
--- /dev/null
+++ b/frontend/src/components/UserPasskey/UserPasskey.module.css
@@ -0,0 +1,31 @@
+/* Copyright 2025 New Vector Ltd.
+*
+* SPDX-License-Identifier: AGPL-3.0-only
+* Please see LICENSE in the repository root for full details.
+*/
+
+.user-passkey-delete-icon {
+ color: var(--cpd-color-icon-critical-primary);
+}
+
+button[disabled] .user-passkey-delete-icon {
+ color: var(--cpd-color-icon-disabled);
+}
+
+.passkey-modal-box {
+ display: flex;
+ align-items: center;
+ gap: var(--cpd-space-4x);
+ border: 1px solid var(--cpd-color-gray-400);
+ padding: var(--cpd-space-3x);
+ font: var(--cpd-font-body-md-semibold);
+
+ & > svg {
+ color: var(--cpd-color-icon-secondary);
+ background-color: var(--cpd-color-bg-subtle-secondary);
+ padding: var(--cpd-space-2x);
+ border-radius: var(--cpd-space-2x);
+ inline-size: var(--cpd-space-10x);
+ block-size: var(--cpd-space-10x);
+ }
+}
diff --git a/frontend/src/components/UserPasskey/UserPasskey.tsx b/frontend/src/components/UserPasskey/UserPasskey.tsx
new file mode 100644
index 000000000..55bf28422
--- /dev/null
+++ b/frontend/src/components/UserPasskey/UserPasskey.tsx
@@ -0,0 +1,224 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
+import {
+ Button,
+ EditInPlace,
+ ErrorMessage,
+ Form,
+ IconButton,
+ Tooltip,
+} from "@vector-im/compound-web";
+import { parseISO } from "date-fns";
+import { type ComponentProps, type ReactNode, useState } from "react";
+import { Translation, useTranslation } from "react-i18next";
+import { type FragmentType, graphql, useFragment } from "../../gql";
+import { graphqlRequest } from "../../graphql";
+import { formatReadableDate } from "../DateTime";
+import { Close, Description, Dialog, Title } from "../Dialog";
+import styles from "./UserPasskey.module.css";
+
+const FRAGMENT = graphql(/* GraphQL */ `
+ fragment UserPasskey_passkey on UserPasskey {
+ id
+ name
+ lastUsedAt
+ createdAt
+ }
+`);
+
+const REMOVE_PASSKEY_MUTATION = graphql(/* GraphQL */ `
+ mutation RemovePasskey($id: ID!) {
+ removePasskey(input: { id: $id }) {
+ status
+ }
+ }
+`);
+
+const RENAME_PASSKEY_MUTATION = graphql(/* GraphQL */ `
+ mutation RenamePasskey($id: ID!, $name: String!) {
+ renamePasskey(input: { id: $id, name: $name }) {
+ status
+ }
+ }
+`);
+
+const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
+ disabled,
+ onClick,
+}) => (
+
+ {(t): ReactNode => (
+
+
+
+
+
+ )}
+
+);
+
+const DeleteButtonWithConfirmation: React.FC<
+ ComponentProps & { name: string }
+> = ({ name, onClick, ...rest }) => {
+ const { t } = useTranslation();
+ const onConfirm = (): void => {
+ onClick?.();
+ };
+
+ // NOOP function, otherwise we dont render a cancel button
+ const onDeny = (): void => {};
+
+ return (
+ }>
+
+ {t("frontend.account.passkeys.delete_button_confirmation_modal.body")}
+
+
+ {name}
+
+
+
+
+ {t(
+ "frontend.account.passkeys.delete_button_confirmation_modal.action",
+ )}
+
+
+
+
+ {t("action.cancel")}
+
+
+
+
+ );
+};
+
+const UserPasskey: React.FC<{
+ passkey: FragmentType;
+ onRemove: () => void;
+}> = ({ passkey, onRemove }) => {
+ const { t } = useTranslation();
+ const data = useFragment(FRAGMENT, passkey);
+ const [value, setValue] = useState(data.name);
+ const queryClient = useQueryClient();
+
+ const removePasskey = useMutation({
+ mutationFn: (id: string) =>
+ graphqlRequest({ query: REMOVE_PASSKEY_MUTATION, variables: { id } }),
+ onSuccess: (_data) => {
+ onRemove?.();
+ queryClient.invalidateQueries({ queryKey: ["userPasskeys"] });
+ },
+ });
+ const renamePasskey = useMutation({
+ mutationFn: ({ id, name }: { id: string; name: string }) =>
+ graphqlRequest({
+ query: RENAME_PASSKEY_MUTATION,
+ variables: { id, name },
+ }),
+ onSuccess: (data) => {
+ if (data.renamePasskey.status !== "RENAMED") {
+ return;
+ }
+ queryClient.invalidateQueries({ queryKey: ["userPasskeys"] });
+ },
+ });
+
+ const formattedLastUsed = data.lastUsedAt
+ ? formatReadableDate(parseISO(data.lastUsedAt), new Date())
+ : "";
+ const formattedCreated = formatReadableDate(
+ parseISO(data.createdAt),
+ new Date(),
+ );
+ const status = renamePasskey.data?.renamePasskey.status ?? null;
+
+ const onRemoveClick = (): void => {
+ removePasskey.mutate(data.id);
+ };
+
+ const onInput = (e: React.ChangeEvent) => {
+ setValue(e.target.value);
+ };
+ const onCancel = () => {
+ console.log("wee");
+ setValue(data.name);
+ };
+ const handleSubmit = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+
+ const formData = new FormData(e.currentTarget);
+ const name = formData.get("input") as string;
+
+ await renamePasskey.mutateAsync({ id: data.id, name });
+ };
+
+ return (
+
+
+
+
+ {t("frontend.account.passkeys.name_invalid_error")}
+
+
+
+
+
+
+
+
+
+ {data.lastUsedAt
+ ? t("frontend.account.passkeys.last_used_message", {
+ date: formattedLastUsed,
+ })
+ : t("frontend.account.passkeys.never_used_message")}
+
+
+ {t("frontend.account.passkeys.created_at_message", {
+ date: formattedCreated,
+ })}
+
+
+
+
+ );
+};
+
+export default UserPasskey;
diff --git a/frontend/src/components/UserPasskey/index.ts b/frontend/src/components/UserPasskey/index.ts
new file mode 100644
index 000000000..b35654acd
--- /dev/null
+++ b/frontend/src/components/UserPasskey/index.ts
@@ -0,0 +1,6 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+export { default } from "./UserPasskey";
diff --git a/frontend/src/components/UserProfile/AddPasskeyForm.tsx b/frontend/src/components/UserProfile/AddPasskeyForm.tsx
new file mode 100644
index 000000000..167aa2945
--- /dev/null
+++ b/frontend/src/components/UserProfile/AddPasskeyForm.tsx
@@ -0,0 +1,206 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ Alert,
+ Button,
+ EditInPlace,
+ ErrorMessage,
+} from "@vector-im/compound-web";
+import { useTranslation } from "react-i18next";
+import { graphql } from "../../gql";
+import { graphqlRequest } from "../../graphql";
+import { checkSupport, performRegistration } from "../../utils/webauthn";
+
+const START_REGISTER_PASSKEY_PAYLOAD = graphql(/* GraphQL */ `
+ mutation StartRegisterPasskey {
+ startRegisterPasskey {
+ id
+ options
+ }
+ }
+`);
+
+const COMPLETE_REGISTER_PASSKEY_PAYLOAD = graphql(/* GraphQL */ `
+ mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {
+ completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {
+ status
+ error
+ }
+ }
+`);
+
+const AddPasskeyForm: React.FC = () => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const startRegister = useMutation({
+ mutationFn: () =>
+ graphqlRequest({
+ query: START_REGISTER_PASSKEY_PAYLOAD,
+ }),
+ onSuccess: async (data) => {
+ if (
+ !data.startRegisterPasskey?.id ||
+ !data.startRegisterPasskey?.options
+ ) {
+ throw new Error("Unexpected response from server");
+ }
+
+ webauthnCeremony.mutate(data.startRegisterPasskey.options);
+ return;
+ },
+ });
+ const webauthnCeremony = useMutation({
+ mutationFn: async (options: string) => {
+ try {
+ // The error isn't getting caught by the library so instead returning with data
+ return { response: await performRegistration(options) };
+ } catch (e) {
+ console.error(e);
+ return { error: e as Error };
+ }
+ },
+ });
+ const completeRegister = useMutation({
+ mutationFn: ({
+ id,
+ name,
+ response,
+ }: {
+ id: string;
+ name: string;
+ response: string;
+ }) =>
+ graphqlRequest({
+ query: COMPLETE_REGISTER_PASSKEY_PAYLOAD,
+ variables: { id, name, response },
+ }),
+ onSuccess: async (data) => {
+ // Just display error for the name field
+ if (data.completeRegisterPasskey?.status === "INVALID_NAME") {
+ return;
+ }
+
+ startRegister.reset();
+ webauthnCeremony.reset();
+
+ // If there was an error with the passkey registration itself, go back to the add button without resetting the error from this mutation
+ if (
+ data.completeRegisterPasskey?.status === "INVALID_CHALLENGE" ||
+ data.completeRegisterPasskey?.status === "INVALID_RESPONSE" ||
+ data.completeRegisterPasskey?.status === "EXISTS"
+ ) {
+ return;
+ }
+
+ queryClient.invalidateQueries({ queryKey: ["userPasskeys"] });
+
+ completeRegister.reset();
+ },
+ });
+
+ const handleClick = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+
+ if (startRegister.data?.startRegisterPasskey?.options) {
+ // Reuse the registration we already have if it was interrupted by an error
+ webauthnCeremony.mutate(startRegister.data.startRegisterPasskey.options);
+ } else {
+ await startRegister.mutateAsync();
+ }
+ };
+ const handleSubmit = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+
+ if (
+ !startRegister.data?.startRegisterPasskey.id ||
+ !webauthnCeremony.data?.response
+ )
+ return;
+
+ const formData = new FormData(e.currentTarget);
+ const name = formData.get("input") as string;
+
+ await completeRegister.mutateAsync({
+ id: startRegister.data?.startRegisterPasskey.id,
+ name,
+ response: webauthnCeremony.data?.response,
+ });
+ };
+
+ const status = completeRegister.data?.completeRegisterPasskey.status ?? null;
+ const support = checkSupport();
+
+ return (
+ <>
+ {webauthnCeremony.data?.response ? (
+
+
+ {t("frontend.account.passkeys.name_invalid_error")}
+
+
+ ) : (
+ <>
+ {status === "INVALID_CHALLENGE" && (
+
+ )}
+ {status === "INVALID_RESPONSE" && (
+
+ )}
+ {status === "EXISTS" && (
+
+ )}
+ {webauthnCeremony.data?.error &&
+ webauthnCeremony.data?.error.name !== "NotAllowedError" && (
+
+ )}
+
+ {t("frontend.account.passkeys.add")}
+
+ >
+ )}
+ >
+ );
+};
+
+export default AddPasskeyForm;
diff --git a/frontend/src/components/UserProfile/UserPasskeyList.tsx b/frontend/src/components/UserProfile/UserPasskeyList.tsx
new file mode 100644
index 000000000..acbd824ed
--- /dev/null
+++ b/frontend/src/components/UserProfile/UserPasskeyList.tsx
@@ -0,0 +1,104 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
+import { notFound } from "@tanstack/react-router";
+import { useTransition } from "react";
+import { graphql } from "../../gql";
+import { graphqlRequest } from "../../graphql";
+import {
+ type AnyPagination,
+ FIRST_PAGE,
+ type Pagination,
+ usePages,
+ usePagination,
+} from "../../pagination";
+import PaginationControls from "../PaginationControls";
+import UserPasskey from "../UserPasskey";
+
+const QUERY = graphql(/* GraphQL */ `
+ query UserPasskeyList(
+ $first: Int
+ $after: String
+ $last: Int
+ $before: String
+ ) {
+ viewer {
+ __typename
+ ... on User {
+ passkeys(first: $first, after: $after, last: $last, before: $before) {
+ edges {
+ cursor
+ node {
+ ...UserPasskey_passkey
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+ }
+`);
+
+const query = (pagination: AnyPagination = { first: 6 }) =>
+ queryOptions({
+ queryKey: ["userPasskeys", pagination],
+ queryFn: ({ signal }) =>
+ graphqlRequest({
+ query: QUERY,
+ variables: pagination,
+ signal,
+ }),
+ });
+
+const UserPasskeyList: React.FC = () => {
+ const [pending, startTransition] = useTransition();
+ const [pagination, setPagination] = usePagination();
+ const result = useSuspenseQuery(query(pagination));
+ if (result.data.viewer.__typename !== "User") throw notFound();
+ const passkeys = result.data.viewer.passkeys;
+
+ const [prevPage, nextPage] = usePages(pagination, passkeys.pageInfo);
+
+ const paginate = (pagination: Pagination): void => {
+ startTransition(() => {
+ setPagination(pagination);
+ });
+ };
+
+ const onRemove = (): void => {
+ startTransition(() => {
+ setPagination(FIRST_PAGE);
+ });
+ };
+
+ return (
+ <>
+ {passkeys.edges.map((edge) => (
+
+ ))}
+
+ paginate(prevPage) : null}
+ onNext={nextPage ? (): void => paginate(nextPage) : null}
+ disabled={pending}
+ />
+ >
+ );
+};
+
+export default UserPasskeyList;
diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts
index 34555560b..847b6fce0 100644
--- a/frontend/src/gql/gql.ts
+++ b/frontend/src/gql/gql.ts
@@ -42,12 +42,18 @@ type Documents = {
"\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.UserGreeting_UserFragmentDoc,
"\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": typeof types.UserGreeting_SiteConfigFragmentDoc,
"\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": typeof types.SetDisplayNameDocument,
+ "\n fragment UserPasskey_passkey on UserPasskey {\n id\n name\n lastUsedAt\n createdAt\n }\n": typeof types.UserPasskey_PasskeyFragmentDoc,
+ "\n mutation RemovePasskey($id: ID!) {\n removePasskey(input: { id: $id }) {\n status\n }\n }\n": typeof types.RemovePasskeyDocument,
+ "\n mutation RenamePasskey($id: ID!, $name: String!) {\n renamePasskey(input: { id: $id, name: $name }) {\n status\n }\n }\n": typeof types.RenamePasskeyDocument,
"\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": typeof types.AddEmailForm_UserFragmentDoc,
"\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": typeof types.AddEmailForm_SiteConfigFragmentDoc,
"\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(\n input: { email: $email, password: $password, language: $language }\n ) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": typeof types.AddEmailDocument,
+ "\n mutation StartRegisterPasskey {\n startRegisterPasskey {\n id\n options\n }\n }\n": typeof types.StartRegisterPasskeyDocument,
+ "\n mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {\n completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {\n status\n error\n }\n }\n": typeof types.CompleteRegisterPasskeyDocument,
"\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": typeof types.UserEmailListDocument,
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc,
+ "\n query UserPasskeyList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n passkeys(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserPasskey_passkey\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": typeof types.UserPasskeyListDocument,
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument,
@@ -99,12 +105,18 @@ const documents: Documents = {
"\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": types.UserGreeting_UserFragmentDoc,
"\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": types.UserGreeting_SiteConfigFragmentDoc,
"\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": types.SetDisplayNameDocument,
+ "\n fragment UserPasskey_passkey on UserPasskey {\n id\n name\n lastUsedAt\n createdAt\n }\n": types.UserPasskey_PasskeyFragmentDoc,
+ "\n mutation RemovePasskey($id: ID!) {\n removePasskey(input: { id: $id }) {\n status\n }\n }\n": types.RemovePasskeyDocument,
+ "\n mutation RenamePasskey($id: ID!, $name: String!) {\n renamePasskey(input: { id: $id, name: $name }) {\n status\n }\n }\n": types.RenamePasskeyDocument,
"\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": types.AddEmailForm_UserFragmentDoc,
"\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": types.AddEmailForm_SiteConfigFragmentDoc,
"\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(\n input: { email: $email, password: $password, language: $language }\n ) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": types.AddEmailDocument,
+ "\n mutation StartRegisterPasskey {\n startRegisterPasskey {\n id\n options\n }\n }\n": types.StartRegisterPasskeyDocument,
+ "\n mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {\n completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {\n status\n error\n }\n }\n": types.CompleteRegisterPasskeyDocument,
"\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserEmailListDocument,
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc,
+ "\n query UserPasskeyList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n passkeys(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserPasskey_passkey\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserPasskeyListDocument,
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument,
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument,
@@ -237,6 +249,18 @@ export function graphql(source: "\n fragment UserGreeting_siteConfig on SiteCon
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n"): typeof import('./graphql').SetDisplayNameDocument;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n fragment UserPasskey_passkey on UserPasskey {\n id\n name\n lastUsedAt\n createdAt\n }\n"): typeof import('./graphql').UserPasskey_PasskeyFragmentDoc;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation RemovePasskey($id: ID!) {\n removePasskey(input: { id: $id }) {\n status\n }\n }\n"): typeof import('./graphql').RemovePasskeyDocument;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation RenamePasskey($id: ID!, $name: String!) {\n renamePasskey(input: { id: $id, name: $name }) {\n status\n }\n }\n"): typeof import('./graphql').RenamePasskeyDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -249,6 +273,14 @@ export function graphql(source: "\n fragment AddEmailForm_siteConfig on SiteCon
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(\n input: { email: $email, password: $password, language: $language }\n ) {\n status\n violations\n authentication {\n id\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation StartRegisterPasskey {\n startRegisterPasskey {\n id\n options\n }\n }\n"): typeof import('./graphql').StartRegisterPasskeyDocument;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {\n completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {\n status\n error\n }\n }\n"): typeof import('./graphql').CompleteRegisterPasskeyDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -261,6 +293,10 @@ export function graphql(source: "\n fragment UserEmailList_user on User {\n
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc;
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query UserPasskeyList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n passkeys(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserPasskey_passkey\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n"): typeof import('./graphql').UserPasskeyListDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
index 7519727e3..0adb04638 100644
--- a/frontend/src/gql/graphql.ts
+++ b/frontend/src/gql/graphql.ts
@@ -1980,6 +1980,23 @@ export type SetDisplayNameMutationVariables = Exact<{
export type SetDisplayNameMutation = { __typename?: 'Mutation', setDisplayName: { __typename?: 'SetDisplayNamePayload', status: SetDisplayNameStatus } };
+export type UserPasskey_PasskeyFragment = { __typename?: 'UserPasskey', id: string, name: string, lastUsedAt?: string | null, createdAt: string } & { ' $fragmentName'?: 'UserPasskey_PasskeyFragment' };
+
+export type RemovePasskeyMutationVariables = Exact<{
+ id: Scalars['ID']['input'];
+}>;
+
+
+export type RemovePasskeyMutation = { __typename?: 'Mutation', removePasskey: { __typename?: 'RemovePasskeyPayload', status: RemovePasskeyStatus } };
+
+export type RenamePasskeyMutationVariables = Exact<{
+ id: Scalars['ID']['input'];
+ name: Scalars['String']['input'];
+}>;
+
+
+export type RenamePasskeyMutation = { __typename?: 'Mutation', renamePasskey: { __typename?: 'RenamePasskeyPayload', status: RenamePasskeyStatus } };
+
export type AddEmailForm_UserFragment = { __typename?: 'User', hasPassword: boolean } & { ' $fragmentName'?: 'AddEmailForm_UserFragment' };
export type AddEmailForm_SiteConfigFragment = { __typename?: 'SiteConfig', passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AddEmailForm_SiteConfigFragment' };
@@ -1993,6 +2010,20 @@ export type AddEmailMutationVariables = Exact<{
export type AddEmailMutation = { __typename?: 'Mutation', startEmailAuthentication: { __typename?: 'StartEmailAuthenticationPayload', status: StartEmailAuthenticationStatus, violations?: Array | null, authentication?: { __typename?: 'UserEmailAuthentication', id: string } | null } };
+export type StartRegisterPasskeyMutationVariables = Exact<{ [key: string]: never; }>;
+
+
+export type StartRegisterPasskeyMutation = { __typename?: 'Mutation', startRegisterPasskey: { __typename?: 'StartRegisterPasskeyPayload', id: string, options: string } };
+
+export type CompleteRegisterPasskeyMutationVariables = Exact<{
+ id: Scalars['ID']['input'];
+ name: Scalars['String']['input'];
+ response: Scalars['String']['input'];
+}>;
+
+
+export type CompleteRegisterPasskeyMutation = { __typename?: 'Mutation', completeRegisterPasskey: { __typename?: 'CompleteRegisterPasskeyPayload', status: CompleteRegisterPasskeyStatus, error?: string | null } };
+
export type UserEmailListQueryVariables = Exact<{
first?: InputMaybe;
after?: InputMaybe;
@@ -2010,6 +2041,19 @@ export type UserEmailList_UserFragment = { __typename?: 'User', hasPassword: boo
export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' };
+export type UserPasskeyListQueryVariables = Exact<{
+ first?: InputMaybe;
+ after?: InputMaybe;
+ last?: InputMaybe;
+ before?: InputMaybe;
+}>;
+
+
+export type UserPasskeyListQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', passkeys: { __typename?: 'UserPasskeyConnection', totalCount: number, edges: Array<{ __typename?: 'UserPasskeyEdge', cursor: string, node: (
+ { __typename?: 'UserPasskey' }
+ & { ' $fragmentRefs'?: { 'UserPasskey_PasskeyFragment': UserPasskey_PasskeyFragment } }
+ ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } };
+
export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' };
export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2518,6 +2562,14 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(`
displayNameChangeAllowed
}
`, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString;
+export const UserPasskey_PasskeyFragmentDoc = new TypedDocumentString(`
+ fragment UserPasskey_passkey on UserPasskey {
+ id
+ name
+ lastUsedAt
+ createdAt
+}
+ `, {"fragmentName":"UserPasskey_passkey"}) as unknown as TypedDocumentString;
export const AddEmailForm_UserFragmentDoc = new TypedDocumentString(`
fragment AddEmailForm_user on User {
hasPassword
@@ -2652,6 +2704,20 @@ export const SetDisplayNameDocument = new TypedDocumentString(`
}
}
`) as unknown as TypedDocumentString;
+export const RemovePasskeyDocument = new TypedDocumentString(`
+ mutation RemovePasskey($id: ID!) {
+ removePasskey(input: {id: $id}) {
+ status
+ }
+}
+ `) as unknown as TypedDocumentString;
+export const RenamePasskeyDocument = new TypedDocumentString(`
+ mutation RenamePasskey($id: ID!, $name: String!) {
+ renamePasskey(input: {id: $id, name: $name}) {
+ status
+ }
+}
+ `) as unknown as TypedDocumentString;
export const AddEmailDocument = new TypedDocumentString(`
mutation AddEmail($email: String!, $password: String, $language: String!) {
startEmailAuthentication(
@@ -2665,6 +2731,22 @@ export const AddEmailDocument = new TypedDocumentString(`
}
}
`) as unknown as TypedDocumentString;
+export const StartRegisterPasskeyDocument = new TypedDocumentString(`
+ mutation StartRegisterPasskey {
+ startRegisterPasskey {
+ id
+ options
+ }
+}
+ `) as unknown as TypedDocumentString;
+export const CompleteRegisterPasskeyDocument = new TypedDocumentString(`
+ mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {
+ completeRegisterPasskey(input: {id: $id, name: $name, response: $response}) {
+ status
+ error
+ }
+}
+ `) as unknown as TypedDocumentString;
export const UserEmailListDocument = new TypedDocumentString(`
query UserEmailList($first: Int, $after: String, $last: Int, $before: String) {
viewer {
@@ -2692,6 +2774,35 @@ export const UserEmailListDocument = new TypedDocumentString(`
id
email
}`) as unknown as TypedDocumentString;
+export const UserPasskeyListDocument = new TypedDocumentString(`
+ query UserPasskeyList($first: Int, $after: String, $last: Int, $before: String) {
+ viewer {
+ __typename
+ ... on User {
+ passkeys(first: $first, after: $after, last: $last, before: $before) {
+ edges {
+ cursor
+ node {
+ ...UserPasskey_passkey
+ }
+ }
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+}
+ fragment UserPasskey_passkey on UserPasskey {
+ id
+ name
+ lastUsedAt
+ createdAt
+}`) as unknown as TypedDocumentString;
export const UserProfileDocument = new TypedDocumentString(`
query UserProfile {
viewerSession {
@@ -3395,6 +3506,50 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver {
+ * const { id } = variables;
+ * return HttpResponse.json({
+ * data: { removePasskey }
+ * })
+ * },
+ * requestOptions
+ * )
+ */
+export const mockRemovePasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) =>
+ graphql.mutation(
+ 'RemovePasskey',
+ resolver,
+ options
+ )
+
+/**
+ * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
+ * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
+ * @see https://mswjs.io/docs/basics/response-resolver
+ * @example
+ * mockRenamePasskeyMutation(
+ * ({ query, variables }) => {
+ * const { id, name } = variables;
+ * return HttpResponse.json({
+ * data: { renamePasskey }
+ * })
+ * },
+ * requestOptions
+ * )
+ */
+export const mockRenamePasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) =>
+ graphql.mutation(
+ 'RenamePasskey',
+ resolver,
+ options
+ )
+
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
@@ -3417,6 +3572,49 @@ export const mockAddEmailMutation = (resolver: GraphQLResponseResolver {
+ * return HttpResponse.json({
+ * data: { startRegisterPasskey }
+ * })
+ * },
+ * requestOptions
+ * )
+ */
+export const mockStartRegisterPasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) =>
+ graphql.mutation(
+ 'StartRegisterPasskey',
+ resolver,
+ options
+ )
+
+/**
+ * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
+ * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
+ * @see https://mswjs.io/docs/basics/response-resolver
+ * @example
+ * mockCompleteRegisterPasskeyMutation(
+ * ({ query, variables }) => {
+ * const { id, name, response } = variables;
+ * return HttpResponse.json({
+ * data: { completeRegisterPasskey }
+ * })
+ * },
+ * requestOptions
+ * )
+ */
+export const mockCompleteRegisterPasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) =>
+ graphql.mutation(
+ 'CompleteRegisterPasskey',
+ resolver,
+ options
+ )
+
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
@@ -3439,6 +3637,28 @@ export const mockUserEmailListQuery = (resolver: GraphQLResponseResolver {
+ * const { first, after, last, before } = variables;
+ * return HttpResponse.json({
+ * data: { viewer }
+ * })
+ * },
+ * requestOptions
+ * )
+ */
+export const mockUserPasskeyListQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) =>
+ graphql.query(
+ 'UserPasskeyList',
+ resolver,
+ options
+ )
+
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx
index e49fb3915..0dde64fbb 100644
--- a/frontend/src/routes/_account.index.tsx
+++ b/frontend/src/routes/_account.index.tsx
@@ -8,6 +8,7 @@ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { notFound, redirect, useNavigate } from "@tanstack/react-router";
import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
import { Button, Text } from "@vector-im/compound-web";
+import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import * as v from "valibot";
import AccountDeleteButton from "../components/AccountDeleteButton";
@@ -19,9 +20,11 @@ import LoadingSpinner from "../components/LoadingSpinner";
import Separator from "../components/Separator";
import { useEndBrowserSession } from "../components/Session/EndBrowserSessionButton";
import AddEmailForm from "../components/UserProfile/AddEmailForm";
+import AddPasskeyForm from "../components/UserProfile/AddPasskeyForm";
import UserEmailList, {
query as userEmailListQuery,
} from "../components/UserProfile/UserEmailList";
+import UserPasskeyList from "../components/UserProfile/UserPasskeyList";
import { graphql } from "../gql";
import { graphqlRequest } from "../graphql";
@@ -230,7 +233,10 @@ function Index(): React.ReactElement {
{siteConfig.passkeysEnabled && (
<>
- placeholder text
+ }>
+
+
+
diff --git a/frontend/src/utils/webauthn.ts b/frontend/src/utils/webauthn.ts
new file mode 100644
index 000000000..54c1a8dae
--- /dev/null
+++ b/frontend/src/utils/webauthn.ts
@@ -0,0 +1,118 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+// Polyfills for fromJSON and toJSON utils which aren't stable yet
+if (typeof window !== "undefined" && window.PublicKeyCredential) {
+ const b64urlDecode = (b64: string) =>
+ Uint8Array.from(
+ atob(b64.replace(/-/g, "+").replace(/_/g, "/")),
+ (c) => c.codePointAt(0) as number,
+ );
+ const b64urlEncode = (buf: ArrayBuffer) =>
+ btoa(
+ Array.from(new Uint8Array(buf), (b) => String.fromCodePoint(b)).join(""),
+ )
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+
+ // TS doesn't know AuthenticatorAttestationResponseJSON and AuthenticatorAssertionResponseJSON yet
+ type AuthenticatorAttestationResponseJSON = {
+ clientDataJSON: Base64URLString;
+ authenticatorData: Base64URLString;
+ transports: string[];
+ publicKey?: Base64URLString;
+ publicKeyAlgorithm: COSEAlgorithmIdentifier;
+ attestationObject: Base64URLString;
+ };
+
+ type AuthenticatorAssertionResponseJSON = {
+ clientDataJSON: Base64URLString;
+ authenticatorData: Base64URLString;
+ signature: Base64URLString;
+ userHandle?: Base64URLString;
+ };
+
+ if (!window.PublicKeyCredential.parseCreationOptionsFromJSON) {
+ window.PublicKeyCredential.parseCreationOptionsFromJSON = (options) =>
+ ({
+ ...options,
+ user: {
+ ...options.user,
+ id: b64urlDecode(options.user.id),
+ },
+ challenge: b64urlDecode(options.challenge),
+ excludeCredentials: options.excludeCredentials?.map((c) => ({
+ ...c,
+ id: b64urlDecode,
+ })),
+ }) as PublicKeyCredentialCreationOptions;
+ }
+
+ if (!window.PublicKeyCredential.parseRequestOptionsFromJSON) {
+ window.PublicKeyCredential.parseRequestOptionsFromJSON = (options) =>
+ ({
+ ...options,
+ challenge: b64urlDecode(options.challenge),
+ allowCredentials: options.allowCredentials?.map((c) => ({
+ ...c,
+ id: b64urlDecode,
+ })),
+ }) as PublicKeyCredentialRequestOptions;
+ }
+
+ if (!window.PublicKeyCredential.prototype.toJSON) {
+ window.PublicKeyCredential.prototype.toJSON = function () {
+ const cred = {
+ id: this.id,
+ rawId: b64urlEncode(this.rawId),
+ response: {
+ clientDataJSON: b64urlEncode(this.response.clientDataJSON),
+ },
+ authenticatorAttachment: this.authenticatorAttachment,
+ clientExtensionResults: this.getClientExtensionResults(),
+ type: this.type,
+ } as PublicKeyCredentialJSON;
+
+ if (this.response instanceof window.AuthenticatorAttestationResponse) {
+ const publicKey = this.response.getPublicKey();
+ cred.response = {
+ ...cred.response,
+ authenticatorData: b64urlEncode(this.response.getAuthenticatorData()),
+ transports: this.response.getTransports(),
+ publicKey: publicKey ? b64urlEncode(publicKey) : null,
+ publicKeyAlgorithm: this.response.getPublicKeyAlgorithm(),
+ attestationObject: b64urlEncode(this.response.attestationObject),
+ } as AuthenticatorAttestationResponseJSON;
+ }
+
+ if (this.response instanceof window.AuthenticatorAssertionResponse) {
+ const userHandle = this.response.userHandle;
+ cred.response = {
+ ...cred.response,
+ authenticatorData: b64urlEncode(this.response.authenticatorData),
+ signature: b64urlEncode(this.response.signature),
+ userHandle: userHandle ? b64urlEncode(userHandle) : null,
+ } as AuthenticatorAssertionResponseJSON;
+ }
+
+ return cred;
+ };
+ }
+}
+
+export function checkSupport(): boolean {
+ return !!window?.PublicKeyCredential;
+}
+
+export async function performRegistration(options: string): Promise {
+ const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(
+ JSON.parse(options),
+ );
+
+ const credential = await navigator.credentials.create({ publicKey });
+
+ return JSON.stringify(credential);
+}
From 9ec383c54acb6f5a4639f16eb4ca85a6a58c54f3 Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Thu, 13 Mar 2025 13:54:46 +0000
Subject: [PATCH 5/7] Template for passkey login
---
crates/templates/src/context.rs | 24 ++++++-
frontend/knip.config.ts | 7 +-
frontend/locales/en.json | 3 +
.../src/components/PasskeyLoginButton.tsx | 65 +++++++++++++++++++
frontend/src/i18n.ts | 3 +-
frontend/src/template_passkey.tsx | 35 ++++++++++
frontend/src/utils/webauthn.ts | 10 +++
frontend/vite.config.ts | 1 +
templates/pages/login.html | 30 ++++++---
translations/en.json | 30 ++++-----
10 files changed, 178 insertions(+), 30 deletions(-)
create mode 100644 frontend/src/components/PasskeyLoginButton.tsx
create mode 100644 frontend/src/template_passkey.tsx
diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs
index 0a04677eb..0fdc3580c 100644
--- a/crates/templates/src/context.rs
+++ b/crates/templates/src/context.rs
@@ -394,13 +394,19 @@ pub enum LoginFormField {
/// The password field
Password,
+
+ /// The passkey challenge
+ PasskeyChallengeId,
+
+ /// The passkey response
+ PasskeyResponse,
}
impl FormField for LoginFormField {
fn keep(&self) -> bool {
match self {
- Self::Username => true,
- Self::Password => false,
+ Self::Username | Self::PasskeyChallengeId => true,
+ Self::Password | Self::PasskeyResponse => false,
}
}
}
@@ -461,6 +467,7 @@ pub struct LoginContext {
form: FormState,
next: Option,
providers: Vec,
+ webauthn_options: String,
}
impl TemplateContext for LoginContext {
@@ -478,11 +485,13 @@ impl TemplateContext for LoginContext {
form: FormState::default(),
next: None,
providers: Vec::new(),
+ webauthn_options: String::new(),
},
LoginContext {
form: FormState::default(),
next: None,
providers: Vec::new(),
+ webauthn_options: String::new(),
},
LoginContext {
form: FormState::default()
@@ -496,12 +505,14 @@ impl TemplateContext for LoginContext {
),
next: None,
providers: Vec::new(),
+ webauthn_options: String::new(),
},
LoginContext {
form: FormState::default()
.with_error_on_field(LoginFormField::Username, FieldError::Exists),
next: None,
providers: Vec::new(),
+ webauthn_options: String::new(),
},
]
}
@@ -533,6 +544,15 @@ impl LoginContext {
..self
}
}
+
+ /// Set the webauthn options
+ #[must_use]
+ pub fn with_webauthn_options(self, webauthn_options: String) -> Self {
+ Self {
+ webauthn_options,
+ ..self
+ }
+ }
}
/// Fields of the registration form
diff --git a/frontend/knip.config.ts b/frontend/knip.config.ts
index b5e382961..7f596360c 100644
--- a/frontend/knip.config.ts
+++ b/frontend/knip.config.ts
@@ -6,7 +6,12 @@
import type { KnipConfig } from "knip";
export default {
- entry: ["src/main.tsx", "src/swagger.ts", "src/routes/*"],
+ entry: [
+ "src/main.tsx",
+ "src/swagger.ts",
+ "src/template_passkey.tsx",
+ "src/routes/*",
+ ],
ignore: ["src/gql/*", "src/routeTree.gen.ts", ".storybook/locales.ts"],
ignoreDependencies: [
// This is used by the tailwind PostCSS plugin, but not detected by knip
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 5a5b294bf..3fd06dc27 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -346,5 +346,8 @@
"view_messages": "View your existing messages and data",
"view_profile": "See your profile info and contact details"
}
+ },
+ "passkeys": {
+ "login": "Continue with Passkey"
}
}
diff --git a/frontend/src/components/PasskeyLoginButton.tsx b/frontend/src/components/PasskeyLoginButton.tsx
new file mode 100644
index 000000000..2bf1136b8
--- /dev/null
+++ b/frontend/src/components/PasskeyLoginButton.tsx
@@ -0,0 +1,65 @@
+import { useMutation } from "@tanstack/react-query";
+import IconKey from "@vector-im/compound-design-tokens/assets/web/icons/key";
+import { Alert, Button } from "@vector-im/compound-web";
+import { useTranslation } from "react-i18next";
+import { checkSupport, performAuthentication } from "../utils/webauthn";
+
+const PasskeyLoginButton: React.FC<{ options?: string }> = ({ options }) => {
+ const { t } = useTranslation();
+ const webauthnCeremony = useMutation({
+ mutationFn: async (options: string) => {
+ try {
+ return { response: await performAuthentication(options) };
+ } catch (e) {
+ console.error(e);
+ return { error: e as Error };
+ }
+ },
+ onSuccess: (data) => {
+ if (data.response) {
+ const form = document.querySelector("form") as HTMLFormElement;
+ const formResponse = form?.querySelector(
+ '[name="passkey_response"]',
+ ) as HTMLInputElement;
+
+ formResponse.value = data.response;
+ form.submit();
+ }
+ },
+ });
+
+ if (!options) return;
+
+ const handleClick = async (
+ e: React.FormEvent,
+ ): Promise => {
+ e.preventDefault();
+
+ webauthnCeremony.mutate(options);
+ };
+
+ const support = checkSupport();
+
+ return (
+
+ {webauthnCeremony.data?.error &&
+ webauthnCeremony.data?.error.name !== "NotAllowedError" && (
+
+ )}
+
+ {t("passkeys.login")}
+
+
+ );
+};
+
+export default PasskeyLoginButton;
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 77e5b7284..fa91e5e46 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -81,7 +81,7 @@ const Backend = {
},
} satisfies BackendModule;
-export const setupI18n = () => {
+export const setupI18n = () =>
i18n
.use(Backend)
.use(LanguageDetector)
@@ -96,7 +96,6 @@ export const setupI18n = () => {
escapeValue: false, // React has built-in XSS protections
},
} satisfies InitOptions);
-};
import.meta.hot?.on("locales-update", () => {
i18n.reloadResources().then(() => {
diff --git a/frontend/src/template_passkey.tsx b/frontend/src/template_passkey.tsx
new file mode 100644
index 000000000..608f99676
--- /dev/null
+++ b/frontend/src/template_passkey.tsx
@@ -0,0 +1,35 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+import { QueryClientProvider } from "@tanstack/react-query";
+import { StrictMode, Suspense } from "react";
+import { createRoot } from "react-dom/client";
+import { I18nextProvider } from "react-i18next";
+import LoadingSpinner from "./components/LoadingSpinner";
+import PasskeyLoginButton from "./components/PasskeyLoginButton";
+import { queryClient } from "./graphql";
+import i18n, { setupI18n } from "./i18n";
+
+setupI18n();
+
+interface IWindow {
+ WEBAUTHN_OPTIONS?: string;
+}
+
+const options =
+ (typeof window !== "undefined" && (window as IWindow).WEBAUTHN_OPTIONS) ||
+ undefined;
+
+createRoot(document.getElementById("passkey-root") as HTMLElement).render(
+
+
+ }>
+
+
+
+
+
+ ,
+);
diff --git a/frontend/src/utils/webauthn.ts b/frontend/src/utils/webauthn.ts
index 54c1a8dae..307661fc9 100644
--- a/frontend/src/utils/webauthn.ts
+++ b/frontend/src/utils/webauthn.ts
@@ -116,3 +116,13 @@ export async function performRegistration(options: string): Promise {
return JSON.stringify(credential);
}
+
+export async function performAuthentication(options: string): Promise {
+ const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
+ JSON.parse(options),
+ );
+
+ const credential = await navigator.credentials.get({ publicKey });
+
+ return JSON.stringify(credential);
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index fdc00cab7..b8f3ff687 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -59,6 +59,7 @@ export default defineConfig((env) => ({
resolve(__dirname, "src/shared.css"),
resolve(__dirname, "src/templates.css"),
resolve(__dirname, "src/swagger.ts"),
+ resolve(__dirname, "src/template_passkey.tsx"),
],
},
},
diff --git a/templates/pages/login.html b/templates/pages/login.html
index 10802adf8..2c9516c59 100644
--- a/templates/pages/login.html
+++ b/templates/pages/login.html
@@ -10,6 +10,8 @@
{% from "components/idp_brand.html" import logo %}
+{% set params = next["params"] | default({}) | to_params(prefix="?") %}
+
{% block content %}
- {% set params = next["params"] | default({}) | to_params(prefix="?") %}
{{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }}
{% endif %}
- {% if not providers and not features.password_login %}
+ {% if not providers and not features.password_login and not features.passkeys_enabled %}
{{ _("mas.login.no_login_methods") }}
{% endif %}
+
+ {% if features.passkeys_enabled %}
+
+ {{ include_asset('src/template_passkey.tsx') | indent(4) | safe }}
+ {% endif %}
{% endblock content %}
diff --git a/translations/en.json b/translations/en.json
index 27410e0d5..65628f824 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -10,11 +10,11 @@
},
"continue": "Continue",
"@continue": {
- "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
+ "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:70:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
},
"create_account": "Create Account",
"@create_account": {
- "context": "pages/login.html:102:33-59, pages/upstream_oauth2/do_register.html:191:26-52"
+ "context": "pages/login.html:109:33-59, pages/upstream_oauth2/do_register.html:191:26-52"
},
"sign_in": "Sign in",
"@sign_in": {
@@ -91,7 +91,7 @@
},
"password": "Password",
"@password": {
- "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53"
+ "context": "pages/login.html:58:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53"
},
"password_confirm": "Confirm password",
"@password_confirm": {
@@ -99,7 +99,7 @@
},
"username": "Username",
"@username": {
- "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59"
+ "context": "pages/login.html:53:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59"
}
},
"error": {
@@ -419,47 +419,43 @@
"login": {
"call_to_register": "Don't have an account yet?",
"@call_to_register": {
- "context": "pages/login.html:98:13-44"
+ "context": "pages/login.html:106:13-44"
},
"continue_with_provider": "Continue with %(provider)s",
"@continue_with_provider": {
- "context": "pages/login.html:89:15-67, pages/register/index.html:53:15-67",
+ "context": "pages/login.html:97:15-67, pages/register/index.html:53:15-67",
"description": "Button to log in with an upstream provider"
},
"description": "Please sign in to continue:",
"@description": {
- "context": "pages/login.html:29:29-55"
+ "context": "pages/login.html:31:29-55"
},
"forgot_password": "Forgot password?",
"@forgot_password": {
- "context": "pages/login.html:61:35-65",
+ "context": "pages/login.html:63:35-65",
"description": "On the login page, link to the account recovery process"
},
"headline": "Sign in",
"@headline": {
- "context": "pages/login.html:28:31-54"
+ "context": "pages/login.html:30:31-54"
},
"link": {
"description": "Linking your %(provider)s account",
"@description": {
- "context": "pages/login.html:24:29-75"
+ "context": "pages/login.html:26:29-75"
},
"headline": "Sign in to link",
"@headline": {
- "context": "pages/login.html:22:31-59"
+ "context": "pages/login.html:24:31-59"
}
},
"no_login_methods": "No login methods available.",
"@no_login_methods": {
- "context": "pages/login.html:108:11-42"
+ "context": "pages/login.html:115:11-42"
},
"username_or_email": "Username or Email",
"@username_or_email": {
- "context": "pages/login.html:47:39-71"
- },
- "with_passkey": "Sign in with a Passkey",
- "@with_passkey": {
- "context": "pages/login.html:76:28-55"
+ "context": "pages/login.html:49:39-71"
}
},
"navbar": {
From 1865a47591f9665ac28954d5964e9e41d82a92ab Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Fri, 14 Mar 2025 12:18:21 +0000
Subject: [PATCH 6/7] Passkey login handler
---
crates/handlers/src/lib.rs | 2 +
crates/handlers/src/test_utils.rs | 8 +
crates/handlers/src/views/login/cookie.rs | 89 ++++
.../src/views/{login.rs => login/mod.rs} | 422 +++++++++++++-----
crates/handlers/src/webauthn.rs | 166 ++++++-
5 files changed, 577 insertions(+), 110 deletions(-)
create mode 100644 crates/handlers/src/views/login/cookie.rs
rename crates/handlers/src/views/{login.rs => login/mod.rs} (79%)
diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs
index 8e2f3ff3d..d020ecc07 100644
--- a/crates/handlers/src/lib.rs
+++ b/crates/handlers/src/lib.rs
@@ -48,6 +48,7 @@ use opentelemetry::metrics::Meter;
use sqlx::PgPool;
use tower::util::AndThenLayer;
use tower_http::cors::{Any, CorsLayer};
+use webauthn::Webauthn;
use self::{graphql::ExtraRouterParameters, passwords::PasswordManager};
@@ -348,6 +349,7 @@ where
Limiter: FromRef,
reqwest::Client: FromRef,
Arc: FromRef,
+ Webauthn: FromRef,
BoxClock: FromRequestParts,
BoxRng: FromRequestParts,
Policy: FromRequestParts,
diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs
index 19d08f187..38ce6abf9 100644
--- a/crates/handlers/src/test_utils.rs
+++ b/crates/handlers/src/test_utils.rs
@@ -113,6 +113,7 @@ pub(crate) struct TestState {
pub rng: Arc>,
pub http_client: reqwest::Client,
pub task_tracker: TaskTracker,
+ pub webauthn: Webauthn,
queue_worker: Arc>,
#[allow(dead_code)] // It is used, as it will cancel the CancellationToken when dropped
@@ -280,6 +281,7 @@ impl TestState {
rng,
http_client,
task_tracker,
+ webauthn,
queue_worker,
cancellation_drop_guard: Arc::new(shutdown_token.drop_guard()),
})
@@ -585,6 +587,12 @@ impl FromRef for reqwest::Client {
}
}
+impl FromRef for Webauthn {
+ fn from_ref(input: &TestState) -> Self {
+ input.webauthn.clone()
+ }
+}
+
impl FromRequestParts for ActivityTracker {
type Rejection = Infallible;
diff --git a/crates/handlers/src/views/login/cookie.rs b/crates/handlers/src/views/login/cookie.rs
new file mode 100644
index 000000000..e9e7c231e
--- /dev/null
+++ b/crates/handlers/src/views/login/cookie.rs
@@ -0,0 +1,89 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+use std::collections::BTreeSet;
+
+use chrono::{DateTime, Duration, Utc};
+use mas_axum_utils::cookies::CookieJar;
+use mas_data_model::{Clock, UserPasskeyChallenge};
+use serde::{Deserialize, Serialize};
+use ulid::Ulid;
+
+/// Name of the cookie
+static COOKIE_NAME: &str = "user-passkey-challenges";
+
+/// Sessions expire after an hour
+static SESSION_MAX_TIME: Duration = Duration::hours(1);
+
+/// The content of the cookie, which stores a list of user passkey challenge IDs
+#[derive(Serialize, Deserialize, Default, Debug)]
+pub struct UserPasskeyChallenges(BTreeSet);
+
+impl UserPasskeyChallenges {
+ /// Load the user passkey challenges cookie
+ pub fn load(cookie_jar: &CookieJar) -> Self {
+ match cookie_jar.load(COOKIE_NAME) {
+ Ok(Some(challenges)) => challenges,
+ Ok(None) => Self::default(),
+ Err(e) => {
+ tracing::warn!(
+ error = &e as &dyn std::error::Error,
+ "Invalid passkey challenges cookie"
+ );
+ Self::default()
+ }
+ }
+ }
+
+ /// Returns true if the cookie is empty
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Save the user passkey challenges to the cookie jar
+ pub fn save(self, cookie_jar: CookieJar, clock: &C) -> CookieJar
+ where
+ C: Clock,
+ {
+ let this = self.expire(clock.now());
+
+ if this.is_empty() {
+ cookie_jar.remove(COOKIE_NAME)
+ } else {
+ cookie_jar.save(COOKIE_NAME, &this, false)
+ }
+ }
+
+ fn expire(mut self, now: DateTime) -> Self {
+ self.0.retain(|id| {
+ let Ok(ts) = id.timestamp_ms().try_into() else {
+ return false;
+ };
+ let Some(when) = DateTime::from_timestamp_millis(ts) else {
+ return false;
+ };
+ now - when < SESSION_MAX_TIME
+ });
+
+ self
+ }
+
+ /// Add a new challenge
+ pub fn add(mut self, passkey_challenge: &UserPasskeyChallenge) -> Self {
+ self.0.insert(passkey_challenge.id);
+ self
+ }
+
+ /// Check if the challenge is in the list
+ pub fn contains(&self, passkey_challenge_id: &Ulid) -> bool {
+ self.0.contains(passkey_challenge_id)
+ }
+
+ /// Mark a challenge as consumed to avoid replay
+ pub fn consume_challenge(mut self, passkey_challenge_id: &Ulid) -> Self {
+ self.0.remove(passkey_challenge_id);
+ self
+ }
+}
diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login/mod.rs
similarity index 79%
rename from crates/handlers/src/views/login.rs
rename to crates/handlers/src/views/login/mod.rs
index 4c15156f8..2da5d4db3 100644
--- a/crates/handlers/src/views/login.rs
+++ b/crates/handlers/src/views/login/mod.rs
@@ -4,6 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
+mod cookie;
+
use std::sync::{Arc, LazyLock};
use axum::{
@@ -11,13 +13,14 @@ use axum::{
response::{Html, IntoResponse, Response},
};
use axum_extra::typed_header::TypedHeader;
+use cookie::UserPasskeyChallenges;
use hyper::StatusCode;
use mas_axum_utils::{
InternalError, SessionInfoExt,
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
};
-use mas_data_model::{BoxClock, BoxRng, Clock, oauth2::LoginHint};
+use mas_data_model::{BoxClock, BoxRng, Clock, Password, User, UserPasskey, oauth2::LoginHint};
use mas_i18n::DataLocale;
use mas_matrix::HomeserverConnection;
use mas_router::{UpstreamOAuth2Authorize, UrlBuilder};
@@ -31,8 +34,9 @@ use mas_templates::{
PostAuthContext, PostAuthContextInner, TemplateContext, Templates, ToFormState,
};
use opentelemetry::{Key, KeyValue, metrics::Counter};
-use rand::Rng;
+use rand::RngCore;
use serde::{Deserialize, Serialize};
+use ulid::Ulid;
use zeroize::Zeroizing;
use super::shared::OptionalPostAuthAction;
@@ -40,6 +44,7 @@ use crate::{
BoundActivityTracker, Limiter, METER, PreferredLanguage, RequesterFingerprint, SiteConfig,
passwords::{PasswordManager, PasswordVerificationResult},
session::{SessionOrFallback, load_session_or_fallback},
+ webauthn::{Webauthn, WebauthnError},
};
static PASSWORD_LOGIN_COUNTER: LazyLock> = LazyLock::new(|| {
@@ -55,12 +60,20 @@ const RESULT: Key = Key::from_static_str("result");
pub(crate) struct LoginForm {
username: String,
password: String,
+ passkey_challenge_id: Option,
+ passkey_response: Option,
}
impl ToFormState for LoginForm {
type Field = LoginFormField;
}
+#[derive(Debug)]
+enum AuthenticatedWith {
+ Password(Password),
+ Passkey(UserPasskey),
+}
+
#[tracing::instrument(name = "handlers.views.login.get", skip_all)]
pub(crate) async fn get(
mut rng: BoxRng,
@@ -69,6 +82,7 @@ pub(crate) async fn get(
State(templates): State,
State(url_builder): State,
State(site_config): State,
+ State(webauthn): State,
State(homeserver): State>,
mut repo: BoxRepository,
activity_tracker: BoundActivityTracker,
@@ -119,12 +133,13 @@ pub(crate) async fn get(
cookie_jar,
FormState::default(),
query,
- &mut repo,
+ repo,
&clock,
&mut rng,
&templates,
&homeserver,
&site_config,
+ webauthn,
)
.await
}
@@ -134,7 +149,7 @@ pub(crate) async fn post(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
- State(password_manager): State,
+ (State(password_manager), State(webauthn)): (State, State),
State(site_config): State,
State(templates): State,
State(url_builder): State,
@@ -159,11 +174,11 @@ pub(crate) async fn post(
// Validate the form
let mut form_state = form.to_form_state();
- if form.username.is_empty() {
+ if form.username.is_empty() && form.passkey_response.as_ref().is_none_or(String::is_empty) {
form_state.add_error_on_field(LoginFormField::Username, FieldError::Required);
}
- if form.password.is_empty() {
+ if form.password.is_empty() && form.passkey_response.as_ref().is_none_or(String::is_empty) {
form_state.add_error_on_field(LoginFormField::Password, FieldError::Required);
}
@@ -175,60 +190,208 @@ pub(crate) async fn post(
cookie_jar,
form_state,
query,
- &mut repo,
+ repo,
&clock,
&mut rng,
&templates,
&homeserver,
&site_config,
+ webauthn,
)
.await;
}
- // Extract the localpart of the MXID, fallback to the bare username
- let username = homeserver
- .localpart(&form.username)
- .unwrap_or(&form.username);
+ let cookie_jar = if let Some(form_challenge_id) = &form.passkey_challenge_id {
+ // Validate passkey challenge cookie
+ let challenge_id = Ulid::from_string(form_challenge_id).unwrap_or_default();
+ let challenges = UserPasskeyChallenges::load(&cookie_jar);
+ if !challenges.contains(&challenge_id) {
+ let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
+ return render(
+ locale,
+ cookie_jar,
+ form_state,
+ query,
+ repo,
+ &clock,
+ &mut rng,
+ &templates,
+ &homeserver,
+ &site_config,
+ webauthn,
+ )
+ .await;
+ }
- // First, lookup the user
- let Some(user) = get_user_by_email_or_by_username(&site_config, &mut repo, username).await?
- else {
- tracing::warn!(username, "User not found");
- let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
- PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
+ // Consume the cookie already as we'll give them a new one anyway
+ challenges
+ .consume_challenge(&challenge_id)
+ .save(cookie_jar, &clock)
+ } else {
+ cookie_jar
+ };
+
+ let validation = match (
+ form.password.is_empty(),
+ form.passkey_response.as_ref().is_none_or(String::is_empty),
+ ) {
+ // Password login
+ (false, true) => {
+ password_login(
+ &mut rng,
+ &clock,
+ password_manager,
+ &site_config,
+ limiter,
+ &homeserver,
+ &mut repo,
+ requester,
+ form,
+ form_state,
+ )
+ .await?
+ }
+ // Passkey login. User's password manager may have prefilled the password despite using a
+ // passkey
+ (_, false) => {
+ if !site_config.passkeys_enabled {
+ let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
+ return render(
+ locale,
+ cookie_jar,
+ form_state,
+ query,
+ repo,
+ &clock,
+ &mut rng,
+ &templates,
+ &homeserver,
+ &site_config,
+ webauthn,
+ )
+ .await;
+ }
+ passkey_login(
+ &clock, &webauthn, limiter, &mut repo, requester, form, form_state,
+ )
+ .await?
+ }
+ _ => Err(form_state.with_error_on_form(FormError::Internal)),
+ };
+
+ let Ok((user, auth_with)) = validation else {
+ let form_state = validation.unwrap_err();
return render(
locale,
cookie_jar,
form_state,
query,
- &mut repo,
+ repo,
&clock,
&mut rng,
&templates,
&homeserver,
&site_config,
+ webauthn,
)
.await;
};
+ // Now that we have checked the user password, we now want to show an error if
+ // the user is locked or deactivated
+ if user.deactivated_at.is_some() {
+ tracing::warn!(user.username, "User is deactivated");
+ PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
+ let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
+ let ctx = AccountInactiveContext::new(user)
+ .with_csrf(csrf_token.form_value())
+ .with_language(locale);
+ let content = templates.render_account_deactivated(&ctx)?;
+ return Ok((cookie_jar, Html(content)).into_response());
+ }
+
+ if user.locked_at.is_some() {
+ tracing::warn!(user.username, "User is locked");
+ PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
+ let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
+ let ctx = AccountInactiveContext::new(user)
+ .with_csrf(csrf_token.form_value())
+ .with_language(locale);
+ let content = templates.render_account_locked(&ctx)?;
+ return Ok((cookie_jar, Html(content)).into_response());
+ }
+
+ // At this point, we should have a 'valid' user. In case we missed something, we
+ // want it to crash in tests/debug builds
+ debug_assert!(user.is_valid());
+
+ // Start a new session
+ let user_session = repo
+ .browser_session()
+ .add(&mut rng, &clock, &user, user_agent)
+ .await?;
+
+ match auth_with {
+ AuthenticatedWith::Password(user_password) => {
+ // And mark it as authenticated by the password
+ repo.browser_session()
+ .authenticate_with_password(&mut rng, &clock, &user_session, &user_password)
+ .await?;
+ }
+ AuthenticatedWith::Passkey(passkey) => {
+ // And mark it as authenticated by the passkey
+ repo.browser_session()
+ .authenticate_with_passkey(&mut rng, &clock, &user_session, &passkey)
+ .await?;
+ }
+ }
+
+ repo.save().await?;
+
+ PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]);
+
+ activity_tracker
+ .record_browser_session(&clock, &user_session)
+ .await;
+
+ let cookie_jar = cookie_jar.set_session(&user_session);
+ let reply = query.go_next(&url_builder);
+ Ok((cookie_jar, reply).into_response())
+}
+
+async fn password_login(
+ mut rng: &mut BoxRng,
+ clock: &BoxClock,
+ password_manager: PasswordManager,
+ site_config: &SiteConfig,
+ limiter: Limiter,
+ homeserver: &dyn HomeserverConnection,
+ repo: &mut impl RepositoryAccess,
+ requester: RequesterFingerprint,
+ form: LoginForm,
+ form_state: FormState,
+) -> Result>, InternalError> {
+ // Extract the localpart of the MXID, fallback to the bare username
+ let username = homeserver
+ .localpart(&form.username)
+ .unwrap_or(&form.username);
+
+ // First, lookup the user
+ let Some(user) = get_user_by_email_or_by_username(site_config, repo, username).await? else {
+ tracing::warn!(username, "User not found");
+ PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::InvalidCredentials)
+ ));
+ };
+
// Check the rate limit
if let Err(e) = limiter.check_password(requester, &user) {
tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded");
- let form_state = form_state.with_error_on_form(FormError::RateLimitExceeded);
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
- return render(
- locale,
- cookie_jar,
- form_state,
- query,
- &mut repo,
- &clock,
- &mut rng,
- &templates,
- &homeserver,
- &site_config,
- )
- .await;
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::RateLimitExceeded)
+ ));
}
// And its password
@@ -236,21 +399,10 @@ pub(crate) async fn post(
// There is no password for this user, but we don't want to disclose that. Show
// a generic 'invalid credentials' error instead
tracing::warn!(username, "No password for user");
- let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
- return render(
- locale,
- cookie_jar,
- form_state,
- query,
- &mut repo,
- &clock,
- &mut rng,
- &templates,
- &homeserver,
- &site_config,
- )
- .await;
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::InvalidCredentials)
+ ));
};
let password = Zeroizing::new(form.password);
@@ -270,7 +422,7 @@ pub(crate) async fn post(
repo.user_password()
.add(
&mut rng,
- &clock,
+ clock,
&user,
version,
new_password_hash,
@@ -281,75 +433,104 @@ pub(crate) async fn post(
Ok(PasswordVerificationResult::Success(None)) => user_password,
Ok(PasswordVerificationResult::Failure) => {
tracing::warn!(username, "Failed to verify/upgrade password for user");
- let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "mismatch")]);
- return render(
- locale,
- cookie_jar,
- form_state,
- query,
- &mut repo,
- &clock,
- &mut rng,
- &templates,
- &homeserver,
- &site_config,
- )
- .await;
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::InvalidCredentials)
+ ));
}
Err(err) => return Err(InternalError::from_anyhow(err)),
};
- // Now that we have checked the user password, we now want to show an error if
- // the user is locked or deactivated
- if user.deactivated_at.is_some() {
- tracing::warn!(username, "User is deactivated");
- PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
- let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
- let ctx = AccountInactiveContext::new(user)
- .with_csrf(csrf_token.form_value())
- .with_language(locale);
- let content = templates.render_account_deactivated(&ctx)?;
- return Ok((cookie_jar, Html(content)).into_response());
- }
+ Ok(Ok((user, AuthenticatedWith::Password(user_password))))
+}
- if user.locked_at.is_some() {
- tracing::warn!(username, "User is locked");
- PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]);
- let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
- let ctx = AccountInactiveContext::new(user)
- .with_csrf(csrf_token.form_value())
- .with_language(locale);
- let content = templates.render_account_locked(&ctx)?;
- return Ok((cookie_jar, Html(content)).into_response());
- }
+async fn passkey_login(
+ clock: &BoxClock,
+ webauthn: &Webauthn,
+ limiter: Limiter,
+ repo: &mut impl RepositoryAccess,
+ requester: RequesterFingerprint,
+ form: LoginForm,
+ form_state: FormState,
+) -> Result>, InternalError> {
+ let Some(passkey_challenge_id) = form.passkey_challenge_id else {
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::InvalidCredentials)
+ ));
+ };
- // At this point, we should have a 'valid' user. In case we missed something, we
- // want it to crash in tests/debug builds
- debug_assert!(user.is_valid());
+ let Some(passkey_response) = form.passkey_response else {
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::InvalidCredentials)
+ ));
+ };
- // Start a new session
- let user_session = repo
- .browser_session()
- .add(&mut rng, &clock, &user, user_agent)
- .await?;
+ let challenge_id = Ulid::from_string(&passkey_challenge_id).unwrap_or_default();
+
+ // Find the challenge
+ let challenge = match webauthn
+ .lookup_challenge(repo, clock, challenge_id, None)
+ .await
+ .map_err(anyhow::Error::downcast::)
+ {
+ Ok(c) => c,
+ Err(err) => {
+ let form_state = form_state.with_error_on_form(match err {
+ Ok(_) => FormError::InvalidCredentials,
+ Err(_) => FormError::Internal,
+ });
+ return Ok(Err(form_state));
+ }
+ };
- // And mark it as authenticated by the password
- repo.browser_session()
- .authenticate_with_password(&mut rng, &clock, &user_session, &user_password)
+ // Mark challenge as completed
+ let challenge = repo
+ .user_passkey()
+ .complete_challenge(clock, challenge)
.await?;
- repo.save().await?;
+ // Get the user and passkey from the authenticator response
+ let (response, user, passkey) = match webauthn
+ .discover_credential(repo, passkey_response)
+ .await
+ .map_err(anyhow::Error::downcast::)
+ {
+ Ok(v) => v,
+ Err(err) => {
+ let form_state = form_state.with_error_on_form(match err {
+ Ok(_) => FormError::InvalidCredentials,
+ Err(_) => FormError::Internal,
+ });
+ return Ok(Err(form_state));
+ }
+ };
- PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]);
+ // XXX: Reusing the password rate limiter. Maybe it should be renamed to login
+ // ratelimiter or have a passkey specific one
+ if let Err(e) = limiter.check_password(requester, &user) {
+ tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded");
+ return Ok(Err(
+ form_state.with_error_on_form(FormError::RateLimitExceeded)
+ ));
+ }
- activity_tracker
- .record_browser_session(&clock, &user_session)
- .await;
+ // Validate the passkey
+ let passkey = match webauthn
+ .finish_passkey_authentication(repo, clock, challenge, response, passkey)
+ .await
+ .map_err(anyhow::Error::downcast::)
+ {
+ Ok(p) => p,
+ Err(err) => {
+ let form_state = form_state.with_error_on_form(match err {
+ Ok(_) => FormError::InvalidCredentials,
+ Err(_) => FormError::Internal,
+ });
+ return Ok(Err(form_state));
+ }
+ };
- let cookie_jar = cookie_jar.set_session(&user_session);
- let reply = query.go_next(&url_builder);
- Ok((cookie_jar, reply).into_response())
+ Ok(Ok((user, AuthenticatedWith::Passkey(passkey))))
}
async fn get_user_by_email_or_by_username(
@@ -404,24 +585,47 @@ fn handle_login_hint(
async fn render(
locale: DataLocale,
cookie_jar: CookieJar,
- form_state: FormState,
+ mut form_state: FormState,
action: OptionalPostAuthAction,
- repo: &mut impl RepositoryAccess,
+ mut repo: BoxRepository,
clock: &impl Clock,
- rng: impl Rng,
+ rng: &mut (dyn RngCore + Send),
templates: &Templates,
homeserver: &dyn HomeserverConnection,
site_config: &SiteConfig,
+ webauthn: Webauthn,
) -> Result {
- let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
+ let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, &mut *rng);
let providers = repo.upstream_oauth_provider().all_enabled().await?;
- let ctx = LoginContext::default()
+ let ctx = LoginContext::default();
+
+ let (ctx, cookie_jar) = if site_config.passkeys_enabled {
+ let (options, challenge) = webauthn
+ .start_passkey_authentication(&mut repo, &mut *rng, clock)
+ .await
+ .map_err(InternalError::from_anyhow)?;
+
+ form_state.set_value(
+ LoginFormField::PasskeyChallengeId,
+ Some(challenge.id.to_string()),
+ );
+
+ let cookie_jar = UserPasskeyChallenges::load(&cookie_jar)
+ .add(&challenge)
+ .save(cookie_jar, clock);
+
+ (ctx.with_webauthn_options(options), cookie_jar)
+ } else {
+ (ctx, cookie_jar)
+ };
+
+ let ctx = ctx
.with_form_state(form_state)
.with_upstream_providers(providers);
let next = action
- .load_context(repo)
+ .load_context(&mut repo)
.await
.map_err(InternalError::from_anyhow)?;
let ctx = if let Some(next) = next {
@@ -432,6 +636,8 @@ async fn render(
};
let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale);
+ repo.save().await?;
+
let content = templates.render_login(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
diff --git a/crates/handlers/src/webauthn.rs b/crates/handlers/src/webauthn.rs
index fb59d512f..9ba3c92c1 100644
--- a/crates/handlers/src/webauthn.rs
+++ b/crates/handlers/src/webauthn.rs
@@ -15,15 +15,21 @@ use rand::RngCore;
use ulid::Ulid;
use url::Url;
use webauthn_rp::{
- PublicKeyCredentialCreationOptions, RegistrationServerState,
+ AuthenticatedCredential, DiscoverableAuthentication16, DiscoverableAuthenticationServerState,
+ DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions,
+ RegistrationServerState,
bin::{Decode, Encode},
request::{
DomainOrigin, Port, PublicKeyCredentialDescriptor, RpId, Scheme,
+ auth::AuthenticationVerificationOptions,
register::{PublicKeyCredentialUserEntity, RegistrationVerificationOptions, UserHandle},
},
response::{
CredentialId,
- register::{error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed},
+ auth::{error::AuthCeremonyErr, ser_relaxed::AuthenticationRelaxed},
+ register::{
+ DynamicState, StaticState, error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed,
+ },
},
};
@@ -33,12 +39,24 @@ pub enum WebauthnError {
#[error(transparent)]
RegistrationCeremonyError(#[from] RegCeremonyErr),
+ #[error(transparent)]
+ AuthenticationCeremonyError(#[from] AuthCeremonyErr),
+
#[error("The challenge doesn't exist, expired or doesn't belong for this session")]
InvalidChallenge,
#[error("Credential already exists")]
Exists,
+ #[error("Authenticator did not include the userHandle in the response")]
+ UserHandleMissing,
+
+ #[error("Failed to find a user based on the userHandle")]
+ UserNotFound,
+
+ #[error("Failed to find a passkey based on the credential_id")]
+ PasskeyNotFound,
+
#[error("The passkey belongs to a different user")]
UserMismatch,
}
@@ -265,4 +283,148 @@ impl Webauthn {
Ok(user_passkey)
}
+
+ /// Creates a passkey authentication challenge
+ ///
+ /// # Returns
+ /// 1. The JSON options to `navigator.credentials.get()` on the frontend
+ /// 2. The created [`UserPasskeyChallenge`]
+ ///
+ /// # Errors
+ /// Various anyhow errors that should be treated as internal errors
+ pub async fn start_passkey_authentication(
+ &self,
+ repo: &mut impl RepositoryAccess,
+ rng: &mut (dyn RngCore + Send),
+ clock: &impl Clock,
+ ) -> Result<(String, UserPasskeyChallenge)> {
+ let options = DiscoverableCredentialRequestOptions::passkey(&self.rpid);
+
+ let (server_state, client_state) = options.start_ceremony()?;
+
+ let user_passkey_challenge = repo
+ .user_passkey()
+ .add_challenge(rng, clock, server_state.encode()?)
+ .await?;
+
+ Ok((
+ serde_json::to_string(&client_state)?,
+ user_passkey_challenge,
+ ))
+ }
+
+ /// Finds the passkey and user based on the challenge response and validates
+ /// that the passkey belongs to the user
+ ///
+ /// # Returns
+ /// 1. The parsed response for use later
+ /// 2. The [`User`] trying to authenticate
+ /// 3. The [`UserPasskey`] used
+ ///
+ /// # Errors
+ /// [`WebauthnError::UserHandleMissing`] if the reponse doesn't contain the
+ /// user handle.
+ ///
+ /// [`WebauthnError::UserNotFound`] if the user wasn't found.
+ ///
+ /// [`WebauthnError::PasskeyNotFound`] if the passkey wasn't found.
+ ///
+ /// [`WebauthnError::UserMismatch`] if the passkey is tied to a different
+ /// user.
+ ///
+ /// The rest of the anyhow errors should be treated as internal errors
+ pub async fn discover_credential(
+ &self,
+ repo: &mut impl RepositoryAccess,
+ response: String,
+ ) -> Result<(DiscoverableAuthentication16, User, UserPasskey)> {
+ let AuthenticationRelaxed::<16, true>(response) = serde_json::from_str(&response)?;
+
+ let credential_id: CredentialId> = response.raw_id().into();
+
+ let id_bytes = response.response().user_handle().encode()?;
+ let user_id = Ulid::from_bytes(id_bytes);
+
+ let user = repo
+ .user()
+ .lookup(user_id)
+ .await?
+ .ok_or(WebauthnError::UserNotFound)?;
+
+ let user_passkey = repo
+ .user_passkey()
+ .find(&credential_id)
+ .await?
+ .ok_or(WebauthnError::PasskeyNotFound)?;
+
+ if user_passkey.user_id != user.id {
+ return Err(WebauthnError::UserMismatch.into());
+ }
+
+ Ok((response, user, user_passkey))
+ }
+
+ /// Validates the authentication challenge response
+ ///
+ /// # Errors
+ /// [`WebauthnError::AuthenticationCeremonyError`] if the response from the
+ /// user was invalid.
+ ///
+ /// The rest of the anyhow errors should be treated as internal errors
+ pub async fn finish_passkey_authentication(
+ &self,
+ repo: &mut impl RepositoryAccess,
+ clock: &impl Clock,
+ user_passkey_challenge: UserPasskeyChallenge,
+ response: DiscoverableAuthentication16,
+ user_passkey: UserPasskey,
+ ) -> Result {
+ let server_state =
+ DiscoverableAuthenticationServerState::decode(&user_passkey_challenge.state)?;
+
+ let options = AuthenticationVerificationOptions:: {
+ allowed_origins: &self.get_allowed_origins(),
+ client_data_json_relaxed: true,
+ ..Default::default()
+ };
+
+ let user_handle = UserHandle::decode(user_passkey.user_id.to_bytes())?;
+
+ // Construct the correct type of credential ID...
+ let credential_id = CredentialId::<&[u8]>::from(&user_passkey.credential_id);
+
+ // Convert stored passkey to a usable credential
+ let mut cred = AuthenticatedCredential::new(
+ credential_id,
+ &user_handle,
+ StaticState::decode(&user_passkey.static_state)?,
+ DynamicState::decode(
+ user_passkey
+ .dynamic_state
+ .clone()
+ .try_into()
+ .map_err(|_| anyhow::Error::msg("Failed to parse dynamic state"))?,
+ )?,
+ )?;
+
+ server_state
+ .verify(&self.rpid, &response, &mut cred, &options)
+ .map_err(WebauthnError::from)?;
+
+ // Update last used date and dynamic state
+ let dynamic_state = cred.dynamic_state().encode()?.to_vec();
+ let user_passkey = repo
+ .user_passkey()
+ .update(clock, user_passkey, dynamic_state)
+ .await?;
+
+ // Ensure that the challenge gets marked as completed if it wasn't already
+ if user_passkey_challenge.completed_at.is_none() {
+ repo.user_passkey()
+ .complete_challenge(clock, user_passkey_challenge)
+ .await?;
+ }
+
+ Ok(user_passkey)
+ }
}
From 5421274019d74d37b1688e069f037ad49b4c428f Mon Sep 17 00:00:00 2001
From: Tonkku
Date: Fri, 14 Mar 2025 14:53:30 +0000
Subject: [PATCH 7/7] Job to clean up old challenges
---
...b3f660e90d610aa74813ac0fdd2a3439a2e99.json | 14 ++++++++++
crates/storage-pg/src/user/passkey.rs | 27 +++++++++++++++++-
crates/storage/src/queue/tasks.rs | 8 ++++++
crates/storage/src/user/passkey.rs | 15 ++++++++++
crates/tasks/src/database.rs | 28 ++++++++++++++++++-
crates/tasks/src/lib.rs | 6 ++++
6 files changed, 96 insertions(+), 2 deletions(-)
create mode 100644 crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json
diff --git a/crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json b/crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json
new file mode 100644
index 000000000..1aa61638a
--- /dev/null
+++ b/crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM user_passkey_challenges\n WHERE created_at < $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99"
+}
diff --git a/crates/storage-pg/src/user/passkey.rs b/crates/storage-pg/src/user/passkey.rs
index e4def890c..827ffe641 100644
--- a/crates/storage-pg/src/user/passkey.rs
+++ b/crates/storage-pg/src/user/passkey.rs
@@ -4,7 +4,7 @@
// Please see LICENSE in the repository root for full details.
use async_trait::async_trait;
-use chrono::{DateTime, Utc};
+use chrono::{DateTime, Duration, Utc};
use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge};
use mas_storage::{
Page, Pagination,
@@ -654,4 +654,29 @@ impl UserPasskeyRepository for PgUserPasskeyRepository<'_> {
user_passkey_challenge.completed_at = Some(completed_at);
Ok(user_passkey_challenge)
}
+
+ #[tracing::instrument(
+ name = "db.user_passkey.cleanup_challenges",
+ skip_all,
+ fields(
+ db.query.text,
+ ),
+ err,
+ )]
+ async fn cleanup_challenges(&mut self, clock: &dyn Clock) -> Result {
+ // Cleanup challenges that were created more than an hour ago
+ let threshold = clock.now() - Duration::microseconds(60 * 60 * 1000 * 1000);
+ let res = sqlx::query!(
+ r#"
+ DELETE FROM user_passkey_challenges
+ WHERE created_at < $1
+ "#,
+ threshold,
+ )
+ .traced()
+ .execute(&mut *self.conn)
+ .await?;
+
+ Ok(res.rows_affected().try_into().unwrap_or(usize::MAX))
+ }
}
diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs
index eb16f6e29..2fd8cfb4b 100644
--- a/crates/storage/src/queue/tasks.rs
+++ b/crates/storage/src/queue/tasks.rs
@@ -325,6 +325,14 @@ impl InsertableJob for CleanupExpiredTokensJob {
const QUEUE_NAME: &'static str = "cleanup-expired-tokens";
}
+/// Cleanup old passkey challenges
+#[derive(Serialize, Deserialize, Debug, Clone, Default)]
+pub struct CleanupOldPasskeyChallenges;
+
+impl InsertableJob for CleanupOldPasskeyChallenges {
+ const QUEUE_NAME: &'static str = "cleanup-old-passkey-challenges";
+}
+
/// Scheduled job to expire inactive sessions
///
/// This job will trigger jobs to expire inactive compat, oauth and user
diff --git a/crates/storage/src/user/passkey.rs b/crates/storage/src/user/passkey.rs
index 40bef966d..2eab63ba6 100644
--- a/crates/storage/src/user/passkey.rs
+++ b/crates/storage/src/user/passkey.rs
@@ -262,6 +262,19 @@ pub trait UserPasskeyRepository: Send + Sync {
clock: &dyn Clock,
user_passkey_challenge: UserPasskeyChallenge,
) -> Result;
+
+ /// Cleanup old challenges
+ ///
+ /// Returns the number of challenges that were cleaned up
+ ///
+ /// # Parameters
+ ///
+ /// * `clock`: The clock used to get the current time
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Self::Error`] if the underlying repository fails
+ async fn cleanup_challenges(&mut self, clock: &dyn Clock) -> Result;
}
repository_impl!(UserPasskeyRepository:
@@ -325,4 +338,6 @@ repository_impl!(UserPasskeyRepository:
clock: &dyn Clock,
user_passkey_challenge: UserPasskeyChallenge,
) -> Result;
+
+ async fn cleanup_challenges(&mut self, clock: &dyn Clock) -> Result;
);
diff --git a/crates/tasks/src/database.rs b/crates/tasks/src/database.rs
index bc14215f8..67e1892ba 100644
--- a/crates/tasks/src/database.rs
+++ b/crates/tasks/src/database.rs
@@ -7,7 +7,9 @@
//! Database-related tasks
use async_trait::async_trait;
-use mas_storage::queue::{CleanupExpiredTokensJob, PruneStalePolicyDataJob};
+use mas_storage::queue::{
+ CleanupExpiredTokensJob, CleanupOldPasskeyChallenges, PruneStalePolicyDataJob,
+};
use tracing::{debug, info};
use crate::{
@@ -63,3 +65,27 @@ impl RunnableJob for PruneStalePolicyDataJob {
Ok(())
}
}
+
+#[async_trait]
+impl RunnableJob for CleanupOldPasskeyChallenges {
+ #[tracing::instrument(name = "job.cleanup_old_passkey_challenges", skip_all, err)]
+ async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> {
+ let clock = state.clock();
+ let mut repo = state.repository().await.map_err(JobError::retry)?;
+
+ let count = repo
+ .user_passkey()
+ .cleanup_challenges(clock)
+ .await
+ .map_err(JobError::retry)?;
+ repo.save().await.map_err(JobError::retry)?;
+
+ if count == 0 {
+ debug!("no challenges to clean up");
+ } else {
+ info!(count, "cleaned up old challenges");
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs
index a480a8d50..4ff394443 100644
--- a/crates/tasks/src/lib.rs
+++ b/crates/tasks/src/lib.rs
@@ -143,6 +143,7 @@ pub async fn init(
.register_handler::()
.register_handler::()
.register_handler::()
+ .register_handler::()
.add_schedule(
"cleanup-expired-tokens",
"0 0 * * * *".parse()?,
@@ -159,6 +160,11 @@ pub async fn init(
// Run once a day
"0 0 2 * * *".parse()?,
mas_storage::queue::PruneStalePolicyDataJob,
+ )
+ .add_schedule(
+ "cleanup-old-passkey-challenges",
+ "0 0 * * * *".parse()?,
+ mas_storage::queue::CleanupOldPasskeyChallenges,
);
Ok(worker)