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}
+
+
+ + + + + + +
+
+ ); +}; + +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" && ( + + )} + + + )} + + ); +}; + +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 %}
@@ -73,7 +75,7 @@

{{ _("mas.login.headline") }}

{% endif %} {% if features.passkeys_enabled %} - {{ button.link(text=_("mas.login.with_passkey")) }} + {{ button.link(text=_("mas.login.with_passkey"), href="/login/passkey" ~ params) }} {% endif %} {% if (features.password_login or features.passkeys_enabled) and providers %} @@ -81,7 +83,6 @@

{{ _("mas.login.headline") }}

{% endif %} {% if providers %} - {% set params = next["params"] | default({}) | to_params(prefix="?") %} {% for provider in providers %} {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} @@ -98,12 +99,11 @@

{{ _("mas.login.headline") }}

{{ _("mas.login.call_to_register") }}

- {% 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.user_profile_solid() }} +
+ +
+

{{ _("mas.login.headline") }}

+

{{ _("mas.login.passkey_description") }}

+
+
+ +
+
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} +
+ + + + {% call(f) field.field(name="id", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(name="response", form_state=form) %} + + {% endcall %} +
+ + + + + {{ 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());