From 35a1c517314302f3d362fc0657766ebdee24e54d Mon Sep 17 00:00:00 2001 From: Tonkku Date: Mon, 3 Mar 2025 13:15:11 +0000 Subject: [PATCH 1/7] Experimental configuration toggle for passkeys --- crates/cli/src/util.rs | 4 +++ crates/config/src/sections/experimental.rs | 17 +++++++++++ crates/data-model/src/site_config.rs | 3 ++ .../handlers/src/graphql/model/site_config.rs | 4 +++ crates/handlers/src/test_utils.rs | 1 + crates/handlers/src/views/login.rs | 7 +++-- crates/templates/src/context/ext.rs | 1 + crates/templates/src/context/features.rs | 5 ++++ crates/templates/src/lib.rs | 1 + docs/config.schema.json | 19 ++++++++++++ frontend/locales/en.json | 3 ++ frontend/schema.graphql | 4 +++ frontend/src/gql/gql.ts | 6 ++-- frontend/src/gql/graphql.ts | 5 +++- frontend/src/routes/_account.index.tsx | 11 +++++++ templates/pages/login.html | 30 ++++++++++++------- translations/en.json | 16 ++++++---- 17 files changed, 113 insertions(+), 24 deletions(-) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 959c8ba0f..e3f2166c1 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -224,6 +224,10 @@ pub fn site_config_from_config( session_expiration, login_with_email_allowed: account_config.login_with_email_allowed, plan_management_iframe_uri: experimental_config.plan_management_iframe_uri.clone(), + passkeys_enabled: experimental_config + .passkeys + .as_ref() + .is_some_and(|c| c.enabled), }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index c6c50e88d..62ad599cc 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -45,6 +45,15 @@ pub struct InactiveSessionExpirationConfig { pub expire_user_sessions: bool, } +/// Configuration options for passkeys +#[serde_as] +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct PasskeysConfig { + /// Whether passkeys are enabled or not + #[serde(default)] + pub enabled: bool, +} + /// Configuration sections for experimental options /// /// Do not change these options unless you know what you are doing. @@ -81,6 +90,12 @@ pub struct ExperimentalConfig { /// validation. #[serde(skip_serializing_if = "Option::is_none")] pub plan_management_iframe_uri: Option, + + /// Experimental passkey support + /// + /// Disabled by default + #[serde(skip_serializing_if = "Option::is_none")] + pub passkeys: Option, } impl Default for ExperimentalConfig { @@ -90,6 +105,7 @@ impl Default for ExperimentalConfig { compat_token_ttl: default_token_ttl(), inactive_session_expiration: None, plan_management_iframe_uri: None, + passkeys: None, } } } @@ -100,6 +116,7 @@ impl ExperimentalConfig { && is_default_token_ttl(&self.compat_token_ttl) && self.inactive_session_expiration.is_none() && self.plan_management_iframe_uri.is_none() + && self.passkeys.is_none() } } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index ac0d7e6b8..552d2a20a 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -96,4 +96,7 @@ pub struct SiteConfig { /// The iframe URL to show in the plan tab of the UI pub plan_management_iframe_uri: Option, + + /// Whether passkeys are enabled + pub passkeys_enabled: bool, } diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs index d6966907e..5657a22ea 100644 --- a/crates/handlers/src/graphql/model/site_config.rs +++ b/crates/handlers/src/graphql/model/site_config.rs @@ -59,6 +59,9 @@ pub struct SiteConfig { /// Experimental plan management iframe URI. plan_management_iframe_uri: Option, + + /// Whether passkeys are enabled + passkeys_enabled: bool, } #[derive(SimpleObject)] @@ -106,6 +109,7 @@ impl SiteConfig { minimum_password_complexity: data_model.minimum_password_complexity, login_with_email_allowed: data_model.login_with_email_allowed, plan_management_iframe_uri: data_model.plan_management_iframe_uri.clone(), + passkeys_enabled: data_model.passkeys_enabled, } } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index e43194776..7c4660a34 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -147,6 +147,7 @@ pub fn test_site_config() -> SiteConfig { session_expiration: None, login_with_email_allowed: true, plan_management_iframe_uri: None, + passkeys_enabled: false, } } diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 57091e5fc..4c15156f8 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -99,9 +99,10 @@ pub(crate) async fn get( let providers = repo.upstream_oauth_provider().all_enabled().await?; - // If password-based login is disabled, and there is only one upstream provider, - // we can directly start an authorization flow - if !site_config.password_login_enabled && providers.len() == 1 { + // If password-based login and passkeys are disabled, and there is only one + // upstream provider, we can directly start an authorization flow + if !site_config.password_login_enabled && !site_config.passkeys_enabled && providers.len() == 1 + { let provider = providers.into_iter().next().unwrap(); let mut destination = UpstreamOAuth2Authorize::new(provider.id); diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index e4ae3886c..2c17e7288 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -48,6 +48,7 @@ impl SiteConfigExt for SiteConfig { password_login: self.password_login_enabled, account_recovery: self.account_recovery_allowed, login_with_email_allowed: self.login_with_email_allowed, + passkeys_enabled: self.passkeys_enabled, } } } diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index d514b5c63..ba7cb0cf1 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -26,6 +26,9 @@ pub struct SiteFeatures { /// Whether users can log in with their email address. pub login_with_email_allowed: bool, + + /// Whether passkeys are enabled + pub passkeys_enabled: bool, } impl Object for SiteFeatures { @@ -35,6 +38,7 @@ impl Object for SiteFeatures { "password_login" => Some(Value::from(self.password_login)), "account_recovery" => Some(Value::from(self.account_recovery)), "login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)), + "passkeys_enabled" => Some(Value::from(self.passkeys_enabled)), _ => None, } } @@ -45,6 +49,7 @@ impl Object for SiteFeatures { "password_login", "account_recovery", "login_with_email_allowed", + "passkeys_enabled", ]) } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 1c0bef423..045b8af50 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -511,6 +511,7 @@ mod tests { password_registration: true, account_recovery: true, login_with_email_allowed: true, + passkeys_enabled: true, }; let vite_manifest_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); diff --git a/docs/config.schema.json b/docs/config.schema.json index 761f1dd62..350c49614 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2657,6 +2657,14 @@ "plan_management_iframe_uri": { "description": "Experimental feature to show a plan management tab and iframe. This value is passed through \"as is\" to the client without any validation.", "type": "string" + }, + "passkeys": { + "description": "Experimental passkey support\n\nDisabled by default", + "allOf": [ + { + "$ref": "#/definitions/PasskeysConfig" + } + ] } } }, @@ -2690,6 +2698,17 @@ "type": "boolean" } } + }, + "PasskeysConfig": { + "description": "Configuration options for passkeys", + "type": "object", + "properties": { + "enabled": { + "description": "Whether passkeys are enabled or not", + "default": false, + "type": "boolean" + } + } } } } \ No newline at end of file diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 929b1a99c..d36d1b793 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -55,6 +55,9 @@ "title": "Edit profile", "username_label": "Username" }, + "passkeys": { + "title": "Passkeys" + }, "password": { "change": "Change password", "change_disabled": "Password changes are disabled by the administrator.", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 99da32010..d2b03b0a0 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1762,6 +1762,10 @@ type SiteConfig implements Node { """ planManagementIframeUri: String """ + Whether passkeys are enabled + """ + passkeysEnabled: Boolean! + """ The ID of the site configuration. """ id: ID! diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 635e117ca..34555560b 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -49,7 +49,7 @@ type Documents = { "\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc, "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc, - "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument, + "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument, "\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument, "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": typeof types.BrowserSessionListDocument, "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": typeof types.SessionsOverviewDocument, @@ -106,7 +106,7 @@ const documents: Documents = { "\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc, "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc, - "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument, + "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument, "\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument, "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument, "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewDocument, @@ -268,7 +268,7 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument; +export function graphql(source: "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index b6f357170..84b98f8ce 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1290,6 +1290,8 @@ export type SiteConfig = Node & { * in use is . */ minimumPasswordComplexity: Scalars['Int']['output']; + /** Whether passkeys are enabled */ + passkeysEnabled: Scalars['Boolean']['output']; /** Whether passwords are enabled and users can change their own passwords. */ passwordChangeAllowed: Scalars['Boolean']['output']; /** Whether passwords are enabled for login. */ @@ -1856,7 +1858,7 @@ export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typena { __typename?: 'User', hasPassword: boolean, emails: { __typename?: 'UserEmailConnection', totalCount: number } } & { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment;'AccountDeleteButton_UserFragment': AccountDeleteButton_UserFragment } } ) } | { __typename: 'Oauth2Session' }, siteConfig: ( - { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean, accountDeactivationAllowed: boolean } + { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean, passkeysEnabled: boolean, accountDeactivationAllowed: boolean } & { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment;'AccountDeleteButton_SiteConfigFragment': AccountDeleteButton_SiteConfigFragment } } ) }; @@ -2549,6 +2551,7 @@ export const UserProfileDocument = new TypedDocumentString(` siteConfig { emailChangeAllowed passwordLoginEnabled + passkeysEnabled accountDeactivationAllowed ...AddEmailForm_siteConfig ...UserEmailList_siteConfig diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 4b21b326c..e49fb3915 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -46,6 +46,7 @@ const QUERY = graphql(/* GraphQL */ ` siteConfig { emailChangeAllowed passwordLoginEnabled + passkeysEnabled accountDeactivationAllowed ...AddEmailForm_siteConfig ...UserEmailList_siteConfig @@ -226,6 +227,16 @@ function Index(): React.ReactElement { )} + {siteConfig.passkeysEnabled && ( + <> + + placeholder text + + + + + )} + {t("frontend.reset_cross_signing.description")} diff --git a/templates/pages/login.html b/templates/pages/login.html index 57bccd03e..10802adf8 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -42,17 +42,17 @@

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

- {% if features.login_with_email_allowed %} - {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %} - - {% endcall %} - {% else %} - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {% endcall %} - {% endif %} - {% if features.password_login %} + {% if features.login_with_email_allowed %} + {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %} + + {% endcall %} + {% else %} + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + + {% endcall %} + {% endif %} + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} {% endcall %} @@ -68,7 +68,15 @@

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

{{ button.button(text=_("action.continue")) }} {% endif %} - {% if features.password_login and providers %} + {% if features.password_login and features.passkeys_enabled %} + {{ field.separator() }} + {% endif %} + + {% if features.passkeys_enabled %} + {{ button.link(text=_("mas.login.with_passkey")) }} + {% endif %} + + {% if (features.password_login or features.passkeys_enabled) and providers %} {{ field.separator() }} {% endif %} diff --git a/translations/en.json b/translations/en.json index 0d263fb55..27410e0d5 100644 --- a/translations/en.json +++ b/translations/en.json @@ -14,7 +14,7 @@ }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:94:33-59, pages/upstream_oauth2/do_register.html:191:26-52" + "context": "pages/login.html:102:33-59, pages/upstream_oauth2/do_register.html:191:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -99,7 +99,7 @@ }, "username": "Username", "@username": { - "context": "pages/login.html:50:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" } }, "error": { @@ -419,11 +419,11 @@ "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { - "context": "pages/login.html:90:13-44" + "context": "pages/login.html:98:13-44" }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:81:15-67, pages/register/index.html:53:15-67", + "context": "pages/login.html:89:15-67, pages/register/index.html:53:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -451,11 +451,15 @@ }, "no_login_methods": "No login methods available.", "@no_login_methods": { - "context": "pages/login.html:100:11-42" + "context": "pages/login.html:108:11-42" }, "username_or_email": "Username or Email", "@username_or_email": { - "context": "pages/login.html:46:37-69" + "context": "pages/login.html:47:39-71" + }, + "with_passkey": "Sign in with a Passkey", + "@with_passkey": { + "context": "pages/login.html:76:28-55" } }, "navbar": { From 79bbd44792aa2026b34c489e22abc48dd79a4b21 Mon Sep 17 00:00:00 2001 From: Tonkku Date: Mon, 3 Mar 2025 17:08:34 +0000 Subject: [PATCH 2/7] Database model changes --- Cargo.lock | 130 ++++ Cargo.toml | 4 + crates/data-model/Cargo.toml | 1 + crates/data-model/src/lib.rs | 5 +- crates/data-model/src/users.rs | 25 + ...43b6bdf6a41896d787a13be3399022fe5e43d.json | 46 ++ ...90af5ecca2f977b8d74311b4cad401e2650e5.json | 17 + ...02ffb44631d77b14796db971064ef27abb739.json | 16 + ...61e4f55b19405def7e2e51051b6f564f5e4c5.json | 76 ++ ...7b0bc4a2f963aadca4264f5e6e250afde4965.json | 22 + ...8c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json | 76 ++ ...4d5215d39ea6d1390056c9e3ab0981673c84e.json | 14 + ...ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json | 15 + ...a78edec1b66be5ff6d57ab90ac06e0f1098be.json | 16 + ...b180b0d5c60e877dc6818f60662aa801b49ac.json | 76 ++ ...00ece02fb9a5bed134fda19de1e4731bc9911.json | 15 + ...32ef8fc5eaac1176471d2a5f25ee8cf1f849a.json | 17 + crates/storage-pg/Cargo.toml | 1 + .../migrations/20250303142622_passkeys.sql | 63 ++ ...ion_authentications_user_passkey_id_fk.sql | 9 + crates/storage-pg/src/iden.rs | 15 + crates/storage-pg/src/repository.rs | 16 +- crates/storage-pg/src/user/mod.rs | 6 +- crates/storage-pg/src/user/passkey.rs | 657 ++++++++++++++++++ crates/storage-pg/src/user/session.rs | 51 +- crates/storage/Cargo.toml | 1 + crates/storage/src/repository.rs | 27 +- crates/storage/src/user/mod.rs | 2 + crates/storage/src/user/passkey.rs | 328 +++++++++ crates/storage/src/user/session.rs | 29 + 30 files changed, 1759 insertions(+), 17 deletions(-) create mode 100644 crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json create mode 100644 crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json create mode 100644 crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json create mode 100644 crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json create mode 100644 crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json create mode 100644 crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json create mode 100644 crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json create mode 100644 crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json create mode 100644 crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json create mode 100644 crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json create mode 100644 crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json create mode 100644 crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json create mode 100644 crates/storage-pg/migrations/20250303142622_passkeys.sql create mode 100644 crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql create mode 100644 crates/storage-pg/src/user/passkey.rs create mode 100644 crates/storage/src/user/passkey.rs diff --git a/Cargo.lock b/Cargo.lock index b973e1cc7..718a502fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,32 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -1488,6 +1514,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deadpool" version = "0.10.0" @@ -1656,6 +1688,27 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + [[package]] name = "either" version = "1.15.0" @@ -1816,6 +1869,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "figment" version = "0.10.19" @@ -3292,6 +3351,7 @@ dependencies = [ "thiserror 2.0.16", "ulid", "url", + "webauthn_rp", "woothee", ] @@ -3670,6 +3730,7 @@ dependencies = [ "tracing-opentelemetry", "ulid", "url", + "webauthn_rp", ] [[package]] @@ -3697,6 +3758,7 @@ dependencies = [ "ulid", "url", "uuid", + "webauthn_rp", ] [[package]] @@ -4625,6 +4687,40 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precis-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2e7b31f132e0c6f8682cfb7bf4a5340dbe925b7986618d0826a56dfe0c8e56" +dependencies = [ + "precis-tools", + "ucd-parse", + "unicode-normalization", +] + +[[package]] +name = "precis-profiles" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc4f67f78f50388f03494794766ba824a704db16fb5d400fe8d545fa7bc0d3f1" +dependencies = [ + "lazy_static", + "precis-core", + "precis-tools", + "unicode-normalization", +] + +[[package]] +name = "precis-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc1eb2d5887ac7bfd2c0b745764db89edb84b856e4214e204ef48ef96d10c4a" +dependencies = [ + "lazy_static", + "regex", + "ucd-parse", +] + [[package]] name = "prettyplease" version = "0.2.36" @@ -4988,6 +5084,12 @@ dependencies = [ "regex-syntax 0.8.5", ] +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -5079,6 +5181,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2", "signature", "spki", "subtle", @@ -6697,6 +6800,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-parse" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06ff81122fcbf4df4c1660b15f7e3336058e7aec14437c9f85c6b31a0f279b9" +dependencies = [ + "regex-lite", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -7295,6 +7407,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn_rp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a3b672b5e6ffc799106fb40c86d5787e331d5491452ffc3eb616b418e04e85" +dependencies = [ + "data-encoding", + "ed25519-dalek", + "p256", + "p384", + "precis-profiles", + "rand 0.9.2", + "rsa", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-root-certs" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 1c90e5707..e618f640f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -727,6 +727,10 @@ features = ["rustc"] [workspace.dependencies.walkdir] version = "2.5.0" +[workspace.dependencies.webauthn_rp] +version = "0.3.0" +features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] + # HTTP mock server [workspace.dependencies.wiremock] version = "0.6.4" diff --git a/crates/data-model/Cargo.toml b/crates/data-model/Cargo.toml index da7021b11..3464c308a 100644 --- a/crates/data-model/Cargo.toml +++ b/crates/data-model/Cargo.toml @@ -31,6 +31,7 @@ regex.workspace = true woothee.workspace = true ruma-common.workspace = true lettre.workspace = true +webauthn_rp.workspace = true mas-iana.workspace = true mas-jose.workspace = true diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 6be06b4d9..9c00230d4 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -53,8 +53,9 @@ pub use self::{ user_agent::{DeviceType, UserAgent}, users::{ Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, - UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, - UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, + UserEmailAuthentication, UserEmailAuthenticationCode, UserPasskey, UserPasskeyChallenge, + UserRecoverySession, UserRecoveryTicket, UserRegistration, UserRegistrationPassword, + UserRegistrationToken, }, utils::{BoxClock, BoxRng}, }; diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 920726ef8..c6ccefa7a 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -11,6 +11,7 @@ use rand::Rng; use serde::Serialize; use ulid::Ulid; use url::Url; +use webauthn_rp::response::{AuthTransports, CredentialId}; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct User { @@ -67,6 +68,7 @@ pub struct Authentication { pub enum AuthenticationMethod { Password { user_password_id: Ulid }, UpstreamOAuth2 { upstream_oauth2_session_id: Ulid }, + Passkey { user_passkey_id: Ulid }, Unknown, } @@ -262,3 +264,26 @@ pub struct UserRegistration { pub created_at: DateTime, pub completed_at: Option>, } + +#[derive(Debug, Clone, Serialize)] +pub struct UserPasskey { + pub id: Ulid, + pub user_id: Ulid, + pub credential_id: CredentialId>, + pub name: String, + pub transports: AuthTransports, + pub static_state: Vec, + pub dynamic_state: Vec, + pub metadata: Vec, + pub last_used_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserPasskeyChallenge { + pub id: Ulid, + pub user_session_id: Option, + pub state: Vec, + pub created_at: DateTime, + pub completed_at: Option>, +} diff --git a/crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json b/crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json new file mode 100644 index 000000000..1f3390208 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_passkey_challenge_id\n , user_session_id\n , state\n , created_at\n , completed_at\n FROM user_passkey_challenges\n WHERE user_passkey_challenge_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_passkey_challenge_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + false, + true + ] + }, + "hash": "182ff71d6007b3607761a48971e43b6bdf6a41896d787a13be3399022fe5e43d" +} diff --git a/crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json b/crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json new file mode 100644 index 000000000..096dafc48 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_passkey_challenges\n ( user_passkey_challenge_id\n , user_session_id\n , state\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "74e377397d05062d15b8b7a674390af5ecca2f977b8d74311b4cad401e2650e5" +} diff --git a/crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json b/crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json new file mode 100644 index 000000000..90e65187f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_passkey_challenges\n ( user_passkey_challenge_id\n , state\n , created_at\n )\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "74f98b0965300fbd37c29e7d4fa02ffb44631d77b14796db971064ef27abb739" +} diff --git a/crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json b/crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json new file mode 100644 index 000000000..014e1235c --- /dev/null +++ b/crates/storage-pg/.sqlx/query-7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_passkey_id\n , user_id\n , credential_id\n , name\n , transports\n , static_state\n , dynamic_state\n , metadata\n , last_used_at\n , created_at\n FROM user_passkeys\n\n WHERE user_id = $1\n\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_passkey_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "credential_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "transports", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "static_state", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "dynamic_state", + "type_info": "Bytea" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Bytea" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "7fc5dc880ff8318ff34bb19378961e4f55b19405def7e2e51051b6f564f5e4c5" +} diff --git a/crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json b/crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json new file mode 100644 index 000000000..f47e262c6 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_passkeys (user_passkey_id, user_id, credential_id, name, transports, static_state, dynamic_state, metadata, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Jsonb", + "Bytea", + "Bytea", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8b4a9cb99c562407aa697908b1c7b0bc4a2f963aadca4264f5e6e250afde4965" +} diff --git a/crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json b/crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json new file mode 100644 index 000000000..42b3c684f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_passkey_id\n , user_id\n , credential_id\n , name\n , transports\n , static_state\n , dynamic_state\n , metadata\n , last_used_at\n , created_at\n FROM user_passkeys\n\n WHERE user_passkey_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_passkey_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "credential_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "transports", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "static_state", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "dynamic_state", + "type_info": "Bytea" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Bytea" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "931498961affc31cff251f051828c4ccc2f6dbd7ba57dbd75d770453ac5221c3" +} diff --git a/crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json b/crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json new file mode 100644 index 000000000..0e5cf52b3 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_passkeys\n WHERE user_passkey_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a23cc4e35678d4421b998dfdba94d5215d39ea6d1390056c9e3ab0981673c84e" +} diff --git a/crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json b/crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json new file mode 100644 index 000000000..09d0c404c --- /dev/null +++ b/crates/storage-pg/.sqlx/query-ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_passkey_challenges\n SET completed_at = $2\n WHERE user_passkey_challenge_id = $1\n AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "ab4faaeb099656b160a7e4b0324ea5812e8941c53e6acc4ecc030dcd6d5ed8fe" +} diff --git a/crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json b/crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json new file mode 100644 index 000000000..98527fd45 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_passkeys\n SET last_used_at = $2, dynamic_state = $3\n WHERE user_passkey_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "cfc57569729d5fc900d4ebc0136a78edec1b66be5ff6d57ab90ac06e0f1098be" +} diff --git a/crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json b/crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json new file mode 100644 index 000000000..8fc36f528 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_passkey_id\n , user_id\n , credential_id\n , name\n , transports\n , static_state\n , dynamic_state\n , metadata\n , last_used_at\n , created_at\n FROM user_passkeys\n\n WHERE credential_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_passkey_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "credential_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "transports", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "static_state", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "dynamic_state", + "type_info": "Bytea" + }, + { + "ordinal": 7, + "name": "metadata", + "type_info": "Bytea" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "d897ababa19c82d1b2cfafe5d94b180b0d5c60e877dc6818f60662aa801b49ac" +} diff --git a/crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json b/crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json new file mode 100644 index 000000000..2ae52acf3 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_passkeys\n SET name = $2\n WHERE user_passkey_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d985a1f94ef8455be550d53e80300ece02fb9a5bed134fda19de1e4731bc9911" +} diff --git a/crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json b/crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json new file mode 100644 index 000000000..749ff96e0 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_session_authentications\n (user_session_authentication_id, user_session_id, created_at, user_passkey_id)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "de7e83e586b633e6f7acb572e4132ef8fc5eaac1176471d2a5f25ee8cf1f849a" +} diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml index 149e92fc6..c6a6c287f 100644 --- a/crates/storage-pg/Cargo.toml +++ b/crates/storage-pg/Cargo.toml @@ -33,6 +33,7 @@ tracing.workspace = true ulid.workspace = true url.workspace = true uuid.workspace = true +webauthn_rp.workspace = true oauth2-types.workspace = true mas-storage.workspace = true diff --git a/crates/storage-pg/migrations/20250303142622_passkeys.sql b/crates/storage-pg/migrations/20250303142622_passkeys.sql new file mode 100644 index 000000000..4095af5fa --- /dev/null +++ b/crates/storage-pg/migrations/20250303142622_passkeys.sql @@ -0,0 +1,63 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +CREATE TABLE "user_passkeys" ( + "user_passkey_id" UUID NOT NULL + CONSTRAINT "user_passkey_id_pkey" + PRIMARY KEY, + + "user_id" UUID NOT NULL + CONSTRAINT "user_passkeys_user_id_fkey" + REFERENCES "users" ("user_id") + ON DELETE CASCADE, + + "credential_id" TEXT NOT NULL + CONSTRAINT "user_passkeys_credential_id_unique" + UNIQUE, + + "name" TEXT NOT NULL, + + "transports" JSONB NOT NULL, + + "static_state" BYTEA NOT NULL, + + "dynamic_state" BYTEA NOT NULL, + + "metadata" BYTEA NOT NULL, + + "last_used_at" TIMESTAMP WITH TIME ZONE, + + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX + user_passkeys_user_id_fk + ON user_passkeys (user_id); + +CREATE TABLE "user_passkey_challenges" ( + "user_passkey_challenge_id" UUID NOT NULL + CONSTRAINT "user_passkey_challenge_id_pkey" + PRIMARY KEY, + + "user_session_id" UUID + REFERENCES "user_sessions" ("user_session_id") + ON DELETE SET NULL, + + "state" BYTEA NOT NULL, + + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + + "completed_at" TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX + user_passkey_challenges_user_session_id_fk + ON user_passkey_challenges (user_session_id); + +ALTER TABLE "user_session_authentications" + ADD COLUMN "user_passkey_id" UUID + REFERENCES "user_passkeys" ("user_passkey_id") + ON DELETE SET NULL; + diff --git a/crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql b/crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql new file mode 100644 index 000000000..b1285c80b --- /dev/null +++ b/crates/storage-pg/migrations/20250303142623_idx_user_session_authentications_user_passkey_id_fk.sql @@ -0,0 +1,9 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +CREATE INDEX CONCURRENTLY + user_session_authentications_user_passkey_id_fk + ON user_session_authentications (user_passkey_id); diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index e6c03acc4..a79b9b44b 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -188,3 +188,18 @@ pub enum UserRegistrationTokens { ExpiresAt, RevokedAt, } + +#[derive(sea_query::Iden)] +pub enum UserPasskeys { + Table, + UserPasskeyId, + UserId, + CredentialId, + Name, + Transports, + StaticState, + DynamicState, + Metadata, + LastUsedAt, + CreatedAt, +} diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 7911cd2b6..4d9e11065 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -27,9 +27,9 @@ use mas_storage::{ UpstreamOAuthSessionRepository, }, user::{ - BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRecoveryRepository, UserRegistrationRepository, UserRegistrationTokenRepository, - UserRepository, UserTermsRepository, + BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository, + UserPasswordRepository, UserRecoveryRepository, UserRegistrationRepository, + UserRegistrationTokenRepository, UserRepository, UserTermsRepository, }, }; use sqlx::{PgConnection, PgPool, Postgres, Transaction}; @@ -58,9 +58,9 @@ use crate::{ PgUpstreamOAuthSessionRepository, }, user::{ - PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository, - PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRegistrationTokenRepository, - PgUserRepository, PgUserTermsRepository, + PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasskeyRepository, + PgUserPasswordRepository, PgUserRecoveryRepository, PgUserRegistrationRepository, + PgUserRegistrationTokenRepository, PgUserRepository, PgUserTermsRepository, }, }; @@ -228,6 +228,10 @@ where Box::new(PgUserEmailRepository::new(self.conn.as_mut())) } + fn user_passkey<'c>(&'c mut self) -> Box + 'c> { + Box::new(PgUserPasskeyRepository::new(self.conn.as_mut())) + } + fn user_password<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 8af61b61c..6951b1b7b 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -26,6 +26,7 @@ use crate::{ }; mod email; +mod passkey; mod password; mod recovery; mod registration; @@ -37,8 +38,9 @@ mod terms; mod tests; pub use self::{ - email::PgUserEmailRepository, password::PgUserPasswordRepository, - recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository, + email::PgUserEmailRepository, passkey::PgUserPasskeyRepository, + password::PgUserPasswordRepository, recovery::PgUserRecoveryRepository, + registration::PgUserRegistrationRepository, registration_token::PgUserRegistrationTokenRepository, session::PgBrowserSessionRepository, terms::PgUserTermsRepository, }; diff --git a/crates/storage-pg/src/user/passkey.rs b/crates/storage-pg/src/user/passkey.rs new file mode 100644 index 000000000..e4def890c --- /dev/null +++ b/crates/storage-pg/src/user/passkey.rs @@ -0,0 +1,657 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge}; +use mas_storage::{ + Page, Pagination, + user::{UserPasskeyFilter, UserPasskeyRepository}, +}; +use rand::RngCore; +use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; +use sea_query_binder::SqlxBinder; +use sqlx::PgConnection; +use ulid::Ulid; +use uuid::Uuid; +use webauthn_rp::response::{AuthTransports, CredentialId}; + +use crate::{ + DatabaseError, DatabaseInconsistencyError, + filter::{Filter, StatementExt}, + iden::UserPasskeys, + pagination::QueryBuilderExt, + tracing::ExecuteExt, +}; + +/// An implementation of [`UserPasskeyRepository`] for a PostgreSQL connection +pub struct PgUserPasskeyRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgUserPasskeyRepository<'c> { + /// Create a new [`PgUserPasskeyRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +#[enum_def] +struct UserPasskeyLookup { + user_passkey_id: Uuid, + user_id: Uuid, + credential_id: String, + name: String, + transports: serde_json::Value, + static_state: Vec, + dynamic_state: Vec, + metadata: Vec, + last_used_at: Option>, + created_at: DateTime, +} + +impl TryFrom for UserPasskey { + type Error = DatabaseInconsistencyError; + + fn try_from(value: UserPasskeyLookup) -> Result { + Ok(UserPasskey { + id: value.user_passkey_id.into(), + user_id: value.user_id.into(), + credential_id: serde_json::from_str(&value.credential_id).map_err(|e| { + DatabaseInconsistencyError::on("user_passkeys") + .column("credential_id") + .row(value.user_passkey_id.into()) + .source(e) + })?, + name: value.name, + transports: serde_json::from_value(value.transports).map_err(|e| { + DatabaseInconsistencyError::on("user_passkeys") + .column("transports") + .row(value.user_passkey_id.into()) + .source(e) + })?, + static_state: value.static_state, + dynamic_state: value.dynamic_state, + metadata: value.metadata, + last_used_at: value.last_used_at, + created_at: value.created_at, + }) + } +} + +struct UserPasskeyChallengeLookup { + user_passkey_challenge_id: Uuid, + user_session_id: Option, + state: Vec, + created_at: DateTime, + completed_at: Option>, +} + +impl From for UserPasskeyChallenge { + fn from(value: UserPasskeyChallengeLookup) -> Self { + UserPasskeyChallenge { + id: value.user_passkey_challenge_id.into(), + user_session_id: value.user_session_id.map(Ulid::from), + state: value.state, + created_at: value.created_at, + completed_at: value.completed_at, + } + } +} + +impl Filter for UserPasskeyFilter<'_> { + fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { + sea_query::Condition::all().add_option(self.user().map(|user| { + Expr::col((UserPasskeys::Table, UserPasskeys::UserId)).eq(Uuid::from(user.id)) + })) + } +} + +#[async_trait] +impl UserPasskeyRepository for PgUserPasskeyRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.user_passkey.lookup", + skip_all, + fields( + db.query.text, + user_passkey.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserPasskeyLookup, + r#" + SELECT user_passkey_id + , user_id + , credential_id + , name + , transports + , static_state + , dynamic_state + , metadata + , last_used_at + , created_at + FROM user_passkeys + + WHERE user_passkey_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(user_passkey) = res else { + return Ok(None); + }; + + Ok(Some(user_passkey.try_into()?)) + } + + #[tracing::instrument( + name = "db.user_passkey.find", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find( + &mut self, + credential_id: &CredentialId>, + ) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserPasskeyLookup, + r#" + SELECT user_passkey_id + , user_id + , credential_id + , name + , transports + , static_state + , dynamic_state + , metadata + , last_used_at + , created_at + FROM user_passkeys + + WHERE credential_id = $1 + "#, + serde_json::to_string(&credential_id).map_err(DatabaseError::to_invalid_operation)?, + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(user_passkey) = res else { + return Ok(None); + }; + + Ok(Some(user_passkey.try_into()?)) + } + + #[tracing::instrument( + name = "db.user_passkey.all", + skip_all, + fields( + db.query.text, + %user.id, + ), + err, + )] + async fn all(&mut self, user: &User) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserPasskeyLookup, + r#" + SELECT user_passkey_id + , user_id + , credential_id + , name + , transports + , static_state + , dynamic_state + , metadata + , last_used_at + , created_at + FROM user_passkeys + + WHERE user_id = $1 + + ORDER BY created_at ASC + "#, + Uuid::from(user.id), + ) + .traced() + .fetch_all(&mut *self.conn) + .await?; + + Ok(res + .into_iter() + .map(TryInto::try_into) + .collect::>()?) + } + + #[tracing::instrument( + name = "db.user_passkey.list", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn list( + &mut self, + filter: UserPasskeyFilter<'_>, + pagination: Pagination, + ) -> Result, DatabaseError> { + let (sql, arguments) = Query::select() + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::UserPasskeyId)), + UserPasskeyLookupIden::UserPasskeyId, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::UserId)), + UserPasskeyLookupIden::UserId, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::CredentialId)), + UserPasskeyLookupIden::CredentialId, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::Name)), + UserPasskeyLookupIden::Name, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::Transports)), + UserPasskeyLookupIden::Transports, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::StaticState)), + UserPasskeyLookupIden::StaticState, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::DynamicState)), + UserPasskeyLookupIden::DynamicState, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::Metadata)), + UserPasskeyLookupIden::Metadata, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::LastUsedAt)), + UserPasskeyLookupIden::LastUsedAt, + ) + .expr_as( + Expr::col((UserPasskeys::Table, UserPasskeys::CreatedAt)), + UserPasskeyLookupIden::CreatedAt, + ) + .from(UserPasskeys::Table) + .apply_filter(filter) + .generate_pagination( + (UserPasskeys::Table, UserPasskeys::UserPasskeyId), + pagination, + ) + .build_sqlx(PostgresQueryBuilder); + + let edges: Vec = sqlx::query_as_with(&sql, arguments) + .traced() + .fetch_all(&mut *self.conn) + .await?; + + let page = pagination.process(edges).try_map(TryFrom::try_from)?; + + Ok(page) + } + + #[tracing::instrument( + name = "db.user_passkey.count", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn count(&mut self, filter: UserPasskeyFilter<'_>) -> Result { + let (sql, arguments) = Query::select() + .expr(Expr::col((UserPasskeys::Table, UserPasskeys::UserPasskeyId)).count()) + .from(UserPasskeys::Table) + .apply_filter(filter) + .build_sqlx(PostgresQueryBuilder); + + let count: i64 = sqlx::query_scalar_with(&sql, arguments) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count + .try_into() + .map_err(DatabaseError::to_invalid_operation) + } + + #[tracing::instrument( + name = "db.user_passkey.add", + skip_all, + fields( + db.query.text, + %user.id, + user_passkey.id, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user: &User, + name: String, + credential_id: CredentialId>, + transports: AuthTransports, + static_state: Vec, + dynamic_state: Vec, + metadata: Vec, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("user_passkey.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_passkeys (user_passkey_id, user_id, credential_id, name, transports, static_state, dynamic_state, metadata, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + Uuid::from(id), + Uuid::from(user.id), + serde_json::to_string(&credential_id).map_err(DatabaseError::to_invalid_operation)?, + &name, + serde_json::to_value(transports).map_err(DatabaseError::to_invalid_operation)?, + static_state, + dynamic_state, + metadata, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserPasskey { + id, + user_id: user.id, + credential_id, + name, + transports, + static_state, + dynamic_state, + metadata, + last_used_at: None, + created_at, + }) + } + + #[tracing::instrument( + name = "db.user_passkey.rename", + skip_all, + fields( + db.query.text, + %user_passkey.id, + ), + err, + )] + async fn rename( + &mut self, + mut user_passkey: UserPasskey, + name: String, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_passkeys + SET name = $2 + WHERE user_passkey_id = $1 + "#, + Uuid::from(user_passkey.id), + name, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_passkey.name = name; + Ok(user_passkey) + } + + #[tracing::instrument( + name = "db.user_passkey.update", + skip_all, + fields( + db.query.text, + %user_passkey.id, + ), + err, + )] + async fn update( + &mut self, + clock: &dyn Clock, + mut user_passkey: UserPasskey, + dynamic_state: Vec, + ) -> Result { + let last_used_at = clock.now(); + + let res = sqlx::query!( + r#" + UPDATE user_passkeys + SET last_used_at = $2, dynamic_state = $3 + WHERE user_passkey_id = $1 + "#, + Uuid::from(user_passkey.id), + last_used_at, + dynamic_state + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_passkey.last_used_at = Some(last_used_at); + user_passkey.dynamic_state = dynamic_state; + Ok(user_passkey) + } + + #[tracing::instrument( + name = "db.user_passkey.remove", + skip_all, + fields( + db.query.text, + user.id = %user_passkey.user_id, + %user_passkey.id, + ), + err, + )] + async fn remove(&mut self, user_passkey: UserPasskey) -> Result<(), Self::Error> { + let res = sqlx::query!( + r#" + DELETE FROM user_passkeys + WHERE user_passkey_id = $1 + "#, + Uuid::from(user_passkey.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + Ok(()) + } + + #[tracing::instrument( + name = "db.user_passkey.add_challenge_for_session", + skip_all, + fields( + db.query.text, + %session.id, + user_passkey_challenge.id, + ), + err, + )] + async fn add_challenge_for_session( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + state: Vec, + session: &BrowserSession, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("user_passkey_challenge.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_passkey_challenges + ( user_passkey_challenge_id + , user_session_id + , state + , created_at + ) + VALUES ($1, $2, $3, $4) + "#, + Uuid::from(id), + Uuid::from(session.id), + state, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserPasskeyChallenge { + id, + user_session_id: Some(session.id), + state, + created_at, + completed_at: None, + }) + } + + #[tracing::instrument( + name = "db.user_passkey.add_challenge", + skip_all, + fields( + db.query.text, + user_passkey_challenge.id, + ), + err, + )] + async fn add_challenge( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + state: Vec, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("user_passkey_challenge.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_passkey_challenges + ( user_passkey_challenge_id + , state + , created_at + ) + VALUES ($1, $2, $3) + "#, + Uuid::from(id), + state, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserPasskeyChallenge { + id, + user_session_id: None, + state, + created_at, + completed_at: None, + }) + } + + #[tracing::instrument( + name = "db.user_passkey.lookup_challenge", + skip_all, + fields( + db.query.text, + user_passkey_challenge.id = %id, + ), + err, + )] + async fn lookup_challenge( + &mut self, + id: Ulid, + ) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserPasskeyChallengeLookup, + r#" + SELECT user_passkey_challenge_id + , user_session_id + , state + , created_at + , completed_at + FROM user_passkey_challenges + WHERE user_passkey_challenge_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + Ok(res.map(UserPasskeyChallenge::from)) + } + + #[tracing::instrument( + name = "db.user_passkey.complete_challenge", + skip_all, + fields( + db.query.text, + %user_passkey_challenge.id, + ), + err, + )] + async fn complete_challenge( + &mut self, + clock: &dyn Clock, + mut user_passkey_challenge: UserPasskeyChallenge, + ) -> Result { + let completed_at = clock.now(); + + let res = sqlx::query!( + r#" + UPDATE user_passkey_challenges + SET completed_at = $2 + WHERE user_passkey_challenge_id = $1 + AND completed_at IS NULL + "#, + Uuid::from(user_passkey_challenge.id), + completed_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_passkey_challenge.completed_at = Some(completed_at); + Ok(user_passkey_challenge) + } +} diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index a8c5ab458..b42b0e909 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ Authentication, AuthenticationMethod, BrowserSession, Clock, Password, - UpstreamOAuthAuthorizationSession, User, + UpstreamOAuthAuthorizationSession, User, UserPasskey, }; use mas_storage::{ Page, Pagination, @@ -539,6 +539,55 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { }) } + #[tracing::instrument( + name = "db.browser_session.authenticate_with_passkey", + skip_all, + fields( + db.query.text, + %user_session.id, + %user_passkey.id, + user_session_authentication.id, + ), + err, + )] + async fn authenticate_with_passkey( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user_session: &BrowserSession, + user_passkey: &UserPasskey, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record( + "user_session_authentication.id", + tracing::field::display(id), + ); + + sqlx::query!( + r#" + INSERT INTO user_session_authentications + (user_session_authentication_id, user_session_id, created_at, user_passkey_id) + VALUES ($1, $2, $3, $4) + "#, + Uuid::from(id), + Uuid::from(user_session.id), + created_at, + Uuid::from(user_passkey.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(Authentication { + id, + created_at, + authentication_method: AuthenticationMethod::Passkey { + user_passkey_id: user_passkey.id, + }, + }) + } + #[tracing::instrument( name = "db.browser_session.get_last_authentication", skip_all, diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 07f4330c6..d636806c4 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -29,6 +29,7 @@ tracing-opentelemetry.workspace = true tracing.workspace = true ulid.workspace = true url.workspace = true +webauthn_rp.workspace = true oauth2-types.workspace = true mas-data-model.workspace = true diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 518769eb1..3c329a77a 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -25,9 +25,9 @@ use crate::{ UpstreamOAuthSessionRepository, }, user::{ - BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRecoveryRepository, UserRegistrationRepository, UserRegistrationTokenRepository, - UserRepository, UserTermsRepository, + BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository, + UserPasswordRepository, UserRecoveryRepository, UserRegistrationRepository, + UserRegistrationTokenRepository, UserRepository, UserTermsRepository, }, }; @@ -136,6 +136,9 @@ pub trait RepositoryAccess: Send { /// Get an [`UserEmailRepository`] fn user_email<'c>(&'c mut self) -> Box + 'c>; + /// Get an [`UserPasskeyRepository`] + fn user_passkey<'c>(&'c mut self) -> Box + 'c>; + /// Get an [`UserPasswordRepository`] fn user_password<'c>(&'c mut self) -> Box + 'c>; @@ -254,9 +257,9 @@ mod impls { UpstreamOAuthSessionRepository, }, user::{ - BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRegistrationRepository, UserRegistrationTokenRepository, UserRepository, - UserTermsRepository, + BrowserSessionRepository, UserEmailRepository, UserPasskeyRepository, + UserPasswordRepository, UserRegistrationRepository, UserRegistrationTokenRepository, + UserRepository, UserTermsRepository, }, }; @@ -334,6 +337,12 @@ mod impls { Box::new(MapErr::new(self.inner.user_email(), &mut self.mapper)) } + fn user_passkey<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new(self.inner.user_passkey(), &mut self.mapper)) + } + fn user_password<'c>( &'c mut self, ) -> Box + 'c> { @@ -510,6 +519,12 @@ mod impls { (**self).user_email() } + fn user_passkey<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).user_passkey() + } + fn user_password<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 0294d83b4..1d0077e42 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -14,6 +14,7 @@ use ulid::Ulid; use crate::{Page, Pagination, repository_impl}; mod email; +mod passkey; mod password; mod recovery; mod registration; @@ -23,6 +24,7 @@ mod terms; pub use self::{ email::{UserEmailFilter, UserEmailRepository}, + passkey::{UserPasskeyFilter, UserPasskeyRepository}, password::UserPasswordRepository, recovery::UserRecoveryRepository, registration::UserRegistrationRepository, diff --git a/crates/storage/src/user/passkey.rs b/crates/storage/src/user/passkey.rs new file mode 100644 index 000000000..40bef966d --- /dev/null +++ b/crates/storage/src/user/passkey.rs @@ -0,0 +1,328 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use async_trait::async_trait; +use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge}; +use rand_core::RngCore; +use ulid::Ulid; +use webauthn_rp::response::{AuthTransports, CredentialId}; + +use crate::{Page, Pagination, repository_impl}; + +/// Filter parameters for listing user passkeys +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct UserPasskeyFilter<'a> { + user: Option<&'a User>, +} + +impl<'a> UserPasskeyFilter<'a> { + /// Create a new [`UserPasskeyFilter`] with default values + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Filter for passkeys of a specific user + #[must_use] + pub fn for_user(mut self, user: &'a User) -> Self { + self.user = Some(user); + self + } + + /// Get the user filter + /// + /// Returns [`None`] if no user filter is set + #[must_use] + pub fn user(&self) -> Option<&User> { + self.user + } +} + +/// A [`UserPasskeyRepository`] helps interacting with [`UserPasskey`] saved in +/// the storage backend +#[allow(clippy::too_many_arguments)] +#[async_trait] +pub trait UserPasskeyRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup an [`UserPasskey`] by its ID + /// + /// Returns `None` if no [`UserPasskey`] was found + /// + /// # Parameters + /// + /// * `id`: The ID of the [`UserPasskey`] to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Lookup an [`UserPasskey`] by its credential ID + /// + /// Returns `None` if no matching [`UserPasskey`] was found + /// + /// # Parameters + /// + /// * `credential_id`: The credential ID to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find( + &mut self, + credential_id: &CredentialId>, + ) -> Result, Self::Error>; + + /// Get all [`UserPasskey`] of a [`User`] + /// + /// # Parameters + /// + /// * `user`: The [`User`] for whom to lookup the [`UserPasskey`] + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn all(&mut self, user: &User) -> Result, Self::Error>; + + /// List [`UserPasskey`] with the given filter and pagination + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// * `pagination`: The pagination parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn list( + &mut self, + filter: UserPasskeyFilter<'_>, + pagination: Pagination, + ) -> Result, Self::Error>; + + /// Count the [`UserPasskey`] with the given filter + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn count(&mut self, filter: UserPasskeyFilter<'_>) -> Result; + + /// Create a new [`UserPasskey`] for a [`User`] + /// + /// Returns the newly created [`UserPasskey`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock to use + /// * `user`: The [`User`] for whom to create the [`UserPasskey`] + /// * `name`: The name for the [`UserPasskey`] + /// * `data`: The passkey data of the [`UserPasskey`] + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user: &User, + name: String, + credential_id: CredentialId>, + transports: AuthTransports, + static_state: Vec, + dynamic_state: Vec, + metadata: Vec, + ) -> Result; + + /// Rename a [`UserPasskey`] + /// + /// Returns the modified [`UserPasskey`] + /// + /// # Parameters + /// + /// * `user_passkey`: The [`UserPasskey`] to rename + /// * `name`: The new name + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn rename( + &mut self, + user_passkey: UserPasskey, + name: String, + ) -> Result; + + /// Update a [`UserPasskey`] + /// + /// Returns the modified [`UserPasskey`] + /// + /// # Parameters + /// + /// * `clock`: The clock to use + /// * `user_passkey`: The [`UserPasskey`] to update + /// * `data`: The new passkey data + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn update( + &mut self, + clock: &dyn Clock, + user_passkey: UserPasskey, + dynamic_state: Vec, + ) -> Result; + + /// Delete a [`UserPasskey`] + /// + /// # Parameters + /// + /// * `user_passkey`: The [`UserPasskey`] to delete + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn remove(&mut self, user_passkey: UserPasskey) -> Result<(), Self::Error>; + + /// Add a new [`UserPasskeyChallenge`] for a [`BrowserSession`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock to use + /// * `state`: The challenge state to add + /// * `session`: The [`BrowserSession`] for which to add the + /// [`UserPasskeyChallenge`] + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn add_challenge_for_session( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + state: Vec, + session: &BrowserSession, + ) -> Result; + + /// Add a new [`UserPasskeyChallenge`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock to use + /// * `state`: The challenge state to add + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn add_challenge( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + state: Vec, + ) -> Result; + + /// Lookup a [`UserPasskeyChallenge`] + /// + /// # Parameters + /// + /// * `id`: The ID of the [`UserPasskeyChallenge`] to lookup + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn lookup_challenge( + &mut self, + id: Ulid, + ) -> Result, Self::Error>; + + /// Complete a [`UserPasskeyChallenge`] by using the given code + /// + /// Returns the completed [`UserPasskeyChallenge`] + /// + /// # Parameters + /// + /// * `clock`: The clock to use to generate timestamps + /// * `challenge`: The [`UserPasskeyChallenge`] to complete + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn complete_challenge( + &mut self, + clock: &dyn Clock, + user_passkey_challenge: UserPasskeyChallenge, + ) -> Result; +} + +repository_impl!(UserPasskeyRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + async fn find(&mut self, credential_id: &CredentialId>) -> Result, Self::Error>; + async fn all(&mut self, user: &User) -> Result, Self::Error>; + + async fn list( + &mut self, + filter: UserPasskeyFilter<'_>, + pagination: Pagination, + ) -> Result, Self::Error>; + async fn count(&mut self, filter: UserPasskeyFilter<'_>) -> Result; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user: &User, + name: String, + credential_id: CredentialId>, + transports: AuthTransports, + static_state: Vec, + dynamic_state: Vec, + metadata: Vec, + ) -> Result; + async fn rename( + &mut self, + user_passkey: UserPasskey, + name: String, + ) -> Result; + async fn update( + &mut self, + clock: &dyn Clock, + user_passkey: UserPasskey, + dynamic_state: Vec, + ) -> Result; + async fn remove(&mut self, user_passkey: UserPasskey) -> Result<(), Self::Error>; + + async fn add_challenge_for_session( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + state: Vec, + session: &BrowserSession, + ) -> Result; + async fn add_challenge( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + state: Vec, + ) -> Result; + + async fn lookup_challenge( + &mut self, + id: Ulid, + ) -> Result, Self::Error>; + + async fn complete_challenge( + &mut self, + clock: &dyn Clock, + user_passkey_challenge: UserPasskeyChallenge, + ) -> Result; +); diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index 155556454..7bac9a520 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ Authentication, BrowserSession, Clock, Password, UpstreamOAuthAuthorizationSession, User, + UserPasskey, }; use rand_core::RngCore; use ulid::Ulid; @@ -279,6 +280,26 @@ pub trait BrowserSessionRepository: Send + Sync { upstream_oauth_session: &UpstreamOAuthAuthorizationSession, ) -> Result; + /// Authenticate a [`BrowserSession`] with the given [`UserPasskey`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `user_session`: The session to authenticate + /// * `user_passkey`: The passkey which was used to authenticate + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn authenticate_with_passkey( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user_session: &BrowserSession, + user_passkey: &UserPasskey, + ) -> Result; + /// Get the last successful authentication for a [`BrowserSession`] /// /// # Params @@ -354,6 +375,14 @@ repository_impl!(BrowserSessionRepository: upstream_oauth_session: &UpstreamOAuthAuthorizationSession, ) -> Result; + async fn authenticate_with_passkey( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + user_session: &BrowserSession, + user_passkey: &UserPasskey, + ) -> Result; + async fn get_last_authentication( &mut self, user_session: &BrowserSession, From 8f1c6de338ee66faf19f11f6da85b7bfa2c61a00 Mon Sep 17 00:00:00 2001 From: Tonkku Date: Wed, 5 Mar 2025 17:32:07 +0200 Subject: [PATCH 3/7] GraphQL API changes --- Cargo.lock | 1 + crates/cli/src/app_state.rs | 9 +- crates/cli/src/commands/server.rs | 6 +- crates/cli/src/util.rs | 16 +- crates/config/src/sections/experimental.rs | 8 + crates/config/src/sections/mod.rs | 2 +- crates/handlers/Cargo.toml | 1 + crates/handlers/src/graphql/mod.rs | 15 +- crates/handlers/src/graphql/model/mod.rs | 4 +- crates/handlers/src/graphql/model/node.rs | 6 + crates/handlers/src/graphql/model/users.rs | 92 ++++- crates/handlers/src/graphql/mutations/mod.rs | 2 + .../src/graphql/mutations/user_passkey.rs | 383 ++++++++++++++++++ crates/handlers/src/graphql/query/mod.rs | 8 +- crates/handlers/src/graphql/state.rs | 3 +- crates/handlers/src/lib.rs | 1 + crates/handlers/src/test_utils.rs | 9 + crates/handlers/src/webauthn.rs | 268 ++++++++++++ docs/config.schema.json | 13 + frontend/schema.graphql | 251 ++++++++++++ frontend/src/gql/graphql.ts | 161 ++++++++ 21 files changed, 1246 insertions(+), 13 deletions(-) create mode 100644 crates/handlers/src/graphql/mutations/user_passkey.rs create mode 100644 crates/handlers/src/webauthn.rs diff --git a/Cargo.lock b/Cargo.lock index 718a502fe..2bf2dea3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3441,6 +3441,7 @@ dependencies = [ "tracing-subscriber", "ulid", "url", + "webauthn_rp", "wiremock", "zeroize", "zxcvbn", diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index f9f761338..a5b1de4d0 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -12,7 +12,7 @@ use mas_context::LogContext; use mas_data_model::{BoxClock, BoxRng, SiteConfig, SystemClock}; use mas_handlers::{ ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter, - MetadataCache, RequesterFingerprint, passwords::PasswordManager, + MetadataCache, RequesterFingerprint, passwords::PasswordManager, webauthn::Webauthn, }; use mas_i18n::Translator; use mas_keystore::{Encrypter, Keystore}; @@ -47,6 +47,7 @@ pub struct AppState { pub activity_tracker: ActivityTracker, pub trusted_proxies: Vec, pub limiter: Limiter, + pub webauthn: Webauthn, } impl AppState { @@ -214,6 +215,12 @@ impl FromRef for Arc { } } +impl FromRef for Webauthn { + fn from_ref(input: &AppState) -> Self { + input.webauthn.clone() + } +} + impl FromRequestParts for BoxClock { type Rejection = Infallible; diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 1367f131e..fb9a46ecb 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -29,7 +29,7 @@ use crate::{ database_pool_from_config, homeserver_connection_from_config, load_policy_factory_dynamic_data_continuously, mailer_from_config, password_manager_from_config, policy_factory_from_config, site_config_from_config, - templates_from_config, test_mailer_in_background, + templates_from_config, test_mailer_in_background, webauthn_from_config, }, }; @@ -191,6 +191,8 @@ impl Options { let password_manager = password_manager_from_config(&config.passwords).await?; + let webauthn = webauthn_from_config(&config.http, &config.experimental)?; + // The upstream OIDC metadata cache let metadata_cache = MetadataCache::new(); @@ -226,6 +228,7 @@ impl Options { password_manager.clone(), url_builder.clone(), limiter.clone(), + webauthn.clone(), ); let state = { @@ -246,6 +249,7 @@ impl Options { activity_tracker, trusted_proxies, limiter, + webauthn, }; s.init_metrics(); s.init_metadata_cache(); diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index e3f2166c1..8389cbb1b 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -9,13 +9,13 @@ use std::{sync::Arc, time::Duration}; use anyhow::Context; use mas_config::{ AccountConfig, BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, - EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, PasswordsConfig, - PolicyConfig, TemplatesConfig, + EmailTransportKind, ExperimentalConfig, HomeserverKind, HttpConfig, MatrixConfig, + PasswordsConfig, PolicyConfig, TemplatesConfig, }; use mas_context::LogContext; use mas_data_model::{SessionExpirationConfig, SiteConfig}; use mas_email::{MailTransport, Mailer}; -use mas_handlers::passwords::PasswordManager; +use mas_handlers::{passwords::PasswordManager, webauthn::Webauthn}; use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection}; use mas_matrix_synapse::{LegacySynapseConnection, SynapseConnection}; use mas_policy::PolicyFactory; @@ -500,6 +500,16 @@ pub async fn homeserver_connection_from_config( }) } +pub fn webauthn_from_config( + http_config: &HttpConfig, + experimental_config: &ExperimentalConfig, +) -> Result { + Webauthn::new( + &http_config.public_base, + experimental_config.passkeys.as_ref(), + ) +} + #[cfg(test)] mod tests { use rand::SeedableRng; diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 62ad599cc..ab83aeb05 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -52,6 +52,14 @@ pub struct PasskeysConfig { /// Whether passkeys are enabled or not #[serde(default)] pub enabled: bool, + /// Relying Party Identifier to use + /// + /// If not set, the host from `public_base` is used + #[serde(default)] + pub rpid: Option, + /// Additional allowed origins. `rpid` and `public_base` are already allowed + #[serde(default)] + pub allowed_origins: Option>, } /// Configuration sections for experimental options diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index f992d8698..3cc278e35 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -32,7 +32,7 @@ pub use self::{ clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, database::{DatabaseConfig, PgSslMode}, email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, - experimental::ExperimentalConfig, + experimental::{ExperimentalConfig, PasskeysConfig}, http::{ BindConfig as HttpBindConfig, HttpConfig, ListenerConfig as HttpListenerConfig, Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp, diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 57f391854..52928da9d 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -66,6 +66,7 @@ tower.workspace = true tracing.workspace = true ulid.workspace = true url.workspace = true +webauthn_rp.workspace = true zeroize.workspace = true mas-axum-utils.workspace = true diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 7ccf9e51b..e2a58f635 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -55,7 +55,7 @@ use self::{ }; use crate::{ BoundActivityTracker, Limiter, RequesterFingerprint, impl_from_error_for_route, - passwords::PasswordManager, + passwords::PasswordManager, webauthn::Webauthn, }; #[cfg(test)] @@ -76,6 +76,7 @@ struct GraphQLState { password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + webauthn: Webauthn, } #[async_trait::async_trait] @@ -108,6 +109,10 @@ impl state::State for GraphQLState { &self.limiter } + fn webauthn(&self) -> &Webauthn { + &self.webauthn + } + fn clock(&self) -> BoxClock { let clock = SystemClock::default(); Box::new(clock) @@ -131,6 +136,7 @@ pub fn schema( password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + webauthn: Webauthn, ) -> Schema { let state = GraphQLState { repository_factory, @@ -140,6 +146,7 @@ pub fn schema( password_manager, url_builder, limiter, + webauthn, }; let state: BoxState = Box::new(state); @@ -518,6 +525,12 @@ impl OwnerId for mas_data_model::UpstreamOAuthLink { } } +impl OwnerId for mas_data_model::UserPasskey { + fn owner_id(&self) -> Option { + Some(self.user_id) + } +} + /// A dumb wrapper around a `Ulid` to implement `OwnerId` for it. pub struct UserId(Ulid); diff --git a/crates/handlers/src/graphql/model/mod.rs b/crates/handlers/src/graphql/model/mod.rs index 063a63fb0..bc7faaaec 100644 --- a/crates/handlers/src/graphql/model/mod.rs +++ b/crates/handlers/src/graphql/model/mod.rs @@ -26,7 +26,9 @@ pub use self::{ oauth::{OAuth2Client, OAuth2Session}, site_config::{SITE_CONFIG_ID, SiteConfig}, upstream_oauth::{UpstreamOAuth2Link, UpstreamOAuth2Provider}, - users::{AppSession, User, UserEmail, UserEmailAuthentication, UserRecoveryTicket}, + users::{ + AppSession, User, UserEmail, UserEmailAuthentication, UserPasskey, UserRecoveryTicket, + }, viewer::{Anonymous, Viewer, ViewerSession}, }; diff --git a/crates/handlers/src/graphql/model/node.rs b/crates/handlers/src/graphql/model/node.rs index e63d2b387..987e4ae19 100644 --- a/crates/handlers/src/graphql/model/node.rs +++ b/crates/handlers/src/graphql/model/node.rs @@ -29,6 +29,8 @@ pub enum NodeType { UserEmail, UserEmailAuthentication, UserRecoveryTicket, + UserPasskey, + UserPasskeyChallenge, } #[derive(Debug, Error)] @@ -55,6 +57,8 @@ impl NodeType { NodeType::UserEmail => "user_email", NodeType::UserEmailAuthentication => "user_email_authentication", NodeType::UserRecoveryTicket => "user_recovery_ticket", + NodeType::UserPasskey => "user_passkey", + NodeType::UserPasskeyChallenge => "user_passkey_challenge", } } @@ -72,6 +76,8 @@ impl NodeType { "user_email" => Some(NodeType::UserEmail), "user_email_authentication" => Some(NodeType::UserEmailAuthentication), "user_recovery_ticket" => Some(NodeType::UserRecoveryTicket), + "user_passkey" => Some(NodeType::UserPasskey), + "user_passkey_challenge" => Some(NodeType::UserPasskeyChallenge), _ => None, } } diff --git a/crates/handlers/src/graphql/model/users.rs b/crates/handlers/src/graphql/model/users.rs index 11522c6b4..375ce6d20 100644 --- a/crates/handlers/src/graphql/model/users.rs +++ b/crates/handlers/src/graphql/model/users.rs @@ -17,7 +17,10 @@ use mas_storage::{ compat::{CompatSessionFilter, CompatSsoLoginFilter, CompatSsoLoginRepository}, oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository}, - user::{BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository}, + user::{ + BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository, + UserPasskeyFilter, + }, }; use super::{ @@ -706,6 +709,66 @@ impl User { .await } + /// Get the list of passkeys, chronologically sorted + async fn passkeys( + &self, + ctx: &Context<'_>, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] + after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] + before: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let mut repo = state.repository().await?; + + query( + after, + before, + first, + last, + async |after, before, first, last| { + let after_id = after + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::UserPasskey)) + .transpose()?; + let before_id = before + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::UserPasskey)) + .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; + + let filter = UserPasskeyFilter::new().for_user(&self.0); + + let page = repo.user_passkey().list(filter, pagination).await?; + + // Preload the total count if requested + let count = if ctx.look_ahead().field("totalCount").exists() { + Some(repo.user_passkey().count(filter).await?) + } else { + None + }; + + repo.cancel().await?; + + let mut connection = Connection::with_additional_fields( + page.has_previous_page, + page.has_next_page, + PreloadedTotalCount(count), + ); + connection.edges.extend(page.edges.into_iter().map(|u| { + Edge::new( + OpaqueCursor(NodeCursor(NodeType::UserPasskey, u.id)), + UserPasskey(u), + ) + })); + + Ok::<_, async_graphql::Error>(connection) + }, + ) + .await + } + /// Check if the user has a password set. async fn has_password(&self, ctx: &Context<'_>) -> Result { let state = ctx.state(); @@ -887,3 +950,30 @@ impl UserEmailAuthentication { &self.0.email } } + +/// A passkey +#[derive(Description)] +pub struct UserPasskey(pub mas_data_model::UserPasskey); + +#[Object(use_type_description)] +impl UserPasskey { + /// ID of the object + pub async fn id(&self) -> ID { + NodeType::UserPasskey.id(self.0.id) + } + + /// Name of the passkey + pub async fn name(&self) -> &str { + &self.0.name + } + + /// When the object was created. + pub async fn created_at(&self) -> DateTime { + self.0.created_at + } + + /// When the passkey was last used + pub async fn last_used_at(&self) -> Option> { + self.0.last_used_at + } +} diff --git a/crates/handlers/src/graphql/mutations/mod.rs b/crates/handlers/src/graphql/mutations/mod.rs index af6caab62..c81d414c2 100644 --- a/crates/handlers/src/graphql/mutations/mod.rs +++ b/crates/handlers/src/graphql/mutations/mod.rs @@ -10,6 +10,7 @@ mod matrix; mod oauth2_session; mod user; mod user_email; +mod user_passkey; use anyhow::Context as _; use async_graphql::MergedObject; @@ -24,6 +25,7 @@ use crate::passwords::PasswordManager; #[derive(Default, MergedObject)] pub struct Mutation( user_email::UserEmailMutations, + user_passkey::UserPasskeyMutations, user::UserMutations, oauth2_session::OAuth2SessionMutations, compat_session::CompatSessionMutations, diff --git a/crates/handlers/src/graphql/mutations/user_passkey.rs b/crates/handlers/src/graphql/mutations/user_passkey.rs new file mode 100644 index 000000000..29eab64a8 --- /dev/null +++ b/crates/handlers/src/graphql/mutations/user_passkey.rs @@ -0,0 +1,383 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; +use mas_storage::RepositoryAccess; +use ulid::Ulid; + +use crate::{ + graphql::{ + model::{NodeType, UserPasskey}, + state::ContextExt, + }, + webauthn::WebauthnError, +}; + +#[derive(Default)] +pub struct UserPasskeyMutations { + _private: (), +} + +/// The payload of the `startRegisterPasskey` mutation +#[derive(Description)] +struct StartRegisterPasskeyPayload { + id: Ulid, + options: String, +} + +#[Object(use_type_description)] +impl StartRegisterPasskeyPayload { + async fn id(&self) -> ID { + NodeType::UserPasskeyChallenge.id(self.id) + } + + /// The options to pass to `navigator.credentials.create()` as a JSON string + async fn options(&self) -> &str { + &self.options + } +} + +/// The input for the `completeRegisterPasskey` mutation +#[derive(InputObject)] +struct CompleteRegisterPasskeyInput { + /// The ID of the passkey challenge to complete + id: ID, + + /// Name of the passkey + name: String, + + /// The response from `navigator.credentials.create()` as a JSON string + response: String, +} + +/// The payload of the `completeRegisterPasskey` mutation +#[derive(Description)] +enum CompleteRegisterPasskeyPayload { + Added(Box), + InvalidChallenge, + InvalidResponse(WebauthnError), + InvalidName, + Exists, +} + +/// The status of the `completeRegisterPasskey` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum CompleteRegisterPasskeyStatus { + /// The passkey was added + Added, + /// The challenge was invalid + InvalidChallenge, + /// The response was invalid + InvalidResponse, + /// The name for the passkey was invalid + InvalidName, + /// The passkey credential already exists + Exists, +} + +#[Object(use_type_description)] +impl CompleteRegisterPasskeyPayload { + /// Status of the operation + async fn status(&self) -> CompleteRegisterPasskeyStatus { + match self { + Self::Added(_) => CompleteRegisterPasskeyStatus::Added, + Self::InvalidChallenge => CompleteRegisterPasskeyStatus::InvalidChallenge, + Self::InvalidResponse(_) => CompleteRegisterPasskeyStatus::InvalidResponse, + Self::InvalidName => CompleteRegisterPasskeyStatus::InvalidName, + Self::Exists => CompleteRegisterPasskeyStatus::Exists, + } + } + + /// The passkey that was added + async fn passkey(&self) -> Option { + match self { + Self::Added(passkey) => Some(UserPasskey(*passkey.clone())), + _ => None, + } + } + + /// The error when the status is `INVALID_RESPONSE` + async fn error(&self) -> Option { + match self { + Self::InvalidResponse(e) => Some(e.to_string()), + _ => None, + } + } +} + +/// The input for the `renamePasskey` mutation +#[derive(InputObject)] +struct RenamePasskeyInput { + /// The ID of the passkey to rename + id: ID, + + /// new name for the passkey + name: String, +} + +/// The payload of the `renamePasskey` mutation +#[derive(Description)] +enum RenamePasskeyPayload { + Renamed(Box), + Invalid, + NotFound, +} + +/// The status of the `renamePasskey` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum RenamePasskeyStatus { + /// The passkey was renamed + Renamed, + /// The new name was invalid + Invalid, + /// The passkey was not found + NotFound, +} + +#[Object(use_type_description)] +impl RenamePasskeyPayload { + /// Status of the operation + async fn status(&self) -> RenamePasskeyStatus { + match self { + Self::Renamed(_) => RenamePasskeyStatus::Renamed, + Self::Invalid => RenamePasskeyStatus::Invalid, + Self::NotFound => RenamePasskeyStatus::NotFound, + } + } + + /// The passkey that was renamed + async fn passkey(&self) -> Option { + match self { + Self::Renamed(passkey) => Some(UserPasskey(*passkey.clone())), + _ => None, + } + } +} + +/// The input for the `removePasskey` mutation +#[derive(InputObject)] +struct RemovePasskeyInput { + /// The ID of the passkey to remove + id: ID, +} + +/// The payload of the `removePasskey` mutation +#[derive(Description)] +enum RemovePasskeyPayload { + Removed(Box), + NotFound, +} + +/// The status of the `removePasskey` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum RemovePasskeyStatus { + /// The passkey was removed + Removed, + /// The passkey was not found + NotFound, +} + +#[Object(use_type_description)] +impl RemovePasskeyPayload { + /// Status of the operation + async fn status(&self) -> RemovePasskeyStatus { + match self { + Self::Removed(_) => RemovePasskeyStatus::Removed, + Self::NotFound => RemovePasskeyStatus::NotFound, + } + } + + /// The passkey that was removed + async fn passkey(&self) -> Option { + match self { + Self::Removed(passkey) => Some(UserPasskey(*passkey.clone())), + Self::NotFound => None, + } + } +} + +#[Object] +impl UserPasskeyMutations { + /// Start registering a new passkey + async fn start_register_passkey( + &self, + ctx: &Context<'_>, + ) -> Result { + let state = ctx.state(); + let mut rng = state.rng(); + let clock = state.clock(); + let mut repo = state.repository().await?; + let conn = state.homeserver_connection(); + let requester = ctx.requester(); + + // Only allow calling this if the requester is a browser session + let Some(browser_session) = requester.browser_session() else { + return Err(async_graphql::Error::new("Unauthorized")); + }; + + let user = &browser_session.user; + + // Allow registering passkeys if the site config allows it + if !state.site_config().passkeys_enabled { + return Err(async_graphql::Error::new( + "Passkeys are not allowed on this server", + )); + } + + let webauthn = state.webauthn(); + + let (options, challenge) = webauthn + .start_passkey_registration(&mut repo, &mut rng, &clock, &conn, user, browser_session) + .await?; + + repo.save().await?; + + Ok(StartRegisterPasskeyPayload { + id: challenge.id, + options, + }) + } + + /// Complete registering a new passkey + async fn complete_register_passkey( + &self, + ctx: &Context<'_>, + input: CompleteRegisterPasskeyInput, + ) -> Result { + let state = ctx.state(); + let mut rng = state.rng(); + let clock = state.clock(); + let mut repo = state.repository().await?; + + let id = NodeType::UserPasskeyChallenge.extract_ulid(&input.id)?; + + if input.name.len() > 256 || input.name.is_empty() { + return Ok(CompleteRegisterPasskeyPayload::InvalidName); + } + + let Some(browser_session) = ctx.requester().browser_session() else { + return Err(async_graphql::Error::new("Unauthorized")); + }; + + // Allow registering passkeys if the site config allows it + if !state.site_config().passkeys_enabled { + return Err(async_graphql::Error::new( + "Passkeys are not allowed on this server", + )); + } + + let webauthn = state.webauthn(); + + let challenge = match webauthn + .lookup_challenge(&mut repo, &clock, id, Some(browser_session)) + .await + .map_err(anyhow::Error::downcast) + { + Ok(c) => c, + Err(Ok(WebauthnError::InvalidChallenge)) => { + return Ok(CompleteRegisterPasskeyPayload::InvalidChallenge); + } + Err(Ok(e)) => return Err(e.into()), + Err(Err(e)) => return Err(e.into()), + }; + + let user_passkey = match webauthn + .finish_passkey_registration( + &mut repo, + &mut rng, + &clock, + &browser_session.user, + challenge, + input.response, + input.name, + ) + .await + .map_err(anyhow::Error::downcast) + { + Ok(p) => p, + Err(Ok(WebauthnError::Exists)) => { + return Ok(CompleteRegisterPasskeyPayload::Exists); + } + Err(Ok(e)) => return Ok(CompleteRegisterPasskeyPayload::InvalidResponse(e)), + Err(Err(e)) => return Err(e.into()), + }; + + repo.save().await?; + + Ok(CompleteRegisterPasskeyPayload::Added(Box::new( + user_passkey, + ))) + } + + /// Rename a passkey + async fn rename_passkey( + &self, + ctx: &Context<'_>, + input: RenamePasskeyInput, + ) -> Result { + let state = ctx.state(); + let requester = ctx.requester(); + + let id = NodeType::UserPasskey.extract_ulid(&input.id)?; + + if input.name.len() > 256 || input.name.is_empty() { + return Ok(RenamePasskeyPayload::Invalid); + } + + let mut repo = state.repository().await?; + let user_passkey = repo.user_passkey().lookup(id).await?; + let Some(user_passkey) = user_passkey else { + return Ok(RenamePasskeyPayload::NotFound); + }; + + if !requester.is_owner_or_admin(&user_passkey) { + return Ok(RenamePasskeyPayload::NotFound); + } + + // Allow non-admins to rename passkeys if the site config allows it + if !requester.is_admin() && !state.site_config().passkeys_enabled { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let user_passkey = repo.user_passkey().rename(user_passkey, input.name).await?; + + repo.save().await?; + + Ok(RenamePasskeyPayload::Renamed(Box::new(user_passkey))) + } + + /// Remove a passkey + async fn remove_passkey( + &self, + ctx: &Context<'_>, + input: RemovePasskeyInput, + ) -> Result { + let state = ctx.state(); + let requester = ctx.requester(); + + let id = NodeType::UserPasskey.extract_ulid(&input.id)?; + + let mut repo = state.repository().await?; + let user_passkey = repo.user_passkey().lookup(id).await?; + let Some(user_passkey) = user_passkey else { + return Ok(RemovePasskeyPayload::NotFound); + }; + + if !requester.is_owner_or_admin(&user_passkey) { + return Ok(RemovePasskeyPayload::NotFound); + } + + // Allow non-admins to remove passkeys if the site config allows it + if !requester.is_admin() && !state.site_config().passkeys_enabled { + return Err(async_graphql::Error::new("Unauthorized")); + } + + repo.user_passkey().remove(user_passkey.clone()).await?; + + repo.save().await?; + + Ok(RemovePasskeyPayload::Removed(Box::new(user_passkey))) + } +} diff --git a/crates/handlers/src/graphql/query/mod.rs b/crates/handlers/src/graphql/query/mod.rs index 66e6b38bb..c2234a93f 100644 --- a/crates/handlers/src/graphql/query/mod.rs +++ b/crates/handlers/src/graphql/query/mod.rs @@ -240,9 +240,11 @@ impl BaseQuery { let ret = match node_type { // TODO - NodeType::Authentication | NodeType::CompatSsoLogin | NodeType::UserRecoveryTicket => { - None - } + NodeType::Authentication + | NodeType::CompatSsoLogin + | NodeType::UserRecoveryTicket + | NodeType::UserPasskey + | NodeType::UserPasskeyChallenge => None, NodeType::UpstreamOAuth2Provider => UpstreamOAuthQuery .upstream_oauth2_provider(ctx, id) diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index 7faf76334..2def565aa 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -11,7 +11,7 @@ use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{BoxRepository, RepositoryError}; -use crate::{Limiter, graphql::Requester, passwords::PasswordManager}; +use crate::{Limiter, graphql::Requester, passwords::PasswordManager, webauthn::Webauthn}; const CLEAR_SESSION_SENTINEL: &str = "__CLEAR_SESSION__"; @@ -26,6 +26,7 @@ pub trait State { fn site_config(&self) -> &SiteConfig; fn url_builder(&self) -> &UrlBuilder; fn limiter(&self) -> &Limiter; + fn webauthn(&self) -> &Webauthn; } pub type BoxState = Box; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 65a75f550..8e2f3ff3d 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -59,6 +59,7 @@ mod oauth2; pub mod passwords; pub mod upstream_oauth2; mod views; +pub mod webauthn; mod activity_tracker; mod captcha; diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 7c4660a34..19d08f187 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -55,6 +55,7 @@ use crate::{ ActivityTracker, BoundActivityTracker, Limiter, RequesterFingerprint, graphql, passwords::{Hasher, PasswordManager}, upstream_oauth2::cache::MetadataCache, + webauthn::Webauthn, }; /// Setup rustcrypto and tracing for tests. @@ -203,6 +204,8 @@ impl TestState { PasswordManager::disabled() }; + let webauthn = Webauthn::new(&url_builder.http_base(), None)?; + let policy_factory = policy_factory(&site_config.server_name, serde_json::json!({})).await?; @@ -224,6 +227,7 @@ impl TestState { password_manager: password_manager.clone(), url_builder: url_builder.clone(), limiter: limiter.clone(), + webauthn: webauthn.clone(), }; let state: crate::graphql::BoxState = Box::new(graphql_state); @@ -437,6 +441,7 @@ struct TestGraphQLState { password_manager: PasswordManager, url_builder: UrlBuilder, limiter: Limiter, + webauthn: Webauthn, } #[async_trait::async_trait] @@ -473,6 +478,10 @@ impl graphql::State for TestGraphQLState { &self.limiter } + fn webauthn(&self) -> &Webauthn { + &self.webauthn + } + fn rng(&self) -> BoxRng { let mut parent_rng = self.rng.lock().expect("Failed to lock RNG"); let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG"); diff --git a/crates/handlers/src/webauthn.rs b/crates/handlers/src/webauthn.rs new file mode 100644 index 000000000..fb59d512f --- /dev/null +++ b/crates/handlers/src/webauthn.rs @@ -0,0 +1,268 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use chrono::Duration; +use mas_config::PasskeysConfig; +use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge}; +use mas_matrix::HomeserverConnection; +use mas_storage::RepositoryAccess; +use rand::RngCore; +use ulid::Ulid; +use url::Url; +use webauthn_rp::{ + PublicKeyCredentialCreationOptions, RegistrationServerState, + bin::{Decode, Encode}, + request::{ + DomainOrigin, Port, PublicKeyCredentialDescriptor, RpId, Scheme, + register::{PublicKeyCredentialUserEntity, RegistrationVerificationOptions, UserHandle}, + }, + response::{ + CredentialId, + register::{error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed}, + }, +}; + +/// User-facing errors +#[derive(Debug, thiserror::Error)] +pub enum WebauthnError { + #[error(transparent)] + RegistrationCeremonyError(#[from] RegCeremonyErr), + + #[error("The challenge doesn't exist, expired or doesn't belong for this session")] + InvalidChallenge, + + #[error("Credential already exists")] + Exists, + + #[error("The passkey belongs to a different user")] + UserMismatch, +} + +#[derive(Clone, Debug)] +pub struct Webauthn { + rpid: Arc, + allowed_origins: Vec, +} + +impl Webauthn { + /// Creates a new instance + /// + /// # Errors + /// If the `public_base` has no valid host domain + pub fn new(public_base: &Url, passkey_config: Option<&PasskeysConfig>) -> Result { + let public_base_host = public_base + .host_str() + .context("Public base doesn't have a host")? + .to_owned(); + + let rpid_host = if let Some(rpid) = passkey_config.and_then(|c| c.rpid.clone()) { + rpid + } else { + public_base_host.clone() + }; + + let rpid = Arc::new(RpId::Domain(rpid_host.clone().try_into()?)); + + let mut allowed_origins = vec![rpid_host, public_base_host]; + allowed_origins.extend( + passkey_config + .and_then(|c| c.allowed_origins.clone()) + .unwrap_or_default(), + ); + + Ok(Self { + rpid, + allowed_origins, + }) + } + + #[must_use] + pub fn get_allowed_origins(&self) -> Vec> { + self.allowed_origins + .iter() + .map(|host| { + // Allow any port and http when using localhost for development + // (normally no port is allowed and https is required) + if host == "localhost" { + DomainOrigin { + scheme: Scheme::Any, + host, + port: Port::Any, + } + } else { + DomainOrigin::new(host) + } + }) + .collect() + } + + /// Finds a challenge and does some checks on it + /// + /// # Errors + /// [`WebauthnError::InvalidChallenge`] if the challenge is not found or is + /// invalid. + /// + /// The rest of the anyhow errors should be treated as internal errors + pub async fn lookup_challenge( + &self, + repo: &mut impl RepositoryAccess, + clock: &impl Clock, + id: Ulid, + browser_session: Option<&BrowserSession>, + ) -> Result { + let user_passkey_challenge = repo + .user_passkey() + .lookup_challenge(id) + .await? + .ok_or(WebauthnError::InvalidChallenge)?; + + // Check that challenge belongs to a browser session if provided or belongs to + // no session if not provided. If not tied to a session, challenge should + // be tied by a cookie and checked in the handler + if user_passkey_challenge.user_session_id != browser_session.map(|s| s.id) { + return Err(WebauthnError::InvalidChallenge.into()); + } + + // Challenge was already completed + if user_passkey_challenge.completed_at.is_some() { + return Err(WebauthnError::InvalidChallenge.into()); + } + + // Challenge has expired + if clock.now() - user_passkey_challenge.created_at > Duration::hours(1) { + return Err(WebauthnError::InvalidChallenge.into()); + } + + Ok(user_passkey_challenge) + } + + /// Creates a passkey registration challenge + /// + /// # Returns + /// 1. The JSON options to `navigator.credentials.create()` on the frontend + /// 2. The created [`UserPasskeyChallenge`] + /// + /// # Errors + /// Various anyhow errors that should be treated as internal errors + pub async fn start_passkey_registration( + &self, + repo: &mut impl RepositoryAccess, + rng: &mut (dyn RngCore + Send), + clock: &impl Clock, + conn: &impl HomeserverConnection, + user: &User, + browser_session: &BrowserSession, + ) -> Result<(String, UserPasskeyChallenge)> { + let matrix_user = conn.query_user(&user.username).await?; + let user_entity = PublicKeyCredentialUserEntity { + name: user.username.as_str().try_into()?, + id: &UserHandle::decode(user.id.to_bytes())?, + display_name: matrix_user.displayname.and_then(|d| d.try_into().ok()), + }; + + let exclude_credentials = repo + .user_passkey() + .all(user) + .await? + .into_iter() + .map(|v| PublicKeyCredentialDescriptor { + id: v.credential_id, + transports: v.transports, + }) + .collect(); + + let options = PublicKeyCredentialCreationOptions::passkey( + &self.rpid, + user_entity, + exclude_credentials, + ); + + let (server_state, client_state) = options.start_ceremony()?; + + let user_passkey_challenge = repo + .user_passkey() + .add_challenge_for_session(rng, clock, server_state.encode()?, browser_session) + .await?; + + Ok(( + serde_json::to_string(&client_state)?, + user_passkey_challenge, + )) + } + + /// Validates and creates a passkey from a challenge response + /// + /// # Errors + /// [`WebauthnError::Exists`] if the passkey credential the user is trying + /// to register already exists. + /// + /// [`WebauthnError::RegistrationCeremonyError`] if the response from the + /// user is invalid. + /// + /// [`WebauthnError::UserMismatch`] if the user handle in the response + /// doesn't match. + /// + /// The rest of the anyhow errors should be treated as internal errors + pub async fn finish_passkey_registration( + &self, + repo: &mut impl RepositoryAccess, + rng: &mut (dyn RngCore + Send), + clock: &impl Clock, + user: &User, + user_passkey_challenge: UserPasskeyChallenge, + response: String, + name: String, + ) -> Result { + let server_state = RegistrationServerState::decode(&user_passkey_challenge.state)?; + + let RegistrationRelaxed(response) = serde_json::from_str(&response)?; + + let options = RegistrationVerificationOptions:: { + allowed_origins: &self.get_allowed_origins(), + client_data_json_relaxed: true, + ..Default::default() + }; + + let credential = server_state + .verify(&self.rpid, &response, &options) + .map_err(WebauthnError::from)?; + + let user_id = Ulid::from_bytes(credential.user_id().encode()?); + if user_id != user.id { + return Err(WebauthnError::UserMismatch.into()); + } + + let cred_id: CredentialId> = credential.id().into(); + + // Webauthn requires that credential IDs be unique globally + if repo.user_passkey().find(&cred_id).await?.is_some() { + return Err(WebauthnError::Exists.into()); + } + + let user_passkey = repo + .user_passkey() + .add( + rng, + clock, + user, + name, + cred_id, + credential.transports(), + credential.static_state().encode()?, + credential.dynamic_state().encode()?.to_vec(), + credential.metadata().encode()?, + ) + .await?; + + repo.user_passkey() + .complete_challenge(clock, user_passkey_challenge) + .await?; + + Ok(user_passkey) + } +} diff --git a/docs/config.schema.json b/docs/config.schema.json index 350c49614..40bf3c85d 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2707,6 +2707,19 @@ "description": "Whether passkeys are enabled or not", "default": false, "type": "boolean" + }, + "rpid": { + "description": "Relying Party Identifier to use\n\nIf not set, the host from `public_base` is used", + "default": null, + "type": "string" + }, + "allowed_origins": { + "description": "Additional allowed origins. `rpid` and `public_base` are already allowed", + "default": null, + "type": "array", + "items": { + "type": "string" + } } } } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index d2b03b0a0..d490effc6 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -538,6 +538,68 @@ enum CompleteEmailAuthenticationStatus { IN_USE } +""" +The input for the `completeRegisterPasskey` mutation +""" +input CompleteRegisterPasskeyInput { + """ + The ID of the passkey challenge to complete + """ + id: ID! + """ + Name of the passkey + """ + name: String! + """ + The response from `navigator.credentials.create()` as a JSON string + """ + response: String! +} + +""" +The payload of the `completeRegisterPasskey` mutation +""" +type CompleteRegisterPasskeyPayload { + """ + Status of the operation + """ + status: CompleteRegisterPasskeyStatus! + """ + The passkey that was added + """ + passkey: UserPasskey + """ + The error when the status is `INVALID_RESPONSE` + """ + error: String +} + +""" +The status of the `completeRegisterPasskey` mutation +""" +enum CompleteRegisterPasskeyStatus { + """ + The passkey was added + """ + ADDED + """ + The challenge was invalid + """ + INVALID_CHALLENGE + """ + The response was invalid + """ + INVALID_RESPONSE + """ + The name for the passkey was invalid + """ + INVALID_NAME + """ + The passkey credential already exists + """ + EXISTS +} + """ The input of the `createOauth2Session` mutation. """ @@ -878,6 +940,24 @@ type Mutation { input: CompleteEmailAuthenticationInput! ): CompleteEmailAuthenticationPayload! """ + Start registering a new passkey + """ + startRegisterPasskey: StartRegisterPasskeyPayload! + """ + Complete registering a new passkey + """ + completeRegisterPasskey( + input: CompleteRegisterPasskeyInput! + ): CompleteRegisterPasskeyPayload! + """ + Rename a passkey + """ + renamePasskey(input: RenamePasskeyInput!): RenamePasskeyPayload! + """ + Remove a passkey + """ + removePasskey(input: RemovePasskeyInput!): RemovePasskeyPayload! + """ Add a user. This is only available to administrators. """ addUser(input: AddUserInput!): AddUserPayload! @@ -1313,6 +1393,90 @@ enum RemoveEmailStatus { INCORRECT_PASSWORD } +""" +The input for the `removePasskey` mutation +""" +input RemovePasskeyInput { + """ + The ID of the passkey to remove + """ + id: ID! +} + +""" +The payload of the `removePasskey` mutation +""" +type RemovePasskeyPayload { + """ + Status of the operation + """ + status: RemovePasskeyStatus! + """ + The passkey that was removed + """ + passkey: UserPasskey +} + +""" +The status of the `removePasskey` mutation +""" +enum RemovePasskeyStatus { + """ + The passkey was removed + """ + REMOVED + """ + The passkey was not found + """ + NOT_FOUND +} + +""" +The input for the `renamePasskey` mutation +""" +input RenamePasskeyInput { + """ + The ID of the passkey to rename + """ + id: ID! + """ + new name for the passkey + """ + name: String! +} + +""" +The payload of the `renamePasskey` mutation +""" +type RenamePasskeyPayload { + """ + Status of the operation + """ + status: RenamePasskeyStatus! + """ + The passkey that was renamed + """ + passkey: UserPasskey +} + +""" +The status of the `renamePasskey` mutation +""" +enum RenamePasskeyStatus { + """ + The passkey was renamed + """ + RENAMED + """ + The new name was invalid + """ + INVALID + """ + The passkey was not found + """ + NOT_FOUND +} + """ The input for the `resendEmailAuthenticationCode` mutation """ @@ -1838,6 +2002,17 @@ enum StartEmailAuthenticationStatus { INCORRECT_PASSWORD } +""" +The payload of the `startRegisterPasskey` mutation +""" +type StartRegisterPasskeyPayload { + id: ID! + """ + The options to pass to `navigator.credentials.create()` as a JSON string + """ + options: String! +} + """ The input for the `unlockUser` mutation. """ @@ -2239,6 +2414,27 @@ type User implements Node { last: Int ): AppSessionConnection! """ + Get the list of passkeys, chronologically sorted + """ + passkeys( + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): UserPasskeyConnection! + """ Check if the user has a password set. """ hasPassword: Boolean! @@ -2403,6 +2599,61 @@ enum UserEmailState { CONFIRMED } +""" +A passkey +""" +type UserPasskey { + """ + ID of the object + """ + id: ID! + """ + Name of the passkey + """ + name: String! + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the passkey was last used + """ + lastUsedAt: DateTime +} + +type UserPasskeyConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [UserPasskeyEdge!]! + """ + A list of nodes. + """ + nodes: [UserPasskey!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type UserPasskeyEdge { + """ + The item at the end of the edge + """ + node: UserPasskey! + """ + A cursor for use in pagination + """ + cursor: String! +} + """ A recovery ticket """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 84b98f8ce..7519727e3 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -356,6 +356,40 @@ export type CompleteEmailAuthenticationStatus = /** Too many attempts to complete an email authentication */ | 'RATE_LIMITED'; +/** The input for the `completeRegisterPasskey` mutation */ +export type CompleteRegisterPasskeyInput = { + /** The ID of the passkey challenge to complete */ + id: Scalars['ID']['input']; + /** Name of the passkey */ + name: Scalars['String']['input']; + /** The response from `navigator.credentials.create()` as a JSON string */ + response: Scalars['String']['input']; +}; + +/** The payload of the `completeRegisterPasskey` mutation */ +export type CompleteRegisterPasskeyPayload = { + __typename?: 'CompleteRegisterPasskeyPayload'; + /** The error when the status is `INVALID_RESPONSE` */ + error?: Maybe; + /** The passkey that was added */ + passkey?: Maybe; + /** Status of the operation */ + status: CompleteRegisterPasskeyStatus; +}; + +/** The status of the `completeRegisterPasskey` mutation */ +export type CompleteRegisterPasskeyStatus = + /** The passkey was added */ + | 'ADDED' + /** The passkey credential already exists */ + | 'EXISTS' + /** The challenge was invalid */ + | 'INVALID_CHALLENGE' + /** The name for the passkey was invalid */ + | 'INVALID_NAME' + /** The response was invalid */ + | 'INVALID_RESPONSE'; + /** The input of the `createOauth2Session` mutation. */ export type CreateOAuth2SessionInput = { /** Whether the session should issue a never-expiring access token */ @@ -547,6 +581,8 @@ export type Mutation = { allowUserCrossSigningReset: AllowUserCrossSigningResetPayload; /** Complete the email authentication flow */ completeEmailAuthentication: CompleteEmailAuthenticationPayload; + /** Complete registering a new passkey */ + completeRegisterPasskey: CompleteRegisterPasskeyPayload; /** * Create a new arbitrary OAuth 2.0 Session. * @@ -567,6 +603,10 @@ export type Mutation = { lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; + /** Remove a passkey */ + removePasskey: RemovePasskeyPayload; + /** Rename a passkey */ + renamePasskey: RenamePasskeyPayload; /** Resend the email authentication code */ resendEmailAuthenticationCode: ResendEmailAuthenticationCodePayload; /** @@ -604,6 +644,8 @@ export type Mutation = { setPrimaryEmail: SetPrimaryEmailPayload; /** Start a new email authentication flow */ startEmailAuthentication: StartEmailAuthenticationPayload; + /** Start registering a new passkey */ + startRegisterPasskey: StartRegisterPasskeyPayload; /** Unlock and reactivate a user. This is only available to administrators. */ unlockUser: UnlockUserPayload; }; @@ -633,6 +675,12 @@ export type MutationCompleteEmailAuthenticationArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationCompleteRegisterPasskeyArgs = { + input: CompleteRegisterPasskeyInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationCreateOauth2SessionArgs = { input: CreateOAuth2SessionInput; @@ -675,6 +723,18 @@ export type MutationRemoveEmailArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationRemovePasskeyArgs = { + input: RemovePasskeyInput; +}; + + +/** The mutations root of the GraphQL interface. */ +export type MutationRenamePasskeyArgs = { + input: RenamePasskeyInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationResendEmailAuthenticationCodeArgs = { input: ResendEmailAuthenticationCodeInput; @@ -1027,6 +1087,54 @@ export type RemoveEmailStatus = /** The email address was removed */ | 'REMOVED'; +/** The input for the `removePasskey` mutation */ +export type RemovePasskeyInput = { + /** The ID of the passkey to remove */ + id: Scalars['ID']['input']; +}; + +/** The payload of the `removePasskey` mutation */ +export type RemovePasskeyPayload = { + __typename?: 'RemovePasskeyPayload'; + /** The passkey that was removed */ + passkey?: Maybe; + /** Status of the operation */ + status: RemovePasskeyStatus; +}; + +/** The status of the `removePasskey` mutation */ +export type RemovePasskeyStatus = + /** The passkey was not found */ + | 'NOT_FOUND' + /** The passkey was removed */ + | 'REMOVED'; + +/** The input for the `renamePasskey` mutation */ +export type RenamePasskeyInput = { + /** The ID of the passkey to rename */ + id: Scalars['ID']['input']; + /** new name for the passkey */ + name: Scalars['String']['input']; +}; + +/** The payload of the `renamePasskey` mutation */ +export type RenamePasskeyPayload = { + __typename?: 'RenamePasskeyPayload'; + /** The passkey that was renamed */ + passkey?: Maybe; + /** Status of the operation */ + status: RenamePasskeyStatus; +}; + +/** The status of the `renamePasskey` mutation */ +export type RenamePasskeyStatus = + /** The new name was invalid */ + | 'INVALID' + /** The passkey was not found */ + | 'NOT_FOUND' + /** The passkey was renamed */ + | 'RENAMED'; + /** The input for the `resendEmailAuthenticationCode` mutation */ export type ResendEmailAuthenticationCodeInput = { /** The ID of the authentication session to resend the code for */ @@ -1347,6 +1455,14 @@ export type StartEmailAuthenticationStatus = /** The email address was started */ | 'STARTED'; +/** The payload of the `startRegisterPasskey` mutation */ +export type StartRegisterPasskeyPayload = { + __typename?: 'StartRegisterPasskeyPayload'; + id: Scalars['ID']['output']; + /** The options to pass to `navigator.credentials.create()` as a JSON string */ + options: Scalars['String']['output']; +}; + /** The input for the `unlockUser` mutation. */ export type UnlockUserInput = { /** The ID of the user to unlock */ @@ -1479,6 +1595,8 @@ export type User = Node & { matrix: MatrixUser; /** Get the list of OAuth 2.0 sessions, chronologically sorted */ oauth2Sessions: Oauth2SessionConnection; + /** Get the list of passkeys, chronologically sorted */ + passkeys: UserPasskeyConnection; /** Get the list of upstream OAuth 2.0 links */ upstreamOauth2Links: UpstreamOAuth2LinkConnection; /** Username chosen by the user. */ @@ -1553,6 +1671,15 @@ export type UserOauth2SessionsArgs = { }; +/** A user is an individual's account. */ +export type UserPasskeysArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + + /** A user is an individual's account. */ export type UserUpstreamOauth2LinksArgs = { after?: InputMaybe; @@ -1659,6 +1786,40 @@ export type UserEmailState = /** The email address is pending confirmation. */ | 'PENDING'; +/** A passkey */ +export type UserPasskey = { + __typename?: 'UserPasskey'; + /** When the object was created. */ + createdAt: Scalars['DateTime']['output']; + /** ID of the object */ + id: Scalars['ID']['output']; + /** When the passkey was last used */ + lastUsedAt?: Maybe; + /** Name of the passkey */ + name: Scalars['String']['output']; +}; + +export type UserPasskeyConnection = { + __typename?: 'UserPasskeyConnection'; + /** A list of edges. */ + edges: Array; + /** A list of nodes. */ + nodes: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; + /** Identifies the total count of items in the connection. */ + totalCount: Scalars['Int']['output']; +}; + +/** An edge in a connection. */ +export type UserPasskeyEdge = { + __typename?: 'UserPasskeyEdge'; + /** A cursor for use in pagination */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge */ + node: UserPasskey; +}; + /** A recovery ticket */ export type UserRecoveryTicket = CreationEvent & Node & { __typename?: 'UserRecoveryTicket'; From 54b4c9d3b1f8f5c25e2fe43e20e629da63df2d00 Mon Sep 17 00:00:00 2001 From: Tonkku Date: Mon, 10 Mar 2025 15:35:19 +0000 Subject: [PATCH 4/7] Frontend passkey management --- frontend/locales/en.json | 15 ++ .../UserPasskey/UserPasskey.module.css | 31 +++ .../components/UserPasskey/UserPasskey.tsx | 224 ++++++++++++++++++ frontend/src/components/UserPasskey/index.ts | 6 + .../components/UserProfile/AddPasskeyForm.tsx | 206 ++++++++++++++++ .../UserProfile/UserPasskeyList.tsx | 104 ++++++++ frontend/src/gql/gql.ts | 36 +++ frontend/src/gql/graphql.ts | 220 +++++++++++++++++ frontend/src/routes/_account.index.tsx | 8 +- frontend/src/utils/webauthn.ts | 118 +++++++++ 10 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/UserPasskey/UserPasskey.module.css create mode 100644 frontend/src/components/UserPasskey/UserPasskey.tsx create mode 100644 frontend/src/components/UserPasskey/index.ts create mode 100644 frontend/src/components/UserProfile/AddPasskeyForm.tsx create mode 100644 frontend/src/components/UserProfile/UserPasskeyList.tsx create mode 100644 frontend/src/utils/webauthn.ts diff --git a/frontend/locales/en.json b/frontend/locales/en.json index d36d1b793..5a5b294bf 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -56,6 +56,21 @@ "username_label": "Username" }, "passkeys": { + "add": "Add passkey", + "challenge_invalid_error": "The request expired, please try again.", + "created_at_message": "Created {{date}}", + "delete_button_confirmation_modal": { + "action": "Delete passkey", + "body": "Delete this passkey?" + }, + "delete_button_title": "Remove passkey", + "exists_error": "This passkey already exists.", + "last_used_message": "Last used {{date}}", + "name_field_help": "Give your passkey a name you can identify later", + "name_field_label": "Name", + "name_invalid_error": "The entered name is invalid", + "never_used_message": "Never used", + "response_invalid_error": "The response from your passkey was invalid: {{error}}", "title": "Passkeys" }, "password": { diff --git a/frontend/src/components/UserPasskey/UserPasskey.module.css b/frontend/src/components/UserPasskey/UserPasskey.module.css new file mode 100644 index 000000000..d9539220e --- /dev/null +++ b/frontend/src/components/UserPasskey/UserPasskey.module.css @@ -0,0 +1,31 @@ +/* Copyright 2025 New Vector Ltd. +* +* SPDX-License-Identifier: AGPL-3.0-only +* Please see LICENSE in the repository root for full details. +*/ + +.user-passkey-delete-icon { + color: var(--cpd-color-icon-critical-primary); +} + +button[disabled] .user-passkey-delete-icon { + color: var(--cpd-color-icon-disabled); +} + +.passkey-modal-box { + display: flex; + align-items: center; + gap: var(--cpd-space-4x); + border: 1px solid var(--cpd-color-gray-400); + padding: var(--cpd-space-3x); + font: var(--cpd-font-body-md-semibold); + + & > svg { + color: var(--cpd-color-icon-secondary); + background-color: var(--cpd-color-bg-subtle-secondary); + padding: var(--cpd-space-2x); + border-radius: var(--cpd-space-2x); + inline-size: var(--cpd-space-10x); + block-size: var(--cpd-space-10x); + } +} diff --git a/frontend/src/components/UserPasskey/UserPasskey.tsx b/frontend/src/components/UserPasskey/UserPasskey.tsx new file mode 100644 index 000000000..55bf28422 --- /dev/null +++ b/frontend/src/components/UserPasskey/UserPasskey.tsx @@ -0,0 +1,224 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete"; +import { + Button, + EditInPlace, + ErrorMessage, + Form, + IconButton, + Tooltip, +} from "@vector-im/compound-web"; +import { parseISO } from "date-fns"; +import { type ComponentProps, type ReactNode, useState } from "react"; +import { Translation, useTranslation } from "react-i18next"; +import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; +import { formatReadableDate } from "../DateTime"; +import { Close, Description, Dialog, Title } from "../Dialog"; +import styles from "./UserPasskey.module.css"; + +const FRAGMENT = graphql(/* GraphQL */ ` + fragment UserPasskey_passkey on UserPasskey { + id + name + lastUsedAt + createdAt + } +`); + +const REMOVE_PASSKEY_MUTATION = graphql(/* GraphQL */ ` + mutation RemovePasskey($id: ID!) { + removePasskey(input: { id: $id }) { + status + } + } +`); + +const RENAME_PASSKEY_MUTATION = graphql(/* GraphQL */ ` + mutation RenamePasskey($id: ID!, $name: String!) { + renamePasskey(input: { id: $id, name: $name }) { + status + } + } +`); + +const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({ + disabled, + onClick, +}) => ( + + {(t): ReactNode => ( + + + + + + )} + +); + +const DeleteButtonWithConfirmation: React.FC< + ComponentProps & { name: string } +> = ({ name, onClick, ...rest }) => { + const { t } = useTranslation(); + const onConfirm = (): void => { + onClick?.(); + }; + + // NOOP function, otherwise we dont render a cancel button + const onDeny = (): void => {}; + + return ( + }> + + {t("frontend.account.passkeys.delete_button_confirmation_modal.body")} + + +
{name}
+
+
+ + + + + + +
+
+ ); +}; + +const UserPasskey: React.FC<{ + passkey: FragmentType; + onRemove: () => void; +}> = ({ passkey, onRemove }) => { + const { t } = useTranslation(); + const data = useFragment(FRAGMENT, passkey); + const [value, setValue] = useState(data.name); + const queryClient = useQueryClient(); + + const removePasskey = useMutation({ + mutationFn: (id: string) => + graphqlRequest({ query: REMOVE_PASSKEY_MUTATION, variables: { id } }), + onSuccess: (_data) => { + onRemove?.(); + queryClient.invalidateQueries({ queryKey: ["userPasskeys"] }); + }, + }); + const renamePasskey = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + graphqlRequest({ + query: RENAME_PASSKEY_MUTATION, + variables: { id, name }, + }), + onSuccess: (data) => { + if (data.renamePasskey.status !== "RENAMED") { + return; + } + queryClient.invalidateQueries({ queryKey: ["userPasskeys"] }); + }, + }); + + const formattedLastUsed = data.lastUsedAt + ? formatReadableDate(parseISO(data.lastUsedAt), new Date()) + : ""; + const formattedCreated = formatReadableDate( + parseISO(data.createdAt), + new Date(), + ); + const status = renamePasskey.data?.renamePasskey.status ?? null; + + const onRemoveClick = (): void => { + removePasskey.mutate(data.id); + }; + + const onInput = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + const onCancel = () => { + console.log("wee"); + setValue(data.name); + }; + const handleSubmit = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + const name = formData.get("input") as string; + + await renamePasskey.mutateAsync({ id: data.id, name }); + }; + + return ( +
+
+ + + {t("frontend.account.passkeys.name_invalid_error")} + + + + +
+ + + + + {data.lastUsedAt + ? t("frontend.account.passkeys.last_used_message", { + date: formattedLastUsed, + }) + : t("frontend.account.passkeys.never_used_message")} + + + {t("frontend.account.passkeys.created_at_message", { + date: formattedCreated, + })} + + + +
+ ); +}; + +export default UserPasskey; diff --git a/frontend/src/components/UserPasskey/index.ts b/frontend/src/components/UserPasskey/index.ts new file mode 100644 index 000000000..b35654acd --- /dev/null +++ b/frontend/src/components/UserPasskey/index.ts @@ -0,0 +1,6 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +export { default } from "./UserPasskey"; diff --git a/frontend/src/components/UserProfile/AddPasskeyForm.tsx b/frontend/src/components/UserProfile/AddPasskeyForm.tsx new file mode 100644 index 000000000..167aa2945 --- /dev/null +++ b/frontend/src/components/UserProfile/AddPasskeyForm.tsx @@ -0,0 +1,206 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Button, + EditInPlace, + ErrorMessage, +} from "@vector-im/compound-web"; +import { useTranslation } from "react-i18next"; +import { graphql } from "../../gql"; +import { graphqlRequest } from "../../graphql"; +import { checkSupport, performRegistration } from "../../utils/webauthn"; + +const START_REGISTER_PASSKEY_PAYLOAD = graphql(/* GraphQL */ ` + mutation StartRegisterPasskey { + startRegisterPasskey { + id + options + } + } +`); + +const COMPLETE_REGISTER_PASSKEY_PAYLOAD = graphql(/* GraphQL */ ` + mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) { + completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) { + status + error + } + } +`); + +const AddPasskeyForm: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const startRegister = useMutation({ + mutationFn: () => + graphqlRequest({ + query: START_REGISTER_PASSKEY_PAYLOAD, + }), + onSuccess: async (data) => { + if ( + !data.startRegisterPasskey?.id || + !data.startRegisterPasskey?.options + ) { + throw new Error("Unexpected response from server"); + } + + webauthnCeremony.mutate(data.startRegisterPasskey.options); + return; + }, + }); + const webauthnCeremony = useMutation({ + mutationFn: async (options: string) => { + try { + // The error isn't getting caught by the library so instead returning with data + return { response: await performRegistration(options) }; + } catch (e) { + console.error(e); + return { error: e as Error }; + } + }, + }); + const completeRegister = useMutation({ + mutationFn: ({ + id, + name, + response, + }: { + id: string; + name: string; + response: string; + }) => + graphqlRequest({ + query: COMPLETE_REGISTER_PASSKEY_PAYLOAD, + variables: { id, name, response }, + }), + onSuccess: async (data) => { + // Just display error for the name field + if (data.completeRegisterPasskey?.status === "INVALID_NAME") { + return; + } + + startRegister.reset(); + webauthnCeremony.reset(); + + // If there was an error with the passkey registration itself, go back to the add button without resetting the error from this mutation + if ( + data.completeRegisterPasskey?.status === "INVALID_CHALLENGE" || + data.completeRegisterPasskey?.status === "INVALID_RESPONSE" || + data.completeRegisterPasskey?.status === "EXISTS" + ) { + return; + } + + queryClient.invalidateQueries({ queryKey: ["userPasskeys"] }); + + completeRegister.reset(); + }, + }); + + const handleClick = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + + if (startRegister.data?.startRegisterPasskey?.options) { + // Reuse the registration we already have if it was interrupted by an error + webauthnCeremony.mutate(startRegister.data.startRegisterPasskey.options); + } else { + await startRegister.mutateAsync(); + } + }; + const handleSubmit = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + + if ( + !startRegister.data?.startRegisterPasskey.id || + !webauthnCeremony.data?.response + ) + return; + + const formData = new FormData(e.currentTarget); + const name = formData.get("input") as string; + + await completeRegister.mutateAsync({ + id: startRegister.data?.startRegisterPasskey.id, + name, + response: webauthnCeremony.data?.response, + }); + }; + + const status = completeRegister.data?.completeRegisterPasskey.status ?? null; + const support = checkSupport(); + + return ( + <> + {webauthnCeremony.data?.response ? ( + + + {t("frontend.account.passkeys.name_invalid_error")} + + + ) : ( + <> + {status === "INVALID_CHALLENGE" && ( + + )} + {status === "INVALID_RESPONSE" && ( + + )} + {status === "EXISTS" && ( + + )} + {webauthnCeremony.data?.error && + webauthnCeremony.data?.error.name !== "NotAllowedError" && ( + + )} + + + )} + + ); +}; + +export default AddPasskeyForm; diff --git a/frontend/src/components/UserProfile/UserPasskeyList.tsx b/frontend/src/components/UserProfile/UserPasskeyList.tsx new file mode 100644 index 000000000..acbd824ed --- /dev/null +++ b/frontend/src/components/UserProfile/UserPasskeyList.tsx @@ -0,0 +1,104 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; +import { notFound } from "@tanstack/react-router"; +import { useTransition } from "react"; +import { graphql } from "../../gql"; +import { graphqlRequest } from "../../graphql"; +import { + type AnyPagination, + FIRST_PAGE, + type Pagination, + usePages, + usePagination, +} from "../../pagination"; +import PaginationControls from "../PaginationControls"; +import UserPasskey from "../UserPasskey"; + +const QUERY = graphql(/* GraphQL */ ` + query UserPasskeyList( + $first: Int + $after: String + $last: Int + $before: String + ) { + viewer { + __typename + ... on User { + passkeys(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...UserPasskey_passkey + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + } + } +`); + +const query = (pagination: AnyPagination = { first: 6 }) => + queryOptions({ + queryKey: ["userPasskeys", pagination], + queryFn: ({ signal }) => + graphqlRequest({ + query: QUERY, + variables: pagination, + signal, + }), + }); + +const UserPasskeyList: React.FC = () => { + const [pending, startTransition] = useTransition(); + const [pagination, setPagination] = usePagination(); + const result = useSuspenseQuery(query(pagination)); + if (result.data.viewer.__typename !== "User") throw notFound(); + const passkeys = result.data.viewer.passkeys; + + const [prevPage, nextPage] = usePages(pagination, passkeys.pageInfo); + + const paginate = (pagination: Pagination): void => { + startTransition(() => { + setPagination(pagination); + }); + }; + + const onRemove = (): void => { + startTransition(() => { + setPagination(FIRST_PAGE); + }); + }; + + return ( + <> + {passkeys.edges.map((edge) => ( + + ))} + + paginate(prevPage) : null} + onNext={nextPage ? (): void => paginate(nextPage) : null} + disabled={pending} + /> + + ); +}; + +export default UserPasskeyList; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 34555560b..847b6fce0 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -42,12 +42,18 @@ type Documents = { "\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.UserGreeting_UserFragmentDoc, "\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": typeof types.UserGreeting_SiteConfigFragmentDoc, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": typeof types.SetDisplayNameDocument, + "\n fragment UserPasskey_passkey on UserPasskey {\n id\n name\n lastUsedAt\n createdAt\n }\n": typeof types.UserPasskey_PasskeyFragmentDoc, + "\n mutation RemovePasskey($id: ID!) {\n removePasskey(input: { id: $id }) {\n status\n }\n }\n": typeof types.RemovePasskeyDocument, + "\n mutation RenamePasskey($id: ID!, $name: String!) {\n renamePasskey(input: { id: $id, name: $name }) {\n status\n }\n }\n": typeof types.RenamePasskeyDocument, "\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": typeof types.AddEmailForm_UserFragmentDoc, "\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": typeof types.AddEmailForm_SiteConfigFragmentDoc, "\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(\n input: { email: $email, password: $password, language: $language }\n ) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": typeof types.AddEmailDocument, + "\n mutation StartRegisterPasskey {\n startRegisterPasskey {\n id\n options\n }\n }\n": typeof types.StartRegisterPasskeyDocument, + "\n mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {\n completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {\n status\n error\n }\n }\n": typeof types.CompleteRegisterPasskeyDocument, "\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": typeof types.UserEmailListDocument, "\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc, "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc, + "\n query UserPasskeyList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n passkeys(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserPasskey_passkey\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": typeof types.UserPasskeyListDocument, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc, "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument, "\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument, @@ -99,12 +105,18 @@ const documents: Documents = { "\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": types.UserGreeting_UserFragmentDoc, "\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": types.UserGreeting_SiteConfigFragmentDoc, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": types.SetDisplayNameDocument, + "\n fragment UserPasskey_passkey on UserPasskey {\n id\n name\n lastUsedAt\n createdAt\n }\n": types.UserPasskey_PasskeyFragmentDoc, + "\n mutation RemovePasskey($id: ID!) {\n removePasskey(input: { id: $id }) {\n status\n }\n }\n": types.RemovePasskeyDocument, + "\n mutation RenamePasskey($id: ID!, $name: String!) {\n renamePasskey(input: { id: $id, name: $name }) {\n status\n }\n }\n": types.RenamePasskeyDocument, "\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": types.AddEmailForm_UserFragmentDoc, "\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": types.AddEmailForm_SiteConfigFragmentDoc, "\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(\n input: { email: $email, password: $password, language: $language }\n ) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": types.AddEmailDocument, + "\n mutation StartRegisterPasskey {\n startRegisterPasskey {\n id\n options\n }\n }\n": types.StartRegisterPasskeyDocument, + "\n mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {\n completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {\n status\n error\n }\n }\n": types.CompleteRegisterPasskeyDocument, "\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserEmailListDocument, "\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc, "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc, + "\n query UserPasskeyList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n passkeys(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserPasskey_passkey\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserPasskeyListDocument, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc, "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n passkeysEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument, "\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument, @@ -237,6 +249,18 @@ export function graphql(source: "\n fragment UserGreeting_siteConfig on SiteCon * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n"): typeof import('./graphql').SetDisplayNameDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment UserPasskey_passkey on UserPasskey {\n id\n name\n lastUsedAt\n createdAt\n }\n"): typeof import('./graphql').UserPasskey_PasskeyFragmentDoc; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation RemovePasskey($id: ID!) {\n removePasskey(input: { id: $id }) {\n status\n }\n }\n"): typeof import('./graphql').RemovePasskeyDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation RenamePasskey($id: ID!, $name: String!) {\n renamePasskey(input: { id: $id, name: $name }) {\n status\n }\n }\n"): typeof import('./graphql').RenamePasskeyDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -249,6 +273,14 @@ export function graphql(source: "\n fragment AddEmailForm_siteConfig on SiteCon * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(\n input: { email: $email, password: $password, language: $language }\n ) {\n status\n violations\n authentication {\n id\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation StartRegisterPasskey {\n startRegisterPasskey {\n id\n options\n }\n }\n"): typeof import('./graphql').StartRegisterPasskeyDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) {\n completeRegisterPasskey(input: { id: $id, name: $name, response: $response }) {\n status\n error\n }\n }\n"): typeof import('./graphql').CompleteRegisterPasskeyDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -261,6 +293,10 @@ export function graphql(source: "\n fragment UserEmailList_user on User {\n * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query UserPasskeyList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n passkeys(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserPasskey_passkey\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n"): typeof import('./graphql').UserPasskeyListDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 7519727e3..0adb04638 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1980,6 +1980,23 @@ export type SetDisplayNameMutationVariables = Exact<{ export type SetDisplayNameMutation = { __typename?: 'Mutation', setDisplayName: { __typename?: 'SetDisplayNamePayload', status: SetDisplayNameStatus } }; +export type UserPasskey_PasskeyFragment = { __typename?: 'UserPasskey', id: string, name: string, lastUsedAt?: string | null, createdAt: string } & { ' $fragmentName'?: 'UserPasskey_PasskeyFragment' }; + +export type RemovePasskeyMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type RemovePasskeyMutation = { __typename?: 'Mutation', removePasskey: { __typename?: 'RemovePasskeyPayload', status: RemovePasskeyStatus } }; + +export type RenamePasskeyMutationVariables = Exact<{ + id: Scalars['ID']['input']; + name: Scalars['String']['input']; +}>; + + +export type RenamePasskeyMutation = { __typename?: 'Mutation', renamePasskey: { __typename?: 'RenamePasskeyPayload', status: RenamePasskeyStatus } }; + export type AddEmailForm_UserFragment = { __typename?: 'User', hasPassword: boolean } & { ' $fragmentName'?: 'AddEmailForm_UserFragment' }; export type AddEmailForm_SiteConfigFragment = { __typename?: 'SiteConfig', passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AddEmailForm_SiteConfigFragment' }; @@ -1993,6 +2010,20 @@ export type AddEmailMutationVariables = Exact<{ export type AddEmailMutation = { __typename?: 'Mutation', startEmailAuthentication: { __typename?: 'StartEmailAuthenticationPayload', status: StartEmailAuthenticationStatus, violations?: Array | null, authentication?: { __typename?: 'UserEmailAuthentication', id: string } | null } }; +export type StartRegisterPasskeyMutationVariables = Exact<{ [key: string]: never; }>; + + +export type StartRegisterPasskeyMutation = { __typename?: 'Mutation', startRegisterPasskey: { __typename?: 'StartRegisterPasskeyPayload', id: string, options: string } }; + +export type CompleteRegisterPasskeyMutationVariables = Exact<{ + id: Scalars['ID']['input']; + name: Scalars['String']['input']; + response: Scalars['String']['input']; +}>; + + +export type CompleteRegisterPasskeyMutation = { __typename?: 'Mutation', completeRegisterPasskey: { __typename?: 'CompleteRegisterPasskeyPayload', status: CompleteRegisterPasskeyStatus, error?: string | null } }; + export type UserEmailListQueryVariables = Exact<{ first?: InputMaybe; after?: InputMaybe; @@ -2010,6 +2041,19 @@ export type UserEmailList_UserFragment = { __typename?: 'User', hasPassword: boo export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; +export type UserPasskeyListQueryVariables = Exact<{ + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; +}>; + + +export type UserPasskeyListQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', passkeys: { __typename?: 'UserPasskeyConnection', totalCount: number, edges: Array<{ __typename?: 'UserPasskeyEdge', cursor: string, node: ( + { __typename?: 'UserPasskey' } + & { ' $fragmentRefs'?: { 'UserPasskey_PasskeyFragment': UserPasskey_PasskeyFragment } } + ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } }; + export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' }; export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>; @@ -2518,6 +2562,14 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(` displayNameChangeAllowed } `, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString; +export const UserPasskey_PasskeyFragmentDoc = new TypedDocumentString(` + fragment UserPasskey_passkey on UserPasskey { + id + name + lastUsedAt + createdAt +} + `, {"fragmentName":"UserPasskey_passkey"}) as unknown as TypedDocumentString; export const AddEmailForm_UserFragmentDoc = new TypedDocumentString(` fragment AddEmailForm_user on User { hasPassword @@ -2652,6 +2704,20 @@ export const SetDisplayNameDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; +export const RemovePasskeyDocument = new TypedDocumentString(` + mutation RemovePasskey($id: ID!) { + removePasskey(input: {id: $id}) { + status + } +} + `) as unknown as TypedDocumentString; +export const RenamePasskeyDocument = new TypedDocumentString(` + mutation RenamePasskey($id: ID!, $name: String!) { + renamePasskey(input: {id: $id, name: $name}) { + status + } +} + `) as unknown as TypedDocumentString; export const AddEmailDocument = new TypedDocumentString(` mutation AddEmail($email: String!, $password: String, $language: String!) { startEmailAuthentication( @@ -2665,6 +2731,22 @@ export const AddEmailDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; +export const StartRegisterPasskeyDocument = new TypedDocumentString(` + mutation StartRegisterPasskey { + startRegisterPasskey { + id + options + } +} + `) as unknown as TypedDocumentString; +export const CompleteRegisterPasskeyDocument = new TypedDocumentString(` + mutation CompleteRegisterPasskey($id: ID!, $name: String!, $response: String!) { + completeRegisterPasskey(input: {id: $id, name: $name, response: $response}) { + status + error + } +} + `) as unknown as TypedDocumentString; export const UserEmailListDocument = new TypedDocumentString(` query UserEmailList($first: Int, $after: String, $last: Int, $before: String) { viewer { @@ -2692,6 +2774,35 @@ export const UserEmailListDocument = new TypedDocumentString(` id email }`) as unknown as TypedDocumentString; +export const UserPasskeyListDocument = new TypedDocumentString(` + query UserPasskeyList($first: Int, $after: String, $last: Int, $before: String) { + viewer { + __typename + ... on User { + passkeys(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...UserPasskey_passkey + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + } +} + fragment UserPasskey_passkey on UserPasskey { + id + name + lastUsedAt + createdAt +}`) as unknown as TypedDocumentString; export const UserProfileDocument = new TypedDocumentString(` query UserProfile { viewerSession { @@ -3395,6 +3506,50 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver { + * const { id } = variables; + * return HttpResponse.json({ + * data: { removePasskey } + * }) + * }, + * requestOptions + * ) + */ +export const mockRemovePasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'RemovePasskey', + resolver, + options + ) + +/** + * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) + * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) + * @see https://mswjs.io/docs/basics/response-resolver + * @example + * mockRenamePasskeyMutation( + * ({ query, variables }) => { + * const { id, name } = variables; + * return HttpResponse.json({ + * data: { renamePasskey } + * }) + * }, + * requestOptions + * ) + */ +export const mockRenamePasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'RenamePasskey', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) @@ -3417,6 +3572,49 @@ export const mockAddEmailMutation = (resolver: GraphQLResponseResolver { + * return HttpResponse.json({ + * data: { startRegisterPasskey } + * }) + * }, + * requestOptions + * ) + */ +export const mockStartRegisterPasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'StartRegisterPasskey', + resolver, + options + ) + +/** + * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) + * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) + * @see https://mswjs.io/docs/basics/response-resolver + * @example + * mockCompleteRegisterPasskeyMutation( + * ({ query, variables }) => { + * const { id, name, response } = variables; + * return HttpResponse.json({ + * data: { completeRegisterPasskey } + * }) + * }, + * requestOptions + * ) + */ +export const mockCompleteRegisterPasskeyMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'CompleteRegisterPasskey', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) @@ -3439,6 +3637,28 @@ export const mockUserEmailListQuery = (resolver: GraphQLResponseResolver { + * const { first, after, last, before } = variables; + * return HttpResponse.json({ + * data: { viewer } + * }) + * }, + * requestOptions + * ) + */ +export const mockUserPasskeyListQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.query( + 'UserPasskeyList', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index e49fb3915..0dde64fbb 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -8,6 +8,7 @@ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import { notFound, redirect, useNavigate } from "@tanstack/react-router"; import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out"; import { Button, Text } from "@vector-im/compound-web"; +import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import * as v from "valibot"; import AccountDeleteButton from "../components/AccountDeleteButton"; @@ -19,9 +20,11 @@ import LoadingSpinner from "../components/LoadingSpinner"; import Separator from "../components/Separator"; import { useEndBrowserSession } from "../components/Session/EndBrowserSessionButton"; import AddEmailForm from "../components/UserProfile/AddEmailForm"; +import AddPasskeyForm from "../components/UserProfile/AddPasskeyForm"; import UserEmailList, { query as userEmailListQuery, } from "../components/UserProfile/UserEmailList"; +import UserPasskeyList from "../components/UserProfile/UserPasskeyList"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; @@ -230,7 +233,10 @@ function Index(): React.ReactElement { {siteConfig.passkeysEnabled && ( <> - placeholder text + }> + + + diff --git a/frontend/src/utils/webauthn.ts b/frontend/src/utils/webauthn.ts new file mode 100644 index 000000000..54c1a8dae --- /dev/null +++ b/frontend/src/utils/webauthn.ts @@ -0,0 +1,118 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +// Polyfills for fromJSON and toJSON utils which aren't stable yet +if (typeof window !== "undefined" && window.PublicKeyCredential) { + const b64urlDecode = (b64: string) => + Uint8Array.from( + atob(b64.replace(/-/g, "+").replace(/_/g, "/")), + (c) => c.codePointAt(0) as number, + ); + const b64urlEncode = (buf: ArrayBuffer) => + btoa( + Array.from(new Uint8Array(buf), (b) => String.fromCodePoint(b)).join(""), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + // TS doesn't know AuthenticatorAttestationResponseJSON and AuthenticatorAssertionResponseJSON yet + type AuthenticatorAttestationResponseJSON = { + clientDataJSON: Base64URLString; + authenticatorData: Base64URLString; + transports: string[]; + publicKey?: Base64URLString; + publicKeyAlgorithm: COSEAlgorithmIdentifier; + attestationObject: Base64URLString; + }; + + type AuthenticatorAssertionResponseJSON = { + clientDataJSON: Base64URLString; + authenticatorData: Base64URLString; + signature: Base64URLString; + userHandle?: Base64URLString; + }; + + if (!window.PublicKeyCredential.parseCreationOptionsFromJSON) { + window.PublicKeyCredential.parseCreationOptionsFromJSON = (options) => + ({ + ...options, + user: { + ...options.user, + id: b64urlDecode(options.user.id), + }, + challenge: b64urlDecode(options.challenge), + excludeCredentials: options.excludeCredentials?.map((c) => ({ + ...c, + id: b64urlDecode, + })), + }) as PublicKeyCredentialCreationOptions; + } + + if (!window.PublicKeyCredential.parseRequestOptionsFromJSON) { + window.PublicKeyCredential.parseRequestOptionsFromJSON = (options) => + ({ + ...options, + challenge: b64urlDecode(options.challenge), + allowCredentials: options.allowCredentials?.map((c) => ({ + ...c, + id: b64urlDecode, + })), + }) as PublicKeyCredentialRequestOptions; + } + + if (!window.PublicKeyCredential.prototype.toJSON) { + window.PublicKeyCredential.prototype.toJSON = function () { + const cred = { + id: this.id, + rawId: b64urlEncode(this.rawId), + response: { + clientDataJSON: b64urlEncode(this.response.clientDataJSON), + }, + authenticatorAttachment: this.authenticatorAttachment, + clientExtensionResults: this.getClientExtensionResults(), + type: this.type, + } as PublicKeyCredentialJSON; + + if (this.response instanceof window.AuthenticatorAttestationResponse) { + const publicKey = this.response.getPublicKey(); + cred.response = { + ...cred.response, + authenticatorData: b64urlEncode(this.response.getAuthenticatorData()), + transports: this.response.getTransports(), + publicKey: publicKey ? b64urlEncode(publicKey) : null, + publicKeyAlgorithm: this.response.getPublicKeyAlgorithm(), + attestationObject: b64urlEncode(this.response.attestationObject), + } as AuthenticatorAttestationResponseJSON; + } + + if (this.response instanceof window.AuthenticatorAssertionResponse) { + const userHandle = this.response.userHandle; + cred.response = { + ...cred.response, + authenticatorData: b64urlEncode(this.response.authenticatorData), + signature: b64urlEncode(this.response.signature), + userHandle: userHandle ? b64urlEncode(userHandle) : null, + } as AuthenticatorAssertionResponseJSON; + } + + return cred; + }; + } +} + +export function checkSupport(): boolean { + return !!window?.PublicKeyCredential; +} + +export async function performRegistration(options: string): Promise { + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON( + JSON.parse(options), + ); + + const credential = await navigator.credentials.create({ publicKey }); + + return JSON.stringify(credential); +} From 9ec383c54acb6f5a4639f16eb4ca85a6a58c54f3 Mon Sep 17 00:00:00 2001 From: Tonkku Date: Thu, 13 Mar 2025 13:54:46 +0000 Subject: [PATCH 5/7] Template for passkey login --- crates/templates/src/context.rs | 24 ++++++- frontend/knip.config.ts | 7 +- frontend/locales/en.json | 3 + .../src/components/PasskeyLoginButton.tsx | 65 +++++++++++++++++++ frontend/src/i18n.ts | 3 +- frontend/src/template_passkey.tsx | 35 ++++++++++ frontend/src/utils/webauthn.ts | 10 +++ frontend/vite.config.ts | 1 + templates/pages/login.html | 30 ++++++--- translations/en.json | 30 ++++----- 10 files changed, 178 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/PasskeyLoginButton.tsx create mode 100644 frontend/src/template_passkey.tsx diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0a04677eb..0fdc3580c 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -394,13 +394,19 @@ pub enum LoginFormField { /// The password field Password, + + /// The passkey challenge + PasskeyChallengeId, + + /// The passkey response + PasskeyResponse, } impl FormField for LoginFormField { fn keep(&self) -> bool { match self { - Self::Username => true, - Self::Password => false, + Self::Username | Self::PasskeyChallengeId => true, + Self::Password | Self::PasskeyResponse => false, } } } @@ -461,6 +467,7 @@ pub struct LoginContext { form: FormState, next: Option, providers: Vec, + webauthn_options: String, } impl TemplateContext for LoginContext { @@ -478,11 +485,13 @@ impl TemplateContext for LoginContext { form: FormState::default(), next: None, providers: Vec::new(), + webauthn_options: String::new(), }, LoginContext { form: FormState::default(), next: None, providers: Vec::new(), + webauthn_options: String::new(), }, LoginContext { form: FormState::default() @@ -496,12 +505,14 @@ impl TemplateContext for LoginContext { ), next: None, providers: Vec::new(), + webauthn_options: String::new(), }, LoginContext { form: FormState::default() .with_error_on_field(LoginFormField::Username, FieldError::Exists), next: None, providers: Vec::new(), + webauthn_options: String::new(), }, ] } @@ -533,6 +544,15 @@ impl LoginContext { ..self } } + + /// Set the webauthn options + #[must_use] + pub fn with_webauthn_options(self, webauthn_options: String) -> Self { + Self { + webauthn_options, + ..self + } + } } /// Fields of the registration form diff --git a/frontend/knip.config.ts b/frontend/knip.config.ts index b5e382961..7f596360c 100644 --- a/frontend/knip.config.ts +++ b/frontend/knip.config.ts @@ -6,7 +6,12 @@ import type { KnipConfig } from "knip"; export default { - entry: ["src/main.tsx", "src/swagger.ts", "src/routes/*"], + entry: [ + "src/main.tsx", + "src/swagger.ts", + "src/template_passkey.tsx", + "src/routes/*", + ], ignore: ["src/gql/*", "src/routeTree.gen.ts", ".storybook/locales.ts"], ignoreDependencies: [ // This is used by the tailwind PostCSS plugin, but not detected by knip diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 5a5b294bf..3fd06dc27 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -346,5 +346,8 @@ "view_messages": "View your existing messages and data", "view_profile": "See your profile info and contact details" } + }, + "passkeys": { + "login": "Continue with Passkey" } } diff --git a/frontend/src/components/PasskeyLoginButton.tsx b/frontend/src/components/PasskeyLoginButton.tsx new file mode 100644 index 000000000..2bf1136b8 --- /dev/null +++ b/frontend/src/components/PasskeyLoginButton.tsx @@ -0,0 +1,65 @@ +import { useMutation } from "@tanstack/react-query"; +import IconKey from "@vector-im/compound-design-tokens/assets/web/icons/key"; +import { Alert, Button } from "@vector-im/compound-web"; +import { useTranslation } from "react-i18next"; +import { checkSupport, performAuthentication } from "../utils/webauthn"; + +const PasskeyLoginButton: React.FC<{ options?: string }> = ({ options }) => { + const { t } = useTranslation(); + const webauthnCeremony = useMutation({ + mutationFn: async (options: string) => { + try { + return { response: await performAuthentication(options) }; + } catch (e) { + console.error(e); + return { error: e as Error }; + } + }, + onSuccess: (data) => { + if (data.response) { + const form = document.querySelector("form") as HTMLFormElement; + const formResponse = form?.querySelector( + '[name="passkey_response"]', + ) as HTMLInputElement; + + formResponse.value = data.response; + form.submit(); + } + }, + }); + + if (!options) return; + + const handleClick = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + + webauthnCeremony.mutate(options); + }; + + const support = checkSupport(); + + return ( +
+ {webauthnCeremony.data?.error && + webauthnCeremony.data?.error.name !== "NotAllowedError" && ( + + )} + +
+ ); +}; + +export default PasskeyLoginButton; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 77e5b7284..fa91e5e46 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -81,7 +81,7 @@ const Backend = { }, } satisfies BackendModule; -export const setupI18n = () => { +export const setupI18n = () => i18n .use(Backend) .use(LanguageDetector) @@ -96,7 +96,6 @@ export const setupI18n = () => { escapeValue: false, // React has built-in XSS protections }, } satisfies InitOptions); -}; import.meta.hot?.on("locales-update", () => { i18n.reloadResources().then(() => { diff --git a/frontend/src/template_passkey.tsx b/frontend/src/template_passkey.tsx new file mode 100644 index 000000000..608f99676 --- /dev/null +++ b/frontend/src/template_passkey.tsx @@ -0,0 +1,35 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode, Suspense } from "react"; +import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import LoadingSpinner from "./components/LoadingSpinner"; +import PasskeyLoginButton from "./components/PasskeyLoginButton"; +import { queryClient } from "./graphql"; +import i18n, { setupI18n } from "./i18n"; + +setupI18n(); + +interface IWindow { + WEBAUTHN_OPTIONS?: string; +} + +const options = + (typeof window !== "undefined" && (window as IWindow).WEBAUTHN_OPTIONS) || + undefined; + +createRoot(document.getElementById("passkey-root") as HTMLElement).render( + + + }> + + + + + + , +); diff --git a/frontend/src/utils/webauthn.ts b/frontend/src/utils/webauthn.ts index 54c1a8dae..307661fc9 100644 --- a/frontend/src/utils/webauthn.ts +++ b/frontend/src/utils/webauthn.ts @@ -116,3 +116,13 @@ export async function performRegistration(options: string): Promise { return JSON.stringify(credential); } + +export async function performAuthentication(options: string): Promise { + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( + JSON.parse(options), + ); + + const credential = await navigator.credentials.get({ publicKey }); + + return JSON.stringify(credential); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fdc00cab7..b8f3ff687 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -59,6 +59,7 @@ export default defineConfig((env) => ({ resolve(__dirname, "src/shared.css"), resolve(__dirname, "src/templates.css"), resolve(__dirname, "src/swagger.ts"), + resolve(__dirname, "src/template_passkey.tsx"), ], }, }, diff --git a/templates/pages/login.html b/templates/pages/login.html index 10802adf8..2c9516c59 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -10,6 +10,8 @@ {% from "components/idp_brand.html" import logo %} +{% set params = next["params"] | default({}) | to_params(prefix="?") %} + {% block content %}
@@ -68,12 +70,19 @@

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

{{ button.button(text=_("action.continue")) }} {% endif %} - {% 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 %} @@ -81,7 +90,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,15 +106,21 @@

{{ _("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") }}
{% endif %} + + {% if features.passkeys_enabled %} + + {{ include_asset('src/template_passkey.tsx') | indent(4) | safe }} + {% endif %} {% endblock content %} diff --git a/translations/en.json b/translations/en.json index 27410e0d5..65628f824 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,11 +10,11 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:70:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:102:33-59, pages/upstream_oauth2/do_register.html:191:26-52" + "context": "pages/login.html:109:33-59, pages/upstream_oauth2/do_register.html:191:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -91,7 +91,7 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" + "context": "pages/login.html:58:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { @@ -99,7 +99,7 @@ }, "username": "Username", "@username": { - "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + "context": "pages/login.html:53:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" } }, "error": { @@ -419,47 +419,43 @@ "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { - "context": "pages/login.html:98:13-44" + "context": "pages/login.html:106:13-44" }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:89:15-67, pages/register/index.html:53:15-67", + "context": "pages/login.html:97:15-67, pages/register/index.html:53:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", "@description": { - "context": "pages/login.html:29:29-55" + "context": "pages/login.html:31:29-55" }, "forgot_password": "Forgot password?", "@forgot_password": { - "context": "pages/login.html:61:35-65", + "context": "pages/login.html:63:35-65", "description": "On the login page, link to the account recovery process" }, "headline": "Sign in", "@headline": { - "context": "pages/login.html:28:31-54" + "context": "pages/login.html:30:31-54" }, "link": { "description": "Linking your %(provider)s account", "@description": { - "context": "pages/login.html:24:29-75" + "context": "pages/login.html:26:29-75" }, "headline": "Sign in to link", "@headline": { - "context": "pages/login.html:22:31-59" + "context": "pages/login.html:24:31-59" } }, "no_login_methods": "No login methods available.", "@no_login_methods": { - "context": "pages/login.html:108:11-42" + "context": "pages/login.html:115:11-42" }, "username_or_email": "Username or Email", "@username_or_email": { - "context": "pages/login.html:47:39-71" - }, - "with_passkey": "Sign in with a Passkey", - "@with_passkey": { - "context": "pages/login.html:76:28-55" + "context": "pages/login.html:49:39-71" } }, "navbar": { From 1865a47591f9665ac28954d5964e9e41d82a92ab Mon Sep 17 00:00:00 2001 From: Tonkku Date: Fri, 14 Mar 2025 12:18:21 +0000 Subject: [PATCH 6/7] Passkey login handler --- crates/handlers/src/lib.rs | 2 + crates/handlers/src/test_utils.rs | 8 + crates/handlers/src/views/login/cookie.rs | 89 ++++ .../src/views/{login.rs => login/mod.rs} | 422 +++++++++++++----- crates/handlers/src/webauthn.rs | 166 ++++++- 5 files changed, 577 insertions(+), 110 deletions(-) create mode 100644 crates/handlers/src/views/login/cookie.rs rename crates/handlers/src/views/{login.rs => login/mod.rs} (79%) diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 8e2f3ff3d..d020ecc07 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -48,6 +48,7 @@ use opentelemetry::metrics::Meter; use sqlx::PgPool; use tower::util::AndThenLayer; use tower_http::cors::{Any, CorsLayer}; +use webauthn::Webauthn; use self::{graphql::ExtraRouterParameters, passwords::PasswordManager}; @@ -348,6 +349,7 @@ where Limiter: FromRef, reqwest::Client: FromRef, Arc: FromRef, + Webauthn: FromRef, BoxClock: FromRequestParts, BoxRng: FromRequestParts, Policy: FromRequestParts, diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 19d08f187..38ce6abf9 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -113,6 +113,7 @@ pub(crate) struct TestState { pub rng: Arc>, pub http_client: reqwest::Client, pub task_tracker: TaskTracker, + pub webauthn: Webauthn, queue_worker: Arc>, #[allow(dead_code)] // It is used, as it will cancel the CancellationToken when dropped @@ -280,6 +281,7 @@ impl TestState { rng, http_client, task_tracker, + webauthn, queue_worker, cancellation_drop_guard: Arc::new(shutdown_token.drop_guard()), }) @@ -585,6 +587,12 @@ impl FromRef for reqwest::Client { } } +impl FromRef for Webauthn { + fn from_ref(input: &TestState) -> Self { + input.webauthn.clone() + } +} + impl FromRequestParts for ActivityTracker { type Rejection = Infallible; diff --git a/crates/handlers/src/views/login/cookie.rs b/crates/handlers/src/views/login/cookie.rs new file mode 100644 index 000000000..e9e7c231e --- /dev/null +++ b/crates/handlers/src/views/login/cookie.rs @@ -0,0 +1,89 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Duration, Utc}; +use mas_axum_utils::cookies::CookieJar; +use mas_data_model::{Clock, UserPasskeyChallenge}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +/// Name of the cookie +static COOKIE_NAME: &str = "user-passkey-challenges"; + +/// Sessions expire after an hour +static SESSION_MAX_TIME: Duration = Duration::hours(1); + +/// The content of the cookie, which stores a list of user passkey challenge IDs +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct UserPasskeyChallenges(BTreeSet); + +impl UserPasskeyChallenges { + /// Load the user passkey challenges cookie + pub fn load(cookie_jar: &CookieJar) -> Self { + match cookie_jar.load(COOKIE_NAME) { + Ok(Some(challenges)) => challenges, + Ok(None) => Self::default(), + Err(e) => { + tracing::warn!( + error = &e as &dyn std::error::Error, + "Invalid passkey challenges cookie" + ); + Self::default() + } + } + } + + /// Returns true if the cookie is empty + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Save the user passkey challenges to the cookie jar + pub fn save(self, cookie_jar: CookieJar, clock: &C) -> CookieJar + where + C: Clock, + { + let this = self.expire(clock.now()); + + if this.is_empty() { + cookie_jar.remove(COOKIE_NAME) + } else { + cookie_jar.save(COOKIE_NAME, &this, false) + } + } + + fn expire(mut self, now: DateTime) -> Self { + self.0.retain(|id| { + let Ok(ts) = id.timestamp_ms().try_into() else { + return false; + }; + let Some(when) = DateTime::from_timestamp_millis(ts) else { + return false; + }; + now - when < SESSION_MAX_TIME + }); + + self + } + + /// Add a new challenge + pub fn add(mut self, passkey_challenge: &UserPasskeyChallenge) -> Self { + self.0.insert(passkey_challenge.id); + self + } + + /// Check if the challenge is in the list + pub fn contains(&self, passkey_challenge_id: &Ulid) -> bool { + self.0.contains(passkey_challenge_id) + } + + /// Mark a challenge as consumed to avoid replay + pub fn consume_challenge(mut self, passkey_challenge_id: &Ulid) -> Self { + self.0.remove(passkey_challenge_id); + self + } +} diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login/mod.rs similarity index 79% rename from crates/handlers/src/views/login.rs rename to crates/handlers/src/views/login/mod.rs index 4c15156f8..2da5d4db3 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login/mod.rs @@ -4,6 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +mod cookie; + use std::sync::{Arc, LazyLock}; use axum::{ @@ -11,13 +13,14 @@ use axum::{ response::{Html, IntoResponse, Response}, }; use axum_extra::typed_header::TypedHeader; +use cookie::UserPasskeyChallenges; use hyper::StatusCode; use mas_axum_utils::{ InternalError, SessionInfoExt, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng, Clock, oauth2::LoginHint}; +use mas_data_model::{BoxClock, BoxRng, Clock, Password, User, UserPasskey, oauth2::LoginHint}; use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; @@ -31,8 +34,9 @@ use mas_templates::{ PostAuthContext, PostAuthContextInner, TemplateContext, Templates, ToFormState, }; use opentelemetry::{Key, KeyValue, metrics::Counter}; -use rand::Rng; +use rand::RngCore; use serde::{Deserialize, Serialize}; +use ulid::Ulid; use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; @@ -40,6 +44,7 @@ use crate::{ BoundActivityTracker, Limiter, METER, PreferredLanguage, RequesterFingerprint, SiteConfig, passwords::{PasswordManager, PasswordVerificationResult}, session::{SessionOrFallback, load_session_or_fallback}, + webauthn::{Webauthn, WebauthnError}, }; static PASSWORD_LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { @@ -55,12 +60,20 @@ const RESULT: Key = Key::from_static_str("result"); pub(crate) struct LoginForm { username: String, password: String, + passkey_challenge_id: Option, + passkey_response: Option, } impl ToFormState for LoginForm { type Field = LoginFormField; } +#[derive(Debug)] +enum AuthenticatedWith { + Password(Password), + Passkey(UserPasskey), +} + #[tracing::instrument(name = "handlers.views.login.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, @@ -69,6 +82,7 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(site_config): State, + State(webauthn): State, State(homeserver): State>, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -119,12 +133,13 @@ pub(crate) async fn get( cookie_jar, FormState::default(), query, - &mut repo, + repo, &clock, &mut rng, &templates, &homeserver, &site_config, + webauthn, ) .await } @@ -134,7 +149,7 @@ pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, PreferredLanguage(locale): PreferredLanguage, - State(password_manager): State, + (State(password_manager), State(webauthn)): (State, State), State(site_config): State, State(templates): State, State(url_builder): State, @@ -159,11 +174,11 @@ pub(crate) async fn post( // Validate the form let mut form_state = form.to_form_state(); - if form.username.is_empty() { + if form.username.is_empty() && form.passkey_response.as_ref().is_none_or(String::is_empty) { form_state.add_error_on_field(LoginFormField::Username, FieldError::Required); } - if form.password.is_empty() { + if form.password.is_empty() && form.passkey_response.as_ref().is_none_or(String::is_empty) { form_state.add_error_on_field(LoginFormField::Password, FieldError::Required); } @@ -175,60 +190,208 @@ pub(crate) async fn post( cookie_jar, form_state, query, - &mut repo, + repo, &clock, &mut rng, &templates, &homeserver, &site_config, + webauthn, ) .await; } - // Extract the localpart of the MXID, fallback to the bare username - let username = homeserver - .localpart(&form.username) - .unwrap_or(&form.username); + let cookie_jar = if let Some(form_challenge_id) = &form.passkey_challenge_id { + // Validate passkey challenge cookie + let challenge_id = Ulid::from_string(form_challenge_id).unwrap_or_default(); + let challenges = UserPasskeyChallenges::load(&cookie_jar); + if !challenges.contains(&challenge_id) { + let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); + return render( + locale, + cookie_jar, + form_state, + query, + repo, + &clock, + &mut rng, + &templates, + &homeserver, + &site_config, + webauthn, + ) + .await; + } - // First, lookup the user - let Some(user) = get_user_by_email_or_by_username(&site_config, &mut repo, username).await? - else { - tracing::warn!(username, "User not found"); - let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); - PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); + // Consume the cookie already as we'll give them a new one anyway + challenges + .consume_challenge(&challenge_id) + .save(cookie_jar, &clock) + } else { + cookie_jar + }; + + let validation = match ( + form.password.is_empty(), + form.passkey_response.as_ref().is_none_or(String::is_empty), + ) { + // Password login + (false, true) => { + password_login( + &mut rng, + &clock, + password_manager, + &site_config, + limiter, + &homeserver, + &mut repo, + requester, + form, + form_state, + ) + .await? + } + // Passkey login. User's password manager may have prefilled the password despite using a + // passkey + (_, false) => { + if !site_config.passkeys_enabled { + let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); + return render( + locale, + cookie_jar, + form_state, + query, + repo, + &clock, + &mut rng, + &templates, + &homeserver, + &site_config, + webauthn, + ) + .await; + } + passkey_login( + &clock, &webauthn, limiter, &mut repo, requester, form, form_state, + ) + .await? + } + _ => Err(form_state.with_error_on_form(FormError::Internal)), + }; + + let Ok((user, auth_with)) = validation else { + let form_state = validation.unwrap_err(); return render( locale, cookie_jar, form_state, query, - &mut repo, + repo, &clock, &mut rng, &templates, &homeserver, &site_config, + webauthn, ) .await; }; + // Now that we have checked the user password, we now want to show an error if + // the user is locked or deactivated + if user.deactivated_at.is_some() { + tracing::warn!(user.username, "User is deactivated"); + PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = AccountInactiveContext::new(user) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + let content = templates.render_account_deactivated(&ctx)?; + return Ok((cookie_jar, Html(content)).into_response()); + } + + if user.locked_at.is_some() { + tracing::warn!(user.username, "User is locked"); + PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = AccountInactiveContext::new(user) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + let content = templates.render_account_locked(&ctx)?; + return Ok((cookie_jar, Html(content)).into_response()); + } + + // At this point, we should have a 'valid' user. In case we missed something, we + // want it to crash in tests/debug builds + debug_assert!(user.is_valid()); + + // Start a new session + let user_session = repo + .browser_session() + .add(&mut rng, &clock, &user, user_agent) + .await?; + + match auth_with { + AuthenticatedWith::Password(user_password) => { + // And mark it as authenticated by the password + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &user_session, &user_password) + .await?; + } + AuthenticatedWith::Passkey(passkey) => { + // And mark it as authenticated by the passkey + repo.browser_session() + .authenticate_with_passkey(&mut rng, &clock, &user_session, &passkey) + .await?; + } + } + + repo.save().await?; + + PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]); + + activity_tracker + .record_browser_session(&clock, &user_session) + .await; + + let cookie_jar = cookie_jar.set_session(&user_session); + let reply = query.go_next(&url_builder); + Ok((cookie_jar, reply).into_response()) +} + +async fn password_login( + mut rng: &mut BoxRng, + clock: &BoxClock, + password_manager: PasswordManager, + site_config: &SiteConfig, + limiter: Limiter, + homeserver: &dyn HomeserverConnection, + repo: &mut impl RepositoryAccess, + requester: RequesterFingerprint, + form: LoginForm, + form_state: FormState, +) -> Result>, InternalError> { + // Extract the localpart of the MXID, fallback to the bare username + let username = homeserver + .localpart(&form.username) + .unwrap_or(&form.username); + + // First, lookup the user + let Some(user) = get_user_by_email_or_by_username(site_config, repo, username).await? else { + tracing::warn!(username, "User not found"); + PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); + return Ok(Err( + form_state.with_error_on_form(FormError::InvalidCredentials) + )); + }; + // Check the rate limit if let Err(e) = limiter.check_password(requester, &user) { tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded"); - let form_state = form_state.with_error_on_form(FormError::RateLimitExceeded); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - return render( - locale, - cookie_jar, - form_state, - query, - &mut repo, - &clock, - &mut rng, - &templates, - &homeserver, - &site_config, - ) - .await; + return Ok(Err( + form_state.with_error_on_form(FormError::RateLimitExceeded) + )); } // And its password @@ -236,21 +399,10 @@ pub(crate) async fn post( // There is no password for this user, but we don't want to disclose that. Show // a generic 'invalid credentials' error instead tracing::warn!(username, "No password for user"); - let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - return render( - locale, - cookie_jar, - form_state, - query, - &mut repo, - &clock, - &mut rng, - &templates, - &homeserver, - &site_config, - ) - .await; + return Ok(Err( + form_state.with_error_on_form(FormError::InvalidCredentials) + )); }; let password = Zeroizing::new(form.password); @@ -270,7 +422,7 @@ pub(crate) async fn post( repo.user_password() .add( &mut rng, - &clock, + clock, &user, version, new_password_hash, @@ -281,75 +433,104 @@ pub(crate) async fn post( Ok(PasswordVerificationResult::Success(None)) => user_password, Ok(PasswordVerificationResult::Failure) => { tracing::warn!(username, "Failed to verify/upgrade password for user"); - let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "mismatch")]); - return render( - locale, - cookie_jar, - form_state, - query, - &mut repo, - &clock, - &mut rng, - &templates, - &homeserver, - &site_config, - ) - .await; + return Ok(Err( + form_state.with_error_on_form(FormError::InvalidCredentials) + )); } Err(err) => return Err(InternalError::from_anyhow(err)), }; - // Now that we have checked the user password, we now want to show an error if - // the user is locked or deactivated - if user.deactivated_at.is_some() { - tracing::warn!(username, "User is deactivated"); - PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let ctx = AccountInactiveContext::new(user) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - let content = templates.render_account_deactivated(&ctx)?; - return Ok((cookie_jar, Html(content)).into_response()); - } + Ok(Ok((user, AuthenticatedWith::Password(user_password)))) +} - if user.locked_at.is_some() { - tracing::warn!(username, "User is locked"); - PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let ctx = AccountInactiveContext::new(user) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - let content = templates.render_account_locked(&ctx)?; - return Ok((cookie_jar, Html(content)).into_response()); - } +async fn passkey_login( + clock: &BoxClock, + webauthn: &Webauthn, + limiter: Limiter, + repo: &mut impl RepositoryAccess, + requester: RequesterFingerprint, + form: LoginForm, + form_state: FormState, +) -> Result>, InternalError> { + let Some(passkey_challenge_id) = form.passkey_challenge_id else { + return Ok(Err( + form_state.with_error_on_form(FormError::InvalidCredentials) + )); + }; - // At this point, we should have a 'valid' user. In case we missed something, we - // want it to crash in tests/debug builds - debug_assert!(user.is_valid()); + let Some(passkey_response) = form.passkey_response else { + return Ok(Err( + form_state.with_error_on_form(FormError::InvalidCredentials) + )); + }; - // Start a new session - let user_session = repo - .browser_session() - .add(&mut rng, &clock, &user, user_agent) - .await?; + let challenge_id = Ulid::from_string(&passkey_challenge_id).unwrap_or_default(); + + // Find the challenge + let challenge = match webauthn + .lookup_challenge(repo, clock, challenge_id, None) + .await + .map_err(anyhow::Error::downcast::) + { + Ok(c) => c, + Err(err) => { + let form_state = form_state.with_error_on_form(match err { + Ok(_) => FormError::InvalidCredentials, + Err(_) => FormError::Internal, + }); + return Ok(Err(form_state)); + } + }; - // And mark it as authenticated by the password - repo.browser_session() - .authenticate_with_password(&mut rng, &clock, &user_session, &user_password) + // Mark challenge as completed + let challenge = repo + .user_passkey() + .complete_challenge(clock, challenge) .await?; - repo.save().await?; + // Get the user and passkey from the authenticator response + let (response, user, passkey) = match webauthn + .discover_credential(repo, passkey_response) + .await + .map_err(anyhow::Error::downcast::) + { + Ok(v) => v, + Err(err) => { + let form_state = form_state.with_error_on_form(match err { + Ok(_) => FormError::InvalidCredentials, + Err(_) => FormError::Internal, + }); + return Ok(Err(form_state)); + } + }; - PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]); + // XXX: Reusing the password rate limiter. Maybe it should be renamed to login + // ratelimiter or have a passkey specific one + if let Err(e) = limiter.check_password(requester, &user) { + tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded"); + return Ok(Err( + form_state.with_error_on_form(FormError::RateLimitExceeded) + )); + } - activity_tracker - .record_browser_session(&clock, &user_session) - .await; + // Validate the passkey + let passkey = match webauthn + .finish_passkey_authentication(repo, clock, challenge, response, passkey) + .await + .map_err(anyhow::Error::downcast::) + { + Ok(p) => p, + Err(err) => { + let form_state = form_state.with_error_on_form(match err { + Ok(_) => FormError::InvalidCredentials, + Err(_) => FormError::Internal, + }); + return Ok(Err(form_state)); + } + }; - let cookie_jar = cookie_jar.set_session(&user_session); - let reply = query.go_next(&url_builder); - Ok((cookie_jar, reply).into_response()) + Ok(Ok((user, AuthenticatedWith::Passkey(passkey)))) } async fn get_user_by_email_or_by_username( @@ -404,24 +585,47 @@ fn handle_login_hint( async fn render( locale: DataLocale, cookie_jar: CookieJar, - form_state: FormState, + mut form_state: FormState, action: OptionalPostAuthAction, - repo: &mut impl RepositoryAccess, + mut repo: BoxRepository, clock: &impl Clock, - rng: impl Rng, + rng: &mut (dyn RngCore + Send), templates: &Templates, homeserver: &dyn HomeserverConnection, site_config: &SiteConfig, + webauthn: Webauthn, ) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, &mut *rng); let providers = repo.upstream_oauth_provider().all_enabled().await?; - let ctx = LoginContext::default() + let ctx = LoginContext::default(); + + let (ctx, cookie_jar) = if site_config.passkeys_enabled { + let (options, challenge) = webauthn + .start_passkey_authentication(&mut repo, &mut *rng, clock) + .await + .map_err(InternalError::from_anyhow)?; + + form_state.set_value( + LoginFormField::PasskeyChallengeId, + Some(challenge.id.to_string()), + ); + + let cookie_jar = UserPasskeyChallenges::load(&cookie_jar) + .add(&challenge) + .save(cookie_jar, clock); + + (ctx.with_webauthn_options(options), cookie_jar) + } else { + (ctx, cookie_jar) + }; + + let ctx = ctx .with_form_state(form_state) .with_upstream_providers(providers); let next = action - .load_context(repo) + .load_context(&mut repo) .await .map_err(InternalError::from_anyhow)?; let ctx = if let Some(next) = next { @@ -432,6 +636,8 @@ async fn render( }; let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); + repo.save().await?; + let content = templates.render_login(&ctx)?; Ok((cookie_jar, Html(content)).into_response()) } diff --git a/crates/handlers/src/webauthn.rs b/crates/handlers/src/webauthn.rs index fb59d512f..9ba3c92c1 100644 --- a/crates/handlers/src/webauthn.rs +++ b/crates/handlers/src/webauthn.rs @@ -15,15 +15,21 @@ use rand::RngCore; use ulid::Ulid; use url::Url; use webauthn_rp::{ - PublicKeyCredentialCreationOptions, RegistrationServerState, + AuthenticatedCredential, DiscoverableAuthentication16, DiscoverableAuthenticationServerState, + DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions, + RegistrationServerState, bin::{Decode, Encode}, request::{ DomainOrigin, Port, PublicKeyCredentialDescriptor, RpId, Scheme, + auth::AuthenticationVerificationOptions, register::{PublicKeyCredentialUserEntity, RegistrationVerificationOptions, UserHandle}, }, response::{ CredentialId, - register::{error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed}, + auth::{error::AuthCeremonyErr, ser_relaxed::AuthenticationRelaxed}, + register::{ + DynamicState, StaticState, error::RegCeremonyErr, ser_relaxed::RegistrationRelaxed, + }, }, }; @@ -33,12 +39,24 @@ pub enum WebauthnError { #[error(transparent)] RegistrationCeremonyError(#[from] RegCeremonyErr), + #[error(transparent)] + AuthenticationCeremonyError(#[from] AuthCeremonyErr), + #[error("The challenge doesn't exist, expired or doesn't belong for this session")] InvalidChallenge, #[error("Credential already exists")] Exists, + #[error("Authenticator did not include the userHandle in the response")] + UserHandleMissing, + + #[error("Failed to find a user based on the userHandle")] + UserNotFound, + + #[error("Failed to find a passkey based on the credential_id")] + PasskeyNotFound, + #[error("The passkey belongs to a different user")] UserMismatch, } @@ -265,4 +283,148 @@ impl Webauthn { Ok(user_passkey) } + + /// Creates a passkey authentication challenge + /// + /// # Returns + /// 1. The JSON options to `navigator.credentials.get()` on the frontend + /// 2. The created [`UserPasskeyChallenge`] + /// + /// # Errors + /// Various anyhow errors that should be treated as internal errors + pub async fn start_passkey_authentication( + &self, + repo: &mut impl RepositoryAccess, + rng: &mut (dyn RngCore + Send), + clock: &impl Clock, + ) -> Result<(String, UserPasskeyChallenge)> { + let options = DiscoverableCredentialRequestOptions::passkey(&self.rpid); + + let (server_state, client_state) = options.start_ceremony()?; + + let user_passkey_challenge = repo + .user_passkey() + .add_challenge(rng, clock, server_state.encode()?) + .await?; + + Ok(( + serde_json::to_string(&client_state)?, + user_passkey_challenge, + )) + } + + /// Finds the passkey and user based on the challenge response and validates + /// that the passkey belongs to the user + /// + /// # Returns + /// 1. The parsed response for use later + /// 2. The [`User`] trying to authenticate + /// 3. The [`UserPasskey`] used + /// + /// # Errors + /// [`WebauthnError::UserHandleMissing`] if the reponse doesn't contain the + /// user handle. + /// + /// [`WebauthnError::UserNotFound`] if the user wasn't found. + /// + /// [`WebauthnError::PasskeyNotFound`] if the passkey wasn't found. + /// + /// [`WebauthnError::UserMismatch`] if the passkey is tied to a different + /// user. + /// + /// The rest of the anyhow errors should be treated as internal errors + pub async fn discover_credential( + &self, + repo: &mut impl RepositoryAccess, + response: String, + ) -> Result<(DiscoverableAuthentication16, User, UserPasskey)> { + let AuthenticationRelaxed::<16, true>(response) = serde_json::from_str(&response)?; + + let credential_id: CredentialId> = response.raw_id().into(); + + let id_bytes = response.response().user_handle().encode()?; + let user_id = Ulid::from_bytes(id_bytes); + + let user = repo + .user() + .lookup(user_id) + .await? + .ok_or(WebauthnError::UserNotFound)?; + + let user_passkey = repo + .user_passkey() + .find(&credential_id) + .await? + .ok_or(WebauthnError::PasskeyNotFound)?; + + if user_passkey.user_id != user.id { + return Err(WebauthnError::UserMismatch.into()); + } + + Ok((response, user, user_passkey)) + } + + /// Validates the authentication challenge response + /// + /// # Errors + /// [`WebauthnError::AuthenticationCeremonyError`] if the response from the + /// user was invalid. + /// + /// The rest of the anyhow errors should be treated as internal errors + pub async fn finish_passkey_authentication( + &self, + repo: &mut impl RepositoryAccess, + clock: &impl Clock, + user_passkey_challenge: UserPasskeyChallenge, + response: DiscoverableAuthentication16, + user_passkey: UserPasskey, + ) -> Result { + let server_state = + DiscoverableAuthenticationServerState::decode(&user_passkey_challenge.state)?; + + let options = AuthenticationVerificationOptions:: { + allowed_origins: &self.get_allowed_origins(), + client_data_json_relaxed: true, + ..Default::default() + }; + + let user_handle = UserHandle::decode(user_passkey.user_id.to_bytes())?; + + // Construct the correct type of credential ID... + let credential_id = CredentialId::<&[u8]>::from(&user_passkey.credential_id); + + // Convert stored passkey to a usable credential + let mut cred = AuthenticatedCredential::new( + credential_id, + &user_handle, + StaticState::decode(&user_passkey.static_state)?, + DynamicState::decode( + user_passkey + .dynamic_state + .clone() + .try_into() + .map_err(|_| anyhow::Error::msg("Failed to parse dynamic state"))?, + )?, + )?; + + server_state + .verify(&self.rpid, &response, &mut cred, &options) + .map_err(WebauthnError::from)?; + + // Update last used date and dynamic state + let dynamic_state = cred.dynamic_state().encode()?.to_vec(); + let user_passkey = repo + .user_passkey() + .update(clock, user_passkey, dynamic_state) + .await?; + + // Ensure that the challenge gets marked as completed if it wasn't already + if user_passkey_challenge.completed_at.is_none() { + repo.user_passkey() + .complete_challenge(clock, user_passkey_challenge) + .await?; + } + + Ok(user_passkey) + } } From 5421274019d74d37b1688e069f037ad49b4c428f Mon Sep 17 00:00:00 2001 From: Tonkku Date: Fri, 14 Mar 2025 14:53:30 +0000 Subject: [PATCH 7/7] Job to clean up old challenges --- ...b3f660e90d610aa74813ac0fdd2a3439a2e99.json | 14 ++++++++++ crates/storage-pg/src/user/passkey.rs | 27 +++++++++++++++++- crates/storage/src/queue/tasks.rs | 8 ++++++ crates/storage/src/user/passkey.rs | 15 ++++++++++ crates/tasks/src/database.rs | 28 ++++++++++++++++++- crates/tasks/src/lib.rs | 6 ++++ 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json diff --git a/crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json b/crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json new file mode 100644 index 000000000..1aa61638a --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_passkey_challenges\n WHERE created_at < $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8977b2a7dfb1de2cdb917be1a07b3f660e90d610aa74813ac0fdd2a3439a2e99" +} diff --git a/crates/storage-pg/src/user/passkey.rs b/crates/storage-pg/src/user/passkey.rs index e4def890c..827ffe641 100644 --- a/crates/storage-pg/src/user/passkey.rs +++ b/crates/storage-pg/src/user/passkey.rs @@ -4,7 +4,7 @@ // Please see LICENSE in the repository root for full details. use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use mas_data_model::{BrowserSession, Clock, User, UserPasskey, UserPasskeyChallenge}; use mas_storage::{ Page, Pagination, @@ -654,4 +654,29 @@ impl UserPasskeyRepository for PgUserPasskeyRepository<'_> { user_passkey_challenge.completed_at = Some(completed_at); Ok(user_passkey_challenge) } + + #[tracing::instrument( + name = "db.user_passkey.cleanup_challenges", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn cleanup_challenges(&mut self, clock: &dyn Clock) -> Result { + // Cleanup challenges that were created more than an hour ago + let threshold = clock.now() - Duration::microseconds(60 * 60 * 1000 * 1000); + let res = sqlx::query!( + r#" + DELETE FROM user_passkey_challenges + WHERE created_at < $1 + "#, + threshold, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(res.rows_affected().try_into().unwrap_or(usize::MAX)) + } } diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index eb16f6e29..2fd8cfb4b 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -325,6 +325,14 @@ impl InsertableJob for CleanupExpiredTokensJob { const QUEUE_NAME: &'static str = "cleanup-expired-tokens"; } +/// Cleanup old passkey challenges +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct CleanupOldPasskeyChallenges; + +impl InsertableJob for CleanupOldPasskeyChallenges { + const QUEUE_NAME: &'static str = "cleanup-old-passkey-challenges"; +} + /// Scheduled job to expire inactive sessions /// /// This job will trigger jobs to expire inactive compat, oauth and user diff --git a/crates/storage/src/user/passkey.rs b/crates/storage/src/user/passkey.rs index 40bef966d..2eab63ba6 100644 --- a/crates/storage/src/user/passkey.rs +++ b/crates/storage/src/user/passkey.rs @@ -262,6 +262,19 @@ pub trait UserPasskeyRepository: Send + Sync { clock: &dyn Clock, user_passkey_challenge: UserPasskeyChallenge, ) -> Result; + + /// Cleanup old challenges + /// + /// Returns the number of challenges that were cleaned up + /// + /// # Parameters + /// + /// * `clock`: The clock used to get the current time + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn cleanup_challenges(&mut self, clock: &dyn Clock) -> Result; } repository_impl!(UserPasskeyRepository: @@ -325,4 +338,6 @@ repository_impl!(UserPasskeyRepository: clock: &dyn Clock, user_passkey_challenge: UserPasskeyChallenge, ) -> Result; + + async fn cleanup_challenges(&mut self, clock: &dyn Clock) -> Result; ); diff --git a/crates/tasks/src/database.rs b/crates/tasks/src/database.rs index bc14215f8..67e1892ba 100644 --- a/crates/tasks/src/database.rs +++ b/crates/tasks/src/database.rs @@ -7,7 +7,9 @@ //! Database-related tasks use async_trait::async_trait; -use mas_storage::queue::{CleanupExpiredTokensJob, PruneStalePolicyDataJob}; +use mas_storage::queue::{ + CleanupExpiredTokensJob, CleanupOldPasskeyChallenges, PruneStalePolicyDataJob, +}; use tracing::{debug, info}; use crate::{ @@ -63,3 +65,27 @@ impl RunnableJob for PruneStalePolicyDataJob { Ok(()) } } + +#[async_trait] +impl RunnableJob for CleanupOldPasskeyChallenges { + #[tracing::instrument(name = "job.cleanup_old_passkey_challenges", skip_all, err)] + async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { + let clock = state.clock(); + let mut repo = state.repository().await.map_err(JobError::retry)?; + + let count = repo + .user_passkey() + .cleanup_challenges(clock) + .await + .map_err(JobError::retry)?; + repo.save().await.map_err(JobError::retry)?; + + if count == 0 { + debug!("no challenges to clean up"); + } else { + info!(count, "cleaned up old challenges"); + } + + Ok(()) + } +} diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index a480a8d50..4ff394443 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -143,6 +143,7 @@ pub async fn init( .register_handler::() .register_handler::() .register_handler::() + .register_handler::() .add_schedule( "cleanup-expired-tokens", "0 0 * * * *".parse()?, @@ -159,6 +160,11 @@ pub async fn init( // Run once a day "0 0 2 * * *".parse()?, mas_storage::queue::PruneStalePolicyDataJob, + ) + .add_schedule( + "cleanup-old-passkey-challenges", + "0 0 * * * *".parse()?, + mas_storage::queue::CleanupOldPasskeyChallenges, ); Ok(worker)