From 4ab6370ea4d4874c20f5a52ef5814029451cd9e3 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 | 1 +
crates/config/src/sections/experimental.rs | 8 +++++
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 | 4 +++
frontend/locales/en.json | 5 +++-
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, 87 insertions(+), 25 deletions(-)
diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs
index 3588b6895..70f07bad1 100644
--- a/crates/cli/src/util.rs
+++ b/crates/cli/src/util.rs
@@ -222,6 +222,7 @@ pub fn site_config_from_config(
minimum_password_complexity: password_config.minimum_complexity(),
session_expiration,
login_with_email_allowed: account_config.login_with_email_allowed,
+ passkeys_enabled: experimental_config.passkeys,
})
}
diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs
index 17ffa6c4d..81ae3fcc9 100644
--- a/crates/config/src/sections/experimental.rs
+++ b/crates/config/src/sections/experimental.rs
@@ -75,6 +75,12 @@ pub struct ExperimentalConfig {
/// Disabled by default
#[serde(skip_serializing_if = "Option::is_none")]
pub inactive_session_expiration: Option,
+
+ /// Experimental passkey support
+ ///
+ /// Disabled by default
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub passkeys: bool,
}
impl Default for ExperimentalConfig {
@@ -83,6 +89,7 @@ impl Default for ExperimentalConfig {
access_token_ttl: default_token_ttl(),
compat_token_ttl: default_token_ttl(),
inactive_session_expiration: None,
+ passkeys: false,
}
}
}
@@ -92,6 +99,7 @@ impl ExperimentalConfig {
is_default_token_ttl(&self.access_token_ttl)
&& is_default_token_ttl(&self.compat_token_ttl)
&& self.inactive_session_expiration.is_none()
+ && !self.passkeys
}
}
diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs
index de07a03c5..7d2de8321 100644
--- a/crates/data-model/src/site_config.rs
+++ b/crates/data-model/src/site_config.rs
@@ -90,4 +90,7 @@ pub struct SiteConfig {
/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,
+
+ /// Whether passkeys are enabled
+ pub passkeys_enabled: bool,
}
diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs
index 02ba26fa2..30df1c31b 100644
--- a/crates/handlers/src/graphql/model/site_config.rs
+++ b/crates/handlers/src/graphql/model/site_config.rs
@@ -56,6 +56,9 @@ pub struct SiteConfig {
/// Whether users can log in with their email address.
login_with_email_allowed: bool,
+
+ /// Whether passkeys are enabled
+ passkeys_enabled: bool,
}
#[derive(SimpleObject)]
@@ -102,6 +105,7 @@ impl SiteConfig {
account_deactivation_allowed: data_model.account_deactivation_allowed,
minimum_password_complexity: data_model.minimum_password_complexity,
login_with_email_allowed: data_model.login_with_email_allowed,
+ passkeys_enabled: data_model.passkeys_enabled,
}
}
}
diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs
index cdbc981d1..2b4d4201d 100644
--- a/crates/handlers/src/test_utils.rs
+++ b/crates/handlers/src/test_utils.rs
@@ -145,6 +145,7 @@ pub fn test_site_config() -> SiteConfig {
minimum_password_complexity: 1,
session_expiration: None,
login_with_email_allowed: true,
+ passkeys_enabled: false,
}
}
diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs
index 1db44285a..aac40184c 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 5a94430a7..f26b52134 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 a493e0cee..8c1783795 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 431b1f52b..ff33f1fc0 100644
--- a/crates/templates/src/lib.rs
+++ b/crates/templates/src/lib.rs
@@ -494,6 +494,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 3bc0f407d..9cafe32d7 100644
--- a/docs/config.schema.json
+++ b/docs/config.schema.json
@@ -2561,6 +2561,10 @@
"$ref": "#/definitions/InactiveSessionExpirationConfig"
}
]
+ },
+ "passkeys": {
+ "description": "Experimental passkey support\n\nDisabled by default",
+ "type": "boolean"
}
}
},
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 405a4bacf..218c4d76e 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -64,7 +64,10 @@
"button": "Sign out of account",
"dialog": "Sign out of this account?"
},
- "title": "Your account"
+ "title": "Your account",
+ "passkeys": {
+ "title": "Passkeys"
+ }
},
"add_email_form": {
"email_denied_error": "The entered email is not allowed by the server policy",
diff --git a/frontend/schema.graphql b/frontend/schema.graphql
index 8018e4f5b..19f616200 100644
--- a/frontend/schema.graphql
+++ b/frontend/schema.graphql
@@ -1758,6 +1758,10 @@ type SiteConfig implements Node {
"""
loginWithEmailAllowed: Boolean!
"""
+ 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 635ffc9b7..cbbefabaf 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 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,
"\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": typeof types.AppSessionsListDocument,
@@ -105,7 +105,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 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,
"\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListDocument,
@@ -266,7 +266,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 58c8ce0ee..f0516b76b 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. */
@@ -1854,7 +1856,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 } }
) };
@@ -2542,6 +2544,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 5718eee16..31c297658 100644
--- a/frontend/src/routes/_account.index.tsx
+++ b/frontend/src/routes/_account.index.tsx
@@ -51,6 +51,7 @@ const QUERY = graphql(/* GraphQL */ `
siteConfig {
emailChangeAllowed
passwordLoginEnabled
+ passkeysEnabled
accountDeactivationAllowed
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
@@ -227,6 +228,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 637b0a79a..3b3a2e32a 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 5b2a5ad04..25720406f 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:192:26-52"
+ "context": "pages/login.html:102:33-59, pages/upstream_oauth2/do_register.html:192: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 32b24e7434b62ec72a6303aa6cae914d73d658f2 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
---
crates/data-model/src/lib.rs | 4 +-
crates/data-model/src/users.rs | 24 +
...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 +
.../migrations/20250303142622_passkeys.sql | 55 ++
crates/storage-pg/src/iden.rs | 15 +
crates/storage-pg/src/repository.rs | 15 +-
crates/storage-pg/src/user/mod.rs | 8 +-
crates/storage-pg/src/user/passkey.rs | 639 ++++++++++++++++++
crates/storage-pg/src/user/session.rs | 51 +-
crates/storage/src/repository.rs | 25 +-
crates/storage/src/user/mod.rs | 2 +
crates/storage/src/user/passkey.rs | 324 +++++++++
crates/storage/src/user/session.rs | 30 +-
24 files changed, 1583 insertions(+), 15 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/src/user/passkey.rs
create mode 100644 crates/storage/src/user/passkey.rs
diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs
index 8477222c5..6a2e31fa9 100644
--- a/crates/data-model/src/lib.rs
+++ b/crates/data-model/src/lib.rs
@@ -49,7 +49,7 @@ pub use self::{
user_agent::{DeviceType, UserAgent},
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
- UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
- UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
+ UserEmailAuthentication, UserEmailAuthenticationCode, UserPasskey, UserPasskeyChallenge,
+ UserRecoverySession, UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
},
};
diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs
index 7e40f4df2..1fdd07b78 100644
--- a/crates/data-model/src/users.rs
+++ b/crates/data-model/src/users.rs
@@ -67,6 +67,7 @@ pub struct Authentication {
pub enum AuthenticationMethod {
Password { user_password_id: Ulid },
UpstreamOAuth2 { upstream_oauth2_session_id: Ulid },
+ Passkey { user_passkey_id: Ulid },
Unknown,
}
@@ -215,3 +216,26 @@ pub struct UserRegistration {
pub created_at: DateTime,
pub completed_at: Option>,
}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct UserPasskey {
+ pub id: Ulid,
+ pub user_id: Ulid,
+ pub credential_id: String,
+ pub name: String,
+ pub transports: serde_json::Value,
+ pub static_state: Vec,
+ 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/migrations/20250303142622_passkeys.sql b/crates/storage-pg/migrations/20250303142622_passkeys.sql
new file mode 100644
index 000000000..c7b2ffe77
--- /dev/null
+++ b/crates/storage-pg/migrations/20250303142622_passkeys.sql
@@ -0,0 +1,55 @@
+-- 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 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
+);
+
+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/src/iden.rs b/crates/storage-pg/src/iden.rs
index 76067b2fa..972f4664c 100644
--- a/crates/storage-pg/src/iden.rs
+++ b/crates/storage-pg/src/iden.rs
@@ -139,3 +139,18 @@ pub enum UpstreamOAuthLinks {
HumanAccountName,
CreatedAt,
}
+
+#[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 c6668c2e4..617720bca 100644
--- a/crates/storage-pg/src/repository.rs
+++ b/crates/storage-pg/src/repository.rs
@@ -26,7 +26,10 @@ use mas_storage::{
UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository,
UpstreamOAuthSessionRepository,
},
- user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository},
+ user::{
+ BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository,
+ UserPasswordRepository, UserRepository,
+ },
};
use sqlx::{PgConnection, PgPool, Postgres, Transaction};
use tracing::Instrument;
@@ -54,9 +57,9 @@ use crate::{
PgUpstreamOAuthSessionRepository,
},
user::{
- PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository,
- PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRepository,
- PgUserTermsRepository,
+ PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasskeyRepository,
+ PgUserPasswordRepository, PgUserRecoveryRepository, PgUserRegistrationRepository,
+ PgUserRepository, PgUserTermsRepository,
},
};
@@ -224,6 +227,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 659a2172a..449d9e099 100644
--- a/crates/storage-pg/src/user/mod.rs
+++ b/crates/storage-pg/src/user/mod.rs
@@ -29,6 +29,7 @@ use crate::{
};
mod email;
+mod passkey;
mod password;
mod recovery;
mod registration;
@@ -39,9 +40,10 @@ mod terms;
mod tests;
pub use self::{
- email::PgUserEmailRepository, password::PgUserPasswordRepository,
- recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository,
- session::PgBrowserSessionRepository, terms::PgUserTermsRepository,
+ email::PgUserEmailRepository, passkey::PgUserPasskeyRepository,
+ password::PgUserPasswordRepository, recovery::PgUserRecoveryRepository,
+ registration::PgUserRegistrationRepository, session::PgBrowserSessionRepository,
+ terms::PgUserTermsRepository,
};
/// An implementation of [`UserRepository`] for a PostgreSQL connection
diff --git a/crates/storage-pg/src/user/passkey.rs b/crates/storage-pg/src/user/passkey.rs
new file mode 100644
index 000000000..37fd527f8
--- /dev/null
+++ b/crates/storage-pg/src/user/passkey.rs
@@ -0,0 +1,639 @@
+// 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, User, UserPasskey, UserPasskeyChallenge};
+use mas_storage::{
+ Clock, 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 crate::{
+ DatabaseError,
+ 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 From for UserPasskey {
+ fn from(value: UserPasskeyLookup) -> UserPasskey {
+ UserPasskey {
+ id: value.user_passkey_id.into(),
+ user_id: value.user_id.into(),
+ credential_id: value.credential_id,
+ name: value.name,
+ transports: value.transports,
+ 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.into()))
+ }
+
+ #[tracing::instrument(
+ name = "db.user_passkey.find",
+ skip_all,
+ fields(
+ db.query.text,
+ %credential_id,
+ ),
+ err,
+ )]
+ async fn find(&mut self, credential_id: &str) -> 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
+ "#,
+ credential_id
+ )
+ .traced()
+ .fetch_optional(&mut *self.conn)
+ .await?;
+
+ let Some(user_passkey) = res else {
+ return Ok(None);
+ };
+
+ Ok(Some(user_passkey.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(Into::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).map(UserPasskey::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: String,
+ transports: serde_json::Value,
+ 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),
+ &credential_id,
+ &name,
+ transports,
+ 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 3bea6781c..a6fd887e0 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, Password,
- UpstreamOAuthAuthorizationSession, User,
+ UpstreamOAuthAuthorizationSession, User, UserPasskey,
};
use mas_storage::{
Clock, Page, Pagination,
@@ -515,6 +515,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/src/repository.rs b/crates/storage/src/repository.rs
index 93c43d469..c569a37e3 100644
--- a/crates/storage/src/repository.rs
+++ b/crates/storage/src/repository.rs
@@ -25,8 +25,9 @@ use crate::{
UpstreamOAuthSessionRepository,
},
user::{
- BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
- UserRecoveryRepository, UserRegistrationRepository, UserRepository, UserTermsRepository,
+ BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository,
+ UserPasswordRepository, UserRecoveryRepository, UserRegistrationRepository, UserRepository,
+ UserTermsRepository,
},
};
@@ -135,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>;
@@ -248,8 +252,9 @@ mod impls {
UpstreamOAuthSessionRepository,
},
user::{
- BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
- UserRegistrationRepository, UserRepository, UserTermsRepository,
+ BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository,
+ UserPasswordRepository, UserRegistrationRepository, UserRepository,
+ UserTermsRepository,
},
};
@@ -327,6 +332,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> {
@@ -494,6 +505,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 395c6e615..4b8215ac6 100644
--- a/crates/storage/src/user/mod.rs
+++ b/crates/storage/src/user/mod.rs
@@ -14,6 +14,7 @@ use ulid::Ulid;
use crate::{Clock, Page, Pagination, repository_impl};
mod email;
+mod passkey;
mod password;
mod recovery;
mod registration;
@@ -22,6 +23,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..bb6d8ebc7
--- /dev/null
+++ b/crates/storage/src/user/passkey.rs
@@ -0,0 +1,324 @@
+// 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, User, UserPasskey, UserPasskeyChallenge};
+use rand_core::RngCore;
+use ulid::Ulid;
+
+use crate::{Clock, 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: &str) -> 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: String,
+ transports: serde_json::Value,
+ 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: &str) -> 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: String,
+ transports: serde_json::Value,
+ 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 2421ff009..5868930b8 100644
--- a/crates/storage/src/user/session.rs
+++ b/crates/storage/src/user/session.rs
@@ -9,7 +9,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
- Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User,
+ Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, UserPasskey,
};
use rand_core::RngCore;
use ulid::Ulid;
@@ -259,6 +259,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
@@ -334,6 +354,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 3bf4368a2f0c18a52eb6c7bd88137cc302c78f1e 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 | 965 ++++++++++++------
crates/cli/src/app_state.rs | 9 +-
crates/cli/src/commands/server.rs | 6 +-
crates/cli/src/util.rs | 10 +-
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 | 248 +++++
frontend/schema.graphql | 263 +++++
frontend/src/gql/graphql.ts | 161 +++
18 files changed, 1846 insertions(+), 340 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 c0f0ee628..39d96b8c1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -64,14 +64,14 @@ dependencies = [
[[package]]
name = "ahash"
-version = "0.8.11"
+version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
- "zerocopy 0.7.35",
+ "zerocopy",
]
[[package]]
@@ -179,12 +179,12 @@ dependencies = [
[[package]]
name = "anstyle-wincon"
-version = "3.0.7"
+version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
+checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa"
dependencies = [
"anstyle",
- "once_cell",
+ "once_cell_polyfill",
"windows-sys 0.59.0",
]
@@ -271,14 +271,15 @@ dependencies = [
[[package]]
name = "async-executor"
-version = "1.13.1"
+version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec"
+checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
+ "pin-project-lite",
"slab",
]
@@ -299,9 +300,9 @@ dependencies = [
[[package]]
name = "async-graphql"
-version = "7.0.16"
+version = "7.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3ee559e72d983e7e04001ba3bf32e6b71c1d670595780723727fd8a29d36e87"
+checksum = "036618f842229ba0b89652ffe425f96c7c16a49f7e3cb23b56fca7f61fd74980"
dependencies = [
"async-graphql-derive",
"async-graphql-parser",
@@ -333,9 +334,9 @@ dependencies = [
[[package]]
name = "async-graphql-derive"
-version = "7.0.16"
+version = "7.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29db05b624fb6352fc11bfe30c54ab1b16a1fe937d7c05a783f4e88ef1292b3b"
+checksum = "fd45deb3dbe5da5cdb8d6a670a7736d735ba65b455328440f236dfb113727a3d"
dependencies = [
"Inflector",
"async-graphql-parser",
@@ -350,9 +351,9 @@ dependencies = [
[[package]]
name = "async-graphql-parser"
-version = "7.0.16"
+version = "7.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4904895044116aab098ca82c6cec831ec43ed99efd04db9b70a390419bc88c5b"
+checksum = "60b7607e59424a35dadbc085b0d513aa54ec28160ee640cf79ec3b634eba66d3"
dependencies = [
"async-graphql-value",
"pest",
@@ -362,9 +363,9 @@ dependencies = [
[[package]]
name = "async-graphql-value"
-version = "7.0.16"
+version = "7.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0cde74de18e3a00c5dd5cfa002ab6f532e1a06c2a79ee6671e2fc353b400b92"
+checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de"
dependencies = [
"bytes",
"indexmap 2.9.0",
@@ -374,9 +375,9 @@ dependencies = [
[[package]]
name = "async-io"
-version = "2.4.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059"
+checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3"
dependencies = [
"async-lock",
"cfg-if",
@@ -385,7 +386,7 @@ dependencies = [
"futures-lite",
"parking",
"polling",
- "rustix",
+ "rustix 1.0.7",
"slab",
"tracing",
"windows-sys 0.59.0",
@@ -404,9 +405,9 @@ dependencies = [
[[package]]
name = "async-process"
-version = "2.3.0"
+version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb"
+checksum = "cde3f4e40e6021d7acffc90095cbd6dc54cb593903d1de5832f435eb274b85dc"
dependencies = [
"async-channel 2.3.1",
"async-io",
@@ -417,15 +418,15 @@ dependencies = [
"cfg-if",
"event-listener 5.4.0",
"futures-lite",
- "rustix",
+ "rustix 1.0.7",
"tracing",
]
[[package]]
name = "async-signal"
-version = "0.2.10"
+version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3"
+checksum = "d7605a4e50d4b06df3898d5a70bf5fde51ed9059b0434b73105193bc27acce0d"
dependencies = [
"async-io",
"async-lock",
@@ -433,7 +434,7 @@ dependencies = [
"cfg-if",
"futures-core",
"futures-io",
- "rustix",
+ "rustix 1.0.7",
"signal-hook-registry",
"slab",
"windows-sys 0.59.0",
@@ -537,9 +538,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-lc-rs"
-version = "1.13.0"
+version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19b756939cb2f8dc900aa6dcd505e6e2428e9cae7ff7b028c49e3946efa70878"
+checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -547,9 +548,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.28.0"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9f7720b74ed28ca77f90769a71fd8c637a0137f6fae4ae947e1050229cff57f"
+checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079"
dependencies = [
"bindgen",
"cc",
@@ -649,9 +650,9 @@ dependencies = [
[[package]]
name = "backtrace"
-version = "0.3.74"
+version = "0.3.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
+checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
dependencies = [
"addr2line",
"cfg-if",
@@ -688,7 +689,7 @@ checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f"
dependencies = [
"base64",
"blowfish",
- "getrandom 0.3.2",
+ "getrandom 0.3.3",
"subtle",
"zeroize",
]
@@ -801,9 +802,9 @@ dependencies = [
[[package]]
name = "bytemuck"
-version = "1.22.0"
+version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
+checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
[[package]]
name = "byteorder"
@@ -832,9 +833,9 @@ dependencies = [
[[package]]
name = "camino"
-version = "1.1.9"
+version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3"
+checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab"
dependencies = [
"serde",
]
@@ -859,9 +860,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.18"
+version = "1.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c"
+checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951"
dependencies = [
"jobserver",
"libc",
@@ -998,9 +999,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.5.38"
+version = "4.5.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
+checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1008,9 +1009,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.38"
+version = "4.5.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
+checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51"
dependencies = [
"anstream",
"anstyle",
@@ -1154,9 +1155,9 @@ dependencies = [
[[package]]
name = "core-foundation"
-version = "0.10.0"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
@@ -1236,7 +1237,7 @@ dependencies = [
"cranelift-entity",
"cranelift-isle",
"gimli",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"log",
"pulley-interpreter",
"regalloc2",
@@ -1344,7 +1345,7 @@ checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
- "winnow",
+ "winnow 0.6.26",
]
[[package]]
@@ -1443,6 +1444,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"
@@ -1492,6 +1519,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"
@@ -1631,13 +1664,13 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "duration-str"
-version = "0.12.0"
+version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64ad6b66883f70e2f38f1ee99e3797b9d7e7b7fb051ed2e23e027c81753056c8"
+checksum = "eb333721800c025e363e902b293040778f8ac79913db4f013abf1f1d7d382fd7"
dependencies = [
"rust_decimal",
"thiserror 2.0.12",
- "winnow",
+ "winnow 0.7.10",
]
[[package]]
@@ -1660,6 +1693,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.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "sha2",
+ "subtle",
+]
+
[[package]]
name = "either"
version = "1.15.0"
@@ -1692,9 +1746,9 @@ dependencies = [
[[package]]
name = "email-encoding"
-version = "0.4.0"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20b9cde6a71f9f758440470f3de16db6c09a02c443ce66850d87f5410548fb8e"
+checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64",
"memchr",
@@ -1741,9 +1795,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
-version = "0.3.11"
+version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
+checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.59.0",
@@ -1820,6 +1874,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"
@@ -1844,7 +1904,7 @@ checksum = "0febbeb1118a9ecdee6e4520ead6b54882e843dd0592ad233247dbee84c53db8"
dependencies = [
"displaydoc",
"smallvec",
- "writeable",
+ "writeable 0.5.5",
]
[[package]]
@@ -2026,9 +2086,9 @@ dependencies = [
[[package]]
name = "getrandom"
-version = "0.2.15"
+version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
@@ -2039,9 +2099,9 @@ dependencies = [
[[package]]
name = "getrandom"
-version = "0.3.2"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
@@ -2101,7 +2161,7 @@ dependencies = [
"futures-sink",
"futures-timer",
"futures-util",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"nonzero_ext",
"parking_lot",
"portable-atomic",
@@ -2124,9 +2184,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.8"
+version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2"
+checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5"
dependencies = [
"atomic-waker",
"bytes",
@@ -2159,9 +2219,9 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.15.2"
+version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
dependencies = [
"allocator-api2",
"equivalent",
@@ -2175,7 +2235,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
]
[[package]]
@@ -2216,15 +2276,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
-
-[[package]]
-name = "hermit-abi"
-version = "0.4.0"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
+checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
[[package]]
name = "hex"
@@ -2261,13 +2315,13 @@ dependencies = [
[[package]]
name = "hostname"
-version = "0.4.0"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba"
+checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
dependencies = [
"cfg-if",
"libc",
- "windows",
+ "windows-link",
]
[[package]]
@@ -2345,11 +2399,10 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.27.5"
+version = "0.27.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
+checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d"
dependencies = [
- "futures-util",
"http",
"hyper",
"hyper-util",
@@ -2362,17 +2415,21 @@ dependencies = [
[[package]]
name = "hyper-util"
-version = "0.1.11"
+version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
+checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
dependencies = [
+ "base64",
"bytes",
"futures-channel",
+ "futures-core",
"futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
"libc",
+ "percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
@@ -2392,7 +2449,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.61.0",
+ "windows-core",
]
[[package]]
@@ -2415,10 +2472,10 @@ dependencies = [
"icu_calendar_data",
"icu_locid",
"icu_locid_transform",
- "icu_provider",
- "tinystr",
- "writeable",
- "zerovec",
+ "icu_provider 1.5.0",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2434,9 +2491,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
dependencies = [
"displaydoc",
- "yoke",
+ "yoke 0.7.5",
"zerofrom",
- "zerovec",
+ "zerovec 0.10.4",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke 0.8.0",
+ "zerofrom",
+ "zerovec 0.11.2",
]
[[package]]
@@ -2454,12 +2524,12 @@ dependencies = [
"icu_locid",
"icu_locid_transform",
"icu_plurals",
- "icu_provider",
+ "icu_provider 1.5.0",
"icu_timezone",
"smallvec",
- "tinystr",
- "writeable",
- "zerovec",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2478,8 +2548,8 @@ dependencies = [
"fixed_decimal",
"icu_decimal_data",
"icu_locid_transform",
- "icu_provider",
- "writeable",
+ "icu_provider 1.5.0",
+ "writeable 0.5.5",
]
[[package]]
@@ -2496,26 +2566,26 @@ checksum = "844ad7b682a165c758065d694bc4d74ac67f176da1c499a04d85d492c0f193b7"
dependencies = [
"displaydoc",
"fixed_decimal",
- "icu_collections",
+ "icu_collections 1.5.0",
"icu_decimal",
"icu_experimental_data",
"icu_locid",
"icu_locid_transform",
- "icu_normalizer",
+ "icu_normalizer 1.5.0",
"icu_pattern",
"icu_plurals",
- "icu_properties",
- "icu_provider",
- "litemap",
+ "icu_properties 1.5.1",
+ "icu_provider 1.5.0",
+ "litemap 0.7.5",
"num-bigint",
"num-rational",
"num-traits",
"smallvec",
- "tinystr",
- "writeable",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
"zerofrom",
- "zerotrie",
- "zerovec",
+ "zerotrie 0.1.3",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2524,6 +2594,19 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121df92eafb8f5286d4e8ff401c1e7db8384377f806db3f8db77b91e5b7bd4dd"
+[[package]]
+name = "icu_locale_core"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
+dependencies = [
+ "displaydoc",
+ "litemap 0.8.0",
+ "tinystr 0.8.1",
+ "writeable 0.6.1",
+ "zerovec 0.11.2",
+]
+
[[package]]
name = "icu_locid"
version = "1.5.0"
@@ -2531,10 +2614,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
dependencies = [
"displaydoc",
- "litemap",
- "tinystr",
- "writeable",
- "zerovec",
+ "litemap 0.7.5",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2546,9 +2629,9 @@ dependencies = [
"displaydoc",
"icu_locid",
"icu_locid_transform_data",
- "icu_provider",
- "tinystr",
- "zerovec",
+ "icu_provider 1.5.0",
+ "tinystr 0.7.6",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2564,15 +2647,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
dependencies = [
"displaydoc",
- "icu_collections",
- "icu_normalizer_data",
- "icu_properties",
- "icu_provider",
+ "icu_collections 1.5.0",
+ "icu_normalizer_data 1.5.1",
+ "icu_properties 1.5.1",
+ "icu_provider 1.5.0",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
- "zerovec",
+ "zerovec 0.10.4",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
+dependencies = [
+ "displaydoc",
+ "icu_collections 2.0.0",
+ "icu_normalizer_data 2.0.0",
+ "icu_properties 2.0.1",
+ "icu_provider 2.0.0",
+ "smallvec",
+ "zerovec 0.11.2",
]
[[package]]
@@ -2581,6 +2679,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7"
+[[package]]
+name = "icu_normalizer_data"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
+
[[package]]
name = "icu_pattern"
version = "0.2.0"
@@ -2589,8 +2693,8 @@ checksum = "cb7f36aafd098d6717de34e668a8120822275c1fba22b936e757b7de8a2fd7e4"
dependencies = [
"displaydoc",
"either",
- "writeable",
- "yoke",
+ "writeable 0.5.5",
+ "yoke 0.7.5",
"zerofrom",
]
@@ -2604,8 +2708,8 @@ dependencies = [
"fixed_decimal",
"icu_locid_transform",
"icu_plurals_data",
- "icu_provider",
- "zerovec",
+ "icu_provider 1.5.0",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2621,12 +2725,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
dependencies = [
"displaydoc",
- "icu_collections",
+ "icu_collections 1.5.0",
"icu_locid_transform",
- "icu_properties_data",
- "icu_provider",
- "tinystr",
- "zerovec",
+ "icu_properties_data 1.5.1",
+ "icu_provider 1.5.0",
+ "tinystr 0.7.6",
+ "zerovec 0.10.4",
+]
+
+[[package]]
+name = "icu_properties"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
+dependencies = [
+ "displaydoc",
+ "icu_collections 2.0.0",
+ "icu_locale_core",
+ "icu_properties_data 2.0.1",
+ "icu_provider 2.0.0",
+ "potential_utf",
+ "zerotrie 0.2.2",
+ "zerovec 0.11.2",
]
[[package]]
@@ -2635,6 +2755,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2"
+[[package]]
+name = "icu_properties_data"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
+
[[package]]
name = "icu_provider"
version = "1.5.0"
@@ -2645,11 +2771,28 @@ dependencies = [
"icu_locid",
"icu_provider_macros",
"stable_deref_trait",
- "tinystr",
- "writeable",
- "yoke",
+ "tinystr 0.7.6",
+ "writeable 0.5.5",
+ "yoke 0.7.5",
+ "zerofrom",
+ "zerovec 0.10.4",
+]
+
+[[package]]
+name = "icu_provider"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "stable_deref_trait",
+ "tinystr 0.8.1",
+ "writeable 0.6.1",
+ "yoke 0.8.0",
"zerofrom",
- "zerovec",
+ "zerotrie 0.2.2",
+ "zerovec 0.11.2",
]
[[package]]
@@ -2660,9 +2803,9 @@ checksum = "d6324dfd08348a8e0374a447ebd334044d766b1839bb8d5ccf2482a99a77c0bc"
dependencies = [
"icu_locid",
"icu_locid_transform",
- "icu_provider",
- "tinystr",
- "zerovec",
+ "icu_provider 1.5.0",
+ "tinystr 0.7.6",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2684,11 +2827,11 @@ checksum = "aa91ba6a585939a020c787235daa8aee856d9bceebd6355e283c0c310bc6de96"
dependencies = [
"displaydoc",
"icu_calendar",
- "icu_provider",
+ "icu_provider 1.5.0",
"icu_timezone_data",
- "tinystr",
- "zerotrie",
- "zerovec",
+ "tinystr 0.7.6",
+ "zerotrie 0.1.3",
+ "zerovec 0.10.4",
]
[[package]]
@@ -2722,12 +2865,12 @@ dependencies = [
[[package]]
name = "idna_adapter"
-version = "1.2.0"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
- "icu_normalizer",
- "icu_properties",
+ "icu_normalizer 2.0.0",
+ "icu_properties 2.0.1",
]
[[package]]
@@ -2748,7 +2891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"serde",
]
@@ -2813,6 +2956,16 @@ dependencies = [
"serde",
]
+[[package]]
+name = "iri-string"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -2880,7 +3033,7 @@ version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
- "getrandom 0.3.2",
+ "getrandom 0.3.3",
"libc",
]
@@ -2980,9 +3133,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
-version = "0.11.15"
+version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "759bc2b8eabb6a30b235d6f716f7f36479f4b38cbe65b8747aefee51f89e8437"
+checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2"
dependencies = [
"async-std",
"async-trait",
@@ -3006,30 +3159,30 @@ dependencies = [
"tokio-rustls",
"tracing",
"url",
- "webpki-roots",
+ "webpki-roots 1.0.0",
]
[[package]]
name = "libc"
-version = "0.2.171"
+version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
+checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libloading"
-version = "0.8.6"
+version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
+checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
dependencies = [
"cfg-if",
- "windows-targets 0.52.6",
+ "windows-targets 0.53.0",
]
[[package]]
name = "libm"
-version = "0.2.11"
+version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
+checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libsqlite3-sys"
@@ -3047,6 +3200,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+[[package]]
+name = "linux-raw-sys"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
[[package]]
name = "listenfd"
version = "1.0.2"
@@ -3064,6 +3223,12 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
+[[package]]
+name = "litemap"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
+
[[package]]
name = "litrs"
version = "0.4.1"
@@ -3072,9 +3237,9 @@ checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lock_api"
-version = "0.4.12"
+version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
dependencies = [
"autocfg",
"scopeguard",
@@ -3089,6 +3254,12 @@ dependencies = [
"value-bag",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "mach2"
version = "0.4.2"
@@ -3306,7 +3477,7 @@ dependencies = [
"headers",
"hex",
"hyper",
- "icu_normalizer",
+ "icu_normalizer 1.5.0",
"indexmap 2.9.0",
"insta",
"lettre",
@@ -3357,6 +3528,7 @@ dependencies = [
"tracing-subscriber",
"ulid",
"url",
+ "webauthn_rp",
"wiremock",
"zeroize",
"zxcvbn",
@@ -3394,7 +3566,7 @@ dependencies = [
"icu_locid",
"icu_locid_transform",
"icu_plurals",
- "icu_provider",
+ "icu_provider 1.5.0",
"icu_provider_adapters",
"pad",
"pest",
@@ -3402,7 +3574,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.12",
- "writeable",
+ "writeable 0.5.5",
]
[[package]]
@@ -3788,7 +3960,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64"
dependencies = [
- "rustix",
+ "rustix 0.38.44",
]
[[package]]
@@ -3844,22 +4016,22 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
-version = "0.8.7"
+version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430"
+checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
-version = "1.0.3"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
+checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -3990,11 +4162,11 @@ dependencies = [
[[package]]
name = "num_cpus"
-version = "1.16.0"
+version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
- "hermit-abi 0.3.9",
+ "hermit-abi",
"libc",
]
@@ -4034,7 +4206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"crc32fast",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"indexmap 2.9.0",
"memchr",
]
@@ -4045,6 +4217,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+
[[package]]
name = "opa-wasm"
version = "0.1.5"
@@ -4213,7 +4391,7 @@ dependencies = [
"glob",
"opentelemetry",
"percent-encoding",
- "rand 0.9.0",
+ "rand 0.9.1",
"serde_json",
"thiserror 2.0.12",
"tokio",
@@ -4223,9 +4401,9 @@ dependencies = [
[[package]]
name = "os_info"
-version = "3.10.0"
+version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5"
+checksum = "41fc863e2ca13dc2d5c34fb22ea4a588248ac14db929616ba65c45f21744b1e9"
dependencies = [
"log",
"serde",
@@ -4279,9 +4457,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
-version = "0.12.3"
+version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
dependencies = [
"lock_api",
"parking_lot_core",
@@ -4289,9 +4467,9 @@ dependencies = [
[[package]]
name = "parking_lot_core"
-version = "0.9.10"
+version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
@@ -4555,15 +4733,15 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polling"
-version = "3.7.4"
+version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
+checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
dependencies = [
"cfg-if",
"concurrent-queue",
- "hermit-abi 0.4.0",
+ "hermit-abi",
"pin-project-lite",
- "rustix",
+ "rustix 1.0.7",
"tracing",
"windows-sys 0.59.0",
]
@@ -4609,6 +4787,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "potential_utf"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
+dependencies = [
+ "zerovec 0.11.2",
+]
+
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -4621,14 +4808,48 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
- "zerocopy 0.8.24",
+ "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.32"
+version = "0.2.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6"
+checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d"
dependencies = [
"proc-macro2",
"syn",
@@ -4645,18 +4866,18 @@ dependencies = [
[[package]]
name = "proc-macro-crate"
-version = "3.2.0"
+version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
+checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
-version = "1.0.94"
+version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
@@ -4734,9 +4955,9 @@ dependencies = [
[[package]]
name = "psl"
-version = "2.1.114"
+version = "2.1.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1722a421a1f0ae8f40e4cd265bd559fed5f5f67c0bbd7b70a780a43519c8e19"
+checksum = "16f605d6286ebe6f97118b1d1116b7a143b4b860091711a389050dd87fadcfe8"
dependencies = [
"psl-types",
]
@@ -4749,9 +4970,9 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "psm"
-version = "0.1.25"
+version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88"
+checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f"
dependencies = [
"cc",
]
@@ -4784,9 +5005,9 @@ dependencies = [
[[package]]
name = "quinn"
-version = "0.11.7"
+version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012"
+checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
dependencies = [
"bytes",
"cfg_aliases",
@@ -4804,13 +5025,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.10"
+version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
+checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
dependencies = [
"bytes",
- "getrandom 0.3.2",
- "rand 0.9.0",
+ "getrandom 0.3.3",
+ "lru-slab",
+ "rand 0.9.1",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -4824,9 +5046,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
-version = "0.5.11"
+version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5"
+checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
dependencies = [
"cfg_aliases",
"libc",
@@ -4870,13 +5092,12 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.9.0"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
+checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
- "zerocopy 0.8.24",
]
[[package]]
@@ -4905,7 +5126,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
]
[[package]]
@@ -4914,7 +5135,7 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
- "getrandom 0.3.2",
+ "getrandom 0.3.3",
]
[[package]]
@@ -4948,9 +5169,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.5.11"
+version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
+checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
dependencies = [
"bitflags",
]
@@ -4963,7 +5184,7 @@ checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
dependencies = [
"allocator-api2",
"bumpalo",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"log",
"rustc-hash 2.1.1",
"smallvec",
@@ -5001,6 +5222,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"
@@ -5015,9 +5242,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
-version = "0.12.15"
+version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
+checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [
"base64",
"bytes",
@@ -5041,7 +5268,6 @@ dependencies = [
"pin-project-lite",
"quinn",
"rustls",
- "rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
@@ -5051,12 +5277,12 @@ dependencies = [
"tokio-rustls",
"tokio-socks",
"tower",
+ "tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "windows-registry",
]
[[package]]
@@ -5077,7 +5303,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -5097,6 +5323,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
+ "sha2",
"signature",
"spki",
"subtle",
@@ -5202,7 +5429,20 @@ dependencies = [
"bitflags",
"errno",
"libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
]
@@ -5270,7 +5510,7 @@ dependencies = [
"rustls-webpki",
"security-framework",
"security-framework-sys",
- "webpki-root-certs",
+ "webpki-root-certs 0.26.11",
"windows-sys 0.59.0",
]
@@ -5282,9 +5522,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.2"
+version = "0.103.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437"
+checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
dependencies = [
"aws-lc-rs",
"ring",
@@ -5294,9 +5534,9 @@ dependencies = [
[[package]]
name = "rustversion"
-version = "1.0.20"
+version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
+checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]]
name = "ryu"
@@ -5462,9 +5702,9 @@ dependencies = [
[[package]]
name = "self_cell"
-version = "1.1.0"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe"
+checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
[[package]]
name = "semver"
@@ -5771,9 +6011,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
-version = "1.4.2"
+version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
dependencies = [
"libc",
]
@@ -5820,9 +6060,9 @@ dependencies = [
[[package]]
name = "socket2"
-version = "0.5.9"
+version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
@@ -5858,11 +6098,11 @@ dependencies = [
[[package]]
name = "sprintf"
-version = "0.4.0"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46781e6f401f1557f5b4560284baf7268bd9ca531e9e387120a8695fe5bc1fb1"
+checksum = "78222247fc55e10208ed1ba60f8296390bc67a489bc27a36231765d8d6f60ec5"
dependencies = [
- "thiserror 1.0.69",
+ "thiserror 2.0.12",
]
[[package]]
@@ -5901,7 +6141,7 @@ dependencies = [
"futures-intrusive",
"futures-io",
"futures-util",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"hashlink",
"indexmap 2.9.0",
"ipnetwork",
@@ -5920,7 +6160,7 @@ dependencies = [
"tracing",
"url",
"uuid",
- "webpki-roots",
+ "webpki-roots 0.26.11",
]
[[package]]
@@ -6079,9 +6319,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stacker"
-version = "0.1.20"
+version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "601f9201feb9b09c00266478bf459952b9ef9a6b94edb2f21eba14ab681a60a9"
+checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b"
dependencies = [
"cc",
"cfg-if",
@@ -6149,9 +6389,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
-version = "2.0.100"
+version = "2.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
+checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
dependencies = [
"proc-macro2",
"quote",
@@ -6205,9 +6445,9 @@ dependencies = [
[[package]]
name = "synstructure"
-version = "0.13.1"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
@@ -6222,15 +6462,14 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
[[package]]
name = "tempfile"
-version = "3.15.0"
+version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
- "cfg-if",
"fastrand",
- "getrandom 0.2.15",
+ "getrandom 0.3.3",
"once_cell",
- "rustix",
+ "rustix 1.0.7",
"windows-sys 0.59.0",
]
@@ -6355,7 +6594,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
dependencies = [
"displaydoc",
- "zerovec",
+ "zerovec 0.10.4",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
+dependencies = [
+ "displaydoc",
+ "zerovec 0.11.2",
]
[[package]]
@@ -6375,9 +6624,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.45.0"
+version = "1.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
+checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
dependencies = [
"backtrace",
"bytes",
@@ -6458,16 +6707,16 @@ dependencies = [
"futures-core",
"futures-sink",
"futures-util",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
-version = "0.8.19"
+version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e"
+checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
dependencies = [
"serde",
"serde_spanned",
@@ -6477,24 +6726,24 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "0.6.8"
+version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
+checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
-version = "0.22.22"
+version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5"
+checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
- "winnow",
+ "winnow 0.7.10",
]
[[package]]
@@ -6536,9 +6785,9 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
+checksum = "5cc2d9e086a412a451384326f521c8123a99a466b329941a9403696bff9b0da2"
dependencies = [
"bitflags",
"bytes",
@@ -6548,12 +6797,14 @@ dependencies = [
"http-body-util",
"http-range-header",
"httpdate",
+ "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
+ "tower",
"tower-layer",
"tower-service",
"tracing",
@@ -6696,6 +6947,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"
@@ -7054,7 +7314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc28600dcb2ba68d7e5f1c3ba4195c2bddc918c0243fd702d0b6dbd05689b681"
dependencies = [
"bitflags",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"indexmap 2.9.0",
"semver",
"serde",
@@ -7084,7 +7344,7 @@ dependencies = [
"bumpalo",
"cc",
"cfg-if",
- "hashbrown 0.15.2",
+ "hashbrown 0.15.3",
"indexmap 2.9.0",
"libc",
"log",
@@ -7097,7 +7357,7 @@ dependencies = [
"psm",
"pulley-interpreter",
"rayon",
- "rustix",
+ "rustix 0.38.44",
"serde",
"serde_derive",
"smallvec",
@@ -7205,7 +7465,7 @@ dependencies = [
"anyhow",
"cc",
"cfg-if",
- "rustix",
+ "rustix 0.38.44",
"wasmtime-asm-macros",
"wasmtime-versioned-export-macros",
"windows-sys 0.59.0",
@@ -7281,20 +7541,56 @@ 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.1",
+ "rsa",
+ "serde",
+ "serde_json",
+ "url",
+]
+
+[[package]]
+name = "webpki-root-certs"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
+dependencies = [
+ "webpki-root-certs 1.0.0",
+]
+
[[package]]
name = "webpki-root-certs"
-version = "0.26.8"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4"
+checksum = "01a83f7e1a9f8712695c03eabe9ed3fbca0feff0152f33f12593e5a6303cb1a4"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
-version = "0.26.8"
+version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.0",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
dependencies = [
"rustls-pki-types",
]
@@ -7308,7 +7604,7 @@ dependencies = [
"either",
"home",
"once_cell",
- "rustix",
+ "rustix 0.38.44",
]
[[package]]
@@ -7358,36 +7654,17 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-[[package]]
-name = "windows"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
-dependencies = [
- "windows-core 0.52.0",
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-core"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows-core"
-version = "0.61.0"
+version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
- "windows-strings 0.4.0",
+ "windows-strings",
]
[[package]]
@@ -7418,40 +7695,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
-[[package]]
-name = "windows-registry"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
-dependencies = [
- "windows-result",
- "windows-strings 0.3.1",
- "windows-targets 0.53.0",
-]
-
[[package]]
name = "windows-result"
-version = "0.3.2"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-strings"
-version = "0.4.0"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
]
@@ -7743,6 +8000,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winnow"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "wiremock"
version = "0.6.3"
@@ -7819,6 +8085,12 @@ dependencies = [
"either",
]
+[[package]]
+name = "writeable"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
+
[[package]]
name = "yansi"
version = "1.0.1"
@@ -7833,7 +8105,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
dependencies = [
"serde",
"stable_deref_trait",
- "yoke-derive",
+ "yoke-derive 0.7.5",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive 0.8.0",
"zerofrom",
]
@@ -7850,39 +8134,31 @@ dependencies = [
]
[[package]]
-name = "zerocopy"
-version = "0.7.35"
+name = "yoke-derive"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
+checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
- "zerocopy-derive 0.7.35",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
]
[[package]]
name = "zerocopy"
-version = "0.8.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
-dependencies = [
- "zerocopy-derive 0.8.24",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.7.35"
+version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
+checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.24"
+version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
+checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
dependencies = [
"proc-macro2",
"quote",
@@ -7923,7 +8199,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb594dd55d87335c5f60177cee24f19457a5ec10a065e0a3014722ad252d0a1f"
dependencies = [
"displaydoc",
- "yoke",
+ "yoke 0.7.5",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
+dependencies = [
+ "displaydoc",
+ "yoke 0.8.0",
"zerofrom",
]
@@ -7933,9 +8220,20 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
dependencies = [
- "yoke",
+ "yoke 0.7.5",
"zerofrom",
- "zerovec-derive",
+ "zerovec-derive 0.10.3",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428"
+dependencies = [
+ "yoke 0.8.0",
+ "zerofrom",
+ "zerovec-derive 0.11.1",
]
[[package]]
@@ -7949,6 +8247,17 @@ dependencies = [
"syn",
]
+[[package]]
+name = "zerovec-derive"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zxcvbn"
version = "3.1.0"
diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs
index 55b592aea..3b81fbfa3 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::SiteConfig;
use mas_handlers::{
ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter,
- MetadataCache, RequesterFingerprint, passwords::PasswordManager,
+ MetadataCache, RequesterFingerprint, passwords::PasswordManager, webauthn::Webauthn,
};
use mas_i18n::Translator;
use mas_keystore::{Encrypter, Keystore};
@@ -49,6 +49,7 @@ pub struct AppState {
pub activity_tracker: ActivityTracker,
pub trusted_proxies: Vec,
pub limiter: Limiter,
+ pub webauthn: Webauthn,
}
impl AppState {
@@ -216,6 +217,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 4f2fc6205..deb24b5e9 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,
},
};
@@ -187,6 +187,8 @@ impl Options {
let password_manager = password_manager_from_config(&config.passwords).await?;
+ let webauthn = webauthn_from_config(&config.http)?;
+
// The upstream OIDC metadata cache
let metadata_cache = MetadataCache::new();
@@ -222,6 +224,7 @@ impl Options {
password_manager.clone(),
url_builder.clone(),
limiter.clone(),
+ webauthn.clone(),
);
let state = {
@@ -242,6 +245,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 70f07bad1..d29c94674 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::SynapseConnection;
use mas_policy::PolicyFactory;
@@ -487,6 +487,10 @@ pub fn homeserver_connection_from_config(
}
}
+pub fn webauthn_from_config(config: &HttpConfig) -> Result {
+ Webauthn::new(&config.public_base)
+}
+
#[cfg(test)]
mod tests {
use rand::SeedableRng;
diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml
index 0d25d75c2..23d45d316 100644
--- a/crates/handlers/Cargo.toml
+++ b/crates/handlers/Cargo.toml
@@ -88,6 +88,7 @@ rand.workspace = true
rand_chacha.workspace = true
headers.workspace = true
ulid.workspace = true
+webauthn_rp = { version = "0.3.0", features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] }
mas-axum-utils.workspace = true
mas-config.workspace = true
diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs
index cfedd69e9..6cff7b561 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);
@@ -519,6 +526,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 5a3137edf..8800d3b97 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 aa61f4b62..44ba470dd 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 fb54580a0..909f5f1f9 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 21fca3d6c..58319de0e 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 eb86150e5..f2389bd50 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 737f43340..8f4d2696b 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::{BoxClock, BoxRepository, BoxRng, 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 4b3a842c8..dc44644c1 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 2b4d4201d..bf25f05d6 100644
--- a/crates/handlers/src/test_utils.rs
+++ b/crates/handlers/src/test_utils.rs
@@ -56,6 +56,7 @@ use crate::{
ActivityTracker, BoundActivityTracker, Limiter, RequesterFingerprint, graphql,
passwords::{Hasher, PasswordManager},
upstream_oauth2::cache::MetadataCache,
+ webauthn::Webauthn,
};
/// Setup rustcrypto and tracing for tests.
@@ -201,6 +202,8 @@ impl TestState {
PasswordManager::disabled()
};
+ let webauthn = Webauthn::new(&url_builder.http_base())?;
+
let policy_factory =
policy_factory(&site_config.server_name, serde_json::json!({})).await?;
@@ -222,6 +225,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);
@@ -405,6 +409,7 @@ struct TestGraphQLState {
password_manager: PasswordManager,
url_builder: UrlBuilder,
limiter: Limiter,
+ webauthn: Webauthn,
}
#[async_trait::async_trait]
@@ -441,6 +446,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..6a4caf8a1
--- /dev/null
+++ b/crates/handlers/src/webauthn.rs
@@ -0,0 +1,248 @@
+// 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_data_model::{BrowserSession, User, UserPasskey, UserPasskeyChallenge};
+use mas_matrix::HomeserverConnection;
+use mas_storage::{Clock, 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::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,
+}
+
+impl Webauthn {
+ /// Creates a new instance
+ ///
+ /// # Errors
+ /// If the `public_base` has no valid host domain
+ pub fn new(public_base: &Url) -> Result {
+ let host = public_base
+ .host_str()
+ .context("Public base doesn't have a host")?
+ .to_owned();
+
+ let rpid = Arc::new(RpId::Domain(host.try_into()?));
+
+ Ok(Self { rpid })
+ }
+
+ #[must_use]
+ pub fn get_allowed_origin(&self) -> DomainOrigin {
+ let host = (*self.rpid).as_ref();
+ if host == "localhost" {
+ DomainOrigin {
+ scheme: Scheme::Any,
+ host,
+ port: Port::Any,
+ }
+ } else {
+ DomainOrigin::new(host)
+ }
+ }
+
+ /// 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)> {
+ // Get display name or default to username
+ let matrix_user = conn.query_user(&conn.mxid(&user.username)).await?;
+ let display_name = matrix_user
+ .displayname
+ .unwrap_or_else(|| user.username.clone());
+
+ let user_entity = PublicKeyCredentialUserEntity {
+ name: user.username.as_str().try_into()?,
+ id: &UserHandle::decode(user.id.to_bytes())?,
+ display_name: Some(display_name.as_str().try_into()?),
+ };
+
+ let exclude_credentials = repo
+ .user_passkey()
+ .all(user)
+ .await?
+ .into_iter()
+ .map(|v| {
+ Ok(PublicKeyCredentialDescriptor {
+ id: serde_json::from_str(&v.credential_id)?,
+ transports: serde_json::from_value(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 response = serde_json::from_str::(&response)?.0;
+
+ let options = RegistrationVerificationOptions:: {
+ allowed_origins: &[self.get_allowed_origin()],
+ 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 = serde_json::to_string(&credential.id())?;
+
+ // 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,
+ serde_json::to_value(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/frontend/schema.graphql b/frontend/schema.graphql
index 19f616200..b6d4f0926 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
"""
@@ -1834,6 +1998,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.
"""
@@ -2235,6 +2410,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!
@@ -2399,6 +2595,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
"""
@@ -2467,11 +2718,23 @@ Represents the current viewer's session
"""
union ViewerSession = BrowserSession | Oauth2Session | Anonymous
+"""
+Marks an element of a GraphQL schema as no longer supported.
+"""
directive @deprecated(
reason: String = "No longer supported"
) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE
+"""
+Directs the executor to include this field or fragment only when the `if` argument is true.
+"""
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
+"""
+Directs the executor to skip this field or fragment when the `if` argument is true.
+"""
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
+"""
+Provides a scalar specification URL for specifying the behavior of custom scalar types.
+"""
directive @specifiedBy(url: String!) on SCALAR
schema {
query: Query
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
index f0516b76b..3dff10abc 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 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 */
@@ -1345,6 +1453,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 */
@@ -1477,6 +1593,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. */
@@ -1551,6 +1669,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;
@@ -1657,6 +1784,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 589ff86529a1133cc4aac3fbe787e013c58f6ca4 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 | 23 +-
.../UserPasskey/UserPasskey.module.css | 31 +++
.../components/UserPasskey/UserPasskey.tsx | 225 ++++++++++++++++++
frontend/src/components/UserPasskey/index.ts | 6 +
.../components/UserProfile/AddPasskeyForm.tsx | 204 ++++++++++++++++
.../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, 970 insertions(+), 5 deletions(-)
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 218c4d76e..120608add 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -55,6 +55,24 @@
"title": "Edit profile",
"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": {
"change": "Change password",
"change_disabled": "Password changes are disabled by the administrator.",
@@ -64,10 +82,7 @@
"button": "Sign out of account",
"dialog": "Sign out of this account?"
},
- "title": "Your account",
- "passkeys": {
- "title": "Passkeys"
- }
+ "title": "Your account"
},
"add_email_form": {
"email_denied_error": "The entered email is not allowed by the server policy",
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..7cf66da63
--- /dev/null
+++ b/frontend/src/components/UserPasskey/UserPasskey.tsx
@@ -0,0 +1,225 @@
+// 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 type { ReactNode } from "@tanstack/react-router";
+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, 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..87c5e7522
--- /dev/null
+++ b/frontend/src/components/UserProfile/AddPasskeyForm.tsx
@@ -0,0 +1,204 @@
+// 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 cbbefabaf..f947558e1 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(input: {\n email: $email,\n password: $password,\n 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 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,
@@ -98,12 +104,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(input: {\n email: $email,\n password: $password,\n 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 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,
@@ -235,6 +247,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.
*/
@@ -247,6 +271,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(input: {\n email: $email,\n password: $password,\n 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.
*/
@@ -259,6 +291,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 3dff10abc..0b2ea0416 100644
--- a/frontend/src/gql/graphql.ts
+++ b/frontend/src/gql/graphql.ts
@@ -1978,6 +1978,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' };
@@ -1991,6 +2008,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;
@@ -2008,6 +2039,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; }>;
@@ -2511,6 +2555,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
@@ -2645,6 +2697,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(
@@ -2658,6 +2724,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 {
@@ -2685,6 +2767,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 {
@@ -3380,6 +3491,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))
@@ -3402,6 +3557,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))
@@ -3424,6 +3622,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 31c297658..9d49083d6 100644
--- a/frontend/src/routes/_account.index.tsx
+++ b/frontend/src/routes/_account.index.tsx
@@ -13,6 +13,7 @@ import {
} 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";
@@ -24,9 +25,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";
@@ -231,7 +234,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 cba3c5203317486d7bb245c9cca3615825035e2c 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 | 74 ++++++++++++++++++
crates/templates/src/lib.rs | 15 ++--
frontend/knip.config.ts | 1 +
frontend/locales/en.json | 1 +
frontend/src/i18n.ts | 3 +-
frontend/src/template_passkey.ts | 74 ++++++++++++++++++
frontend/src/templates.css | 10 +++
frontend/src/utils/webauthn.ts | 10 +++
frontend/vite.config.ts | 1 +
templates/components/button.html | 6 ++
.../pages/{login.html => login/index.html} | 8 +-
templates/pages/login/passkey.html | 77 +++++++++++++++++++
translations/en.json | 46 +++++++----
13 files changed, 300 insertions(+), 26 deletions(-)
create mode 100644 frontend/src/template_passkey.ts
rename templates/pages/{login.html => login/index.html} (93%)
create mode 100644 templates/pages/login/passkey.html
diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs
index 54b2f193d..292b9a1b8 100644
--- a/crates/templates/src/context.rs
+++ b/crates/templates/src/context.rs
@@ -535,6 +535,80 @@ impl LoginContext {
}
}
+/// Fields of the passkey login form
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum PasskeyLoginFormField {
+ /// The id field
+ Id,
+
+ /// The response field
+ Response,
+}
+
+impl FormField for PasskeyLoginFormField {
+ fn keep(&self) -> bool {
+ match self {
+ Self::Id => true,
+ Self::Response => false,
+ }
+ }
+}
+
+/// Context used by the `login/passkey.html` template
+#[derive(Serialize, Default)]
+pub struct PasskeyLoginContext {
+ form: FormState,
+ next: Option,
+ options: String,
+}
+
+impl TemplateContext for PasskeyLoginContext {
+ fn sample(
+ _now: chrono::DateTime,
+ _rng: &mut impl Rng,
+ _locales: &[DataLocale],
+ ) -> Vec
+ where
+ Self: Sized,
+ {
+ // TODO: samples with errors
+ vec![PasskeyLoginContext {
+ form: FormState::default(),
+ next: None,
+ options: String::new(),
+ }]
+ }
+}
+
+impl PasskeyLoginContext {
+ /// Set the form state
+ #[must_use]
+ pub fn with_form_state(self, form: FormState) -> Self {
+ Self { form, ..self }
+ }
+
+ /// Mutably borrow the form state
+ pub fn form_state_mut(&mut self) -> &mut FormState {
+ &mut self.form
+ }
+
+ /// Add a post authentication action to the context
+ #[must_use]
+ pub fn with_post_action(self, context: PostAuthContext) -> Self {
+ Self {
+ next: Some(context),
+ ..self
+ }
+ }
+
+ /// Set the webauthn options
+ #[must_use]
+ pub fn with_options(self, options: String) -> Self {
+ Self { options, ..self }
+ }
+}
+
/// Fields of the registration form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs
index ff33f1fc0..3ea8917e9 100644
--- a/crates/templates/src/lib.rs
+++ b/crates/templates/src/lib.rs
@@ -38,10 +38,11 @@ pub use self::{
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext,
EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext,
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext,
- PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
- RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
- RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
- RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
+ PasskeyLoginContext, PasskeyLoginFormField, PasswordRegisterContext,
+ PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext,
+ RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
+ RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
+ RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
@@ -323,7 +324,10 @@ register_templates! {
pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
/// Render the login page
- pub fn render_login(WithLanguage>) { "pages/login.html" }
+ pub fn render_login(WithLanguage>) { "pages/login/index.html" }
+
+ /// Render the passkey login page
+ pub fn render_passkey_login(WithLanguage>) { "pages/login/passkey.html" }
/// Render the registration page
pub fn render_register(WithLanguage>) { "pages/register/index.html" }
@@ -439,6 +443,7 @@ impl Templates {
check::render_swagger(self, now, rng)?;
check::render_swagger_callback(self, now, rng)?;
check::render_login(self, now, rng)?;
+ check::render_passkey_login(self, now, rng)?;
check::render_register(self, now, rng)?;
check::render_password_register(self, now, rng)?;
check::render_register_steps_verify_email(self, now, rng)?;
diff --git a/frontend/knip.config.ts b/frontend/knip.config.ts
index a6a4c76cf..37142e307 100644
--- a/frontend/knip.config.ts
+++ b/frontend/knip.config.ts
@@ -9,6 +9,7 @@ export default {
entry: [
"src/main.tsx",
"src/swagger.ts",
+ "src/template_passkey.ts",
"src/routes/*",
"i18next-parser.config.ts",
],
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 120608add..0e5212fa8 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -70,6 +70,7 @@
"name_field_label": "Name",
"name_invalid_error": "The entered name is invalid",
"never_used_message": "Never used",
+ "not_supported": "Passkeys are not supported on this browser",
"response_invalid_error": "The response from your passkey was invalid: {{error}}",
"title": "Passkeys"
},
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 71fc00226..60b96d6fa 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.ts b/frontend/src/template_passkey.ts
new file mode 100644
index 000000000..f9fbd8f4b
--- /dev/null
+++ b/frontend/src/template_passkey.ts
@@ -0,0 +1,74 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+import { setupI18n } from "./i18n";
+import { checkSupport, performAuthentication } from "./utils/webauthn";
+
+const t = await setupI18n();
+
+interface IWindow {
+ WEBAUTHN_OPTIONS?: string;
+}
+
+const options =
+ typeof window !== "undefined" && (window as IWindow).WEBAUTHN_OPTIONS;
+
+const errors = document.getElementById("errors");
+const retryButtonContainer = document.getElementById("retry-button-container");
+const retryButton = document.getElementById("retry-button");
+const form = document.getElementById("passkey-form");
+const formResponse = form?.querySelector('[name="response"]');
+
+function setError(text: string) {
+ const error = document.createElement("div");
+ error.classList.add("text-critical", "font-medium");
+ error.innerText = text;
+ errors?.appendChild(error);
+}
+
+async function run() {
+ if (!options) {
+ throw new Error("WEBAUTHN_OPTIONS is not defined");
+ }
+
+ if (
+ !errors ||
+ !retryButtonContainer ||
+ !retryButton ||
+ !form ||
+ !formResponse
+ ) {
+ throw new Error("Missing elements in document");
+ }
+
+ errors.innerHTML = "";
+
+ if (!checkSupport()) {
+ setError(t("frontend.account.passkeys.not_supported"));
+ return;
+ }
+
+ try {
+ const response = await performAuthentication(options);
+ (formResponse as HTMLInputElement).value = response;
+ (form as HTMLFormElement).submit();
+ } catch (e) {
+ if (e instanceof Error && e.name !== "NotAllowedError") {
+ setError(e.toString());
+ }
+ retryButtonContainer?.classList.remove("hidden");
+ return;
+ }
+}
+
+if (!errors?.children.length) {
+ run();
+} else {
+ retryButtonContainer?.classList.remove("hidden");
+}
+
+retryButton?.addEventListener("click", () => {
+ run();
+});
diff --git a/frontend/src/templates.css b/frontend/src/templates.css
index 00572eafa..15748bb11 100644
--- a/frontend/src/templates.css
+++ b/frontend/src/templates.css
@@ -175,3 +175,13 @@
}
}
}
+
+.fullscreen-noscript {
+ z-index: 99;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100vh;
+ background: var(--cpd-color-bg-canvas-default);
+}
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 58e8b70cb..db7b5d8c8 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.ts"),
],
},
},
diff --git a/templates/components/button.html b/templates/components/button.html
index 3b037f2f5..a97fb93ec 100644
--- a/templates/components/button.html
+++ b/templates/components/button.html
@@ -27,6 +27,7 @@
name="",
type="submit",
class="",
+ id="",
value="",
disabled=False,
kind="primary",
@@ -40,6 +41,7 @@
type="{{ type }}"
{% if disabled %}disabled{% endif %}
class="cpd-button {{ class }}"
+ id="{{id}}"
data-kind="{{ kind }}"
data-size="{{ size }}"
{% if autocapitalize %}autocapitilize="{{ autocapitilize }}"{% endif %}
@@ -53,6 +55,7 @@
name="",
type="submit",
class="",
+ id="",
value="",
disabled=False,
autocomplete=False,
@@ -65,6 +68,7 @@
{% if disabled %}disabled{% endif %}
data-kind="primary"
class="cpd-link {{ class }}"
+ id="{{id}}"
{% if autocapitalize %}autocapitilize="{{ autocapitilize }}"{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if autocorrect %}autocorrect="{{ autocorrect }}"{% endif %}
@@ -76,6 +80,7 @@
name="",
type="submit",
class="",
+ id="",
value="",
disabled=False,
size="lg",
@@ -87,6 +92,7 @@
value="{{ value }}"
type="{{ type }}"
class="cpd-button {{ class }}"
+ id="{{id}}"
data-kind="secondary"
data-size="{{ size }}"
{% if disabled %}disabled{% endif %}
diff --git a/templates/pages/login.html b/templates/pages/login/index.html
similarity index 93%
rename from templates/pages/login.html
rename to templates/pages/login/index.html
index 3b3a2e32a..209e69ec0 100644
--- a/templates/pages/login.html
+++ b/templates/pages/login/index.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") }}
diff --git a/templates/pages/login/passkey.html b/templates/pages/login/passkey.html
new file mode 100644
index 000000000..aff19673f
--- /dev/null
+++ b/templates/pages/login/passkey.html
@@ -0,0 +1,77 @@
+{#
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+-#}
+
+{% extends "base.html" %}
+
+{% set params = next["params"] | default({}) | to_params(prefix="?") %}
+
+{% block content %}
+
+
+
+
+ {{ icon.error_solid() }}
+
+
+
+
+
+ {{ button.link_outline(text=_("action.back"), href="/login" ~ params) }}
+
+
+
+
+
+
+ {{ button.button(text=_("action.try_again"), id="retry-button") }}
+
+
+ {{ button.link_outline(text=_("action.back"), href="/login" ~ params) }}
+
+
+ {{ include_asset('src/template_passkey.ts') | indent(4) | safe }}
+{% endblock content %}
diff --git a/translations/en.json b/translations/en.json
index 25720406f..8a6a09fa3 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -2,7 +2,7 @@
"action": {
"back": "Back",
"@back": {
- "context": "pages/recovery/disabled.html:22:32-48"
+ "context": "pages/login/passkey.html:28:34-50, pages/login/passkey.html:71:30-46, pages/recovery/disabled.html:22:32-48"
},
"cancel": "Cancel",
"@cancel": {
@@ -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/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/index.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/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:192:26-52"
+ "context": "pages/login/index.html:102:33-59, pages/upstream_oauth2/do_register.html:192:26-52"
},
"sign_in": "Sign in",
"@sign_in": {
@@ -31,6 +31,10 @@
"start_over": "Start over",
"@start_over": {
"context": "pages/recovery/consumed.html:22:32-54, pages/recovery/expired.html:30:32-54, pages/register/steps/email_in_use.html:28:32-54"
+ },
+ "try_again": "Try again",
+ "@try_again": {
+ "context": "pages/login/passkey.html:68:26-47"
}
},
"app": {
@@ -91,7 +95,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/index.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 +103,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/index.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": {
@@ -377,6 +381,14 @@
"@invalid_credentials": {
"context": "components/errors.html:11:7-42"
},
+ "passkey_no_js_description": "You need to allow JavaScript in your browser's settings to use passkeys",
+ "@passkey_no_js_description": {
+ "context": "pages/login/passkey.html:23:17-58"
+ },
+ "passkey_no_js_title": "JavaScript required",
+ "@passkey_no_js_title": {
+ "context": "pages/login/passkey.html:21:31-66"
+ },
"password_mismatch": "Password fields don't match",
"@password_mismatch": {
"context": "components/errors.html:13:7-40, components/field.html:88:17-50"
@@ -419,47 +431,51 @@
"login": {
"call_to_register": "Don't have an account yet?",
"@call_to_register": {
- "context": "pages/login.html:98:13-44"
+ "context": "pages/login/index.html:99: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/index.html:90: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/index.html:31:29-55"
},
"forgot_password": "Forgot password?",
"@forgot_password": {
- "context": "pages/login.html:61:35-65",
+ "context": "pages/login/index.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/index.html:30:31-54, pages/login/passkey.html:39:29-52"
},
"link": {
"description": "Linking your %(provider)s account",
"@description": {
- "context": "pages/login.html:24:29-75"
+ "context": "pages/login/index.html:26:29-75"
},
"headline": "Sign in to link",
"@headline": {
- "context": "pages/login.html:22:31-59"
+ "context": "pages/login/index.html:24:31-59"
}
},
"no_login_methods": "No login methods available.",
"@no_login_methods": {
- "context": "pages/login.html:108:11-42"
+ "context": "pages/login/index.html:108:11-42"
+ },
+ "passkey_description": "Follow the prompts from your system to continue",
+ "@passkey_description": {
+ "context": "pages/login/passkey.html:40:27-61"
},
"username_or_email": "Username or Email",
"@username_or_email": {
- "context": "pages/login.html:47:39-71"
+ "context": "pages/login/index.html:49:39-71"
},
"with_passkey": "Sign in with a Passkey",
"@with_passkey": {
- "context": "pages/login.html:76:28-55"
+ "context": "pages/login/index.html:78:28-55"
}
},
"navbar": {
From a3cf2d683154bb9291ae9229ab9fd495d1331056 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 | 6 +
crates/handlers/src/test_utils.rs | 8 +
.../src/views/{login.rs => login/mod.rs} | 2 +
.../src/views/login/passkey/cookie.rs | 101 ++++++
.../handlers/src/views/login/passkey/mod.rs | 330 ++++++++++++++++++
crates/handlers/src/webauthn.rs | 170 ++++++++-
crates/router/src/endpoints.rs | 74 ++++
7 files changed, 689 insertions(+), 2 deletions(-)
rename crates/handlers/src/views/{login.rs => login/mod.rs} (99%)
create mode 100644 crates/handlers/src/views/login/passkey/cookie.rs
create mode 100644 crates/handlers/src/views/login/passkey/mod.rs
diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs
index dc44644c1..817fe2ead 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};
@@ -337,6 +338,7 @@ where
Limiter: FromRef,
reqwest::Client: FromRef,
Arc: FromRef,
+ Webauthn: FromRef,
BoxClock: FromRequestParts,
BoxRng: FromRequestParts,
Policy: FromRequestParts,
@@ -379,6 +381,10 @@ where
mas_router::Login::route(),
get(self::views::login::get).post(self::views::login::post),
)
+ .route(
+ mas_router::PasskeyLogin::route(),
+ get(self::views::login::passkey::get).post(self::views::login::passkey::post),
+ )
.route(mas_router::Logout::route(), post(self::views::logout::post))
.route(
mas_router::Register::route(),
diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs
index bf25f05d6..ddedd03aa 100644
--- a/crates/handlers/src/test_utils.rs
+++ b/crates/handlers/src/test_utils.rs
@@ -114,6 +114,7 @@ pub(crate) struct TestState {
pub rng: Arc>,
pub http_client: reqwest::Client,
pub task_tracker: TaskTracker,
+ pub webauthn: Webauthn,
#[allow(dead_code)] // It is used, as it will cancel the CancellationToken when dropped
cancellation_drop_guard: Arc,
@@ -257,6 +258,7 @@ impl TestState {
rng,
http_client,
task_tracker,
+ webauthn,
cancellation_drop_guard: Arc::new(shutdown_token.drop_guard()),
})
}
@@ -553,6 +555,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.rs b/crates/handlers/src/views/login/mod.rs
similarity index 99%
rename from crates/handlers/src/views/login.rs
rename to crates/handlers/src/views/login/mod.rs
index aac40184c..8fd42fa35 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
// Please see LICENSE in the repository root for full details.
+pub mod passkey;
+
use std::sync::{Arc, LazyLock};
use axum::{
diff --git a/crates/handlers/src/views/login/passkey/cookie.rs b/crates/handlers/src/views/login/passkey/cookie.rs
new file mode 100644
index 000000000..7003c15ea
--- /dev/null
+++ b/crates/handlers/src/views/login/passkey/cookie.rs
@@ -0,0 +1,101 @@
+// 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::UserPasskeyChallenge;
+use mas_storage::Clock;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+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);
+
+#[derive(Debug, Error, PartialEq, Eq)]
+#[error("user passkey challenge not found")]
+pub struct UserPasskeyChallengeNotFound;
+
+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: &UserPasskeyChallenge) -> bool {
+ self.0.contains(&passkey_challenge.id)
+ }
+
+ /// Mark a challenge as consumed to avoid replay
+ pub fn consume_challenge(
+ mut self,
+ passkey_challenge: &UserPasskeyChallenge,
+ ) -> Result {
+ if !self.0.remove(&passkey_challenge.id) {
+ return Err(UserPasskeyChallengeNotFound);
+ }
+
+ Ok(self)
+ }
+}
diff --git a/crates/handlers/src/views/login/passkey/mod.rs b/crates/handlers/src/views/login/passkey/mod.rs
new file mode 100644
index 000000000..0af8d8266
--- /dev/null
+++ b/crates/handlers/src/views/login/passkey/mod.rs
@@ -0,0 +1,330 @@
+// Copyright 2025 New Vector Ltd.
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+// Please see LICENSE in the repository root for full details.
+
+mod cookie;
+
+use axum::{
+ Form,
+ extract::{Query, State},
+ response::{Html, IntoResponse, Response},
+};
+use axum_extra::TypedHeader;
+use cookie::UserPasskeyChallenges;
+use hyper::StatusCode;
+use mas_axum_utils::{
+ InternalError, SessionInfoExt,
+ cookies::CookieJar,
+ csrf::{CsrfExt, ProtectedForm},
+};
+use mas_data_model::SiteConfig;
+use mas_i18n::DataLocale;
+use mas_router::UrlBuilder;
+use mas_storage::{BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess};
+use mas_templates::{
+ AccountInactiveContext, FieldError, FormError, FormState, PasskeyLoginContext,
+ PasskeyLoginFormField, TemplateContext, Templates, ToFormState,
+};
+use rand::RngCore;
+use serde::{Deserialize, Serialize};
+use ulid::Ulid;
+
+use crate::{
+ BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint,
+ session::{SessionOrFallback, load_session_or_fallback},
+ views::shared::OptionalPostAuthAction,
+ webauthn::{Webauthn, WebauthnError},
+};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub(crate) struct PasskeyLoginForm {
+ id: String,
+ response: String,
+}
+
+impl ToFormState for PasskeyLoginForm {
+ type Field = PasskeyLoginFormField;
+}
+
+#[tracing::instrument(name = "handlers.views.login.passkey.get", skip_all)]
+pub(crate) async fn get(
+ mut rng: BoxRng,
+ clock: BoxClock,
+ PreferredLanguage(locale): PreferredLanguage,
+ State(templates): State,
+ State(url_builder): State,
+ State(site_config): State,
+ State(webauthn): State,
+ mut repo: BoxRepository,
+ activity_tracker: BoundActivityTracker,
+ Query(query): Query,
+ cookie_jar: CookieJar,
+) -> Result {
+ let (cookie_jar, maybe_session) = match load_session_or_fallback(
+ cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
+ )
+ .await?
+ {
+ SessionOrFallback::MaybeSession {
+ cookie_jar,
+ maybe_session,
+ ..
+ } => (cookie_jar, maybe_session),
+ SessionOrFallback::Fallback { response } => return Ok(response),
+ };
+
+ if let Some(session) = maybe_session {
+ activity_tracker
+ .record_browser_session(&clock, &session)
+ .await;
+
+ let reply = query.go_next(&url_builder);
+ return Ok((cookie_jar, reply).into_response());
+ }
+
+ if !site_config.passkeys_enabled {
+ // If passkeys are disabled, redirect to the login page here
+ return Ok(url_builder
+ .redirect(&mas_router::Login::from(query.post_auth_action))
+ .into_response());
+ }
+
+ render(
+ locale,
+ cookie_jar,
+ FormState::default(),
+ webauthn,
+ query,
+ repo,
+ &clock,
+ &mut rng,
+ &templates,
+ )
+ .await
+}
+
+#[tracing::instrument(name = "handlers.views.login.passkey.post", skip_all)]
+pub(crate) async fn post(
+ mut rng: BoxRng,
+ clock: BoxClock,
+ PreferredLanguage(locale): PreferredLanguage,
+ State(site_config): State,
+ State(templates): State,
+ State(url_builder): State,
+ State(limiter): State,
+ State(webauthn): State,
+ mut repo: BoxRepository,
+ activity_tracker: BoundActivityTracker,
+ requester: RequesterFingerprint,
+ Query(query): Query,
+ cookie_jar: CookieJar,
+ user_agent: Option>,
+ Form(form): Form>,
+) -> Result {
+ let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
+ if !site_config.passkeys_enabled {
+ return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
+ }
+
+ let form = cookie_jar.verify_form(&clock, form)?;
+
+ let mut form_state = form.to_form_state();
+
+ // Setting Ulid directly on the form field shows an ugly text only form parsing
+ // error about invalid length if the ID is somehow missing
+ let ulid = Ulid::from_string(&form.id).unwrap_or_default();
+ if ulid.is_nil() {
+ form_state.add_error_on_field(PasskeyLoginFormField::Id, FieldError::Required);
+ }
+
+ if form.response.is_empty() {
+ form_state.add_error_on_field(PasskeyLoginFormField::Response, FieldError::Required);
+ }
+
+ if !form_state.is_valid() {
+ return render(
+ locale, cookie_jar, form_state, webauthn, query, repo, &clock, &mut rng, &templates,
+ )
+ .await;
+ }
+
+ // Find the challenge
+ let challenge = match webauthn
+ .lookup_challenge(&mut repo, &clock, ulid, 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 render(
+ locale, cookie_jar, form_state, webauthn, query, repo, &clock, &mut rng, &templates,
+ )
+ .await;
+ }
+ };
+
+ // Validate cookie
+ let challenges = UserPasskeyChallenges::load(&cookie_jar);
+ if !challenges.contains(&challenge) {
+ let form_state = form_state.with_error_on_form(FormError::InvalidCredentials);
+ return render(
+ locale, cookie_jar, form_state, webauthn, query, repo, &clock, &mut rng, &templates,
+ )
+ .await;
+ }
+
+ // Consume and complete the challenge already as we'll give them a new one if
+ // there's an error
+ let cookie_jar = challenges
+ .consume_challenge(&challenge)?
+ .save(cookie_jar, &clock);
+
+ let challenge = repo
+ .user_passkey()
+ .complete_challenge(&clock, challenge)
+ .await?;
+
+ // Get the user and passkey from the authenticator response
+ let (response, user, passkey) = match webauthn
+ .discover_credential(&mut repo, form.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 render(
+ locale, cookie_jar, form_state, webauthn, query, repo, &clock, &mut rng, &templates,
+ )
+ .await;
+ }
+ };
+
+ // 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);
+ let form_state = form_state.with_error_on_form(FormError::RateLimitExceeded);
+ return render(
+ locale, cookie_jar, form_state, webauthn, query, repo, &clock, &mut rng, &templates,
+ )
+ .await;
+ }
+
+ // Validate the passkey
+ let passkey = match webauthn
+ .finish_passkey_authentication(&mut 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 render(
+ locale, cookie_jar, form_state, webauthn, query, repo, &clock, &mut rng, &templates,
+ )
+ .await;
+ }
+ };
+
+ // Now that we have checked the passkey, we now want to show an error if
+ // the user is locked or deactivated
+ if user.deactivated_at.is_some() {
+ 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() {
+ 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?;
+
+ // And mark it as authenticated by the passkey
+ repo.browser_session()
+ .authenticate_with_passkey(&mut rng, &clock, &user_session, &passkey)
+ .await?;
+
+ repo.save().await?;
+
+ 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 render(
+ locale: DataLocale,
+ cookie_jar: CookieJar,
+ mut form_state: FormState,
+ webauthn: Webauthn,
+ action: OptionalPostAuthAction,
+ mut repo: BoxRepository,
+ clock: &impl Clock,
+ rng: &mut (dyn RngCore + Send),
+ templates: &Templates,
+) -> Result {
+ let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, &mut *rng);
+
+ let (options, challenge) = webauthn
+ .start_passkey_authentication(&mut repo, rng, clock)
+ .await
+ .map_err(InternalError::from_anyhow)?;
+
+ form_state.set_value(PasskeyLoginFormField::Id, Some(challenge.id.to_string()));
+
+ let cookie_jar = UserPasskeyChallenges::load(&cookie_jar)
+ .add(&challenge)
+ .save(cookie_jar, clock);
+
+ let ctx = PasskeyLoginContext::default()
+ .with_form_state(form_state)
+ .with_options(options);
+
+ let next = action
+ .load_context(&mut repo)
+ .await
+ .map_err(InternalError::from_anyhow)?;
+ let ctx = if let Some(next) = next {
+ ctx.with_post_action(next)
+ } else {
+ ctx
+ };
+ let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale);
+
+ repo.save().await?;
+
+ let content = templates.render_passkey_login(&ctx)?;
+ Ok((cookie_jar, Html(content)).into_response())
+}
diff --git a/crates/handlers/src/webauthn.rs b/crates/handlers/src/webauthn.rs
index 6a4caf8a1..9176862a8 100644
--- a/crates/handlers/src/webauthn.rs
+++ b/crates/handlers/src/webauthn.rs
@@ -14,13 +14,22 @@ 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::register::{error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed},
+ response::{
+ CredentialId,
+ auth::{error::AuthCeremonyErr, ser_relaxed::DiscoverableAuthenticationRelaxed16},
+ register::{
+ DynamicState, StaticState, error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed,
+ },
+ },
};
/// User-facing errors
@@ -29,12 +38,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,
}
@@ -245,4 +266,149 @@ 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 response = serde_json::from_str::(&response)?.0;
+
+ let credential_id = serde_json::to_string(&response.raw_id())?;
+
+ 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_origin()],
+ 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 =
+ serde_json::from_str::>>(&user_passkey.credential_id)?;
+ let credential_id = CredentialId::<&[u8]>::from(&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 user_passkey = repo
+ .user_passkey()
+ .update(clock, user_passkey, cred.dynamic_state().encode()?.to_vec())
+ .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)
+ }
}
diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs
index a7efeade9..94703f8e7 100644
--- a/crates/router/src/endpoints.rs
+++ b/crates/router/src/endpoints.rs
@@ -245,6 +245,80 @@ impl From> for Login {
}
}
+/// `GET|POST /login/passkey`
+#[derive(Default, Debug, Clone)]
+pub struct PasskeyLogin {
+ post_auth_action: Option,
+}
+
+impl Route for PasskeyLogin {
+ type Query = PostAuthAction;
+
+ fn route() -> &'static str {
+ "/login/passkey"
+ }
+
+ fn query(&self) -> Option<&Self::Query> {
+ self.post_auth_action.as_ref()
+ }
+}
+
+impl PasskeyLogin {
+ #[must_use]
+ pub const fn and_then(action: PostAuthAction) -> Self {
+ Self {
+ post_auth_action: Some(action),
+ }
+ }
+
+ #[must_use]
+ pub const fn and_continue_grant(id: Ulid) -> Self {
+ Self {
+ post_auth_action: Some(PostAuthAction::continue_grant(id)),
+ }
+ }
+
+ #[must_use]
+ pub const fn and_continue_device_code_grant(id: Ulid) -> Self {
+ Self {
+ post_auth_action: Some(PostAuthAction::continue_device_code_grant(id)),
+ }
+ }
+
+ #[must_use]
+ pub const fn and_continue_compat_sso_login(id: Ulid) -> Self {
+ Self {
+ post_auth_action: Some(PostAuthAction::continue_compat_sso_login(id)),
+ }
+ }
+
+ #[must_use]
+ pub const fn and_link_upstream(id: Ulid) -> Self {
+ Self {
+ post_auth_action: Some(PostAuthAction::link_upstream(id)),
+ }
+ }
+
+ /// Get a reference to the login's post auth action.
+ #[must_use]
+ pub fn post_auth_action(&self) -> Option<&PostAuthAction> {
+ self.post_auth_action.as_ref()
+ }
+
+ pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
+ match &self.post_auth_action {
+ Some(action) => action.go_next(url_builder),
+ None => url_builder.redirect(&Index),
+ }
+ }
+}
+
+impl From> for PasskeyLogin {
+ fn from(post_auth_action: Option) -> Self {
+ Self { post_auth_action }
+ }
+}
+
/// `POST /logout`
#[derive(Default, Debug, Clone)]
pub struct Logout;
From 518f203c34464f6890dc28b39b5e6b5cd6fb07de 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 37fd527f8..a313fc603 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, User, UserPasskey, UserPasskeyChallenge};
use mas_storage::{
Clock, Page, Pagination,
@@ -636,4 +636,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 b0075f319..e3883d0b3 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 bb6d8ebc7..04e8dfef9 100644
--- a/crates/storage/src/user/passkey.rs
+++ b/crates/storage/src/user/passkey.rs
@@ -258,6 +258,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:
@@ -321,4 +334,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 fa424d7df..024405d43 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 cb1b16469..a6d3547e0 100644
--- a/crates/tasks/src/lib.rs
+++ b/crates/tasks/src/lib.rs
@@ -139,6 +139,7 @@ pub async fn init(
.register_handler::()
.register_handler::()
.register_handler::()
+ .register_handler::()
.add_schedule(
"cleanup-expired-tokens",
"0 0 * * * *".parse()?,
@@ -155,6 +156,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,
);
task_tracker.spawn(worker.run());