Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(oauth): discoverable passkey authentication

Lewis: May this revision serve well! <lu5a@proton.me>

+751 -269
+14
.sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd" 14 + }
+22
.sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state_json", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60" 22 + }
+2 -2
.sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json .sqlx/query-060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", 3 + "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR did = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 118 118 false 119 119 ] 120 120 }, 121 - "hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa" 121 + "hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d" 122 122 }
+2 -2
.sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json .sqlx/query-aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 3 + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 132 132 null 133 133 ] 134 134 }, 135 - "hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249" 135 + "hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914" 136 136 }
+2 -2
.sqlx/query-c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038.json .sqlx/query-053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", 3 + "query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 24 24 true 25 25 ] 26 26 }, 27 - "hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038" 27 + "hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c" 28 28 }
+18
.sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Bytea", 11 + "Text", 12 + "Timestamptz" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a" 18 + }
+23 -22
Cargo.lock
··· 7405 7405 7406 7406 [[package]] 7407 7407 name = "tranquil-api" 7408 - version = "0.5.0" 7408 + version = "0.5.1" 7409 7409 dependencies = [ 7410 7410 "anyhow", 7411 7411 "axum", ··· 7456 7456 7457 7457 [[package]] 7458 7458 name = "tranquil-auth" 7459 - version = "0.5.0" 7459 + version = "0.5.1" 7460 7460 dependencies = [ 7461 7461 "anyhow", 7462 7462 "base32", ··· 7479 7479 7480 7480 [[package]] 7481 7481 name = "tranquil-cache" 7482 - version = "0.5.0" 7482 + version = "0.5.1" 7483 7483 dependencies = [ 7484 7484 "async-trait", 7485 7485 "base64 0.22.1", ··· 7493 7493 7494 7494 [[package]] 7495 7495 name = "tranquil-comms" 7496 - version = "0.5.0" 7496 + version = "0.5.1" 7497 7497 dependencies = [ 7498 7498 "async-trait", 7499 7499 "base64 0.22.1", ··· 7511 7511 7512 7512 [[package]] 7513 7513 name = "tranquil-config" 7514 - version = "0.5.0" 7514 + version = "0.5.1" 7515 7515 dependencies = [ 7516 7516 "confique", 7517 7517 "serde", ··· 7519 7519 7520 7520 [[package]] 7521 7521 name = "tranquil-crypto" 7522 - version = "0.5.0" 7522 + version = "0.5.1" 7523 7523 dependencies = [ 7524 7524 "aes-gcm", 7525 7525 "base64 0.22.1", ··· 7535 7535 7536 7536 [[package]] 7537 7537 name = "tranquil-db" 7538 - version = "0.5.0" 7538 + version = "0.5.1" 7539 7539 dependencies = [ 7540 7540 "async-trait", 7541 7541 "chrono", ··· 7552 7552 7553 7553 [[package]] 7554 7554 name = "tranquil-db-traits" 7555 - version = "0.5.0" 7555 + version = "0.5.1" 7556 7556 dependencies = [ 7557 7557 "async-trait", 7558 7558 "base64 0.22.1", ··· 7568 7568 7569 7569 [[package]] 7570 7570 name = "tranquil-infra" 7571 - version = "0.5.0" 7571 + version = "0.5.1" 7572 7572 dependencies = [ 7573 7573 "async-trait", 7574 7574 "bytes", ··· 7579 7579 7580 7580 [[package]] 7581 7581 name = "tranquil-lexicon" 7582 - version = "0.5.0" 7582 + version = "0.5.1" 7583 7583 dependencies = [ 7584 7584 "chrono", 7585 7585 "hickory-resolver", ··· 7597 7597 7598 7598 [[package]] 7599 7599 name = "tranquil-oauth" 7600 - version = "0.5.0" 7600 + version = "0.5.1" 7601 7601 dependencies = [ 7602 7602 "anyhow", 7603 7603 "axum", ··· 7620 7620 7621 7621 [[package]] 7622 7622 name = "tranquil-oauth-server" 7623 - version = "0.5.0" 7623 + version = "0.5.1" 7624 7624 dependencies = [ 7625 7625 "axum", 7626 7626 "base64 0.22.1", ··· 7653 7653 7654 7654 [[package]] 7655 7655 name = "tranquil-pds" 7656 - version = "0.5.0" 7656 + version = "0.5.1" 7657 7657 dependencies = [ 7658 7658 "aes-gcm", 7659 7659 "anyhow", ··· 7738 7738 "urlencoding", 7739 7739 "uuid", 7740 7740 "webauthn-rs", 7741 + "webauthn-rs-proto", 7741 7742 "wiremock", 7742 7743 "zip", 7743 7744 ] 7744 7745 7745 7746 [[package]] 7746 7747 name = "tranquil-repo" 7747 - version = "0.5.0" 7748 + version = "0.5.1" 7748 7749 dependencies = [ 7749 7750 "bytes", 7750 7751 "cid", ··· 7756 7757 7757 7758 [[package]] 7758 7759 name = "tranquil-ripple" 7759 - version = "0.5.0" 7760 + version = "0.5.1" 7760 7761 dependencies = [ 7761 7762 "async-trait", 7762 7763 "backon", ··· 7781 7782 7782 7783 [[package]] 7783 7784 name = "tranquil-scopes" 7784 - version = "0.5.0" 7785 + version = "0.5.1" 7785 7786 dependencies = [ 7786 7787 "axum", 7787 7788 "futures", ··· 7797 7798 7798 7799 [[package]] 7799 7800 name = "tranquil-server" 7800 - version = "0.5.0" 7801 + version = "0.5.1" 7801 7802 dependencies = [ 7802 7803 "axum", 7803 7804 "clap", ··· 7818 7819 7819 7820 [[package]] 7820 7821 name = "tranquil-signal" 7821 - version = "0.5.0" 7822 + version = "0.5.1" 7822 7823 dependencies = [ 7823 7824 "async-trait", 7824 7825 "chrono", ··· 7841 7842 7842 7843 [[package]] 7843 7844 name = "tranquil-storage" 7844 - version = "0.5.0" 7845 + version = "0.5.1" 7845 7846 dependencies = [ 7846 7847 "async-trait", 7847 7848 "aws-config", ··· 7858 7859 7859 7860 [[package]] 7860 7861 name = "tranquil-store" 7861 - version = "0.5.0" 7862 + version = "0.5.1" 7862 7863 dependencies = [ 7863 7864 "async-trait", 7864 7865 "bytes", ··· 7904 7905 7905 7906 [[package]] 7906 7907 name = "tranquil-sync" 7907 - version = "0.5.0" 7908 + version = "0.5.1" 7908 7909 dependencies = [ 7909 7910 "anyhow", 7910 7911 "axum", ··· 7926 7927 7927 7928 [[package]] 7928 7929 name = "tranquil-types" 7929 - version = "0.5.0" 7930 + version = "0.5.1" 7930 7931 dependencies = [ 7931 7932 "chrono", 7932 7933 "cid",
+2 -2
Cargo.toml
··· 26 26 ] 27 27 28 28 [workspace.package] 29 - version = "0.5.0" 29 + version = "0.5.1" 30 30 edition = "2024" 31 31 license = "AGPL-3.0-or-later" 32 32 ··· 126 126 tracing-subscriber = "0.3" 127 127 urlencoding = "2.1" 128 128 uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] } 129 - webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 129 + webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] } 130 130 webauthn-rs-proto = "0.5" 131 131 zip = { version = "7.0", default-features = false, features = ["deflate"] } 132 132
+15 -2
crates/tranquil-db-traits/src/user.rs
··· 144 144 145 145 async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError>; 146 146 147 - async fn get_login_check_by_handle_or_email( 147 + async fn get_login_check_by_identifier( 148 148 &self, 149 149 identifier: &str, 150 150 ) -> Result<Option<UserLoginCheck>, DbError>; 151 151 152 - async fn get_login_info_by_handle_or_email( 152 + async fn get_login_info_by_identifier( 153 153 &self, 154 154 identifier: &str, 155 155 ) -> Result<Option<UserLoginInfo>, DbError>; ··· 357 357 did: &Did, 358 358 challenge_type: WebauthnChallengeType, 359 359 ) -> Result<(), DbError>; 360 + 361 + async fn save_discoverable_challenge( 362 + &self, 363 + request_key: &str, 364 + state_json: &str, 365 + ) -> Result<Uuid, DbError>; 366 + 367 + async fn load_discoverable_challenge( 368 + &self, 369 + request_key: &str, 370 + ) -> Result<Option<String>, DbError>; 371 + 372 + async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError>; 360 373 361 374 async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>; 362 375
+58 -5
crates/tranquil-db/src/postgres/user.rs
··· 1102 1102 Ok(()) 1103 1103 } 1104 1104 1105 + async fn save_discoverable_challenge( 1106 + &self, 1107 + request_key: &str, 1108 + state_json: &str, 1109 + ) -> Result<Uuid, DbError> { 1110 + let id = Uuid::new_v4(); 1111 + let challenge = id.as_bytes().to_vec(); 1112 + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); 1113 + sqlx::query!( 1114 + r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) 1115 + VALUES ($1, $2, $3, 'discoverable', $4, $5)"#, 1116 + id, 1117 + request_key, 1118 + challenge, 1119 + state_json, 1120 + expires_at, 1121 + ) 1122 + .execute(&self.pool) 1123 + .await 1124 + .map_err(map_sqlx_error)?; 1125 + 1126 + Ok(id) 1127 + } 1128 + 1129 + async fn load_discoverable_challenge( 1130 + &self, 1131 + request_key: &str, 1132 + ) -> Result<Option<String>, DbError> { 1133 + let row = sqlx::query_scalar!( 1134 + r#"SELECT state_json FROM webauthn_challenges 1135 + WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW() 1136 + ORDER BY created_at DESC LIMIT 1"#, 1137 + request_key, 1138 + ) 1139 + .fetch_optional(&self.pool) 1140 + .await 1141 + .map_err(map_sqlx_error)?; 1142 + 1143 + Ok(row) 1144 + } 1145 + 1146 + async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> { 1147 + sqlx::query!( 1148 + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'", 1149 + request_key, 1150 + ) 1151 + .execute(&self.pool) 1152 + .await 1153 + .map_err(map_sqlx_error)?; 1154 + 1155 + Ok(()) 1156 + } 1157 + 1105 1158 async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> { 1106 1159 let row = sqlx::query!( 1107 1160 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", ··· 1330 1383 Ok(()) 1331 1384 } 1332 1385 1333 - async fn get_login_check_by_handle_or_email( 1386 + async fn get_login_check_by_identifier( 1334 1387 &self, 1335 1388 identifier: &str, 1336 1389 ) -> Result<Option<UserLoginCheck>, DbError> { 1337 1390 sqlx::query!( 1338 - "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", 1391 + "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1", 1339 1392 identifier 1340 1393 ) 1341 1394 .fetch_optional(&self.pool) ··· 1349 1402 }) 1350 1403 } 1351 1404 1352 - async fn get_login_info_by_handle_or_email( 1405 + async fn get_login_info_by_identifier( 1353 1406 &self, 1354 1407 identifier: &str, 1355 1408 ) -> Result<Option<UserLoginInfo>, DbError> { ··· 1361 1414 email_verified, discord_verified, telegram_verified, signal_verified, 1362 1415 account_type as "account_type!: AccountType" 1363 1416 FROM users 1364 - WHERE handle = $1 OR email = $1 1417 + WHERE handle = $1 OR did = $1 1365 1418 "#, 1366 1419 identifier 1367 1420 ) ··· 1524 1577 COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!" 1525 1578 FROM users u 1526 1579 JOIN user_keys k ON u.id = k.user_id 1527 - WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, 1580 + WHERE u.handle = $1 OR u.did = $1"#, 1528 1581 identifier 1529 1582 ) 1530 1583 .fetch_optional(&self.pool)
+3 -1
crates/tranquil-lexicon/src/validate.rs
··· 322 322 323 323 if let Some(ref accept) = lex_blob.accept { 324 324 let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or(""); 325 - let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern)); 325 + let matched = accept 326 + .iter() 327 + .any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern)); 326 328 if !mime_type.is_empty() && !matched { 327 329 return Err(LexValidationError::field( 328 330 path,
+4 -4
crates/tranquil-oauth-server/src/endpoints/authorize/login.rs
··· 108 108 match state 109 109 .repos 110 110 .user 111 - .get_login_check_by_handle_or_email(normalized.as_str()) 111 + .get_login_check_by_identifier(normalized.as_str()) 112 112 .await 113 113 { 114 114 Ok(Some(user)) => { ··· 401 401 let user = match state 402 402 .repos 403 403 .user 404 - .get_login_info_by_handle_or_email(normalized_username.as_str()) 404 + .get_login_info_by_identifier(normalized_username.as_str()) 405 405 .await 406 406 { 407 407 Ok(Some(u)) => u, ··· 410 410 &form.password, 411 411 "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK", 412 412 ); 413 - return show_login_error("Invalid handle/email or password.", json_response); 413 + return show_login_error("Invalid identifier or password.", json_response); 414 414 } 415 415 Err(_) => return show_login_error("An error occurred. Please try again.", json_response), 416 416 }; ··· 486 486 None => false, 487 487 }; 488 488 if !password_valid { 489 - return show_login_error("Invalid handle/email or password.", json_response); 489 + return show_login_error("Invalid identifier or password.", json_response); 490 490 } 491 491 let is_verified = user.channel_verification.has_any_verified(); 492 492 if !is_verified {
+289 -108
crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs
··· 22 22 let user = state 23 23 .repos 24 24 .user 25 - .get_login_check_by_handle_or_email(bare_identifier.as_str()) 25 + .get_login_check_by_identifier(bare_identifier.as_str()) 26 26 .await; 27 27 28 28 let has_passkeys = match user { ··· 55 55 let user = state 56 56 .repos 57 57 .user 58 - .get_login_check_by_handle_or_email(normalized_identifier.as_str()) 58 + .get_login_check_by_identifier(normalized_identifier.as_str()) 59 59 .await; 60 60 61 61 let (has_passkeys, has_totp, has_password, is_delegated, did): ( ··· 99 99 #[derive(Debug, Deserialize)] 100 100 pub struct PasskeyStartInput { 101 101 pub request_uri: String, 102 - pub identifier: String, 102 + pub identifier: Option<String>, 103 103 pub delegated_did: Option<String>, 104 104 } 105 105 ··· 160 160 .into_response(); 161 161 } 162 162 163 + match form.identifier.filter(|s| !s.trim().is_empty()) { 164 + Some(identifier) => { 165 + passkey_start_named( 166 + state, 167 + identifier, 168 + form.delegated_did, 169 + request_data, 170 + passkey_start_request_id, 171 + ) 172 + .await 173 + } 174 + None => passkey_start_discoverable(state, passkey_start_request_id).await, 175 + } 176 + } 177 + 178 + async fn passkey_start_discoverable( 179 + state: AppState, 180 + request_id: RequestId, 181 + ) -> Response { 182 + let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() { 183 + Ok(result) => result, 184 + Err(e) => { 185 + tracing::error!(error = %e, "Failed to start discoverable passkey authentication"); 186 + return ( 187 + StatusCode::INTERNAL_SERVER_ERROR, 188 + Json(serde_json::json!({ 189 + "error": "server_error", 190 + "error_description": "Failed to start authentication." 191 + })), 192 + ) 193 + .into_response(); 194 + } 195 + }; 196 + 197 + let state_json = match serde_json::to_string(&auth_state) { 198 + Ok(j) => j, 199 + Err(e) => { 200 + tracing::error!(error = %e, "Failed to serialize authentication state"); 201 + return ( 202 + StatusCode::INTERNAL_SERVER_ERROR, 203 + Json(serde_json::json!({ 204 + "error": "server_error", 205 + "error_description": "An error occurred." 206 + })), 207 + ) 208 + .into_response(); 209 + } 210 + }; 211 + 212 + if let Err(e) = state 213 + .repos 214 + .user 215 + .save_discoverable_challenge(request_id.as_str(), &state_json) 216 + .await 217 + { 218 + tracing::error!(error = %e, "Failed to save discoverable authentication state"); 219 + return ( 220 + StatusCode::INTERNAL_SERVER_ERROR, 221 + Json(serde_json::json!({ 222 + "error": "server_error", 223 + "error_description": "An error occurred." 224 + })), 225 + ) 226 + .into_response(); 227 + } 228 + 229 + let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); 230 + Json(PasskeyStartResponse { options }).into_response() 231 + } 232 + 233 + async fn passkey_start_named( 234 + state: AppState, 235 + identifier: String, 236 + delegated_did: Option<String>, 237 + request_data: tranquil_pds::oauth::RequestData, 238 + passkey_start_request_id: RequestId, 239 + ) -> Response { 163 240 let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 164 241 let normalized_username = 165 - NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); 242 + NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles); 166 243 167 244 let user = match state 168 245 .repos 169 246 .user 170 - .get_login_info_by_handle_or_email(normalized_username.as_str()) 247 + .get_login_info_by_identifier(normalized_username.as_str()) 171 248 .await 172 249 { 173 250 Ok(Some(u)) => u, ··· 325 402 .into_response(); 326 403 } 327 404 328 - let delegation_from_param = match &form.delegated_did { 405 + let delegation_from_param = match &delegated_did { 329 406 Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() { 330 407 Ok(delegated_did) if delegated_did != user.did => { 331 408 match state ··· 471 548 .into_response(); 472 549 } 473 550 474 - let did_str = match request_data.did { 475 - Some(d) => d, 476 - None => { 477 - return ( 478 - StatusCode::BAD_REQUEST, 479 - Json(serde_json::json!({ 480 - "error": "invalid_request", 481 - "error_description": "No passkey authentication in progress." 482 - })), 483 - ) 484 - .into_response(); 485 - } 486 - }; 487 - let did: tranquil_types::Did = match did_str.parse() { 488 - Ok(d) => d, 489 - Err(_) => { 490 - return ( 491 - StatusCode::BAD_REQUEST, 492 - Json(serde_json::json!({ 493 - "error": "invalid_request", 494 - "error_description": "Invalid DID format." 495 - })), 496 - ) 497 - .into_response(); 498 - } 499 - }; 500 - 501 - let controller_did: Option<tranquil_types::Did> = request_data 502 - .controller_did 503 - .as_ref() 504 - .and_then(|s| s.parse().ok()); 505 - let passkey_owner_did = controller_did.as_ref().unwrap_or(&did); 506 - 507 - let auth_state_json = match state 508 - .repos 509 - .user 510 - .load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) 511 - .await 512 - { 513 - Ok(Some(s)) => s, 514 - Ok(None) => { 515 - return ( 516 - StatusCode::BAD_REQUEST, 517 - Json(serde_json::json!({ 518 - "error": "invalid_request", 519 - "error_description": "No passkey authentication in progress or challenge expired." 520 - })), 521 - ) 522 - .into_response(); 523 - } 524 - Err(e) => { 525 - tracing::error!(error = %e, "Failed to load authentication state"); 526 - return ( 527 - StatusCode::INTERNAL_SERVER_ERROR, 528 - Json(serde_json::json!({ 529 - "error": "server_error", 530 - "error_description": "An error occurred." 531 - })), 532 - ) 533 - .into_response(); 534 - } 535 - }; 536 - 537 - let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication = 538 - match serde_json::from_str(&auth_state_json) { 539 - Ok(s) => s, 540 - Err(e) => { 541 - tracing::error!(error = %e, "Failed to deserialize authentication state"); 542 - return ( 543 - StatusCode::INTERNAL_SERVER_ERROR, 544 - Json(serde_json::json!({ 545 - "error": "server_error", 546 - "error_description": "An error occurred." 547 - })), 548 - ) 549 - .into_response(); 550 - } 551 - }; 552 - 553 551 let credential: webauthn_rs::prelude::PublicKeyCredential = 554 552 match serde_json::from_value(form.credential) { 555 553 Ok(c) => c, ··· 566 564 } 567 565 }; 568 566 569 - let auth_result = match state 570 - .webauthn_config 571 - .finish_authentication(&credential, &auth_state) 572 - { 573 - Ok(r) => r, 574 - Err(e) => { 575 - tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); 576 - return ( 577 - StatusCode::FORBIDDEN, 578 - Json(serde_json::json!({ 579 - "error": "access_denied", 580 - "error_description": "Passkey verification failed." 581 - })), 567 + let (did, auth_result) = match request_data.did.clone() { 568 + Some(did) => match passkey_finish_named(&state, did, &request_data, &credential).await { 569 + Ok(result) => result, 570 + Err(response) => return response, 571 + }, 572 + None => { 573 + let result = match passkey_finish_discoverable( 574 + &state, 575 + &credential, 576 + &passkey_finish_request_id, 582 577 ) 583 - .into_response(); 578 + .await 579 + { 580 + Ok(result) => result, 581 + Err(response) => return response, 582 + }; 583 + if state 584 + .repos 585 + .oauth 586 + .set_authorization_did(&passkey_finish_request_id, &result.0, None) 587 + .await 588 + .is_err() 589 + { 590 + return OAuthError::ServerError("An error occurred.".into()).into_response(); 591 + } 592 + result 584 593 } 585 594 }; 586 - 587 - if let Err(e) = state 588 - .repos 589 - .user 590 - .delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) 591 - .await 592 - { 593 - tracing::warn!(error = %e, "Failed to delete authentication state"); 594 - } 595 595 596 596 if auth_result.needs_update() { 597 597 let cred_id_bytes = auth_result.cred_id().as_slice(); ··· 689 689 "redirect_uri": redirect_url 690 690 })) 691 691 .into_response() 692 + } 693 + 694 + async fn passkey_finish_named( 695 + state: &AppState, 696 + did: tranquil_types::Did, 697 + request_data: &tranquil_pds::oauth::RequestData, 698 + credential: &webauthn_rs::prelude::PublicKeyCredential, 699 + ) -> Result< 700 + ( 701 + tranquil_types::Did, 702 + webauthn_rs::prelude::AuthenticationResult, 703 + ), 704 + Response, 705 + > { 706 + let passkey_owner_did = request_data.controller_did.as_ref().unwrap_or(&did); 707 + 708 + let auth_state_json = state 709 + .repos 710 + .user 711 + .load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) 712 + .await 713 + .map_err(|e| { 714 + tracing::error!(error = %e, "Failed to load authentication state"); 715 + ( 716 + StatusCode::INTERNAL_SERVER_ERROR, 717 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 718 + ).into_response() 719 + })? 720 + .ok_or_else(|| { 721 + ( 722 + StatusCode::BAD_REQUEST, 723 + Json(serde_json::json!({ 724 + "error": "invalid_request", 725 + "error_description": "No passkey authentication in progress or challenge expired." 726 + })), 727 + ).into_response() 728 + })?; 729 + 730 + let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication = 731 + serde_json::from_str(&auth_state_json).map_err(|e| { 732 + tracing::error!(error = %e, "Failed to deserialize authentication state"); 733 + ( 734 + StatusCode::INTERNAL_SERVER_ERROR, 735 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 736 + ).into_response() 737 + })?; 738 + 739 + let auth_result = state 740 + .webauthn_config 741 + .finish_authentication(credential, &auth_state) 742 + .map_err(|e| { 743 + tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); 744 + ( 745 + StatusCode::FORBIDDEN, 746 + Json(serde_json::json!({ 747 + "error": "access_denied", 748 + "error_description": "Passkey verification failed." 749 + })), 750 + ) 751 + .into_response() 752 + })?; 753 + 754 + let _ = state 755 + .repos 756 + .user 757 + .delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) 758 + .await; 759 + 760 + Ok((did, auth_result)) 761 + } 762 + 763 + async fn passkey_finish_discoverable( 764 + state: &AppState, 765 + credential: &webauthn_rs::prelude::PublicKeyCredential, 766 + request_id: &RequestId, 767 + ) -> Result< 768 + ( 769 + tranquil_types::Did, 770 + webauthn_rs::prelude::AuthenticationResult, 771 + ), 772 + Response, 773 + > { 774 + let auth_state_json = state 775 + .repos 776 + .user 777 + .load_discoverable_challenge(request_id.as_str()) 778 + .await 779 + .map_err(|e| { 780 + tracing::error!(error = %e, "Failed to load discoverable authentication state"); 781 + ( 782 + StatusCode::INTERNAL_SERVER_ERROR, 783 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 784 + ).into_response() 785 + })? 786 + .ok_or_else(|| { 787 + ( 788 + StatusCode::BAD_REQUEST, 789 + Json(serde_json::json!({ 790 + "error": "invalid_request", 791 + "error_description": "No passkey authentication in progress or challenge expired." 792 + })), 793 + ).into_response() 794 + })?; 795 + 796 + let auth_state: webauthn_rs::prelude::DiscoverableAuthentication = 797 + serde_json::from_str(&auth_state_json).map_err(|e| { 798 + tracing::error!(error = %e, "Failed to deserialize discoverable authentication state"); 799 + ( 800 + StatusCode::INTERNAL_SERVER_ERROR, 801 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 802 + ).into_response() 803 + })?; 804 + 805 + let (_user_uuid, cred_id) = state 806 + .webauthn_config 807 + .identify_discoverable_authentication(credential) 808 + .map_err(|e| { 809 + tracing::warn!(error = %e, "Failed to identify discoverable credential"); 810 + ( 811 + StatusCode::FORBIDDEN, 812 + Json(serde_json::json!({ 813 + "error": "access_denied", 814 + "error_description": "Passkey verification failed." 815 + })), 816 + ) 817 + .into_response() 818 + })?; 819 + 820 + let stored_passkey = state 821 + .repos 822 + .user 823 + .get_passkey_by_credential_id(cred_id) 824 + .await 825 + .map_err(|e| { 826 + tracing::error!(error = %e, "Failed to look up passkey by credential ID"); 827 + ( 828 + StatusCode::INTERNAL_SERVER_ERROR, 829 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 830 + ).into_response() 831 + })? 832 + .ok_or_else(|| { 833 + tracing::warn!("Discoverable credential not found in database"); 834 + ( 835 + StatusCode::FORBIDDEN, 836 + Json(serde_json::json!({ 837 + "error": "access_denied", 838 + "error_description": "Passkey not recognized." 839 + })), 840 + ).into_response() 841 + })?; 842 + 843 + let discoverable_key: webauthn_rs::prelude::DiscoverableKey = 844 + serde_json::from_slice(&stored_passkey.public_key).map_err(|e| { 845 + tracing::error!(error = %e, "Failed to deserialize stored passkey as DiscoverableKey"); 846 + ( 847 + StatusCode::INTERNAL_SERVER_ERROR, 848 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 849 + ).into_response() 850 + })?; 851 + 852 + let auth_result = state 853 + .webauthn_config 854 + .finish_discoverable_authentication(credential, auth_state, &[discoverable_key]) 855 + .map_err(|e| { 856 + tracing::warn!(error = %e, did = %stored_passkey.did, "Failed to verify discoverable passkey authentication"); 857 + ( 858 + StatusCode::FORBIDDEN, 859 + Json(serde_json::json!({ 860 + "error": "access_denied", 861 + "error_description": "Passkey verification failed." 862 + })), 863 + ).into_response() 864 + })?; 865 + 866 + let _ = state 867 + .repos 868 + .user 869 + .delete_discoverable_challenge(request_id.as_str()) 870 + .await; 871 + 872 + Ok((stored_passkey.did, auth_result)) 692 873 } 693 874 694 875 #[derive(Debug, Deserialize)]
+1
crates/tranquil-pds/Cargo.toml
··· 78 78 urlencoding = { workspace = true } 79 79 uuid = { workspace = true } 80 80 webauthn-rs = { workspace = true } 81 + webauthn-rs-proto = { workspace = true } 81 82 zip = { workspace = true } 82 83 aws-config = { workspace = true, optional = true } 83 84 aws-sdk-s3 = { workspace = true, optional = true }
+57
crates/tranquil-pds/src/auth/webauthn.rs
··· 1 1 use uuid::Uuid; 2 2 use webauthn_rs::prelude::*; 3 + use webauthn_rs_proto::{ 4 + AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationPolicy, 5 + }; 3 6 4 7 #[derive(Debug, thiserror::Error)] 5 8 pub enum WebauthnError { ··· 57 60 None, 58 61 None, 59 62 ) 63 + .map(|(mut ccr, state)| { 64 + let sel = ccr 65 + .public_key 66 + .authenticator_selection 67 + .get_or_insert_with(AuthenticatorSelectionCriteria::default); 68 + sel.resident_key = Some(ResidentKeyRequirement::Required); 69 + sel.require_resident_key = true; 70 + (ccr, state) 71 + }) 60 72 .map_err(|e| WebauthnError::RegistrationFailed(e.to_string())) 61 73 } 62 74 ··· 86 98 ) -> Result<AuthenticationResult, WebauthnError> { 87 99 self.webauthn 88 100 .finish_securitykey_authentication(auth, state) 101 + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) 102 + } 103 + 104 + pub fn start_discoverable_authentication( 105 + &self, 106 + ) -> Result<(RequestChallengeResponse, DiscoverableAuthentication), WebauthnError> { 107 + let (mut rcr, state) = self 108 + .webauthn 109 + .start_discoverable_authentication() 110 + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?; 111 + 112 + rcr.mediation = None; 113 + rcr.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; 114 + 115 + let mut state_json = serde_json::to_value(&state) 116 + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?; 117 + let ast = state_json 118 + .get_mut("ast") 119 + .ok_or_else(|| WebauthnError::AuthenticationFailed( 120 + "webauthn-rs DiscoverableAuthentication missing 'ast' field, library version incompatible".into(), 121 + ))?; 122 + ast["policy"] = serde_json::json!("discouraged"); 123 + let patched: DiscoverableAuthentication = serde_json::from_value(state_json) 124 + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?; 125 + 126 + Ok((rcr, patched)) 127 + } 128 + 129 + pub fn identify_discoverable_authentication<'a>( 130 + &self, 131 + credential: &'a PublicKeyCredential, 132 + ) -> Result<(Uuid, &'a [u8]), WebauthnError> { 133 + self.webauthn 134 + .identify_discoverable_authentication(credential) 135 + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) 136 + } 137 + 138 + pub fn finish_discoverable_authentication( 139 + &self, 140 + credential: &PublicKeyCredential, 141 + state: DiscoverableAuthentication, 142 + creds: &[DiscoverableKey], 143 + ) -> Result<AuthenticationResult, WebauthnError> { 144 + self.webauthn 145 + .finish_discoverable_authentication(credential, state, creds) 89 146 .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) 90 147 } 91 148 }
+45 -4
crates/tranquil-store/src/metastore/client.rs
··· 3487 3487 recv(rx).await 3488 3488 } 3489 3489 3490 - async fn get_login_check_by_handle_or_email( 3490 + async fn get_login_check_by_identifier( 3491 3491 &self, 3492 3492 identifier: &str, 3493 3493 ) -> Result<Option<UserLoginCheck>, DbError> { 3494 3494 let (tx, rx) = oneshot::channel(); 3495 3495 self.pool.send(MetastoreRequest::User( 3496 - UserRequest::GetLoginCheckByHandleOrEmail { 3496 + UserRequest::GetLoginCheckByIdentifier { 3497 3497 identifier: identifier.to_owned(), 3498 3498 tx, 3499 3499 }, ··· 3501 3501 recv(rx).await 3502 3502 } 3503 3503 3504 - async fn get_login_info_by_handle_or_email( 3504 + async fn get_login_info_by_identifier( 3505 3505 &self, 3506 3506 identifier: &str, 3507 3507 ) -> Result<Option<UserLoginInfo>, DbError> { 3508 3508 let (tx, rx) = oneshot::channel(); 3509 3509 self.pool.send(MetastoreRequest::User( 3510 - UserRequest::GetLoginInfoByHandleOrEmail { 3510 + UserRequest::GetLoginInfoByIdentifier { 3511 3511 identifier: identifier.to_owned(), 3512 3512 tx, 3513 3513 }, ··· 4227 4227 UserRequest::DeleteWebauthnChallenge { 4228 4228 did: did.clone(), 4229 4229 challenge_type, 4230 + tx, 4231 + }, 4232 + ))?; 4233 + recv(rx).await 4234 + } 4235 + 4236 + async fn save_discoverable_challenge( 4237 + &self, 4238 + request_key: &str, 4239 + state_json: &str, 4240 + ) -> Result<Uuid, DbError> { 4241 + let (tx, rx) = oneshot::channel(); 4242 + self.pool.send(MetastoreRequest::User( 4243 + UserRequest::SaveDiscoverableChallenge { 4244 + request_key: request_key.to_owned(), 4245 + state_json: state_json.to_owned(), 4246 + tx, 4247 + }, 4248 + ))?; 4249 + recv(rx).await 4250 + } 4251 + 4252 + async fn load_discoverable_challenge( 4253 + &self, 4254 + request_key: &str, 4255 + ) -> Result<Option<String>, DbError> { 4256 + let (tx, rx) = oneshot::channel(); 4257 + self.pool.send(MetastoreRequest::User( 4258 + UserRequest::LoadDiscoverableChallenge { 4259 + request_key: request_key.to_owned(), 4260 + tx, 4261 + }, 4262 + ))?; 4263 + recv(rx).await 4264 + } 4265 + 4266 + async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> { 4267 + let (tx, rx) = oneshot::channel(); 4268 + self.pool.send(MetastoreRequest::User( 4269 + UserRequest::DeleteDiscoverableChallenge { 4270 + request_key: request_key.to_owned(), 4230 4271 tx, 4231 4272 }, 4232 4273 ))?;
+47 -9
crates/tranquil-store/src/metastore/handler.rs
··· 996 996 email: String, 997 997 tx: Tx<Option<UserForVerification>>, 998 998 }, 999 - GetLoginCheckByHandleOrEmail { 999 + GetLoginCheckByIdentifier { 1000 1000 identifier: String, 1001 1001 tx: Tx<Option<UserLoginCheck>>, 1002 1002 }, 1003 - GetLoginInfoByHandleOrEmail { 1003 + GetLoginInfoByIdentifier { 1004 1004 identifier: String, 1005 1005 tx: Tx<Option<UserLoginInfo>>, 1006 1006 }, ··· 1271 1271 DeleteWebauthnChallenge { 1272 1272 did: Did, 1273 1273 challenge_type: WebauthnChallengeType, 1274 + tx: Tx<()>, 1275 + }, 1276 + SaveDiscoverableChallenge { 1277 + request_key: String, 1278 + state_json: String, 1279 + tx: Tx<Uuid>, 1280 + }, 1281 + LoadDiscoverableChallenge { 1282 + request_key: String, 1283 + tx: Tx<Option<String>>, 1284 + }, 1285 + DeleteDiscoverableChallenge { 1286 + request_key: String, 1274 1287 tx: Tx<()>, 1275 1288 }, 1276 1289 GetTotpRecord { ··· 1726 1739 | Self::GetAnyAdminUserId { .. } 1727 1740 | Self::SearchAccounts { .. } 1728 1741 | Self::GetByEmail { .. } 1729 - | Self::GetLoginCheckByHandleOrEmail { .. } 1730 - | Self::GetLoginInfoByHandleOrEmail { .. } 1742 + | Self::GetLoginCheckByIdentifier { .. } 1743 + | Self::GetLoginInfoByIdentifier { .. } 1731 1744 | Self::CheckEmailVerifiedByIdentifier { .. } 1732 1745 | Self::StoreTelegramChatId { .. } 1733 1746 | Self::StoreDiscordUserId { .. } ··· 1743 1756 | Self::CleanupExpiredHandleReservations { .. } 1744 1757 | Self::CheckAndConsumeInviteCode { .. } 1745 1758 | Self::GetPasswordResetInfo { .. } 1746 - | Self::ExpirePasswordResetCode { .. } => Routing::Global, 1759 + | Self::ExpirePasswordResetCode { .. } 1760 + | Self::SaveDiscoverableChallenge { .. } 1761 + | Self::LoadDiscoverableChallenge { .. } 1762 + | Self::DeleteDiscoverableChallenge { .. } => Routing::Global, 1747 1763 } 1748 1764 } 1749 1765 } ··· 5066 5082 UserRequest::GetByEmail { email, tx } => { 5067 5083 let _ = tx.send(user.get_by_email(&email).map_err(metastore_to_db)); 5068 5084 } 5069 - UserRequest::GetLoginCheckByHandleOrEmail { identifier, tx } => { 5085 + UserRequest::GetLoginCheckByIdentifier { identifier, tx } => { 5070 5086 let _ = tx.send( 5071 - user.get_login_check_by_handle_or_email(&identifier) 5087 + user.get_login_check_by_identifier(&identifier) 5072 5088 .map_err(metastore_to_db), 5073 5089 ); 5074 5090 } 5075 - UserRequest::GetLoginInfoByHandleOrEmail { identifier, tx } => { 5091 + UserRequest::GetLoginInfoByIdentifier { identifier, tx } => { 5076 5092 let _ = tx.send( 5077 - user.get_login_info_by_handle_or_email(&identifier) 5093 + user.get_login_info_by_identifier(&identifier) 5078 5094 .map_err(metastore_to_db), 5079 5095 ); 5080 5096 } ··· 5431 5447 } => { 5432 5448 let _ = tx.send( 5433 5449 user.delete_webauthn_challenge(&did, challenge_type) 5450 + .map_err(metastore_to_db), 5451 + ); 5452 + } 5453 + UserRequest::SaveDiscoverableChallenge { 5454 + request_key, 5455 + state_json, 5456 + tx, 5457 + } => { 5458 + let _ = tx.send( 5459 + user.save_discoverable_challenge(&request_key, &state_json) 5460 + .map_err(metastore_to_db), 5461 + ); 5462 + } 5463 + UserRequest::LoadDiscoverableChallenge { request_key, tx } => { 5464 + let _ = tx.send( 5465 + user.load_discoverable_challenge(&request_key) 5466 + .map_err(metastore_to_db), 5467 + ); 5468 + } 5469 + UserRequest::DeleteDiscoverableChallenge { request_key, tx } => { 5470 + let _ = tx.send( 5471 + user.delete_discoverable_challenge(&request_key) 5434 5472 .map_err(metastore_to_db), 5435 5473 ); 5436 5474 }
+56 -11
crates/tranquil-store/src/metastore/user_ops.rs
··· 125 125 } 126 126 127 127 fn load_by_identifier(&self, identifier: &str) -> Result<Option<UserValue>, MetastoreError> { 128 - match identifier.contains('@') { 129 - true => self.load_by_email(identifier).and_then(|opt| match opt { 130 - Some(v) => Ok(Some(v)), 131 - None => self.load_by_handle(identifier), 132 - }), 133 - false => self.load_by_handle(identifier).and_then(|opt| match opt { 134 - Some(v) => Ok(Some(v)), 135 - None => self.load_by_email(identifier), 136 - }), 128 + match identifier.starts_with("did:") { 129 + true => self.load_user_by_did(identifier), 130 + false => self.load_by_handle(identifier), 137 131 } 138 132 } 139 133 ··· 472 466 .transpose() 473 467 } 474 468 475 - pub fn get_login_check_by_handle_or_email( 469 + pub fn get_login_check_by_identifier( 476 470 &self, 477 471 identifier: &str, 478 472 ) -> Result<Option<UserLoginCheck>, MetastoreError> { ··· 487 481 .transpose() 488 482 } 489 483 490 - pub fn get_login_info_by_handle_or_email( 484 + pub fn get_login_info_by_identifier( 491 485 &self, 492 486 identifier: &str, 493 487 ) -> Result<Option<UserLoginInfo>, MetastoreError> { ··· 1506 1500 let user_hash = self.resolve_hash(did.as_str()); 1507 1501 let type_u8 = challenge_type_to_u8(challenge_type); 1508 1502 let key = webauthn_challenge_key(user_hash, type_u8); 1503 + self.auth 1504 + .remove(key.as_slice()) 1505 + .map_err(MetastoreError::Fjall) 1506 + } 1507 + 1508 + const DISCOVERABLE_CHALLENGE_TYPE: u8 = 2; 1509 + 1510 + pub fn save_discoverable_challenge( 1511 + &self, 1512 + request_key: &str, 1513 + state_json: &str, 1514 + ) -> Result<Uuid, MetastoreError> { 1515 + let key_hash = UserHash::from_did(request_key); 1516 + let id = Uuid::new_v4(); 1517 + let now_ms = Utc::now().timestamp_millis(); 1518 + 1519 + let value = WebauthnChallengeValue { 1520 + id, 1521 + challenge_type: Self::DISCOVERABLE_CHALLENGE_TYPE, 1522 + state_json: state_json.to_owned(), 1523 + created_at_ms: now_ms, 1524 + }; 1525 + 1526 + let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE); 1527 + self.auth 1528 + .insert(key.as_slice(), value.serialize_with_ttl()) 1529 + .map_err(MetastoreError::Fjall)?; 1530 + 1531 + Ok(id) 1532 + } 1533 + 1534 + pub fn load_discoverable_challenge( 1535 + &self, 1536 + request_key: &str, 1537 + ) -> Result<Option<String>, MetastoreError> { 1538 + let key_hash = UserHash::from_did(request_key); 1539 + let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE); 1540 + 1541 + let val: Option<WebauthnChallengeValue> = point_lookup( 1542 + &self.auth, 1543 + key.as_slice(), 1544 + WebauthnChallengeValue::deserialize, 1545 + "corrupt webauthn challenge", 1546 + )?; 1547 + 1548 + Ok(val.map(|v| v.state_json)) 1549 + } 1550 + 1551 + pub fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), MetastoreError> { 1552 + let key_hash = UserHash::from_did(request_key); 1553 + let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE); 1509 1554 self.auth 1510 1555 .remove(key.as_slice()) 1511 1556 .map_err(MetastoreError::Fjall)
+1 -1
frontend/src/components/RandomHandle.svelte
··· 1 1 <script lang="ts" module> 2 2 const EXAMPLE_HANDLES = [ 3 3 "nel.pet", 4 - "lewis.moe", 4 + "oyster.cafe", 5 5 "llaama.bsky.social", 6 6 "debugman.wizardry.systems", 7 7 "nonbinary.computer",
+11 -2
frontend/src/components/dashboard/PasskeySection.svelte
··· 15 15 session: Session 16 16 hasPassword: boolean 17 17 onPasskeysChanged?: (count: number) => void 18 + onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void 18 19 } 19 20 20 - let { session, hasPassword, onPasskeysChanged }: Props = $props() 21 + let { session, hasPassword, onPasskeysChanged, onReauthRequired }: Props = $props() 21 22 22 23 interface Passkey { 23 24 id: string ··· 81 82 } 82 83 } 83 84 85 + function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) { 86 + if (e instanceof ApiError && e.error === 'ReauthRequired') { 87 + onReauthRequired(e.reauthMethods || ['password'], retryAction) 88 + } else { 89 + toast.error(e instanceof ApiError ? e.message : fallback) 90 + } 91 + } 92 + 84 93 async function handleDeletePasskey(id: string) { 85 94 const passkey = passkeys.find(p => p.id === id) 86 95 if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return ··· 89 98 await loadPasskeys() 90 99 toast.success($_('security.passkeyDeleted')) 91 100 } catch (e) { 92 - toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey') 101 + handleReauthError(e, 'Failed to delete passkey', () => handleDeletePasskey(id)) 93 102 } 94 103 } 95 104
+1
frontend/src/components/dashboard/SecurityContent.svelte
··· 282 282 {session} 283 283 {hasPassword} 284 284 onPasskeysChanged={(count) => passkeyCount = count} 285 + onReauthRequired={handleReauthRequired} 285 286 /> 286 287 287 288 <TotpSection
+1
frontend/src/locales/en.json
··· 522 522 "passkeyHintNotAvailable": "No passkey registered", 523 523 "passwordPlaceholder": "Password", 524 524 "usePasskey": "Use passkey", 525 + "passkeyNotAllowed": "No passkey available for this site", 525 526 "orUseCredentials": "or", 526 527 "verificationResent": "Verification code sent" 527 528 },
+1
frontend/src/locales/fi.json
··· 509 509 "passkeyHintNotAvailable": "Ei pääsyavainta", 510 510 "passwordPlaceholder": "Salasana", 511 511 "usePasskey": "Käytä pääsyavainta", 512 + "passkeyNotAllowed": "Pääsyavainta ei ole saatavilla tälle sivustolle", 512 513 "orUseCredentials": "tai", 513 514 "verificationResent": "Vahvistuskoodi lähetetty" 514 515 },
+1
frontend/src/locales/ja.json
··· 509 509 "passkeyHintNotAvailable": "パスキーなし", 510 510 "passwordPlaceholder": "パスワード", 511 511 "usePasskey": "パスキーを使用", 512 + "passkeyNotAllowed": "このサイトで利用可能なパスキーがありません", 512 513 "orUseCredentials": "または", 513 514 "verificationResent": "確認コードを送信しました" 514 515 },
+1
frontend/src/locales/ko.json
··· 509 509 "passkeyHintNotAvailable": "패스키 없음", 510 510 "passwordPlaceholder": "비밀번호", 511 511 "usePasskey": "패스키 사용", 512 + "passkeyNotAllowed": "이 사이트에 사용 가능한 패스키가 없습니다", 512 513 "orUseCredentials": "또는", 513 514 "verificationResent": "인증 코드 전송됨" 514 515 },
+1
frontend/src/locales/sv.json
··· 509 509 "passkeyHintNotAvailable": "Ingen nyckel registrerad", 510 510 "passwordPlaceholder": "Lösenord", 511 511 "usePasskey": "Använd nyckel", 512 + "passkeyNotAllowed": "Ingen nyckel tillgänglig för denna webbplats", 512 513 "orUseCredentials": "eller", 513 514 "verificationResent": "Verifieringskod skickad" 514 515 },
+1
frontend/src/locales/zh.json
··· 509 509 "passkeyHintNotAvailable": "未注册通行密钥", 510 510 "passwordPlaceholder": "密码", 511 511 "usePasskey": "使用通行密钥", 512 + "passkeyNotAllowed": "此站点没有可用的通行密钥", 512 513 "orUseCredentials": "或", 513 514 "verificationResent": "验证码已发送" 514 515 },
+72 -92
frontend/src/routes/OAuthLogin.svelte
··· 38 38 let submitting = $state(false) 39 39 let error = $state<string | null>(null) 40 40 let verificationResent = $state(false) 41 - let hasPasskeys = $state(false) 42 - let hasTotp = $state(false) 43 - let hasPassword = $state(true) 44 41 let isDelegated = $state(false) 45 42 let userDid = $state<string | null>(null) 46 - let checkingSecurityStatus = $state(false) 47 - let securityStatusChecked = $state(false) 48 43 let passkeySupported = $state(false) 49 44 let clientName = $state<string | null>(null) 50 45 ··· 160 155 161 156 let checkTimeout: ReturnType<typeof setTimeout> | null = null 162 157 158 + let checkingDelegation = false 159 + 163 160 $effect(() => { 164 161 if (checkTimeout) { 165 162 clearTimeout(checkTimeout) 166 163 } 167 - hasPasskeys = false 168 - hasTotp = false 169 - securityStatusChecked = false 164 + isDelegated = false 170 165 if (username.length >= 3) { 171 - checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500) 166 + checkTimeout = setTimeout(() => checkDelegationStatus(), 500) 172 167 } 173 168 }) 174 169 175 - async function checkUserSecurityStatus() { 176 - if (!username || checkingSecurityStatus) return 177 - checkingSecurityStatus = true 170 + async function checkDelegationStatus() { 171 + if (!username || checkingDelegation) return 172 + checkingDelegation = true 178 173 try { 179 174 const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`) 180 175 if (response.ok) { 181 176 const data = await response.json() 182 - hasPasskeys = passkeySupported && data.hasPasskeys === true 183 - hasTotp = data.hasTotp === true 184 - hasPassword = data.hasPassword !== false 185 177 isDelegated = data.isDelegated === true 186 178 userDid = data.did || null 187 - securityStatusChecked = true 188 179 189 180 if (isDelegated && data.did) { 190 181 const requestUri = getRequestUri() ··· 198 189 } 199 190 } 200 191 } catch { 201 - hasPasskeys = false 202 - hasTotp = false 203 - hasPassword = true 204 192 isDelegated = false 205 193 } finally { 206 - checkingSecurityStatus = false 194 + checkingDelegation = false 207 195 } 208 196 } 209 197 210 198 211 199 async function handlePasskeyLogin() { 212 200 const requestUri = getRequestUri() 213 - if (!requestUri || !username) { 201 + if (!requestUri) { 214 202 error = $_('common.error') 215 203 return 216 204 } ··· 220 208 verificationResent = false 221 209 222 210 try { 211 + const body: Record<string, string> = { request_uri: requestUri } 212 + if (username.trim()) { 213 + body.identifier = username 214 + } 215 + 223 216 const startResponse = await fetch('/oauth/passkey/start', { 224 217 method: 'POST', 225 218 headers: { 226 219 'Content-Type': 'application/json', 227 220 'Accept': 'application/json' 228 221 }, 229 - body: JSON.stringify({ 230 - request_uri: requestUri, 231 - identifier: username 232 - }) 222 + body: JSON.stringify(body) 233 223 }) 234 224 235 225 if (!startResponse.ok) { ··· 306 296 } catch (e) { 307 297 console.error('Passkey login error:', e) 308 298 if (e instanceof DOMException && e.name === 'NotAllowedError') { 309 - error = $_('common.error') 299 + error = $_('oauth.login.passkeyNotAllowed') 310 300 } else { 311 - error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}` 301 + error = e instanceof Error ? e.message : String(e) 312 302 } 313 303 submitting = false 314 304 } ··· 439 429 </div> 440 430 {/if} 441 431 442 - {#if passkeySupported && username.length >= 3} 443 - <div class="auth-methods" class:single-method={!hasPassword}> 432 + {#if passkeySupported} 433 + <div class="auth-methods"> 444 434 <div class="passkey-method"> 445 435 <h3>{$_('oauth.login.signInWithPasskey')}</h3> 446 436 <button 447 437 type="button" 448 438 style="width: 100%" 449 - class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 450 439 onclick={handlePasskeyLogin} 451 - disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 452 - title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')} 440 + disabled={submitting} 453 441 > 454 442 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 455 443 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> ··· 459 447 <span class="passkey-text"> 460 448 {#if submitting} 461 449 {$_('oauth.login.authenticating')} 462 - {:else if checkingSecurityStatus || !securityStatusChecked} 463 - {$_('oauth.login.checkingPasskey')} 464 - {:else if hasPasskeys} 450 + {:else} 465 451 {$_('oauth.login.usePasskey')} 466 - {:else} 467 - {$_('oauth.login.passkeyNotSetUp')} 468 452 {/if} 469 453 </span> 470 454 </button> 471 455 </div> 472 456 473 - {#if hasPassword} 474 - <div class="method-divider"> 475 - <span>{$_('oauth.login.orUsePassword')}</span> 476 - </div> 457 + <div class="method-divider"> 458 + <span>{$_('oauth.login.orUsePassword')}</span> 459 + </div> 477 460 478 - <div class="password-method"> 479 - <h3>{$_('oauth.login.password')}</h3> 480 - <div class="field"> 481 - <input 482 - id="password" 483 - type="password" 484 - bind:value={password} 485 - disabled={submitting} 486 - required 487 - autocomplete="current-password" 488 - placeholder={$_('oauth.login.passwordPlaceholder')} 489 - /> 490 - </div> 461 + <div class="password-method"> 462 + <h3>{$_('oauth.login.password')}</h3> 463 + <div class="field"> 464 + <input 465 + id="password" 466 + type="password" 467 + bind:value={password} 468 + disabled={submitting} 469 + required 470 + autocomplete="current-password" 471 + placeholder={$_('oauth.login.passwordPlaceholder')} 472 + /> 473 + </div> 491 474 492 - <label class="remember-device"> 493 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 494 - <span>{$_('oauth.login.rememberDevice')}</span> 495 - </label> 475 + <label class="remember-device"> 476 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 477 + <span>{$_('oauth.login.rememberDevice')}</span> 478 + </label> 496 479 497 - <div class="actions"> 498 - <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 499 - {$_('common.cancel')} 500 - </button> 501 - <button type="submit" disabled={submitting || !username || !password}> 502 - {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 503 - </button> 504 - </div> 480 + <div class="actions"> 481 + <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 482 + {$_('common.cancel')} 483 + </button> 484 + <button type="submit" disabled={submitting || !username || !password}> 485 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 486 + </button> 505 487 </div> 506 - {/if} 488 + </div> 507 489 </div> 508 490 {:else} 509 - {#if hasPassword || !securityStatusChecked} 510 - <div> 511 - <label for="password">{$_('oauth.login.password')}</label> 512 - <input 513 - id="password" 514 - type="password" 515 - bind:value={password} 516 - disabled={submitting} 517 - required 518 - autocomplete="current-password" 519 - /> 520 - </div> 491 + <div> 492 + <label for="password">{$_('oauth.login.password')}</label> 493 + <input 494 + id="password" 495 + type="password" 496 + bind:value={password} 497 + disabled={submitting} 498 + required 499 + autocomplete="current-password" 500 + /> 501 + </div> 521 502 522 - <label class="remember-device"> 523 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 524 - <span>{$_('oauth.login.rememberDevice')}</span> 525 - </label> 503 + <label class="remember-device"> 504 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 505 + <span>{$_('oauth.login.rememberDevice')}</span> 506 + </label> 526 507 527 - <div class="actions"> 528 - <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 529 - {$_('common.cancel')} 530 - </button> 531 - <button type="submit" disabled={submitting || !username || !password}> 532 - {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 533 - </button> 534 - </div> 535 - {/if} 508 + <div class="actions"> 509 + <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 510 + {$_('common.cancel')} 511 + </button> 512 + <button type="submit" disabled={submitting || !username || !password}> 513 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 514 + </button> 515 + </div> 536 516 {/if} 537 517 </form> 538 518