this repo has no description

Passkey-only accounts

lewis 36f6be7f afa83692

Changed files
+6719 -239
.config
.sqlx
frontend
migrations
src
tests
+13
.config/nextest.toml
··· 1 1 [store] 2 2 dir = "target/nextest" 3 + 3 4 [profile.default] 4 5 retries = 0 5 6 fail-fast = true 6 7 test-threads = "num-cpus" 8 + 7 9 [profile.ci] 8 10 retries = 2 9 11 fail-fast = false 10 12 test-threads = "num-cpus" 13 + 14 + [test-groups] 15 + serial-env-tests = { max-threads = 1 } 16 + 17 + [[profile.default.overrides]] 18 + filter = "test(/import_with_verification/) | test(/plc_migration/)" 19 + test-group = "serial-env-tests" 20 + 21 + [[profile.ci.overrides]] 22 + filter = "test(/import_with_verification/) | test(/plc_migration/)" 23 + test-group = "serial-env-tests"
+15
.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507" 15 + }
+14
.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80" 14 + }
+1 -1
.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json
··· 26 26 }, 27 27 "nullable": [ 28 28 false, 29 - false, 29 + true, 30 30 false 31 31 ] 32 32 },
-28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "code", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32" 28 - }
+2 -1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
··· 38 38 "admin_email", 39 39 "plc_operation", 40 40 "two_factor_code", 41 - "channel_verification" 41 + "channel_verification", 42 + "passkey_recovery" 42 43 ] 43 44 } 44 45 }
+2 -1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
··· 46 46 "admin_email", 47 47 "plc_operation", 48 48 "two_factor_code", 49 - "channel_verification" 49 + "channel_verification", 50 + "passkey_recovery" 50 51 ] 51 52 } 52 53 }
+22
.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT ap.password_hash FROM app_passwords ap\n JOIN users u ON ap.user_id = u.id\n WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "password_hash", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2" 22 + }
+14
.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8" 14 + }
+16
.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Timestamptz", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74" 16 + }
+15
.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d" 15 + }
+2 -1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
··· 38 38 "admin_email", 39 39 "plc_operation", 40 40 "two_factor_code", 41 - "channel_verification" 41 + "channel_verification", 42 + "passkey_recovery" 42 43 ] 43 44 } 44 45 }
-17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, 'email', $2, $3, $4)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text", 10 - "Text", 11 - "Timestamptz" 12 - ] 13 - }, 14 - "nullable": [] 15 - }, 16 - "hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20" 17 - }
+23
.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "trusted_until", 9 + "type_info": "Timestamptz" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + true 20 + ] 21 + }, 22 + "hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65" 23 + }
+16
.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f" 16 + }
+30
.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + { 10 + "Custom": { 11 + "name": "comms_channel", 12 + "kind": { 13 + "Enum": [ 14 + "email", 15 + "discord", 16 + "telegram", 17 + "signal" 18 + ] 19 + } 20 + } 21 + }, 22 + "Text", 23 + "Text", 24 + "Timestamptz" 25 + ] 26 + }, 27 + "nullable": [] 28 + }, 29 + "hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483" 30 + }
+22
.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "?column?", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc" 22 + }
+41
.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + { 21 + "Custom": { 22 + "name": "comms_channel", 23 + "kind": { 24 + "Enum": [ 25 + "email", 26 + "discord", 27 + "telegram", 28 + "signal" 29 + ] 30 + } 31 + } 32 + } 33 + ] 34 + }, 35 + "nullable": [ 36 + false, 37 + false 38 + ] 39 + }, 40 + "hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1" 41 + }
+22
.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "has_password", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed" 22 + }
+23
.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT 1 as one FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.id = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "one", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + null 20 + ] 21 + }, 22 + "hash": "63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3" 23 + }
+22
.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "one", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034" 22 + }
+16
.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Timestamptz", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369" 16 + }
+40
.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "recovery_token", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "recovery_token_expires_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "password_required", 24 + "type_info": "Bool" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + true, 35 + true, 36 + false 37 + ] 38 + }, 39 + "hash": "73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b" 40 + }
+28
.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, password_hash FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "password_hash", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3" 28 + }
+14
.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM passkeys WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180" 14 + }
+15
.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979" 15 + }
+41
.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "handle", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "password_required", 24 + "type_info": "Bool" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text", 30 + "Text" 31 + ] 32 + }, 33 + "nullable": [ 34 + false, 35 + false, 36 + false, 37 + false 38 + ] 39 + }, 40 + "hash": "8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8" 41 + }
+22
.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "last_reauth_at", 9 + "type_info": "Timestamptz" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a" 22 + }
-17
.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text", 10 - "Text", 11 - "Timestamptz" 12 - ] 13 - }, 14 - "nullable": [] 15 - }, 16 - "hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5" 17 - }
+14
.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4" 14 + }
+40
.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "recovery_token", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "recovery_token_expires_at", 24 + "type_info": "Timestamptz" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + true, 36 + true 37 + ] 38 + }, 39 + "hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848" 40 + }
+1 -1
.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
··· 63 63 false, 64 64 false, 65 65 false, 66 - false, 66 + true, 67 67 false, 68 68 false, 69 69 false,
+1 -1
.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
··· 15 15 ] 16 16 }, 17 17 "nullable": [ 18 - false 18 + true 19 19 ] 20 20 }, 21 21 "hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745"
+3 -2
.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json .sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT id FROM users WHERE LOWER(email) = $1", 3 + "query": "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 11 11 ], 12 12 "parameters": { 13 13 "Left": [ 14 + "Text", 14 15 "Text" 15 16 ] 16 17 }, ··· 18 19 false 19 20 ] 20 21 }, 21 - "hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4" 22 + "hash": "7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317" 22 23 }
-15
.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Uuid" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b" 15 - }
+52
.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at\n FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()\n ORDER BY od.last_seen_at DESC", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "user_agent", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "friendly_name", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "trusted_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "trusted_until", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "last_seen_at", 34 + "type_info": "Timestamptz" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + true, 45 + true, 46 + true, 47 + true, 48 + false 49 + ] 50 + }, 51 + "hash": "d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f" 52 + }
+15
.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5" 15 + }
+15
.sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7" 15 + }
+15 -9
.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json .sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, email, password_hash, 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 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 FROM users\n WHERE handle = $1 OR email = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "two_factor_enabled", 28 + "name": "password_required", 29 29 "type_info": "Bool" 30 30 }, 31 31 { 32 32 "ordinal": 5, 33 + "name": "two_factor_enabled", 34 + "type_info": "Bool" 35 + }, 36 + { 37 + "ordinal": 6, 33 38 "name": "preferred_comms_channel: CommsChannel", 34 39 "type_info": { 35 40 "Custom": { ··· 46 51 } 47 52 }, 48 53 { 49 - "ordinal": 6, 54 + "ordinal": 7, 50 55 "name": "deactivated_at", 51 56 "type_info": "Timestamptz" 52 57 }, 53 58 { 54 - "ordinal": 7, 59 + "ordinal": 8, 55 60 "name": "takedown_ref", 56 61 "type_info": "Text" 57 62 }, 58 63 { 59 - "ordinal": 8, 64 + "ordinal": 9, 60 65 "name": "email_verified", 61 66 "type_info": "Bool" 62 67 }, 63 68 { 64 - "ordinal": 9, 69 + "ordinal": 10, 65 70 "name": "discord_verified", 66 71 "type_info": "Bool" 67 72 }, 68 73 { 69 - "ordinal": 10, 74 + "ordinal": 11, 70 75 "name": "telegram_verified", 71 76 "type_info": "Bool" 72 77 }, 73 78 { 74 - "ordinal": 11, 79 + "ordinal": 12, 75 80 "name": "signal_verified", 76 81 "type_info": "Bool" 77 82 } ··· 85 90 false, 86 91 false, 87 92 true, 93 + true, 88 94 false, 89 95 false, 90 96 false, ··· 96 102 false 97 103 ] 98 104 }, 99 - "hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0" 105 + "hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f" 100 106 }
+46
.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "recovery_token", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "recovery_token_expires_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "password_required", 29 + "type_info": "Bool" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false, 40 + true, 41 + true, 42 + false 43 + ] 44 + }, 45 + "hash": "f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289" 46 + }
+22
.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "has_pw", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba" 22 + }
+2 -1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
··· 41 41 "admin_email", 42 42 "plc_operation", 43 43 "two_factor_code", 44 - "channel_verification" 44 + "channel_verification", 45 + "passkey_recovery" 45 46 ] 46 47 } 47 48 }
+40
.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\" FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "preferred_comms_channel: CommsChannel", 14 + "type_info": { 15 + "Custom": { 16 + "name": "comms_channel", 17 + "kind": { 18 + "Enum": [ 19 + "email", 20 + "discord", 21 + "telegram", 22 + "signal" 23 + ] 24 + } 25 + } 26 + } 27 + } 28 + ], 29 + "parameters": { 30 + "Left": [ 31 + "Text" 32 + ] 33 + }, 34 + "nullable": [ 35 + false, 36 + false 37 + ] 38 + }, 39 + "hash": "fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4" 40 + }
+15
frontend/src/App.svelte
··· 3 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 4 import Login from './routes/Login.svelte' 5 5 import Register from './routes/Register.svelte' 6 + import RegisterPasskey from './routes/RegisterPasskey.svelte' 6 7 import Verify from './routes/Verify.svelte' 7 8 import ResetPassword from './routes/ResetPassword.svelte' 9 + import RecoverPasskey from './routes/RecoverPasskey.svelte' 10 + import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte' 8 11 import Dashboard from './routes/Dashboard.svelte' 9 12 import AppPasswords from './routes/AppPasswords.svelte' 10 13 import InviteCodes from './routes/InviteCodes.svelte' ··· 18 21 import OAuthAccounts from './routes/OAuthAccounts.svelte' 19 22 import OAuth2FA from './routes/OAuth2FA.svelte' 20 23 import OAuthTotp from './routes/OAuthTotp.svelte' 24 + import OAuthPasskey from './routes/OAuthPasskey.svelte' 21 25 import OAuthError from './routes/OAuthError.svelte' 22 26 import Security from './routes/Security.svelte' 27 + import TrustedDevices from './routes/TrustedDevices.svelte' 23 28 24 29 const auth = getAuthState() 25 30 ··· 33 38 return Login 34 39 case '/register': 35 40 return Register 41 + case '/register-passkey': 42 + return RegisterPasskey 36 43 case '/verify': 37 44 return Verify 38 45 case '/reset-password': 39 46 return ResetPassword 47 + case '/recover-passkey': 48 + return RecoverPasskey 49 + case '/request-passkey-recovery': 50 + return RequestPasskeyRecovery 40 51 case '/dashboard': 41 52 return Dashboard 42 53 case '/app-passwords': ··· 63 74 return OAuth2FA 64 75 case '/oauth/totp': 65 76 return OAuthTotp 77 + case '/oauth/passkey': 78 + return OAuthPasskey 66 79 case '/oauth/error': 67 80 return OAuthError 68 81 case '/security': 69 82 return Security 83 + case '/trusted-devices': 84 + return TrustedDevices 70 85 default: 71 86 return auth.session ? Dashboard : Login 72 87 }
+430
frontend/src/components/ReauthModal.svelte
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { api, ApiError } from '../lib/api' 4 + 5 + interface Props { 6 + show: boolean 7 + availableMethods?: string[] 8 + onSuccess: () => void 9 + onCancel: () => void 10 + } 11 + 12 + let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props() 13 + 14 + const auth = getAuthState() 15 + let activeMethod = $state<'password' | 'totp' | 'passkey'>('password') 16 + let password = $state('') 17 + let totpCode = $state('') 18 + let loading = $state(false) 19 + let error = $state('') 20 + 21 + $effect(() => { 22 + if (show) { 23 + password = '' 24 + totpCode = '' 25 + error = '' 26 + if (availableMethods.includes('password')) { 27 + activeMethod = 'password' 28 + } else if (availableMethods.includes('totp')) { 29 + activeMethod = 'totp' 30 + } else if (availableMethods.includes('passkey')) { 31 + activeMethod = 'passkey' 32 + } 33 + } 34 + }) 35 + 36 + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 37 + const bytes = new Uint8Array(buffer) 38 + let binary = '' 39 + for (let i = 0; i < bytes.byteLength; i++) { 40 + binary += String.fromCharCode(bytes[i]) 41 + } 42 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 43 + } 44 + 45 + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 46 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 47 + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 48 + const binary = atob(padded) 49 + const bytes = new Uint8Array(binary.length) 50 + for (let i = 0; i < binary.length; i++) { 51 + bytes[i] = binary.charCodeAt(i) 52 + } 53 + return bytes.buffer 54 + } 55 + 56 + function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions { 57 + return { 58 + ...options.publicKey, 59 + challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 60 + allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ 61 + ...cred, 62 + id: base64UrlToArrayBuffer(cred.id) 63 + })) || [] 64 + } 65 + } 66 + 67 + async function handlePasswordSubmit(e: Event) { 68 + e.preventDefault() 69 + if (!auth.session || !password) return 70 + loading = true 71 + error = '' 72 + try { 73 + await api.reauthPassword(auth.session.accessJwt, password) 74 + show = false 75 + onSuccess() 76 + } catch (e) { 77 + error = e instanceof ApiError ? e.message : 'Authentication failed' 78 + } finally { 79 + loading = false 80 + } 81 + } 82 + 83 + async function handleTotpSubmit(e: Event) { 84 + e.preventDefault() 85 + if (!auth.session || !totpCode) return 86 + loading = true 87 + error = '' 88 + try { 89 + await api.reauthTotp(auth.session.accessJwt, totpCode) 90 + show = false 91 + onSuccess() 92 + } catch (e) { 93 + error = e instanceof ApiError ? e.message : 'Invalid code' 94 + } finally { 95 + loading = false 96 + } 97 + } 98 + 99 + async function handlePasskeyAuth() { 100 + if (!auth.session) return 101 + if (!window.PublicKeyCredential) { 102 + error = 'Passkeys are not supported in this browser' 103 + return 104 + } 105 + loading = true 106 + error = '' 107 + try { 108 + const { options } = await api.reauthPasskeyStart(auth.session.accessJwt) 109 + const publicKeyOptions = prepareAuthOptions(options) 110 + const credential = await navigator.credentials.get({ 111 + publicKey: publicKeyOptions 112 + }) 113 + if (!credential) { 114 + error = 'Passkey authentication was cancelled' 115 + return 116 + } 117 + const pkCredential = credential as PublicKeyCredential 118 + const response = pkCredential.response as AuthenticatorAssertionResponse 119 + const credentialResponse = { 120 + id: pkCredential.id, 121 + type: pkCredential.type, 122 + rawId: arrayBufferToBase64Url(pkCredential.rawId), 123 + response: { 124 + clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 125 + authenticatorData: arrayBufferToBase64Url(response.authenticatorData), 126 + signature: arrayBufferToBase64Url(response.signature), 127 + userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null, 128 + }, 129 + } 130 + await api.reauthPasskeyFinish(auth.session.accessJwt, credentialResponse) 131 + show = false 132 + onSuccess() 133 + } catch (e) { 134 + if (e instanceof DOMException && e.name === 'NotAllowedError') { 135 + error = 'Passkey authentication was cancelled' 136 + } else { 137 + error = e instanceof ApiError ? e.message : 'Passkey authentication failed' 138 + } 139 + } finally { 140 + loading = false 141 + } 142 + } 143 + 144 + function handleClose() { 145 + show = false 146 + onCancel() 147 + } 148 + </script> 149 + 150 + {#if show} 151 + <div class="modal-backdrop" onclick={handleClose} role="presentation"> 152 + <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true"> 153 + <div class="modal-header"> 154 + <h2>Re-authentication Required</h2> 155 + <button class="close-btn" onclick={handleClose} aria-label="Close">&times;</button> 156 + </div> 157 + 158 + <p class="modal-description"> 159 + This action requires you to verify your identity. 160 + </p> 161 + 162 + {#if error} 163 + <div class="error-message">{error}</div> 164 + {/if} 165 + 166 + {#if availableMethods.length > 1} 167 + <div class="method-tabs"> 168 + {#if availableMethods.includes('password')} 169 + <button 170 + class="tab" 171 + class:active={activeMethod === 'password'} 172 + onclick={() => activeMethod = 'password'} 173 + > 174 + Password 175 + </button> 176 + {/if} 177 + {#if availableMethods.includes('totp')} 178 + <button 179 + class="tab" 180 + class:active={activeMethod === 'totp'} 181 + onclick={() => activeMethod = 'totp'} 182 + > 183 + TOTP 184 + </button> 185 + {/if} 186 + {#if availableMethods.includes('passkey')} 187 + <button 188 + class="tab" 189 + class:active={activeMethod === 'passkey'} 190 + onclick={() => activeMethod = 'passkey'} 191 + > 192 + Passkey 193 + </button> 194 + {/if} 195 + </div> 196 + {/if} 197 + 198 + <div class="modal-content"> 199 + {#if activeMethod === 'password'} 200 + <form onsubmit={handlePasswordSubmit}> 201 + <div class="form-group"> 202 + <label for="reauth-password">Password</label> 203 + <input 204 + id="reauth-password" 205 + type="password" 206 + bind:value={password} 207 + required 208 + autocomplete="current-password" 209 + /> 210 + </div> 211 + <button type="submit" class="btn-primary" disabled={loading || !password}> 212 + {loading ? 'Verifying...' : 'Verify'} 213 + </button> 214 + </form> 215 + {:else if activeMethod === 'totp'} 216 + <form onsubmit={handleTotpSubmit}> 217 + <div class="form-group"> 218 + <label for="reauth-totp">Authenticator Code</label> 219 + <input 220 + id="reauth-totp" 221 + type="text" 222 + bind:value={totpCode} 223 + required 224 + autocomplete="one-time-code" 225 + inputmode="numeric" 226 + pattern="[0-9]*" 227 + maxlength="6" 228 + /> 229 + </div> 230 + <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 231 + {loading ? 'Verifying...' : 'Verify'} 232 + </button> 233 + </form> 234 + {:else if activeMethod === 'passkey'} 235 + <div class="passkey-auth"> 236 + <p>Click the button below to authenticate with your passkey.</p> 237 + <button 238 + class="btn-primary" 239 + onclick={handlePasskeyAuth} 240 + disabled={loading} 241 + > 242 + {loading ? 'Authenticating...' : 'Use Passkey'} 243 + </button> 244 + </div> 245 + {/if} 246 + </div> 247 + 248 + <div class="modal-footer"> 249 + <button class="btn-secondary" onclick={handleClose} disabled={loading}> 250 + Cancel 251 + </button> 252 + </div> 253 + </div> 254 + </div> 255 + {/if} 256 + 257 + <style> 258 + .modal-backdrop { 259 + position: fixed; 260 + inset: 0; 261 + background: rgba(0, 0, 0, 0.5); 262 + display: flex; 263 + align-items: center; 264 + justify-content: center; 265 + z-index: 1000; 266 + } 267 + 268 + .modal { 269 + background: var(--bg-card); 270 + border-radius: 8px; 271 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 272 + max-width: 400px; 273 + width: 90%; 274 + max-height: 90vh; 275 + overflow-y: auto; 276 + } 277 + 278 + .modal-header { 279 + display: flex; 280 + justify-content: space-between; 281 + align-items: center; 282 + padding: 1rem 1.5rem; 283 + border-bottom: 1px solid var(--border-color); 284 + } 285 + 286 + .modal-header h2 { 287 + margin: 0; 288 + font-size: 1.25rem; 289 + } 290 + 291 + .close-btn { 292 + background: none; 293 + border: none; 294 + font-size: 1.5rem; 295 + cursor: pointer; 296 + color: var(--text-secondary); 297 + padding: 0; 298 + line-height: 1; 299 + } 300 + 301 + .close-btn:hover { 302 + color: var(--text-primary); 303 + } 304 + 305 + .modal-description { 306 + padding: 1rem 1.5rem 0; 307 + margin: 0; 308 + color: var(--text-secondary); 309 + } 310 + 311 + .error-message { 312 + margin: 1rem 1.5rem 0; 313 + padding: 0.75rem; 314 + background: var(--error-bg); 315 + border: 1px solid var(--error-border); 316 + border-radius: 4px; 317 + color: var(--error-text); 318 + font-size: 0.875rem; 319 + } 320 + 321 + .method-tabs { 322 + display: flex; 323 + gap: 0.5rem; 324 + padding: 1rem 1.5rem 0; 325 + } 326 + 327 + .tab { 328 + flex: 1; 329 + padding: 0.5rem 1rem; 330 + background: var(--bg-input); 331 + border: 1px solid var(--border-color); 332 + border-radius: 4px; 333 + cursor: pointer; 334 + color: var(--text-secondary); 335 + font-size: 0.875rem; 336 + } 337 + 338 + .tab:hover { 339 + background: var(--bg-secondary); 340 + } 341 + 342 + .tab.active { 343 + background: var(--accent); 344 + border-color: var(--accent); 345 + color: white; 346 + } 347 + 348 + .modal-content { 349 + padding: 1.5rem; 350 + } 351 + 352 + .form-group { 353 + margin-bottom: 1rem; 354 + } 355 + 356 + .form-group label { 357 + display: block; 358 + margin-bottom: 0.5rem; 359 + font-weight: 500; 360 + } 361 + 362 + .form-group input { 363 + width: 100%; 364 + padding: 0.75rem; 365 + border: 1px solid var(--border-color); 366 + border-radius: 4px; 367 + background: var(--bg-input); 368 + color: var(--text-primary); 369 + font-size: 1rem; 370 + } 371 + 372 + .form-group input:focus { 373 + outline: none; 374 + border-color: var(--accent); 375 + } 376 + 377 + .passkey-auth { 378 + text-align: center; 379 + } 380 + 381 + .passkey-auth p { 382 + margin-bottom: 1rem; 383 + color: var(--text-secondary); 384 + } 385 + 386 + .btn-primary { 387 + width: 100%; 388 + padding: 0.75rem 1.5rem; 389 + background: var(--accent); 390 + color: white; 391 + border: none; 392 + border-radius: 4px; 393 + font-size: 1rem; 394 + cursor: pointer; 395 + } 396 + 397 + .btn-primary:hover:not(:disabled) { 398 + background: var(--accent-hover); 399 + } 400 + 401 + .btn-primary:disabled { 402 + opacity: 0.6; 403 + cursor: not-allowed; 404 + } 405 + 406 + .modal-footer { 407 + padding: 0 1.5rem 1.5rem; 408 + display: flex; 409 + justify-content: flex-end; 410 + } 411 + 412 + .btn-secondary { 413 + padding: 0.5rem 1rem; 414 + background: var(--bg-input); 415 + border: 1px solid var(--border-color); 416 + border-radius: 4px; 417 + color: var(--text-secondary); 418 + cursor: pointer; 419 + font-size: 0.875rem; 420 + } 421 + 422 + .btn-secondary:hover:not(:disabled) { 423 + background: var(--bg-secondary); 424 + } 425 + 426 + .btn-secondary:disabled { 427 + opacity: 0.6; 428 + cursor: not-allowed; 429 + } 430 + </style>
+141 -3
frontend/src/lib/api.ts
··· 2 2 3 3 export class ApiError extends Error { 4 4 public did?: string 5 - constructor(public status: number, public error: string, message: string, did?: string) { 5 + public reauthMethods?: string[] 6 + constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) { 6 7 super(message) 7 8 this.name = 'ApiError' 8 9 this.did = did 10 + this.reauthMethods = reauthMethods 9 11 } 10 12 } 11 13 ··· 35 37 }) 36 38 if (!res.ok) { 37 39 const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 38 - throw new ApiError(res.status, err.error, err.message, err.did) 40 + throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods) 39 41 } 40 42 return res.json() 41 43 } ··· 208 210 }) 209 211 }, 210 212 211 - async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> { 213 + async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> { 212 214 return xrpc('com.atproto.server.requestEmailUpdate', { 213 215 method: 'POST', 214 216 token, 217 + body: { email }, 215 218 }) 216 219 }, 217 220 ··· 317 320 }) 318 321 }, 319 322 323 + async removePassword(token: string): Promise<{ success: boolean }> { 324 + return xrpc('com.tranquil.account.removePassword', { 325 + method: 'POST', 326 + token, 327 + }) 328 + }, 329 + 330 + async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 331 + return xrpc('com.tranquil.account.getPasswordStatus', { token }) 332 + }, 333 + 320 334 async listSessions(token: string): Promise<{ 321 335 sessions: Array<{ 322 336 id: string ··· 567 581 method: 'POST', 568 582 token, 569 583 body: { id, friendlyName }, 584 + }) 585 + }, 586 + 587 + async listTrustedDevices(token: string): Promise<{ 588 + devices: Array<{ 589 + id: string 590 + userAgent: string | null 591 + friendlyName: string | null 592 + trustedAt: string | null 593 + trustedUntil: string | null 594 + lastSeenAt: string 595 + }> 596 + }> { 597 + return xrpc('com.tranquil.account.listTrustedDevices', { token }) 598 + }, 599 + 600 + async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> { 601 + return xrpc('com.tranquil.account.revokeTrustedDevice', { 602 + method: 'POST', 603 + token, 604 + body: { deviceId }, 605 + }) 606 + }, 607 + 608 + async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> { 609 + return xrpc('com.tranquil.account.updateTrustedDevice', { 610 + method: 'POST', 611 + token, 612 + body: { deviceId, friendlyName }, 613 + }) 614 + }, 615 + 616 + async getReauthStatus(token: string): Promise<{ 617 + requiresReauth: boolean 618 + lastReauthAt: string | null 619 + availableMethods: string[] 620 + }> { 621 + return xrpc('com.tranquil.account.getReauthStatus', { token }) 622 + }, 623 + 624 + async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> { 625 + return xrpc('com.tranquil.account.reauthPassword', { 626 + method: 'POST', 627 + token, 628 + body: { password }, 629 + }) 630 + }, 631 + 632 + async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> { 633 + return xrpc('com.tranquil.account.reauthTotp', { 634 + method: 'POST', 635 + token, 636 + body: { code }, 637 + }) 638 + }, 639 + 640 + async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 641 + return xrpc('com.tranquil.account.reauthPasskeyStart', { 642 + method: 'POST', 643 + token, 644 + }) 645 + }, 646 + 647 + async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> { 648 + return xrpc('com.tranquil.account.reauthPasskeyFinish', { 649 + method: 'POST', 650 + token, 651 + body: { credential }, 652 + }) 653 + }, 654 + 655 + async createPasskeyAccount(params: { 656 + handle: string 657 + email?: string 658 + inviteCode?: string 659 + didType?: DidType 660 + did?: string 661 + signingKey?: string 662 + verificationChannel?: VerificationChannel 663 + discordId?: string 664 + telegramUsername?: string 665 + signalNumber?: string 666 + }): Promise<{ 667 + did: string 668 + handle: string 669 + setupToken: string 670 + setupExpiresAt: string 671 + }> { 672 + return xrpc('com.tranquil.account.createPasskeyAccount', { 673 + method: 'POST', 674 + body: params, 675 + }) 676 + }, 677 + 678 + async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> { 679 + return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', { 680 + method: 'POST', 681 + body: { did, setupToken, friendlyName }, 682 + }) 683 + }, 684 + 685 + async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{ 686 + did: string 687 + handle: string 688 + appPassword: string 689 + appPasswordName: string 690 + }> { 691 + return xrpc('com.tranquil.account.completePasskeySetup', { 692 + method: 'POST', 693 + body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 694 + }) 695 + }, 696 + 697 + async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 698 + return xrpc('com.tranquil.account.requestPasskeyRecovery', { 699 + method: 'POST', 700 + body: { email }, 701 + }) 702 + }, 703 + 704 + async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> { 705 + return xrpc('com.tranquil.account.recoverPasskeyAccount', { 706 + method: 'POST', 707 + body: { did, recoveryToken, newPassword }, 570 708 }) 571 709 }, 572 710 }
+1 -1
frontend/src/routes/Login.svelte
··· 142 142 {submitting ? 'Redirecting...' : 'Sign In'} 143 143 </button> 144 144 <p class="forgot-link"> 145 - <a href="#/reset-password">Forgot password?</a> 145 + <a href="#/reset-password">Forgot password?</a> &middot; <a href="#/request-passkey-recovery">Lost passkey?</a> 146 146 </p> 147 147 <p class="register-link"> 148 148 Don't have an account? <a href="#/register">Create one</a>
+2 -2
frontend/src/routes/OAuthConsent.svelte
··· 115 115 try { 116 116 const response = await fetch('/oauth/authorize/deny', { 117 117 method: 'POST', 118 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 119 - body: `request_uri=${encodeURIComponent(consentData.request_uri)}` 118 + headers: { 'Content-Type': 'application/json' }, 119 + body: JSON.stringify({ request_uri: consentData.request_uri }) 120 120 }) 121 121 122 122 if (response.redirected) {
+19
frontend/src/routes/OAuthLogin.svelte
··· 399 399 </button> 400 400 </div> 401 401 </form> 402 + 403 + <p class="help-links"> 404 + <a href="#/reset-password">Forgot password?</a> &middot; <a href="#/request-passkey-recovery">Lost passkey?</a> 405 + </p> 402 406 </div> 403 407 404 408 <style> 409 + .help-links { 410 + text-align: center; 411 + margin-top: 1rem; 412 + font-size: 0.875rem; 413 + } 414 + 415 + .help-links a { 416 + color: var(--accent); 417 + text-decoration: none; 418 + } 419 + 420 + .help-links a:hover { 421 + text-decoration: underline; 422 + } 423 + 405 424 .oauth-login-container { 406 425 max-width: 400px; 407 426 margin: 4rem auto;
+304
frontend/src/routes/OAuthPasskey.svelte
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + 4 + let loading = $state(false) 5 + let error = $state<string | null>(null) 6 + let autoStarted = $state(false) 7 + 8 + function getRequestUri(): string | null { 9 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 + return params.get('request_uri') 11 + } 12 + 13 + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 14 + const bytes = new Uint8Array(buffer) 15 + let binary = '' 16 + for (let i = 0; i < bytes.byteLength; i++) { 17 + binary += String.fromCharCode(bytes[i]) 18 + } 19 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 20 + } 21 + 22 + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 23 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 24 + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 25 + const binary = atob(padded) 26 + const bytes = new Uint8Array(binary.length) 27 + for (let i = 0; i < binary.length; i++) { 28 + bytes[i] = binary.charCodeAt(i) 29 + } 30 + return bytes.buffer 31 + } 32 + 33 + function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions { 34 + return { 35 + ...options.publicKey, 36 + challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 37 + allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ 38 + ...cred, 39 + id: base64UrlToArrayBuffer(cred.id) 40 + })) || [] 41 + } 42 + } 43 + 44 + async function startPasskeyAuth() { 45 + const requestUri = getRequestUri() 46 + if (!requestUri) { 47 + error = 'Missing request_uri parameter' 48 + return 49 + } 50 + 51 + if (!window.PublicKeyCredential) { 52 + error = 'Passkeys are not supported in this browser' 53 + return 54 + } 55 + 56 + loading = true 57 + error = null 58 + 59 + try { 60 + const startResponse = await fetch(`/oauth/authorize/passkey?request_uri=${encodeURIComponent(requestUri)}`, { 61 + method: 'GET', 62 + headers: { 63 + 'Accept': 'application/json' 64 + } 65 + }) 66 + 67 + if (!startResponse.ok) { 68 + const data = await startResponse.json() 69 + error = data.error_description || data.error || 'Failed to start passkey authentication' 70 + loading = false 71 + return 72 + } 73 + 74 + const { options } = await startResponse.json() 75 + const publicKeyOptions = prepareAuthOptions(options) 76 + 77 + const credential = await navigator.credentials.get({ 78 + publicKey: publicKeyOptions 79 + }) 80 + 81 + if (!credential) { 82 + error = 'Passkey authentication was cancelled' 83 + loading = false 84 + return 85 + } 86 + 87 + const pkCredential = credential as PublicKeyCredential 88 + const response = pkCredential.response as AuthenticatorAssertionResponse 89 + const credentialResponse = { 90 + id: pkCredential.id, 91 + type: pkCredential.type, 92 + rawId: arrayBufferToBase64Url(pkCredential.rawId), 93 + response: { 94 + clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 95 + authenticatorData: arrayBufferToBase64Url(response.authenticatorData), 96 + signature: arrayBufferToBase64Url(response.signature), 97 + userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null, 98 + }, 99 + } 100 + 101 + const finishResponse = await fetch('/oauth/authorize/passkey', { 102 + method: 'POST', 103 + headers: { 104 + 'Content-Type': 'application/json', 105 + 'Accept': 'application/json' 106 + }, 107 + body: JSON.stringify({ 108 + request_uri: requestUri, 109 + credential: credentialResponse 110 + }) 111 + }) 112 + 113 + const finishData = await finishResponse.json() 114 + 115 + if (!finishResponse.ok) { 116 + error = finishData.error_description || finishData.error || 'Passkey verification failed' 117 + loading = false 118 + return 119 + } 120 + 121 + if (finishData.redirect_uri) { 122 + window.location.href = finishData.redirect_uri 123 + return 124 + } 125 + 126 + error = 'Unexpected response from server' 127 + loading = false 128 + } catch (e) { 129 + if (e instanceof DOMException && e.name === 'NotAllowedError') { 130 + error = 'Passkey authentication was cancelled' 131 + } else { 132 + error = 'Failed to authenticate with passkey' 133 + } 134 + loading = false 135 + } 136 + } 137 + 138 + function handleCancel() { 139 + const requestUri = getRequestUri() 140 + if (requestUri) { 141 + navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`) 142 + } else { 143 + window.history.back() 144 + } 145 + } 146 + 147 + $effect(() => { 148 + if (!autoStarted) { 149 + autoStarted = true 150 + startPasskeyAuth() 151 + } 152 + }) 153 + </script> 154 + 155 + <div class="oauth-passkey-container"> 156 + <h1>Sign In with Passkey</h1> 157 + <p class="subtitle"> 158 + Your account uses a passkey for authentication. Use your fingerprint, face, or security key to sign in. 159 + </p> 160 + 161 + {#if error} 162 + <div class="error">{error}</div> 163 + {/if} 164 + 165 + <div class="passkey-status"> 166 + {#if loading} 167 + <div class="loading-indicator"> 168 + <div class="spinner"></div> 169 + <p>Waiting for passkey...</p> 170 + </div> 171 + {:else} 172 + <button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}> 173 + Use Passkey 174 + </button> 175 + {/if} 176 + </div> 177 + 178 + <div class="actions"> 179 + <button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}> 180 + Cancel 181 + </button> 182 + </div> 183 + 184 + <p class="help-text"> 185 + If you've lost access to your passkey, you can recover your account using email. 186 + </p> 187 + </div> 188 + 189 + <style> 190 + .oauth-passkey-container { 191 + max-width: 400px; 192 + margin: 4rem auto; 193 + padding: 2rem; 194 + text-align: center; 195 + } 196 + 197 + h1 { 198 + margin: 0 0 0.5rem 0; 199 + } 200 + 201 + .subtitle { 202 + color: var(--text-secondary); 203 + margin: 0 0 2rem 0; 204 + } 205 + 206 + .error { 207 + padding: 0.75rem; 208 + background: var(--error-bg); 209 + border: 1px solid var(--error-border); 210 + border-radius: 4px; 211 + color: var(--error-text); 212 + margin-bottom: 1.5rem; 213 + text-align: left; 214 + } 215 + 216 + .passkey-status { 217 + padding: 2rem; 218 + background: var(--bg-secondary); 219 + border-radius: 8px; 220 + margin-bottom: 1.5rem; 221 + } 222 + 223 + .loading-indicator { 224 + display: flex; 225 + flex-direction: column; 226 + align-items: center; 227 + gap: 1rem; 228 + } 229 + 230 + .spinner { 231 + width: 40px; 232 + height: 40px; 233 + border: 3px solid var(--border-color); 234 + border-top-color: var(--accent); 235 + border-radius: 50%; 236 + animation: spin 1s linear infinite; 237 + } 238 + 239 + @keyframes spin { 240 + to { 241 + transform: rotate(360deg); 242 + } 243 + } 244 + 245 + .loading-indicator p { 246 + margin: 0; 247 + color: var(--text-secondary); 248 + } 249 + 250 + .passkey-btn { 251 + width: 100%; 252 + padding: 1rem; 253 + background: var(--accent); 254 + color: white; 255 + border: none; 256 + border-radius: 4px; 257 + font-size: 1rem; 258 + cursor: pointer; 259 + transition: background-color 0.15s; 260 + } 261 + 262 + .passkey-btn:hover:not(:disabled) { 263 + background: var(--accent-hover); 264 + } 265 + 266 + .passkey-btn:disabled { 267 + opacity: 0.6; 268 + cursor: not-allowed; 269 + } 270 + 271 + .actions { 272 + display: flex; 273 + justify-content: center; 274 + margin-bottom: 1.5rem; 275 + } 276 + 277 + .cancel-btn { 278 + padding: 0.75rem 2rem; 279 + background: var(--bg-secondary); 280 + color: var(--text-primary); 281 + border: 1px solid var(--border-color); 282 + border-radius: 4px; 283 + font-size: 1rem; 284 + cursor: pointer; 285 + transition: background-color 0.15s; 286 + } 287 + 288 + .cancel-btn:hover:not(:disabled) { 289 + background: var(--error-bg); 290 + border-color: var(--error-border); 291 + color: var(--error-text); 292 + } 293 + 294 + .cancel-btn:disabled { 295 + opacity: 0.6; 296 + cursor: not-allowed; 297 + } 298 + 299 + .help-text { 300 + font-size: 0.875rem; 301 + color: var(--text-muted); 302 + margin: 0; 303 + } 304 + </style>
+27 -1
frontend/src/routes/OAuthTotp.svelte
··· 2 2 import { navigate } from '../lib/router.svelte' 3 3 4 4 let code = $state('') 5 + let trustDevice = $state(false) 5 6 let submitting = $state(false) 6 7 let error = $state<string | null>(null) 7 8 ··· 30 31 }, 31 32 body: JSON.stringify({ 32 33 request_uri: requestUri, 33 - code: code.trim().toUpperCase() 34 + code: code.trim().toUpperCase(), 35 + trust_device: trustDevice 34 36 }) 35 37 }) 36 38 ··· 103 105 {/if} 104 106 </p> 105 107 </div> 108 + 109 + <label class="trust-device-label"> 110 + <input 111 + type="checkbox" 112 + bind:checked={trustDevice} 113 + disabled={submitting} 114 + /> 115 + <span>Trust this device for 30 days</span> 116 + </label> 106 117 107 118 <div class="actions"> 108 119 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> ··· 221 232 222 233 .submit-btn:hover:not(:disabled) { 223 234 background: var(--accent-hover); 235 + } 236 + 237 + .trust-device-label { 238 + display: flex; 239 + align-items: center; 240 + gap: 0.5rem; 241 + cursor: pointer; 242 + font-size: 0.875rem; 243 + color: var(--text-secondary); 244 + margin-top: 0.5rem; 245 + } 246 + 247 + .trust-device-label input[type="checkbox"] { 248 + width: auto; 249 + margin: 0; 224 250 } 225 251 </style>
+266
frontend/src/routes/RecoverPasskey.svelte
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + import { api, ApiError } from '../lib/api' 4 + 5 + let newPassword = $state('') 6 + let confirmPassword = $state('') 7 + let submitting = $state(false) 8 + let error = $state<string | null>(null) 9 + let success = $state(false) 10 + 11 + function getUrlParams(): { did: string | null; token: string | null } { 12 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 13 + return { 14 + did: params.get('did'), 15 + token: params.get('token'), 16 + } 17 + } 18 + 19 + let { did, token } = getUrlParams() 20 + 21 + function validateForm(): string | null { 22 + if (!newPassword) return 'New password is required' 23 + if (newPassword.length < 8) return 'Password must be at least 8 characters' 24 + if (newPassword !== confirmPassword) return 'Passwords do not match' 25 + return null 26 + } 27 + 28 + async function handleSubmit(e: Event) { 29 + e.preventDefault() 30 + 31 + if (!did || !token) { 32 + error = 'Invalid recovery link. Please request a new one.' 33 + return 34 + } 35 + 36 + const validationError = validateForm() 37 + if (validationError) { 38 + error = validationError 39 + return 40 + } 41 + 42 + submitting = true 43 + error = null 44 + 45 + try { 46 + await api.recoverPasskeyAccount(did, token, newPassword) 47 + success = true 48 + } catch (err) { 49 + if (err instanceof ApiError) { 50 + if (err.error === 'RecoveryLinkExpired') { 51 + error = 'This recovery link has expired. Please request a new one.' 52 + } else if (err.error === 'InvalidRecoveryLink') { 53 + error = 'Invalid recovery link. Please request a new one.' 54 + } else { 55 + error = err.message || 'Recovery failed' 56 + } 57 + } else if (err instanceof Error) { 58 + error = err.message || 'Recovery failed' 59 + } else { 60 + error = 'Recovery failed' 61 + } 62 + } finally { 63 + submitting = false 64 + } 65 + } 66 + 67 + function goToLogin() { 68 + navigate('/login') 69 + } 70 + 71 + function requestNewLink() { 72 + navigate('/login') 73 + } 74 + </script> 75 + 76 + <div class="recover-container"> 77 + {#if !did || !token} 78 + <h1>Invalid Recovery Link</h1> 79 + <p class="error-message"> 80 + This recovery link is invalid or has been corrupted. Please request a new recovery email. 81 + </p> 82 + <button onclick={requestNewLink}>Go to Login</button> 83 + {:else if success} 84 + <div class="success-content"> 85 + <div class="success-icon">&#x2714;</div> 86 + <h1>Password Set!</h1> 87 + <p class="success-message"> 88 + Your temporary password has been set. You can now sign in with this password. 89 + </p> 90 + <p class="next-steps"> 91 + After signing in, we recommend adding a new passkey in your security settings 92 + to restore passkey-only authentication. 93 + </p> 94 + <button onclick={goToLogin}>Sign In</button> 95 + </div> 96 + {:else} 97 + <h1>Recover Your Account</h1> 98 + <p class="subtitle"> 99 + Set a temporary password to regain access to your passkey-only account. 100 + </p> 101 + 102 + {#if error} 103 + <div class="error">{error}</div> 104 + {/if} 105 + 106 + <form onsubmit={handleSubmit}> 107 + <div class="field"> 108 + <label for="new-password">New Password</label> 109 + <input 110 + id="new-password" 111 + type="password" 112 + bind:value={newPassword} 113 + placeholder="At least 8 characters" 114 + disabled={submitting} 115 + required 116 + minlength="8" 117 + /> 118 + </div> 119 + 120 + <div class="field"> 121 + <label for="confirm-password">Confirm Password</label> 122 + <input 123 + id="confirm-password" 124 + type="password" 125 + bind:value={confirmPassword} 126 + placeholder="Confirm your password" 127 + disabled={submitting} 128 + required 129 + /> 130 + </div> 131 + 132 + <div class="info-box"> 133 + <strong>What happens next?</strong> 134 + <p> 135 + After setting this password, you can sign in and add a new passkey in your security settings. 136 + Once you have a new passkey, you can optionally remove the temporary password. 137 + </p> 138 + </div> 139 + 140 + <button type="submit" disabled={submitting}> 141 + {submitting ? 'Setting password...' : 'Set Password'} 142 + </button> 143 + </form> 144 + {/if} 145 + </div> 146 + 147 + <style> 148 + .recover-container { 149 + max-width: 400px; 150 + margin: 4rem auto; 151 + padding: 2rem; 152 + } 153 + 154 + h1 { 155 + margin: 0 0 0.5rem 0; 156 + } 157 + 158 + .subtitle { 159 + color: var(--text-secondary); 160 + margin: 0 0 2rem 0; 161 + } 162 + 163 + form { 164 + display: flex; 165 + flex-direction: column; 166 + gap: 1rem; 167 + } 168 + 169 + .field { 170 + display: flex; 171 + flex-direction: column; 172 + gap: 0.25rem; 173 + } 174 + 175 + label { 176 + font-size: 0.875rem; 177 + font-weight: 500; 178 + } 179 + 180 + input { 181 + padding: 0.75rem; 182 + border: 1px solid var(--border-color-light); 183 + border-radius: 4px; 184 + font-size: 1rem; 185 + background: var(--bg-input); 186 + color: var(--text-primary); 187 + } 188 + 189 + input:focus { 190 + outline: none; 191 + border-color: var(--accent); 192 + } 193 + 194 + .info-box { 195 + background: var(--bg-secondary); 196 + border: 1px solid var(--border-color); 197 + border-radius: 6px; 198 + padding: 1rem; 199 + font-size: 0.875rem; 200 + } 201 + 202 + .info-box strong { 203 + display: block; 204 + margin-bottom: 0.5rem; 205 + } 206 + 207 + .info-box p { 208 + margin: 0; 209 + color: var(--text-secondary); 210 + } 211 + 212 + button { 213 + padding: 0.75rem; 214 + background: var(--accent); 215 + color: white; 216 + border: none; 217 + border-radius: 4px; 218 + font-size: 1rem; 219 + cursor: pointer; 220 + margin-top: 0.5rem; 221 + } 222 + 223 + button:hover:not(:disabled) { 224 + background: var(--accent-hover); 225 + } 226 + 227 + button:disabled { 228 + opacity: 0.6; 229 + cursor: not-allowed; 230 + } 231 + 232 + .error { 233 + padding: 0.75rem; 234 + background: var(--error-bg); 235 + border: 1px solid var(--error-border); 236 + border-radius: 4px; 237 + color: var(--error-text); 238 + margin-bottom: 1rem; 239 + } 240 + 241 + .error-message { 242 + color: var(--text-secondary); 243 + margin-bottom: 1.5rem; 244 + } 245 + 246 + .success-content { 247 + text-align: center; 248 + } 249 + 250 + .success-icon { 251 + font-size: 4rem; 252 + color: var(--success-text); 253 + margin-bottom: 1rem; 254 + } 255 + 256 + .success-message { 257 + color: var(--text-secondary); 258 + margin-bottom: 0.5rem; 259 + } 260 + 261 + .next-steps { 262 + color: var(--text-muted); 263 + font-size: 0.875rem; 264 + margin-bottom: 1.5rem; 265 + } 266 + </style>
+3
frontend/src/routes/Register.svelte
··· 342 342 <p class="login-link"> 343 343 Already have an account? <a href="#/login">Sign in</a> 344 344 </p> 345 + <p class="login-link"> 346 + Want passwordless security? <a href="#/register-passkey">Create a passkey account</a> 347 + </p> 345 348 {/if} 346 349 </div> 347 350 <style>
+1011
frontend/src/routes/RegisterPasskey.svelte
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 4 + import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte' 5 + 6 + const auth = getAuthState() 7 + 8 + let step = $state<'info' | 'passkey' | 'app-password' | 'verify' | 'success'>('info') 9 + let handle = $state('') 10 + let email = $state('') 11 + let inviteCode = $state('') 12 + let didType = $state<DidType>('plc') 13 + let externalDid = $state('') 14 + let verificationChannel = $state<VerificationChannel>('email') 15 + let discordId = $state('') 16 + let telegramUsername = $state('') 17 + let signalNumber = $state('') 18 + let passkeyName = $state('') 19 + let submitting = $state(false) 20 + let error = $state<string | null>(null) 21 + let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean } | null>(null) 22 + let loadingServerInfo = $state(true) 23 + let serverInfoLoaded = false 24 + 25 + let setupData = $state<{ did: string; handle: string; setupToken: string } | null>(null) 26 + let appPasswordResult = $state<{ appPassword: string; appPasswordName: string } | null>(null) 27 + let appPasswordAcknowledged = $state(false) 28 + let appPasswordCopied = $state(false) 29 + let verificationCode = $state('') 30 + let resendingCode = $state(false) 31 + let resendMessage = $state<string | null>(null) 32 + 33 + $effect(() => { 34 + if (auth.session) { 35 + navigate('/dashboard') 36 + } 37 + }) 38 + 39 + $effect(() => { 40 + if (!serverInfoLoaded) { 41 + serverInfoLoaded = true 42 + loadServerInfo() 43 + } 44 + }) 45 + 46 + async function loadServerInfo() { 47 + try { 48 + serverInfo = await api.describeServer() 49 + } catch (e) { 50 + console.error('Failed to load server info:', e) 51 + } finally { 52 + loadingServerInfo = false 53 + } 54 + } 55 + 56 + function validateInfoStep(): string | null { 57 + if (!handle.trim()) return 'Handle is required' 58 + if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 59 + if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 60 + return 'Invite code is required' 61 + } 62 + if (didType === 'web-external') { 63 + if (!externalDid.trim()) return 'External did:web is required' 64 + if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:' 65 + } 66 + switch (verificationChannel) { 67 + case 'email': 68 + if (!email.trim()) return 'Email is required for email verification' 69 + break 70 + case 'discord': 71 + if (!discordId.trim()) return 'Discord ID is required for Discord verification' 72 + break 73 + case 'telegram': 74 + if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification' 75 + break 76 + case 'signal': 77 + if (!signalNumber.trim()) return 'Phone number is required for Signal verification' 78 + break 79 + } 80 + return null 81 + } 82 + 83 + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 84 + const bytes = new Uint8Array(buffer) 85 + let binary = '' 86 + for (let i = 0; i < bytes.byteLength; i++) { 87 + binary += String.fromCharCode(bytes[i]) 88 + } 89 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 90 + } 91 + 92 + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 93 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 94 + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 95 + const binary = atob(padded) 96 + const bytes = new Uint8Array(binary.length) 97 + for (let i = 0; i < binary.length; i++) { 98 + bytes[i] = binary.charCodeAt(i) 99 + } 100 + return bytes.buffer 101 + } 102 + 103 + function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions { 104 + return { 105 + ...options.publicKey, 106 + challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 107 + user: { 108 + ...options.publicKey.user, 109 + id: base64UrlToArrayBuffer(options.publicKey.user.id) 110 + }, 111 + excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({ 112 + ...cred, 113 + id: base64UrlToArrayBuffer(cred.id) 114 + })) || [] 115 + } 116 + } 117 + 118 + async function handleInfoSubmit(e: Event) { 119 + e.preventDefault() 120 + const validationError = validateInfoStep() 121 + if (validationError) { 122 + error = validationError 123 + return 124 + } 125 + 126 + if (!window.PublicKeyCredential) { 127 + error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.' 128 + return 129 + } 130 + 131 + submitting = true 132 + error = null 133 + 134 + try { 135 + const result = await api.createPasskeyAccount({ 136 + handle: handle.trim(), 137 + email: email.trim() || undefined, 138 + inviteCode: inviteCode.trim() || undefined, 139 + didType, 140 + did: didType === 'web-external' ? externalDid.trim() : undefined, 141 + verificationChannel, 142 + discordId: discordId.trim() || undefined, 143 + telegramUsername: telegramUsername.trim() || undefined, 144 + signalNumber: signalNumber.trim() || undefined, 145 + }) 146 + 147 + setupData = { 148 + did: result.did, 149 + handle: result.handle, 150 + setupToken: result.setupToken, 151 + } 152 + 153 + step = 'passkey' 154 + } catch (err) { 155 + if (err instanceof ApiError) { 156 + error = err.message || 'Registration failed' 157 + } else if (err instanceof Error) { 158 + error = err.message || 'Registration failed' 159 + } else { 160 + error = 'Registration failed' 161 + } 162 + } finally { 163 + submitting = false 164 + } 165 + } 166 + 167 + async function handlePasskeyRegistration() { 168 + if (!setupData) return 169 + 170 + submitting = true 171 + error = null 172 + 173 + try { 174 + const { options } = await api.startPasskeyRegistrationForSetup( 175 + setupData.did, 176 + setupData.setupToken, 177 + passkeyName || undefined 178 + ) 179 + 180 + const publicKeyOptions = preparePublicKeyOptions(options) 181 + const credential = await navigator.credentials.create({ 182 + publicKey: publicKeyOptions 183 + }) 184 + 185 + if (!credential) { 186 + error = 'Passkey creation was cancelled' 187 + submitting = false 188 + return 189 + } 190 + 191 + const pkCredential = credential as PublicKeyCredential 192 + const response = pkCredential.response as AuthenticatorAttestationResponse 193 + const credentialResponse = { 194 + id: pkCredential.id, 195 + type: pkCredential.type, 196 + rawId: arrayBufferToBase64Url(pkCredential.rawId), 197 + response: { 198 + clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 199 + attestationObject: arrayBufferToBase64Url(response.attestationObject), 200 + }, 201 + } 202 + 203 + const result = await api.completePasskeySetup( 204 + setupData.did, 205 + setupData.setupToken, 206 + credentialResponse, 207 + passkeyName || undefined 208 + ) 209 + 210 + appPasswordResult = { 211 + appPassword: result.appPassword, 212 + appPasswordName: result.appPasswordName, 213 + } 214 + 215 + step = 'app-password' 216 + } catch (err) { 217 + if (err instanceof DOMException && err.name === 'NotAllowedError') { 218 + error = 'Passkey creation was cancelled' 219 + } else if (err instanceof ApiError) { 220 + error = err.message || 'Passkey registration failed' 221 + } else if (err instanceof Error) { 222 + error = err.message || 'Passkey registration failed' 223 + } else { 224 + error = 'Passkey registration failed' 225 + } 226 + } finally { 227 + submitting = false 228 + } 229 + } 230 + 231 + function copyAppPassword() { 232 + if (appPasswordResult) { 233 + navigator.clipboard.writeText(appPasswordResult.appPassword) 234 + appPasswordCopied = true 235 + } 236 + } 237 + 238 + function handleFinish() { 239 + step = 'verify' 240 + } 241 + 242 + async function handleVerification() { 243 + if (!setupData || !verificationCode.trim()) return 244 + 245 + submitting = true 246 + error = null 247 + 248 + try { 249 + await confirmSignup(setupData.did, verificationCode.trim()) 250 + navigate('/dashboard') 251 + } catch (err) { 252 + if (err instanceof ApiError) { 253 + error = err.message || 'Verification failed' 254 + } else if (err instanceof Error) { 255 + error = err.message || 'Verification failed' 256 + } else { 257 + error = 'Verification failed' 258 + } 259 + } finally { 260 + submitting = false 261 + } 262 + } 263 + 264 + async function handleResendCode() { 265 + if (!setupData || resendingCode) return 266 + 267 + resendingCode = true 268 + resendMessage = null 269 + error = null 270 + 271 + try { 272 + await resendVerification(setupData.did) 273 + resendMessage = 'Verification code resent!' 274 + } catch (err) { 275 + if (err instanceof ApiError) { 276 + error = err.message || 'Failed to resend code' 277 + } else if (err instanceof Error) { 278 + error = err.message || 'Failed to resend code' 279 + } else { 280 + error = 'Failed to resend code' 281 + } 282 + } finally { 283 + resendingCode = false 284 + } 285 + } 286 + 287 + function channelLabel(ch: string): string { 288 + switch (ch) { 289 + case 'email': return 'Email' 290 + case 'discord': return 'Discord' 291 + case 'telegram': return 'Telegram' 292 + case 'signal': return 'Signal' 293 + default: return ch 294 + } 295 + } 296 + 297 + function goToLogin() { 298 + navigate('/login') 299 + } 300 + 301 + let fullHandle = $derived(() => { 302 + if (!handle.trim()) return '' 303 + if (handle.includes('.')) return handle.trim() 304 + const domain = serverInfo?.availableUserDomains?.[0] 305 + if (domain) return `${handle.trim()}.${domain}` 306 + return handle.trim() 307 + }) 308 + </script> 309 + 310 + <div class="register-passkey-container"> 311 + <h1>Create Passkey Account</h1> 312 + <p class="subtitle"> 313 + {#if step === 'info'} 314 + Create an ultra-secure account using a passkey instead of a password. 315 + {:else if step === 'passkey'} 316 + Register your passkey to secure your account. 317 + {:else if step === 'app-password'} 318 + Save your app password for third-party apps. 319 + {:else if step === 'verify'} 320 + Verify your {channelLabel(verificationChannel)} to complete registration. 321 + {:else} 322 + Your account has been created successfully! 323 + {/if} 324 + </p> 325 + 326 + {#if error} 327 + <div class="error">{error}</div> 328 + {/if} 329 + 330 + {#if loadingServerInfo} 331 + <p class="loading">Loading...</p> 332 + {:else if step === 'info'} 333 + <form onsubmit={handleInfoSubmit}> 334 + <div class="field"> 335 + <label for="handle">Handle</label> 336 + <input 337 + id="handle" 338 + type="text" 339 + bind:value={handle} 340 + placeholder="yourname" 341 + disabled={submitting} 342 + required 343 + /> 344 + {#if handle.includes('.')} 345 + <p class="hint warning">Custom domain handles can be set up after account creation.</p> 346 + {:else if fullHandle()} 347 + <p class="hint">Your full handle will be: @{fullHandle()}</p> 348 + {/if} 349 + </div> 350 + 351 + <fieldset class="section"> 352 + <legend>Contact Method</legend> 353 + <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 354 + <div class="field"> 355 + <label for="verification-channel">Verification Method</label> 356 + <select 357 + id="verification-channel" 358 + bind:value={verificationChannel} 359 + disabled={submitting} 360 + > 361 + <option value="email">Email</option> 362 + <option value="discord">Discord</option> 363 + <option value="telegram">Telegram</option> 364 + <option value="signal">Signal</option> 365 + </select> 366 + </div> 367 + {#if verificationChannel === 'email'} 368 + <div class="field"> 369 + <label for="email">Email Address</label> 370 + <input 371 + id="email" 372 + type="email" 373 + bind:value={email} 374 + placeholder="you@example.com" 375 + disabled={submitting} 376 + required 377 + /> 378 + </div> 379 + {:else if verificationChannel === 'discord'} 380 + <div class="field"> 381 + <label for="discord-id">Discord User ID</label> 382 + <input 383 + id="discord-id" 384 + type="text" 385 + bind:value={discordId} 386 + placeholder="Your Discord user ID" 387 + disabled={submitting} 388 + required 389 + /> 390 + <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 391 + </div> 392 + {:else if verificationChannel === 'telegram'} 393 + <div class="field"> 394 + <label for="telegram-username">Telegram Username</label> 395 + <input 396 + id="telegram-username" 397 + type="text" 398 + bind:value={telegramUsername} 399 + placeholder="@yourusername" 400 + disabled={submitting} 401 + required 402 + /> 403 + </div> 404 + {:else if verificationChannel === 'signal'} 405 + <div class="field"> 406 + <label for="signal-number">Signal Phone Number</label> 407 + <input 408 + id="signal-number" 409 + type="tel" 410 + bind:value={signalNumber} 411 + placeholder="+1234567890" 412 + disabled={submitting} 413 + required 414 + /> 415 + <p class="hint">Include country code (e.g., +1 for US)</p> 416 + </div> 417 + {/if} 418 + </fieldset> 419 + 420 + <fieldset class="section"> 421 + <legend>Identity Type</legend> 422 + <p class="section-hint">Choose how your decentralized identity will be managed.</p> 423 + <div class="radio-group"> 424 + <label class="radio-label"> 425 + <input 426 + type="radio" 427 + name="didType" 428 + value="plc" 429 + bind:group={didType} 430 + disabled={submitting} 431 + /> 432 + <span class="radio-content"> 433 + <strong>did:plc</strong> (Recommended) 434 + <span class="radio-hint">Portable identity managed by PLC Directory</span> 435 + </span> 436 + </label> 437 + <label class="radio-label"> 438 + <input 439 + type="radio" 440 + name="didType" 441 + value="web" 442 + bind:group={didType} 443 + disabled={submitting} 444 + /> 445 + <span class="radio-content"> 446 + <strong>did:web</strong> 447 + <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 448 + </span> 449 + </label> 450 + <label class="radio-label"> 451 + <input 452 + type="radio" 453 + name="didType" 454 + value="web-external" 455 + bind:group={didType} 456 + disabled={submitting} 457 + /> 458 + <span class="radio-content"> 459 + <strong>did:web (BYOD)</strong> 460 + <span class="radio-hint">Bring your own domain</span> 461 + </span> 462 + </label> 463 + </div> 464 + {#if didType === 'web'} 465 + <div class="did-web-warning"> 466 + <strong>Important: Understand the trade-offs</strong> 467 + <ul> 468 + <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li> 469 + <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li> 470 + <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li> 471 + <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li> 472 + </ul> 473 + </div> 474 + {/if} 475 + {#if didType === 'web-external'} 476 + <div class="field"> 477 + <label for="external-did">Your did:web</label> 478 + <input 479 + id="external-did" 480 + type="text" 481 + bind:value={externalDid} 482 + placeholder="did:web:yourdomain.com" 483 + disabled={submitting} 484 + required 485 + /> 486 + <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 487 + </div> 488 + {/if} 489 + </fieldset> 490 + 491 + {#if serverInfo?.inviteCodeRequired} 492 + <div class="field"> 493 + <label for="invite-code">Invite Code <span class="required">*</span></label> 494 + <input 495 + id="invite-code" 496 + type="text" 497 + bind:value={inviteCode} 498 + placeholder="Enter your invite code" 499 + disabled={submitting} 500 + required 501 + /> 502 + </div> 503 + {/if} 504 + 505 + <div class="info-box"> 506 + <strong>Why passkey-only?</strong> 507 + <p> 508 + Passkey accounts are more secure than password-based accounts because they: 509 + </p> 510 + <ul> 511 + <li>Cannot be phished or stolen in data breaches</li> 512 + <li>Use hardware-backed cryptographic keys</li> 513 + <li>Require your biometric or device PIN to use</li> 514 + </ul> 515 + </div> 516 + 517 + <button type="submit" disabled={submitting}> 518 + {submitting ? 'Creating account...' : 'Continue'} 519 + </button> 520 + </form> 521 + 522 + <p class="alt-link"> 523 + Want a traditional password? <a href="#/register">Register with password</a> 524 + </p> 525 + {:else if step === 'passkey'} 526 + <div class="passkey-step"> 527 + <div class="field"> 528 + <label for="passkey-name">Passkey Name (optional)</label> 529 + <input 530 + id="passkey-name" 531 + type="text" 532 + bind:value={passkeyName} 533 + placeholder="e.g., MacBook Touch ID" 534 + disabled={submitting} 535 + /> 536 + <p class="hint">A friendly name to identify this passkey</p> 537 + </div> 538 + 539 + <div class="passkey-instructions"> 540 + <p>Click the button below to create your passkey. You'll be prompted to use:</p> 541 + <ul> 542 + <li>Touch ID or Face ID</li> 543 + <li>Your device PIN or password</li> 544 + <li>A security key (if you have one)</li> 545 + </ul> 546 + </div> 547 + 548 + <button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn"> 549 + {submitting ? 'Creating Passkey...' : 'Create Passkey'} 550 + </button> 551 + 552 + <button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}> 553 + Back 554 + </button> 555 + </div> 556 + {:else if step === 'app-password'} 557 + <div class="app-password-step"> 558 + <div class="warning-box"> 559 + <strong>Important: Save this app password!</strong> 560 + <p> 561 + This app password is required to sign into apps that don't support passkeys yet (like bsky.app). 562 + You will only see this password once. 563 + </p> 564 + </div> 565 + 566 + <div class="app-password-display"> 567 + <div class="app-password-label"> 568 + App Password for: <strong>{appPasswordResult?.appPasswordName}</strong> 569 + </div> 570 + <code class="app-password-code">{appPasswordResult?.appPassword}</code> 571 + <button type="button" class="copy-btn" onclick={copyAppPassword}> 572 + {appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'} 573 + </button> 574 + </div> 575 + 576 + <div class="field acknowledge-field"> 577 + <label class="checkbox-label"> 578 + <input 579 + type="checkbox" 580 + bind:checked={appPasswordAcknowledged} 581 + /> 582 + <span>I have saved my app password in a secure location</span> 583 + </label> 584 + </div> 585 + 586 + <button onclick={handleFinish} disabled={!appPasswordAcknowledged}> 587 + Continue 588 + </button> 589 + </div> 590 + {:else if step === 'verify'} 591 + <div class="verify-step"> 592 + <p class="verify-info"> 593 + We've sent a verification code to your {channelLabel(verificationChannel)}. 594 + Enter it below to complete your account setup. 595 + </p> 596 + 597 + {#if resendMessage} 598 + <div class="success">{resendMessage}</div> 599 + {/if} 600 + 601 + <form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}> 602 + <div class="field"> 603 + <label for="verification-code">Verification Code</label> 604 + <input 605 + id="verification-code" 606 + type="text" 607 + bind:value={verificationCode} 608 + placeholder="Enter 6-digit code" 609 + disabled={submitting} 610 + required 611 + maxlength="6" 612 + inputmode="numeric" 613 + autocomplete="one-time-code" 614 + /> 615 + </div> 616 + 617 + <button type="submit" disabled={submitting || !verificationCode.trim()}> 618 + {submitting ? 'Verifying...' : 'Verify Account'} 619 + </button> 620 + 621 + <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 622 + {resendingCode ? 'Resending...' : 'Resend Code'} 623 + </button> 624 + </form> 625 + </div> 626 + {:else if step === 'success'} 627 + <div class="success-step"> 628 + <div class="success-icon">&#x2714;</div> 629 + <h2>Account Created!</h2> 630 + <p>Your passkey-only account has been created successfully.</p> 631 + <p class="handle-display">@{setupData?.handle}</p> 632 + 633 + <button onclick={goToLogin}> 634 + Sign In 635 + </button> 636 + </div> 637 + {/if} 638 + </div> 639 + 640 + <style> 641 + .register-passkey-container { 642 + max-width: 450px; 643 + margin: 4rem auto; 644 + padding: 2rem; 645 + } 646 + 647 + h1 { 648 + margin: 0 0 0.5rem 0; 649 + } 650 + 651 + h2 { 652 + margin: 0 0 0.5rem 0; 653 + } 654 + 655 + .subtitle { 656 + color: var(--text-secondary); 657 + margin: 0 0 2rem 0; 658 + } 659 + 660 + .loading { 661 + text-align: center; 662 + color: var(--text-secondary); 663 + } 664 + 665 + form { 666 + display: flex; 667 + flex-direction: column; 668 + gap: 1rem; 669 + } 670 + 671 + .field { 672 + display: flex; 673 + flex-direction: column; 674 + gap: 0.25rem; 675 + } 676 + 677 + label { 678 + font-size: 0.875rem; 679 + font-weight: 500; 680 + } 681 + 682 + .required { 683 + color: var(--error-text); 684 + } 685 + 686 + input, select { 687 + padding: 0.75rem; 688 + border: 1px solid var(--border-color-light); 689 + border-radius: 4px; 690 + font-size: 1rem; 691 + background: var(--bg-input); 692 + color: var(--text-primary); 693 + } 694 + 695 + input:focus, select:focus { 696 + outline: none; 697 + border-color: var(--accent); 698 + } 699 + 700 + .hint { 701 + font-size: 0.75rem; 702 + color: var(--text-secondary); 703 + margin: 0.25rem 0 0 0; 704 + } 705 + 706 + .hint.warning { 707 + color: var(--warning-text); 708 + } 709 + 710 + .section { 711 + border: 1px solid var(--border-color-light); 712 + border-radius: 6px; 713 + padding: 1rem; 714 + margin: 0.5rem 0; 715 + } 716 + 717 + .section legend { 718 + font-weight: 600; 719 + padding: 0 0.5rem; 720 + color: var(--text-primary); 721 + } 722 + 723 + .section-hint { 724 + font-size: 0.8rem; 725 + color: var(--text-secondary); 726 + margin: 0 0 1rem 0; 727 + } 728 + 729 + .radio-group { 730 + display: flex; 731 + flex-direction: column; 732 + gap: 0.75rem; 733 + } 734 + 735 + .radio-label { 736 + display: flex; 737 + align-items: flex-start; 738 + gap: 0.5rem; 739 + cursor: pointer; 740 + } 741 + 742 + .radio-label input[type="radio"] { 743 + margin-top: 0.25rem; 744 + } 745 + 746 + .radio-content { 747 + display: flex; 748 + flex-direction: column; 749 + gap: 0.125rem; 750 + } 751 + 752 + .radio-hint { 753 + font-size: 0.75rem; 754 + color: var(--text-secondary); 755 + } 756 + 757 + .did-web-warning { 758 + margin-top: 1rem; 759 + padding: 1rem; 760 + background: var(--warning-bg, #fff3cd); 761 + border: 1px solid var(--warning-border, #ffc107); 762 + border-radius: 6px; 763 + font-size: 0.875rem; 764 + } 765 + 766 + .did-web-warning strong { 767 + color: var(--warning-text, #856404); 768 + } 769 + 770 + .did-web-warning ul { 771 + margin: 0.75rem 0 0 0; 772 + padding-left: 1.25rem; 773 + } 774 + 775 + .did-web-warning li { 776 + margin-bottom: 0.5rem; 777 + line-height: 1.4; 778 + } 779 + 780 + .did-web-warning li:last-child { 781 + margin-bottom: 0; 782 + } 783 + 784 + .did-web-warning code { 785 + background: rgba(0, 0, 0, 0.1); 786 + padding: 0.125rem 0.25rem; 787 + border-radius: 3px; 788 + font-size: 0.8rem; 789 + } 790 + 791 + .info-box { 792 + background: var(--bg-secondary); 793 + border: 1px solid var(--border-color); 794 + border-radius: 6px; 795 + padding: 1rem; 796 + font-size: 0.875rem; 797 + } 798 + 799 + .info-box strong { 800 + display: block; 801 + margin-bottom: 0.5rem; 802 + } 803 + 804 + .info-box p { 805 + margin: 0 0 0.5rem 0; 806 + color: var(--text-secondary); 807 + } 808 + 809 + .info-box ul { 810 + margin: 0; 811 + padding-left: 1.25rem; 812 + color: var(--text-secondary); 813 + } 814 + 815 + .info-box li { 816 + margin-bottom: 0.25rem; 817 + } 818 + 819 + button { 820 + padding: 0.75rem; 821 + background: var(--accent); 822 + color: white; 823 + border: none; 824 + border-radius: 4px; 825 + font-size: 1rem; 826 + cursor: pointer; 827 + margin-top: 0.5rem; 828 + } 829 + 830 + button:hover:not(:disabled) { 831 + background: var(--accent-hover); 832 + } 833 + 834 + button:disabled { 835 + opacity: 0.6; 836 + cursor: not-allowed; 837 + } 838 + 839 + button.secondary { 840 + background: transparent; 841 + color: var(--text-secondary); 842 + border: 1px solid var(--border-color-light); 843 + } 844 + 845 + button.secondary:hover:not(:disabled) { 846 + background: var(--bg-secondary); 847 + } 848 + 849 + .error { 850 + padding: 0.75rem; 851 + background: var(--error-bg); 852 + border: 1px solid var(--error-border); 853 + border-radius: 4px; 854 + color: var(--error-text); 855 + margin-bottom: 1rem; 856 + } 857 + 858 + .alt-link { 859 + text-align: center; 860 + margin-top: 1.5rem; 861 + color: var(--text-secondary); 862 + } 863 + 864 + .alt-link a { 865 + color: var(--accent); 866 + } 867 + 868 + .passkey-step { 869 + display: flex; 870 + flex-direction: column; 871 + gap: 1rem; 872 + } 873 + 874 + .passkey-instructions { 875 + background: var(--bg-secondary); 876 + border-radius: 6px; 877 + padding: 1rem; 878 + } 879 + 880 + .passkey-instructions p { 881 + margin: 0 0 0.5rem 0; 882 + color: var(--text-secondary); 883 + font-size: 0.875rem; 884 + } 885 + 886 + .passkey-instructions ul { 887 + margin: 0; 888 + padding-left: 1.25rem; 889 + color: var(--text-secondary); 890 + font-size: 0.875rem; 891 + } 892 + 893 + .passkey-btn { 894 + padding: 1rem; 895 + font-size: 1.125rem; 896 + } 897 + 898 + .app-password-step { 899 + display: flex; 900 + flex-direction: column; 901 + gap: 1.5rem; 902 + } 903 + 904 + .warning-box { 905 + background: var(--warning-bg); 906 + border: 1px solid var(--warning-border, #ffc107); 907 + border-radius: 6px; 908 + padding: 1rem; 909 + } 910 + 911 + .warning-box strong { 912 + display: block; 913 + margin-bottom: 0.5rem; 914 + color: var(--warning-text); 915 + } 916 + 917 + .warning-box p { 918 + margin: 0; 919 + font-size: 0.875rem; 920 + color: var(--warning-text); 921 + } 922 + 923 + .app-password-display { 924 + background: var(--bg-card); 925 + border: 2px solid var(--accent); 926 + border-radius: 8px; 927 + padding: 1.5rem; 928 + text-align: center; 929 + } 930 + 931 + .app-password-label { 932 + font-size: 0.875rem; 933 + color: var(--text-secondary); 934 + margin-bottom: 0.75rem; 935 + } 936 + 937 + .app-password-code { 938 + display: block; 939 + font-size: 1.5rem; 940 + font-family: monospace; 941 + letter-spacing: 0.1em; 942 + padding: 1rem; 943 + background: var(--bg-input); 944 + border-radius: 4px; 945 + margin-bottom: 1rem; 946 + user-select: all; 947 + } 948 + 949 + .copy-btn { 950 + margin-top: 0; 951 + padding: 0.5rem 1rem; 952 + font-size: 0.875rem; 953 + } 954 + 955 + .acknowledge-field { 956 + margin-top: 0; 957 + } 958 + 959 + .checkbox-label { 960 + display: flex; 961 + align-items: center; 962 + gap: 0.5rem; 963 + cursor: pointer; 964 + font-weight: normal; 965 + } 966 + 967 + .checkbox-label input[type="checkbox"] { 968 + width: auto; 969 + padding: 0; 970 + } 971 + 972 + .success-step { 973 + text-align: center; 974 + } 975 + 976 + .success-icon { 977 + font-size: 4rem; 978 + color: var(--success-text); 979 + margin-bottom: 1rem; 980 + } 981 + 982 + .success-step p { 983 + color: var(--text-secondary); 984 + } 985 + 986 + .handle-display { 987 + font-size: 1.25rem; 988 + font-weight: 600; 989 + color: var(--text-primary) !important; 990 + margin: 1rem 0; 991 + } 992 + 993 + .verify-step { 994 + display: flex; 995 + flex-direction: column; 996 + gap: 1rem; 997 + } 998 + 999 + .verify-info { 1000 + color: var(--text-secondary); 1001 + margin: 0; 1002 + } 1003 + 1004 + .success { 1005 + padding: 0.75rem; 1006 + background: var(--success-bg); 1007 + border: 1px solid var(--success-border); 1008 + border-radius: 4px; 1009 + color: var(--success-text); 1010 + } 1011 + </style>
+205
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + import { api, ApiError } from '../lib/api' 4 + 5 + let identifier = $state('') 6 + let submitting = $state(false) 7 + let error = $state<string | null>(null) 8 + let success = $state(false) 9 + 10 + async function handleSubmit(e: Event) { 11 + e.preventDefault() 12 + submitting = true 13 + error = null 14 + 15 + try { 16 + await api.requestPasskeyRecovery(identifier) 17 + success = true 18 + } catch (err) { 19 + if (err instanceof ApiError) { 20 + error = err.message || 'Failed to send recovery link' 21 + } else if (err instanceof Error) { 22 + error = err.message || 'Failed to send recovery link' 23 + } else { 24 + error = 'Failed to send recovery link' 25 + } 26 + } finally { 27 + submitting = false 28 + } 29 + } 30 + </script> 31 + 32 + <div class="recovery-container"> 33 + {#if success} 34 + <div class="success-content"> 35 + <h1>Recovery Link Sent</h1> 36 + <p class="subtitle"> 37 + If your account exists and is a passkey-only account, you'll receive a recovery link 38 + at your preferred notification channel. 39 + </p> 40 + <p class="info"> 41 + The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal 42 + depending on your account settings. 43 + </p> 44 + <button onclick={() => navigate('/login')}>Back to Sign In</button> 45 + </div> 46 + {:else} 47 + <h1>Recover Passkey Account</h1> 48 + <p class="subtitle"> 49 + Lost access to your passkey? Enter your handle or email and we'll send you a recovery link. 50 + </p> 51 + 52 + {#if error} 53 + <div class="error">{error}</div> 54 + {/if} 55 + 56 + <form onsubmit={handleSubmit}> 57 + <div class="field"> 58 + <label for="identifier">Handle or Email</label> 59 + <input 60 + id="identifier" 61 + type="text" 62 + bind:value={identifier} 63 + placeholder="handle or you@example.com" 64 + disabled={submitting} 65 + required 66 + /> 67 + </div> 68 + 69 + <div class="info-box"> 70 + <strong>How it works</strong> 71 + <p> 72 + We'll send a secure link to your registered notification channel. 73 + Click the link to set a temporary password. Then you can sign in 74 + and add a new passkey. 75 + </p> 76 + </div> 77 + 78 + <button type="submit" disabled={submitting || !identifier.trim()}> 79 + {submitting ? 'Sending...' : 'Send Recovery Link'} 80 + </button> 81 + </form> 82 + {/if} 83 + 84 + <p class="back-link"> 85 + <a href="#/login">Back to Sign In</a> 86 + </p> 87 + </div> 88 + 89 + <style> 90 + .recovery-container { 91 + max-width: 400px; 92 + margin: 4rem auto; 93 + padding: 2rem; 94 + } 95 + 96 + h1 { 97 + margin: 0 0 0.5rem 0; 98 + } 99 + 100 + .subtitle { 101 + color: var(--text-secondary); 102 + margin: 0 0 2rem 0; 103 + } 104 + 105 + form { 106 + display: flex; 107 + flex-direction: column; 108 + gap: 1rem; 109 + } 110 + 111 + .field { 112 + display: flex; 113 + flex-direction: column; 114 + gap: 0.25rem; 115 + } 116 + 117 + label { 118 + font-size: 0.875rem; 119 + font-weight: 500; 120 + } 121 + 122 + input { 123 + padding: 0.75rem; 124 + border: 1px solid var(--border-color-light); 125 + border-radius: 4px; 126 + font-size: 1rem; 127 + background: var(--bg-input); 128 + color: var(--text-primary); 129 + } 130 + 131 + input:focus { 132 + outline: none; 133 + border-color: var(--accent); 134 + } 135 + 136 + .info-box { 137 + background: var(--bg-secondary); 138 + border: 1px solid var(--border-color); 139 + border-radius: 6px; 140 + padding: 1rem; 141 + font-size: 0.875rem; 142 + } 143 + 144 + .info-box strong { 145 + display: block; 146 + margin-bottom: 0.5rem; 147 + } 148 + 149 + .info-box p { 150 + margin: 0; 151 + color: var(--text-secondary); 152 + } 153 + 154 + button { 155 + padding: 0.75rem; 156 + background: var(--accent); 157 + color: white; 158 + border: none; 159 + border-radius: 4px; 160 + font-size: 1rem; 161 + cursor: pointer; 162 + } 163 + 164 + button:hover:not(:disabled) { 165 + background: var(--accent-hover); 166 + } 167 + 168 + button:disabled { 169 + opacity: 0.6; 170 + cursor: not-allowed; 171 + } 172 + 173 + .error { 174 + padding: 0.75rem; 175 + background: var(--error-bg); 176 + border: 1px solid var(--error-border); 177 + border-radius: 4px; 178 + color: var(--error-text); 179 + margin-bottom: 1rem; 180 + } 181 + 182 + .success-content { 183 + text-align: center; 184 + } 185 + 186 + .info { 187 + color: var(--text-secondary); 188 + font-size: 0.875rem; 189 + margin-bottom: 1.5rem; 190 + } 191 + 192 + .back-link { 193 + text-align: center; 194 + margin-top: 2rem; 195 + } 196 + 197 + .back-link a { 198 + color: var(--accent); 199 + text-decoration: none; 200 + } 201 + 202 + .back-link a:hover { 203 + text-decoration: underline; 204 + } 205 + </style>
+7 -7
frontend/src/routes/ResetPassword.svelte
··· 25 25 try { 26 26 await api.requestPasswordReset(email) 27 27 tokenSent = true 28 - success = 'Password reset code sent to your email' 28 + success = 'Password reset code sent! Check your preferred notification channel.' 29 29 } catch (e) { 30 30 error = e instanceof ApiError ? e.message : 'Failed to send reset code' 31 31 } finally { ··· 66 66 {/if} 67 67 {#if tokenSent} 68 68 <h1>Reset Password</h1> 69 - <p class="subtitle">Enter the code from your email and choose a new password.</p> 69 + <p class="subtitle">Enter the code you received and choose a new password.</p> 70 70 <form onsubmit={handleReset}> 71 71 <div class="field"> 72 72 <label for="token">Reset Code</label> ··· 74 74 id="token" 75 75 type="text" 76 76 bind:value={token} 77 - placeholder="Enter code from email" 77 + placeholder="Enter reset code" 78 78 disabled={submitting} 79 79 required 80 80 /> ··· 111 111 </form> 112 112 {:else} 113 113 <h1>Forgot Password</h1> 114 - <p class="subtitle">Enter your email address and we'll send you a code to reset your password.</p> 114 + <p class="subtitle">Enter your handle or email and we'll send you a code to reset your password.</p> 115 115 <form onsubmit={handleRequestReset}> 116 116 <div class="field"> 117 - <label for="email">Email</label> 117 + <label for="email">Handle or Email</label> 118 118 <input 119 119 id="email" 120 - type="email" 120 + type="text" 121 121 bind:value={email} 122 - placeholder="you@example.com" 122 + placeholder="handle or you@example.com" 123 123 disabled={submitting} 124 124 required 125 125 />
+182
frontend/src/routes/Security.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import ReauthModal from '../components/ReauthModal.svelte' 5 6 6 7 const auth = getAuthState() 7 8 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) ··· 38 39 let editingPasskeyId = $state<string | null>(null) 39 40 let editPasskeyName = $state('') 40 41 42 + let hasPassword = $state(true) 43 + let passwordLoading = $state(true) 44 + let showRemovePasswordForm = $state(false) 45 + let removePasswordLoading = $state(false) 46 + 47 + let showReauthModal = $state(false) 48 + let reauthMethods = $state<string[]>(['password']) 49 + let pendingAction = $state<(() => Promise<void>) | null>(null) 50 + 41 51 $effect(() => { 42 52 if (!auth.loading && !auth.session) { 43 53 navigate('/login') ··· 48 58 if (auth.session) { 49 59 loadTotpStatus() 50 60 loadPasskeys() 61 + loadPasswordStatus() 51 62 } 52 63 }) 64 + 65 + async function loadPasswordStatus() { 66 + if (!auth.session) return 67 + passwordLoading = true 68 + try { 69 + const status = await api.getPasswordStatus(auth.session.accessJwt) 70 + hasPassword = status.hasPassword 71 + } catch { 72 + hasPassword = true 73 + } finally { 74 + passwordLoading = false 75 + } 76 + } 77 + 78 + async function handleRemovePassword() { 79 + if (!auth.session) return 80 + removePasswordLoading = true 81 + try { 82 + await api.removePassword(auth.session.accessJwt) 83 + hasPassword = false 84 + showRemovePasswordForm = false 85 + showMessage('success', 'Password removed. Your account is now passkey-only.') 86 + } catch (e) { 87 + if (e instanceof ApiError) { 88 + if (e.error === 'ReauthRequired') { 89 + reauthMethods = e.reauthMethods || ['password'] 90 + pendingAction = handleRemovePassword 91 + showReauthModal = true 92 + } else { 93 + showMessage('error', e.message) 94 + } 95 + } else { 96 + showMessage('error', 'Failed to remove password') 97 + } 98 + } finally { 99 + removePasswordLoading = false 100 + } 101 + } 102 + 103 + function handleReauthSuccess() { 104 + if (pendingAction) { 105 + pendingAction() 106 + pendingAction = null 107 + } 108 + } 109 + 110 + function handleReauthCancel() { 111 + pendingAction = null 112 + } 53 113 54 114 async function loadTotpStatus() { 55 115 if (!auth.session) return ··· 543 603 </div> 544 604 {/if} 545 605 </section> 606 + 607 + <section> 608 + <h2>Password</h2> 609 + <p class="description"> 610 + Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience. 611 + </p> 612 + 613 + {#if passwordLoading} 614 + <div class="loading">Loading...</div> 615 + {:else if hasPassword} 616 + <div class="status enabled"> 617 + <span>Password authentication is <strong>enabled</strong></span> 618 + </div> 619 + 620 + {#if passkeys.length > 0} 621 + {#if !showRemovePasswordForm} 622 + <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 623 + Remove Password 624 + </button> 625 + {:else} 626 + <div class="inline-form danger-form"> 627 + <h3>Remove Password</h3> 628 + <p class="warning-text"> 629 + This will make your account passkey-only. You'll only be able to sign in using your registered passkeys. 630 + If you lose access to all your passkeys, you can recover your account using your notification channel. 631 + </p> 632 + <div class="info-box-inline"> 633 + <strong>Before proceeding:</strong> 634 + <ul> 635 + <li>Make sure you have at least one reliable passkey registered</li> 636 + <li>Consider registering passkeys on multiple devices</li> 637 + <li>Ensure your recovery notification channel is up to date</li> 638 + </ul> 639 + </div> 640 + <div class="actions"> 641 + <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}> 642 + Cancel 643 + </button> 644 + <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}> 645 + {removePasswordLoading ? 'Removing...' : 'Remove Password'} 646 + </button> 647 + </div> 648 + </div> 649 + {/if} 650 + {:else} 651 + <p class="hint">Add at least one passkey before you can remove your password.</p> 652 + {/if} 653 + {:else} 654 + <div class="status passkey-only"> 655 + <span>Your account is <strong>passkey-only</strong></span> 656 + </div> 657 + <p class="hint"> 658 + You sign in using passkeys only. If you ever lose access to your passkeys, 659 + you can recover your account using the "Lost passkey?" link on the login page. 660 + </p> 661 + {/if} 662 + </section> 663 + 664 + <section> 665 + <h2>Trusted Devices</h2> 666 + <p class="description"> 667 + Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device. 668 + </p> 669 + <a href="#/trusted-devices" class="section-link"> 670 + Manage Trusted Devices &rarr; 671 + </a> 672 + </section> 546 673 {/if} 547 674 </div> 675 + 676 + <ReauthModal 677 + bind:show={showReauthModal} 678 + availableMethods={reauthMethods} 679 + onSuccess={handleReauthSuccess} 680 + onCancel={handleReauthCancel} 681 + /> 548 682 549 683 <style> 550 684 .page { ··· 893 1027 894 1028 .add-passkey .field { 895 1029 margin-bottom: 0.75rem; 1030 + } 1031 + 1032 + .section-link { 1033 + display: inline-block; 1034 + color: var(--accent); 1035 + text-decoration: none; 1036 + font-weight: 500; 1037 + } 1038 + 1039 + .section-link:hover { 1040 + text-decoration: underline; 1041 + } 1042 + 1043 + .status.passkey-only { 1044 + background: var(--accent); 1045 + background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 1046 + border: 1px solid var(--accent); 1047 + color: var(--accent); 1048 + } 1049 + 1050 + .hint { 1051 + font-size: 0.875rem; 1052 + color: var(--text-secondary); 1053 + margin: 0; 1054 + } 1055 + 1056 + .info-box-inline { 1057 + background: var(--bg-card); 1058 + border: 1px solid var(--border-color); 1059 + border-radius: 6px; 1060 + padding: 1rem; 1061 + margin-bottom: 1rem; 1062 + font-size: 0.875rem; 1063 + } 1064 + 1065 + .info-box-inline strong { 1066 + display: block; 1067 + margin-bottom: 0.5rem; 1068 + } 1069 + 1070 + .info-box-inline ul { 1071 + margin: 0; 1072 + padding-left: 1.25rem; 1073 + color: var(--text-secondary); 1074 + } 1075 + 1076 + .info-box-inline li { 1077 + margin-bottom: 0.25rem; 896 1078 } 897 1079 </style>
+1 -1
frontend/src/routes/Settings.svelte
··· 37 37 emailLoading = true 38 38 message = null 39 39 try { 40 - const result = await api.requestEmailUpdate(auth.session.accessJwt) 40 + const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) 41 41 emailTokenRequired = result.tokenRequired 42 42 if (emailTokenRequired) { 43 43 showMessage('success', 'Verification code sent to your current email')
+409
frontend/src/routes/TrustedDevices.svelte
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { api, ApiError } from '../lib/api' 5 + 6 + interface TrustedDevice { 7 + id: string 8 + userAgent: string | null 9 + friendlyName: string | null 10 + trustedAt: string | null 11 + trustedUntil: string | null 12 + lastSeenAt: string 13 + } 14 + 15 + const auth = getAuthState() 16 + let devices = $state<TrustedDevice[]>([]) 17 + let loading = $state(true) 18 + let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 19 + let editingDeviceId = $state<string | null>(null) 20 + let editDeviceName = $state('') 21 + 22 + $effect(() => { 23 + if (!auth.loading && !auth.session) { 24 + navigate('/login') 25 + } 26 + }) 27 + 28 + $effect(() => { 29 + if (auth.session) { 30 + loadDevices() 31 + } 32 + }) 33 + 34 + async function loadDevices() { 35 + if (!auth.session) return 36 + loading = true 37 + try { 38 + const result = await api.listTrustedDevices(auth.session.accessJwt) 39 + devices = result.devices 40 + } catch { 41 + showMessage('error', 'Failed to load trusted devices') 42 + } finally { 43 + loading = false 44 + } 45 + } 46 + 47 + function showMessage(type: 'success' | 'error', text: string) { 48 + message = { type, text } 49 + setTimeout(() => { 50 + if (message?.text === text) message = null 51 + }, 5000) 52 + } 53 + 54 + async function handleRevoke(deviceId: string) { 55 + if (!auth.session) return 56 + if (!confirm('Are you sure you want to revoke trust for this device? You will need to enter your 2FA code next time you log in from this device.')) return 57 + try { 58 + await api.revokeTrustedDevice(auth.session.accessJwt, deviceId) 59 + await loadDevices() 60 + showMessage('success', 'Device trust revoked') 61 + } catch (e) { 62 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to revoke device') 63 + } 64 + } 65 + 66 + function startEditDevice(device: TrustedDevice) { 67 + editingDeviceId = device.id 68 + editDeviceName = device.friendlyName || '' 69 + } 70 + 71 + function cancelEditDevice() { 72 + editingDeviceId = null 73 + editDeviceName = '' 74 + } 75 + 76 + async function handleSaveDeviceName() { 77 + if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return 78 + try { 79 + await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim()) 80 + await loadDevices() 81 + editingDeviceId = null 82 + editDeviceName = '' 83 + showMessage('success', 'Device renamed') 84 + } catch (e) { 85 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename device') 86 + } 87 + } 88 + 89 + function formatDate(dateStr: string): string { 90 + return new Date(dateStr).toLocaleDateString(undefined, { 91 + year: 'numeric', 92 + month: 'short', 93 + day: 'numeric', 94 + hour: '2-digit', 95 + minute: '2-digit' 96 + }) 97 + } 98 + 99 + function parseUserAgent(ua: string | null): string { 100 + if (!ua) return 'Unknown device' 101 + if (ua.includes('Firefox')) return 'Firefox' 102 + if (ua.includes('Chrome')) return 'Chrome' 103 + if (ua.includes('Safari')) return 'Safari' 104 + if (ua.includes('Edge')) return 'Edge' 105 + return 'Browser' 106 + } 107 + 108 + function getDaysRemaining(trustedUntil: string | null): number { 109 + if (!trustedUntil) return 0 110 + const now = new Date() 111 + const until = new Date(trustedUntil) 112 + const diff = until.getTime() - now.getTime() 113 + return Math.ceil(diff / (1000 * 60 * 60 * 24)) 114 + } 115 + </script> 116 + 117 + <div class="page"> 118 + <header> 119 + <a href="#/security" class="back">&larr; Security Settings</a> 120 + <h1>Trusted Devices</h1> 121 + </header> 122 + 123 + {#if message} 124 + <div class="message {message.type}">{message.text}</div> 125 + {/if} 126 + 127 + <div class="description"> 128 + <p> 129 + Trusted devices can skip two-factor authentication when logging in. 130 + Trust is granted for 30 days and automatically extends when you use the device. 131 + </p> 132 + </div> 133 + 134 + {#if loading} 135 + <div class="loading">Loading...</div> 136 + {:else if devices.length === 0} 137 + <div class="empty-state"> 138 + <p>No trusted devices yet.</p> 139 + <p class="hint">When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.</p> 140 + </div> 141 + {:else} 142 + <div class="device-list"> 143 + {#each devices as device} 144 + <div class="device-card"> 145 + <div class="device-header"> 146 + {#if editingDeviceId === device.id} 147 + <input 148 + type="text" 149 + class="edit-name-input" 150 + bind:value={editDeviceName} 151 + placeholder="Device name" 152 + /> 153 + <div class="edit-actions"> 154 + <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>Save</button> 155 + <button class="btn-small btn-secondary" onclick={cancelEditDevice}>Cancel</button> 156 + </div> 157 + {:else} 158 + <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3> 159 + <button class="btn-icon" onclick={() => startEditDevice(device)} title="Rename"> 160 + &#9998; 161 + </button> 162 + {/if} 163 + </div> 164 + 165 + <div class="device-details"> 166 + {#if device.userAgent && !device.friendlyName} 167 + <p class="detail"><span class="label">Browser:</span> {device.userAgent}</p> 168 + {:else if device.userAgent} 169 + <p class="detail"><span class="label">Browser:</span> {parseUserAgent(device.userAgent)}</p> 170 + {/if} 171 + <p class="detail"> 172 + <span class="label">Last seen:</span> {formatDate(device.lastSeenAt)} 173 + </p> 174 + {#if device.trustedAt} 175 + <p class="detail"> 176 + <span class="label">Trusted since:</span> {formatDate(device.trustedAt)} 177 + </p> 178 + {/if} 179 + {#if device.trustedUntil} 180 + {@const daysRemaining = getDaysRemaining(device.trustedUntil)} 181 + <p class="detail trust-expiry" class:expiring-soon={daysRemaining <= 7}> 182 + <span class="label">Trust expires:</span> 183 + {#if daysRemaining <= 0} 184 + Expired 185 + {:else if daysRemaining === 1} 186 + Tomorrow 187 + {:else} 188 + In {daysRemaining} days 189 + {/if} 190 + </p> 191 + {/if} 192 + </div> 193 + 194 + <div class="device-actions"> 195 + <button class="btn-danger" onclick={() => handleRevoke(device.id)}> 196 + Revoke Trust 197 + </button> 198 + </div> 199 + </div> 200 + {/each} 201 + </div> 202 + {/if} 203 + </div> 204 + 205 + <style> 206 + .page { 207 + max-width: 600px; 208 + margin: 0 auto; 209 + padding: 2rem 1rem; 210 + } 211 + 212 + header { 213 + margin-bottom: 2rem; 214 + } 215 + 216 + .back { 217 + display: inline-block; 218 + margin-bottom: 1rem; 219 + color: var(--accent); 220 + text-decoration: none; 221 + font-size: 0.875rem; 222 + } 223 + 224 + .back:hover { 225 + text-decoration: underline; 226 + } 227 + 228 + h1 { 229 + margin: 0; 230 + font-size: 1.75rem; 231 + } 232 + 233 + .message { 234 + padding: 0.75rem 1rem; 235 + border-radius: 4px; 236 + margin-bottom: 1rem; 237 + } 238 + 239 + .message.success { 240 + background: var(--success-bg); 241 + color: var(--success-text); 242 + border: 1px solid var(--success-border); 243 + } 244 + 245 + .message.error { 246 + background: var(--error-bg); 247 + color: var(--error-text); 248 + border: 1px solid var(--error-border); 249 + } 250 + 251 + .description { 252 + background: var(--bg-card); 253 + border: 1px solid var(--border-color); 254 + border-radius: 8px; 255 + padding: 1rem; 256 + margin-bottom: 1.5rem; 257 + } 258 + 259 + .description p { 260 + margin: 0; 261 + color: var(--text-secondary); 262 + font-size: 0.9rem; 263 + } 264 + 265 + .loading { 266 + text-align: center; 267 + padding: 2rem; 268 + color: var(--text-secondary); 269 + } 270 + 271 + .empty-state { 272 + text-align: center; 273 + padding: 3rem 1rem; 274 + background: var(--bg-card); 275 + border: 1px solid var(--border-color); 276 + border-radius: 8px; 277 + } 278 + 279 + .empty-state p { 280 + margin: 0; 281 + color: var(--text-secondary); 282 + } 283 + 284 + .empty-state .hint { 285 + margin-top: 0.5rem; 286 + font-size: 0.875rem; 287 + color: var(--text-muted); 288 + } 289 + 290 + .device-list { 291 + display: flex; 292 + flex-direction: column; 293 + gap: 1rem; 294 + } 295 + 296 + .device-card { 297 + background: var(--bg-card); 298 + border: 1px solid var(--border-color); 299 + border-radius: 8px; 300 + padding: 1rem; 301 + } 302 + 303 + .device-header { 304 + display: flex; 305 + align-items: center; 306 + gap: 0.5rem; 307 + margin-bottom: 0.75rem; 308 + } 309 + 310 + .device-header h3 { 311 + margin: 0; 312 + flex: 1; 313 + font-size: 1rem; 314 + } 315 + 316 + .edit-name-input { 317 + flex: 1; 318 + padding: 0.5rem; 319 + border: 1px solid var(--border-color); 320 + border-radius: 4px; 321 + background: var(--bg-input); 322 + color: var(--text-primary); 323 + font-size: 0.9rem; 324 + } 325 + 326 + .edit-actions { 327 + display: flex; 328 + gap: 0.5rem; 329 + } 330 + 331 + .btn-icon { 332 + background: none; 333 + border: none; 334 + color: var(--text-secondary); 335 + cursor: pointer; 336 + padding: 0.25rem; 337 + font-size: 1rem; 338 + } 339 + 340 + .btn-icon:hover { 341 + color: var(--text-primary); 342 + } 343 + 344 + .device-details { 345 + margin-bottom: 0.75rem; 346 + } 347 + 348 + .detail { 349 + margin: 0.25rem 0; 350 + font-size: 0.875rem; 351 + color: var(--text-secondary); 352 + } 353 + 354 + .detail .label { 355 + color: var(--text-muted); 356 + } 357 + 358 + .trust-expiry.expiring-soon { 359 + color: var(--warning-text); 360 + } 361 + 362 + .device-actions { 363 + display: flex; 364 + justify-content: flex-end; 365 + padding-top: 0.75rem; 366 + border-top: 1px solid var(--border-color); 367 + } 368 + 369 + .btn-small { 370 + padding: 0.375rem 0.75rem; 371 + border-radius: 4px; 372 + font-size: 0.8rem; 373 + cursor: pointer; 374 + } 375 + 376 + .btn-primary { 377 + background: var(--accent); 378 + color: white; 379 + border: none; 380 + } 381 + 382 + .btn-primary:hover { 383 + background: var(--accent-hover); 384 + } 385 + 386 + .btn-secondary { 387 + background: var(--bg-input); 388 + border: 1px solid var(--border-color); 389 + color: var(--text-secondary); 390 + } 391 + 392 + .btn-secondary:hover { 393 + background: var(--bg-secondary); 394 + } 395 + 396 + .btn-danger { 397 + background: transparent; 398 + border: 1px solid var(--error-border); 399 + color: var(--error-text); 400 + padding: 0.5rem 1rem; 401 + border-radius: 4px; 402 + cursor: pointer; 403 + font-size: 0.875rem; 404 + } 405 + 406 + .btn-danger:hover { 407 + background: var(--error-bg); 408 + } 409 + </style>
+5
migrations/20251225_passwordless_accounts.sql
··· 1 + ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; 2 + ALTER TABLE users ADD COLUMN password_required BOOLEAN NOT NULL DEFAULT TRUE; 3 + ALTER TABLE users ADD COLUMN recovery_token TEXT; 4 + ALTER TABLE users ADD COLUMN recovery_token_expires_at TIMESTAMPTZ; 5 + CREATE INDEX IF NOT EXISTS idx_users_recovery_token ON users(recovery_token) WHERE recovery_token IS NOT NULL;
+4
migrations/20251226_trusted_devices.sql
··· 1 + ALTER TABLE oauth_device ADD COLUMN trusted_at TIMESTAMPTZ; 2 + ALTER TABLE oauth_device ADD COLUMN trusted_until TIMESTAMPTZ; 3 + ALTER TABLE oauth_device ADD COLUMN friendly_name TEXT; 4 + CREATE INDEX IF NOT EXISTS idx_oauth_device_trusted ON oauth_device(trusted_until) WHERE trusted_until IS NOT NULL;
+1
migrations/20251227_reauth_tracking.sql
··· 1 + ALTER TABLE session_tokens ADD COLUMN last_reauth_at TIMESTAMPTZ;
+1
migrations/20251228_add_passkey_recovery_comms_type.sql
··· 1 + ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'passkey_recovery';
+43 -22
src/api/identity/account.rs
··· 139 139 info!(did = %migration_did, "Processing account migration"); 140 140 } 141 141 142 - let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 142 + let hostname_for_validation = 143 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 143 144 let pds_suffix = format!(".{}", hostname_for_validation); 144 145 145 - let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 146 + let validated_short_handle = if !input.handle.contains('.') 147 + || input.handle.ends_with(&pds_suffix) 148 + { 146 149 let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 147 - input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle) 150 + input 151 + .handle 152 + .strip_suffix(&pds_suffix) 153 + .unwrap_or(&input.handle) 148 154 } else { 149 155 &input.handle 150 156 }; ··· 165 171 Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})), 166 172 ) 167 173 .into_response(); 174 + } 175 + for c in input.handle.chars() { 176 + if !c.is_ascii_alphanumeric() && c != '.' && c != '-' { 177 + return ( 178 + StatusCode::BAD_REQUEST, 179 + Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})), 180 + ) 181 + .into_response(); 182 + } 168 183 } 169 184 input.handle.to_lowercase() 170 185 }; ··· 319 334 ) 320 335 .into_response(); 321 336 } 322 - if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 337 + if let Err(e) = 338 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 339 + { 323 340 return ( 324 341 StatusCode::BAD_REQUEST, 325 342 Json(json!({"error": "InvalidDid", "message": e})), ··· 335 352 info!(did = %d, "Migration with existing did:plc"); 336 353 d.clone() 337 354 } else if d.starts_with("did:web:") { 338 - if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 355 + if let Err(e) = 356 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 357 + .await 358 + { 339 359 return ( 340 360 StatusCode::BAD_REQUEST, 341 361 Json(json!({"error": "InvalidDid", "message": e})), ··· 758 778 }; 759 779 760 780 if !is_migration 761 - && let Err(e) = sqlx::query!( 762 - "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", 763 - user_id, 764 - verification_code, 765 - email, 766 - code_expires_at 767 - ) 768 - .execute(&mut *tx) 769 - .await { 770 - error!("Error inserting verification code: {:?}", e); 771 - return ( 772 - StatusCode::INTERNAL_SERVER_ERROR, 773 - Json(json!({"error": "InternalError"})), 781 + && let Some(ref recipient) = verification_recipient 782 + && let Err(e) = sqlx::query!( 783 + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)", 784 + user_id, 785 + verification_channel as _, 786 + verification_code, 787 + recipient, 788 + code_expires_at 774 789 ) 775 - .into_response(); 776 - } 790 + .execute(&mut *tx) 791 + .await { 792 + error!("Error inserting verification code: {:?}", e); 793 + return ( 794 + StatusCode::INTERNAL_SERVER_ERROR, 795 + Json(json!({"error": "InternalError"})), 796 + ) 797 + .into_response(); 798 + } 777 799 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 778 800 Ok(enc) => enc, 779 801 Err(e) => { ··· 919 941 } 920 942 if !is_migration { 921 943 if let Err(e) = 922 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 923 - .await 944 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 924 945 { 925 946 warn!("Failed to sequence identity event for {}: {}", did, e); 926 947 }
+2 -6
src/api/identity/did.rs
··· 674 674 if let Some(old) = old_handle { 675 675 let _ = state.cache.delete(&format!("handle:{}", old)).await; 676 676 } 677 - let _ = state 678 - .cache 679 - .delete(&format!("handle:{}", handle)) 680 - .await; 677 + let _ = state.cache.delete(&format!("handle:{}", handle)).await; 681 678 if let Err(e) = 682 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 683 - .await 679 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 684 680 { 685 681 warn!("Failed to sequence identity event for handle update: {}", e); 686 682 }
+5 -1
src/api/server/account_status.rs
··· 419 419 .into_response(); 420 420 } 421 421 }; 422 - let password_valid = if verify(password, &password_hash).unwrap_or(false) { 422 + let password_valid = if password_hash 423 + .as_ref() 424 + .map(|h| verify(password, h).unwrap_or(false)) 425 + .unwrap_or(false) 426 + { 423 427 true 424 428 } else { 425 429 let app_pass_rows = sqlx::query!(
+21 -4
src/api/server/mod.rs
··· 3 3 pub mod email; 4 4 pub mod invite; 5 5 pub mod meta; 6 + pub mod passkey_account; 6 7 pub mod passkeys; 7 8 pub mod password; 9 + pub mod reauth; 8 10 pub mod service_auth; 9 11 pub mod session; 10 12 pub mod signing_key; 11 13 pub mod totp; 14 + pub mod trusted_devices; 12 15 13 16 pub use account_status::{ 14 17 activate_account, check_account_status, deactivate_account, delete_account, ··· 18 21 pub use email::{confirm_email, request_email_update, update_email}; 19 22 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 20 23 pub use meta::{describe_server, health, robots_txt}; 24 + pub use passkey_account::{ 25 + complete_passkey_setup, create_passkey_account, recover_passkey_account, 26 + request_passkey_recovery, start_passkey_registration_for_setup, 27 + }; 21 28 pub use passkeys::{ 22 - delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys, 23 - start_passkey_registration, update_passkey, 29 + delete_passkey, finish_passkey_registration, has_passkeys_for_user, has_passkeys_for_user_db, 30 + list_passkeys, start_passkey_registration, update_passkey, 31 + }; 32 + pub use password::{ 33 + change_password, get_password_status, remove_password, request_password_reset, reset_password, 34 + }; 35 + pub use reauth::{ 36 + check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start, 37 + reauth_password, reauth_required_response, reauth_totp, 24 38 }; 25 - pub use password::{change_password, request_password_reset, reset_password}; 26 39 pub use service_auth::get_service_auth; 27 40 pub use session::{ 28 41 confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, ··· 31 44 pub use signing_key::reserve_signing_key; 32 45 pub use totp::{ 33 46 create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled, 34 - regenerate_backup_codes, verify_totp_or_backup_for_user, 47 + has_totp_enabled_db, regenerate_backup_codes, verify_totp_or_backup_for_user, 48 + }; 49 + pub use trusted_devices::{ 50 + extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, 51 + trust_device, update_trusted_device, 35 52 };
+1209
src/api/server/passkey_account.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::State, 4 + http::{HeaderMap, StatusCode}, 5 + response::{IntoResponse, Response}, 6 + }; 7 + use bcrypt::{DEFAULT_COST, hash}; 8 + use chrono::{Duration, Utc}; 9 + use jacquard::types::{did::Did, integer::LimitedU32, string::Tid}; 10 + use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; 11 + use rand::Rng; 12 + use serde::{Deserialize, Serialize}; 13 + use serde_json::json; 14 + use std::sync::Arc; 15 + use tracing::{error, info, warn}; 16 + use uuid::Uuid; 17 + 18 + use crate::state::{AppState, RateLimitKind}; 19 + 20 + fn extract_client_ip(headers: &HeaderMap) -> String { 21 + if let Some(forwarded) = headers.get("x-forwarded-for") 22 + && let Ok(value) = forwarded.to_str() 23 + && let Some(first_ip) = value.split(',').next() 24 + { 25 + return first_ip.trim().to_string(); 26 + } 27 + if let Some(real_ip) = headers.get("x-real-ip") 28 + && let Ok(value) = real_ip.to_str() 29 + { 30 + return value.trim().to_string(); 31 + } 32 + "unknown".to_string() 33 + } 34 + 35 + fn generate_setup_token() -> String { 36 + let mut rng = rand::thread_rng(); 37 + (0..32) 38 + .map(|_| { 39 + let idx = rng.gen_range(0..36); 40 + if idx < 10 { 41 + (b'0' + idx) as char 42 + } else { 43 + (b'a' + idx - 10) as char 44 + } 45 + }) 46 + .collect() 47 + } 48 + 49 + fn generate_app_password() -> String { 50 + let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 51 + let mut rng = rand::thread_rng(); 52 + let segments: Vec<String> = (0..4) 53 + .map(|_| { 54 + (0..4) 55 + .map(|_| chars[rng.gen_range(0..chars.len())] as char) 56 + .collect() 57 + }) 58 + .collect(); 59 + segments.join("-") 60 + } 61 + 62 + #[derive(Deserialize)] 63 + #[serde(rename_all = "camelCase")] 64 + pub struct CreatePasskeyAccountInput { 65 + pub handle: String, 66 + pub email: Option<String>, 67 + pub invite_code: Option<String>, 68 + pub did: Option<String>, 69 + pub did_type: Option<String>, 70 + pub signing_key: Option<String>, 71 + pub verification_channel: Option<String>, 72 + pub discord_id: Option<String>, 73 + pub telegram_username: Option<String>, 74 + pub signal_number: Option<String>, 75 + } 76 + 77 + #[derive(Serialize)] 78 + #[serde(rename_all = "camelCase")] 79 + pub struct CreatePasskeyAccountResponse { 80 + pub did: String, 81 + pub handle: String, 82 + pub setup_token: String, 83 + pub setup_expires_at: chrono::DateTime<Utc>, 84 + } 85 + 86 + pub async fn create_passkey_account( 87 + State(state): State<AppState>, 88 + headers: HeaderMap, 89 + Json(input): Json<CreatePasskeyAccountInput>, 90 + ) -> Response { 91 + let client_ip = extract_client_ip(&headers); 92 + if !state 93 + .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) 94 + .await 95 + { 96 + warn!(ip = %client_ip, "Account creation rate limit exceeded"); 97 + return ( 98 + StatusCode::TOO_MANY_REQUESTS, 99 + Json(json!({ 100 + "error": "RateLimitExceeded", 101 + "message": "Too many account creation attempts. Please try again later." 102 + })), 103 + ) 104 + .into_response(); 105 + } 106 + 107 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 108 + let pds_suffix = format!(".{}", hostname); 109 + 110 + let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 111 + let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 112 + input 113 + .handle 114 + .strip_suffix(&pds_suffix) 115 + .unwrap_or(&input.handle) 116 + } else { 117 + &input.handle 118 + }; 119 + match crate::api::validation::validate_short_handle(handle_to_validate) { 120 + Ok(h) => format!("{}.{}", h, hostname), 121 + Err(e) => { 122 + return ( 123 + StatusCode::BAD_REQUEST, 124 + Json(json!({"error": "InvalidHandle", "message": e.to_string()})), 125 + ) 126 + .into_response(); 127 + } 128 + } 129 + } else { 130 + input.handle.to_lowercase() 131 + }; 132 + 133 + let email = input 134 + .email 135 + .as_ref() 136 + .map(|e| e.trim().to_string()) 137 + .filter(|e| !e.is_empty()); 138 + if let Some(ref email) = email 139 + && !crate::api::validation::is_valid_email(email) 140 + { 141 + return ( 142 + StatusCode::BAD_REQUEST, 143 + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 144 + ) 145 + .into_response(); 146 + } 147 + 148 + if let Some(ref code) = input.invite_code { 149 + let valid = sqlx::query_scalar!( 150 + "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1", 151 + code 152 + ) 153 + .fetch_optional(&state.db) 154 + .await 155 + .ok() 156 + .flatten() 157 + .unwrap_or(Some(false)); 158 + 159 + if valid != Some(true) { 160 + return ( 161 + StatusCode::BAD_REQUEST, 162 + Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})), 163 + ) 164 + .into_response(); 165 + } 166 + } else { 167 + let invite_required = std::env::var("INVITE_CODE_REQUIRED") 168 + .map(|v| v == "true" || v == "1") 169 + .unwrap_or(false); 170 + if invite_required { 171 + return ( 172 + StatusCode::BAD_REQUEST, 173 + Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})), 174 + ) 175 + .into_response(); 176 + } 177 + } 178 + 179 + let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 180 + let verification_recipient = match verification_channel { 181 + "email" => match &email { 182 + Some(e) if !e.is_empty() => e.clone(), 183 + _ => return ( 184 + StatusCode::BAD_REQUEST, 185 + Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), 186 + ).into_response(), 187 + }, 188 + "discord" => match &input.discord_id { 189 + Some(id) if !id.trim().is_empty() => id.trim().to_string(), 190 + _ => return ( 191 + StatusCode::BAD_REQUEST, 192 + Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), 193 + ).into_response(), 194 + }, 195 + "telegram" => match &input.telegram_username { 196 + Some(username) if !username.trim().is_empty() => username.trim().to_string(), 197 + _ => return ( 198 + StatusCode::BAD_REQUEST, 199 + Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), 200 + ).into_response(), 201 + }, 202 + "signal" => match &input.signal_number { 203 + Some(number) if !number.trim().is_empty() => number.trim().to_string(), 204 + _ => return ( 205 + StatusCode::BAD_REQUEST, 206 + Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), 207 + ).into_response(), 208 + }, 209 + _ => return ( 210 + StatusCode::BAD_REQUEST, 211 + Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), 212 + ).into_response(), 213 + }; 214 + 215 + use k256::ecdsa::SigningKey; 216 + use rand::rngs::OsRng; 217 + 218 + let pds_endpoint = format!("https://{}", hostname); 219 + let did_type = input.did_type.as_deref().unwrap_or("plc"); 220 + 221 + let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<Uuid>) = 222 + if let Some(signing_key_did) = &input.signing_key { 223 + let reserved = sqlx::query!( 224 + r#" 225 + SELECT id, private_key_bytes 226 + FROM reserved_signing_keys 227 + WHERE public_key_did_key = $1 228 + AND used_at IS NULL 229 + AND expires_at > NOW() 230 + FOR UPDATE 231 + "#, 232 + signing_key_did 233 + ) 234 + .fetch_optional(&state.db) 235 + .await; 236 + match reserved { 237 + Ok(Some(row)) => (row.private_key_bytes, Some(row.id)), 238 + Ok(None) => { 239 + return ( 240 + StatusCode::BAD_REQUEST, 241 + Json(json!({ 242 + "error": "InvalidSigningKey", 243 + "message": "Signing key not found, already used, or expired" 244 + })), 245 + ) 246 + .into_response(); 247 + } 248 + Err(e) => { 249 + error!("Error looking up reserved signing key: {:?}", e); 250 + return ( 251 + StatusCode::INTERNAL_SERVER_ERROR, 252 + Json(json!({"error": "InternalError"})), 253 + ) 254 + .into_response(); 255 + } 256 + } 257 + } else { 258 + let secret_key = k256::SecretKey::random(&mut OsRng); 259 + (secret_key.to_bytes().to_vec(), None) 260 + }; 261 + 262 + let secret_key = match SigningKey::from_slice(&secret_key_bytes) { 263 + Ok(k) => k, 264 + Err(e) => { 265 + error!("Error creating signing key: {:?}", e); 266 + return ( 267 + StatusCode::INTERNAL_SERVER_ERROR, 268 + Json(json!({"error": "InternalError"})), 269 + ) 270 + .into_response(); 271 + } 272 + }; 273 + 274 + let did = match did_type { 275 + "web" => { 276 + let subdomain_host = format!("{}.{}", input.handle, hostname); 277 + let encoded_subdomain = subdomain_host.replace(':', "%3A"); 278 + let self_hosted_did = format!("did:web:{}", encoded_subdomain); 279 + info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); 280 + self_hosted_did 281 + } 282 + "web-external" => { 283 + let d = match &input.did { 284 + Some(d) if !d.trim().is_empty() => d.trim(), 285 + _ => { 286 + return ( 287 + StatusCode::BAD_REQUEST, 288 + Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})), 289 + ) 290 + .into_response(); 291 + } 292 + }; 293 + if !d.starts_with("did:web:") { 294 + return ( 295 + StatusCode::BAD_REQUEST, 296 + Json( 297 + json!({"error": "InvalidDid", "message": "External DID must be a did:web"}), 298 + ), 299 + ) 300 + .into_response(); 301 + } 302 + if let Err(e) = crate::api::identity::did::verify_did_web( 303 + d, 304 + &hostname, 305 + &input.handle, 306 + input.signing_key.as_deref(), 307 + ) 308 + .await 309 + { 310 + return ( 311 + StatusCode::BAD_REQUEST, 312 + Json(json!({"error": "InvalidDid", "message": e})), 313 + ) 314 + .into_response(); 315 + } 316 + info!(did = %d, "Creating external did:web passkey account"); 317 + d.to_string() 318 + } 319 + _ => { 320 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 321 + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); 322 + 323 + let genesis_result = match crate::plc::create_genesis_operation( 324 + &secret_key, 325 + &rotation_key, 326 + &handle, 327 + &pds_endpoint, 328 + ) { 329 + Ok(r) => r, 330 + Err(e) => { 331 + error!("Error creating PLC genesis operation: {:?}", e); 332 + return ( 333 + StatusCode::INTERNAL_SERVER_ERROR, 334 + Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 335 + ) 336 + .into_response(); 337 + } 338 + }; 339 + 340 + let plc_client = crate::plc::PlcClient::new(None); 341 + if let Err(e) = plc_client 342 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 343 + .await 344 + { 345 + error!("Failed to submit PLC genesis operation: {:?}", e); 346 + return ( 347 + StatusCode::BAD_GATEWAY, 348 + Json(json!({ 349 + "error": "UpstreamError", 350 + "message": format!("Failed to register DID with PLC directory: {}", e) 351 + })), 352 + ) 353 + .into_response(); 354 + } 355 + genesis_result.did 356 + } 357 + }; 358 + 359 + info!(did = %did, handle = %handle, "Created DID for passkey-only account"); 360 + 361 + let verification_code = format!( 362 + "{:06}", 363 + rand::Rng::gen_range(&mut rand::thread_rng(), 0..1_000_000u32) 364 + ); 365 + let verification_code_expires_at = Utc::now() + Duration::minutes(30); 366 + 367 + let setup_token = generate_setup_token(); 368 + let setup_token_hash = match hash(&setup_token, DEFAULT_COST) { 369 + Ok(h) => h, 370 + Err(e) => { 371 + error!("Error hashing setup token: {:?}", e); 372 + return ( 373 + StatusCode::INTERNAL_SERVER_ERROR, 374 + Json(json!({"error": "InternalError"})), 375 + ) 376 + .into_response(); 377 + } 378 + }; 379 + let setup_expires_at = Utc::now() + Duration::hours(1); 380 + 381 + let mut tx = match state.db.begin().await { 382 + Ok(tx) => tx, 383 + Err(e) => { 384 + error!("Error starting transaction: {:?}", e); 385 + return ( 386 + StatusCode::INTERNAL_SERVER_ERROR, 387 + Json(json!({"error": "InternalError"})), 388 + ) 389 + .into_response(); 390 + } 391 + }; 392 + 393 + let user_insert: Result<(Uuid,), _> = sqlx::query_as( 394 + r#"INSERT INTO users ( 395 + handle, email, did, password_hash, password_required, 396 + preferred_comms_channel, 397 + discord_id, telegram_username, signal_number, 398 + recovery_token, recovery_token_expires_at 399 + ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9) RETURNING id"#, 400 + ) 401 + .bind(&handle) 402 + .bind(&email) 403 + .bind(&did) 404 + .bind(verification_channel) 405 + .bind( 406 + input 407 + .discord_id 408 + .as_deref() 409 + .map(|s| s.trim()) 410 + .filter(|s| !s.is_empty()), 411 + ) 412 + .bind( 413 + input 414 + .telegram_username 415 + .as_deref() 416 + .map(|s| s.trim()) 417 + .filter(|s| !s.is_empty()), 418 + ) 419 + .bind( 420 + input 421 + .signal_number 422 + .as_deref() 423 + .map(|s| s.trim()) 424 + .filter(|s| !s.is_empty()), 425 + ) 426 + .bind(&setup_token_hash) 427 + .bind(setup_expires_at) 428 + .fetch_one(&mut *tx) 429 + .await; 430 + 431 + let user_id = match user_insert { 432 + Ok((id,)) => id, 433 + Err(e) => { 434 + if let Some(db_err) = e.as_database_error() 435 + && db_err.code().as_deref() == Some("23505") 436 + { 437 + let constraint = db_err.constraint().unwrap_or(""); 438 + if constraint.contains("handle") { 439 + return ( 440 + StatusCode::BAD_REQUEST, 441 + Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})), 442 + ) 443 + .into_response(); 444 + } else if constraint.contains("email") { 445 + return ( 446 + StatusCode::BAD_REQUEST, 447 + Json( 448 + json!({"error": "InvalidEmail", "message": "Email already registered"}), 449 + ), 450 + ) 451 + .into_response(); 452 + } 453 + } 454 + error!("Error inserting user: {:?}", e); 455 + return ( 456 + StatusCode::INTERNAL_SERVER_ERROR, 457 + Json(json!({"error": "InternalError"})), 458 + ) 459 + .into_response(); 460 + } 461 + }; 462 + 463 + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 464 + Ok(bytes) => bytes, 465 + Err(e) => { 466 + error!("Error encrypting signing key: {:?}", e); 467 + return ( 468 + StatusCode::INTERNAL_SERVER_ERROR, 469 + Json(json!({"error": "InternalError"})), 470 + ) 471 + .into_response(); 472 + } 473 + }; 474 + 475 + if let Err(e) = sqlx::query!( 476 + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", 477 + user_id, 478 + &encrypted_key_bytes[..], 479 + crate::config::ENCRYPTION_VERSION 480 + ) 481 + .execute(&mut *tx) 482 + .await 483 + { 484 + error!("Error inserting user key: {:?}", e); 485 + return ( 486 + StatusCode::INTERNAL_SERVER_ERROR, 487 + Json(json!({"error": "InternalError"})), 488 + ) 489 + .into_response(); 490 + } 491 + 492 + if let Some(key_id) = reserved_key_id 493 + && let Err(e) = sqlx::query!( 494 + "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", 495 + key_id 496 + ) 497 + .execute(&mut *tx) 498 + .await 499 + { 500 + error!("Error marking reserved key as used: {:?}", e); 501 + return ( 502 + StatusCode::INTERNAL_SERVER_ERROR, 503 + Json(json!({"error": "InternalError"})), 504 + ) 505 + .into_response(); 506 + } 507 + 508 + let mst = Mst::new(Arc::new(state.block_store.clone())); 509 + let mst_root = match mst.persist().await { 510 + Ok(c) => c, 511 + Err(e) => { 512 + error!("Error persisting MST: {:?}", e); 513 + return ( 514 + StatusCode::INTERNAL_SERVER_ERROR, 515 + Json(json!({"error": "InternalError"})), 516 + ) 517 + .into_response(); 518 + } 519 + }; 520 + let did_obj = match Did::new(&did) { 521 + Ok(d) => d, 522 + Err(_) => { 523 + return ( 524 + StatusCode::INTERNAL_SERVER_ERROR, 525 + Json(json!({"error": "InternalError", "message": "Invalid DID"})), 526 + ) 527 + .into_response(); 528 + } 529 + }; 530 + let rev = Tid::now(LimitedU32::MIN); 531 + let unsigned_commit = Commit::new_unsigned(did_obj, mst_root, rev, None); 532 + let signed_commit = match unsigned_commit.sign(&secret_key) { 533 + Ok(c) => c, 534 + Err(e) => { 535 + error!("Error signing genesis commit: {:?}", e); 536 + return ( 537 + StatusCode::INTERNAL_SERVER_ERROR, 538 + Json(json!({"error": "InternalError"})), 539 + ) 540 + .into_response(); 541 + } 542 + }; 543 + let commit_bytes = match signed_commit.to_cbor() { 544 + Ok(b) => b, 545 + Err(e) => { 546 + error!("Error serializing genesis commit: {:?}", e); 547 + return ( 548 + StatusCode::INTERNAL_SERVER_ERROR, 549 + Json(json!({"error": "InternalError"})), 550 + ) 551 + .into_response(); 552 + } 553 + }; 554 + let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 555 + Ok(c) => c, 556 + Err(e) => { 557 + error!("Error saving genesis commit: {:?}", e); 558 + return ( 559 + StatusCode::INTERNAL_SERVER_ERROR, 560 + Json(json!({"error": "InternalError"})), 561 + ) 562 + .into_response(); 563 + } 564 + }; 565 + let commit_cid_str = commit_cid.to_string(); 566 + if let Err(e) = sqlx::query!( 567 + "INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", 568 + user_id, 569 + commit_cid_str 570 + ) 571 + .execute(&mut *tx) 572 + .await 573 + { 574 + error!("Error inserting repo: {:?}", e); 575 + return ( 576 + StatusCode::INTERNAL_SERVER_ERROR, 577 + Json(json!({"error": "InternalError"})), 578 + ) 579 + .into_response(); 580 + } 581 + 582 + if let Some(ref code) = input.invite_code { 583 + let _ = sqlx::query!( 584 + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 585 + code 586 + ) 587 + .execute(&mut *tx) 588 + .await; 589 + 590 + let _ = sqlx::query!( 591 + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 592 + code, 593 + user_id 594 + ) 595 + .execute(&mut *tx) 596 + .await; 597 + } 598 + 599 + if let Err(e) = sqlx::query!( 600 + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)", 601 + user_id, 602 + verification_channel as _, 603 + verification_code, 604 + verification_recipient, 605 + verification_code_expires_at 606 + ) 607 + .execute(&mut *tx) 608 + .await 609 + { 610 + error!("Error inserting channel verification: {:?}", e); 611 + return ( 612 + StatusCode::INTERNAL_SERVER_ERROR, 613 + Json(json!({"error": "InternalError"})), 614 + ) 615 + .into_response(); 616 + } 617 + 618 + if let Err(e) = tx.commit().await { 619 + error!("Error committing transaction: {:?}", e); 620 + return ( 621 + StatusCode::INTERNAL_SERVER_ERROR, 622 + Json(json!({"error": "InternalError"})), 623 + ) 624 + .into_response(); 625 + } 626 + 627 + if let Err(e) = 628 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 629 + { 630 + warn!("Failed to sequence identity event for {}: {}", did, e); 631 + } 632 + 633 + if let Err(e) = crate::comms::enqueue_signup_verification( 634 + &state.db, 635 + user_id, 636 + verification_channel, 637 + &verification_recipient, 638 + &verification_code, 639 + ) 640 + .await 641 + { 642 + warn!("Failed to enqueue signup verification: {:?}", e); 643 + } 644 + 645 + info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 646 + 647 + Json(CreatePasskeyAccountResponse { 648 + did, 649 + handle, 650 + setup_token, 651 + setup_expires_at, 652 + }) 653 + .into_response() 654 + } 655 + 656 + #[derive(Deserialize)] 657 + #[serde(rename_all = "camelCase")] 658 + pub struct CompletePasskeySetupInput { 659 + pub did: String, 660 + pub setup_token: String, 661 + pub passkey_credential: serde_json::Value, 662 + pub passkey_friendly_name: Option<String>, 663 + } 664 + 665 + #[derive(Serialize)] 666 + #[serde(rename_all = "camelCase")] 667 + pub struct CompletePasskeySetupResponse { 668 + pub did: String, 669 + pub handle: String, 670 + pub app_password: String, 671 + pub app_password_name: String, 672 + } 673 + 674 + pub async fn complete_passkey_setup( 675 + State(state): State<AppState>, 676 + Json(input): Json<CompletePasskeySetupInput>, 677 + ) -> Response { 678 + let user = sqlx::query!( 679 + r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required 680 + FROM users WHERE did = $1"#, 681 + input.did 682 + ) 683 + .fetch_optional(&state.db) 684 + .await; 685 + 686 + let user = match user { 687 + Ok(Some(u)) => u, 688 + Ok(None) => { 689 + return ( 690 + StatusCode::NOT_FOUND, 691 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 692 + ) 693 + .into_response(); 694 + } 695 + Err(e) => { 696 + error!("DB error: {:?}", e); 697 + return ( 698 + StatusCode::INTERNAL_SERVER_ERROR, 699 + Json(json!({"error": "InternalError"})), 700 + ) 701 + .into_response(); 702 + } 703 + }; 704 + 705 + if user.password_required { 706 + return ( 707 + StatusCode::BAD_REQUEST, 708 + Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})), 709 + ) 710 + .into_response(); 711 + } 712 + 713 + let token_hash = match &user.recovery_token { 714 + Some(h) => h, 715 + None => { 716 + return ( 717 + StatusCode::BAD_REQUEST, 718 + Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})), 719 + ) 720 + .into_response(); 721 + } 722 + }; 723 + 724 + if let Some(expires_at) = user.recovery_token_expires_at 725 + && expires_at < Utc::now() 726 + { 727 + return ( 728 + StatusCode::BAD_REQUEST, 729 + Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})), 730 + ) 731 + .into_response(); 732 + } 733 + 734 + if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 735 + return ( 736 + StatusCode::UNAUTHORIZED, 737 + Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})), 738 + ) 739 + .into_response(); 740 + } 741 + 742 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 743 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 744 + Ok(w) => w, 745 + Err(e) => { 746 + error!("Failed to create WebAuthn config: {:?}", e); 747 + return ( 748 + StatusCode::INTERNAL_SERVER_ERROR, 749 + Json(json!({"error": "InternalError"})), 750 + ) 751 + .into_response(); 752 + } 753 + }; 754 + 755 + let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did) 756 + .await 757 + { 758 + Ok(Some(s)) => s, 759 + Ok(None) => { 760 + return ( 761 + StatusCode::BAD_REQUEST, 762 + Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})), 763 + ) 764 + .into_response(); 765 + } 766 + Err(e) => { 767 + error!("Error loading registration state: {:?}", e); 768 + return ( 769 + StatusCode::INTERNAL_SERVER_ERROR, 770 + Json(json!({"error": "InternalError"})), 771 + ) 772 + .into_response(); 773 + } 774 + }; 775 + 776 + let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = match serde_json::from_value( 777 + input.passkey_credential, 778 + ) { 779 + Ok(c) => c, 780 + Err(e) => { 781 + warn!("Failed to parse credential: {:?}", e); 782 + return ( 783 + StatusCode::BAD_REQUEST, 784 + Json( 785 + json!({"error": "InvalidCredential", "message": "Failed to parse credential"}), 786 + ), 787 + ) 788 + .into_response(); 789 + } 790 + }; 791 + 792 + let security_key = match webauthn.finish_registration(&credential, &reg_state) { 793 + Ok(sk) => sk, 794 + Err(e) => { 795 + warn!("Passkey registration failed: {:?}", e); 796 + return ( 797 + StatusCode::BAD_REQUEST, 798 + Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})), 799 + ) 800 + .into_response(); 801 + } 802 + }; 803 + 804 + if let Err(e) = crate::auth::webauthn::save_passkey( 805 + &state.db, 806 + &input.did, 807 + &security_key, 808 + input.passkey_friendly_name.as_deref(), 809 + ) 810 + .await 811 + { 812 + error!("Error saving passkey: {:?}", e); 813 + return ( 814 + StatusCode::INTERNAL_SERVER_ERROR, 815 + Json(json!({"error": "InternalError"})), 816 + ) 817 + .into_response(); 818 + } 819 + 820 + let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; 821 + 822 + let app_password = generate_app_password(); 823 + let app_password_name = "bsky.app".to_string(); 824 + let password_hash = match hash(&app_password, DEFAULT_COST) { 825 + Ok(h) => h, 826 + Err(e) => { 827 + error!("Error hashing app password: {:?}", e); 828 + return ( 829 + StatusCode::INTERNAL_SERVER_ERROR, 830 + Json(json!({"error": "InternalError"})), 831 + ) 832 + .into_response(); 833 + } 834 + }; 835 + 836 + if let Err(e) = sqlx::query!( 837 + "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", 838 + user.id, 839 + app_password_name, 840 + password_hash 841 + ) 842 + .execute(&state.db) 843 + .await 844 + { 845 + error!("Error creating app password: {:?}", e); 846 + return ( 847 + StatusCode::INTERNAL_SERVER_ERROR, 848 + Json(json!({"error": "InternalError"})), 849 + ) 850 + .into_response(); 851 + } 852 + 853 + if let Err(e) = sqlx::query!( 854 + "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", 855 + input.did 856 + ) 857 + .execute(&state.db) 858 + .await 859 + { 860 + error!("Error clearing setup token: {:?}", e); 861 + } 862 + 863 + info!(did = %input.did, "Passkey-only account setup completed"); 864 + 865 + Json(CompletePasskeySetupResponse { 866 + did: input.did, 867 + handle: user.handle, 868 + app_password, 869 + app_password_name, 870 + }) 871 + .into_response() 872 + } 873 + 874 + pub async fn start_passkey_registration_for_setup( 875 + State(state): State<AppState>, 876 + Json(input): Json<StartPasskeyRegistrationInput>, 877 + ) -> Response { 878 + let user = sqlx::query!( 879 + r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required 880 + FROM users WHERE did = $1"#, 881 + input.did 882 + ) 883 + .fetch_optional(&state.db) 884 + .await; 885 + 886 + let user = match user { 887 + Ok(Some(u)) => u, 888 + Ok(None) => { 889 + return ( 890 + StatusCode::NOT_FOUND, 891 + Json(json!({"error": "AccountNotFound"})), 892 + ) 893 + .into_response(); 894 + } 895 + Err(e) => { 896 + error!("DB error: {:?}", e); 897 + return ( 898 + StatusCode::INTERNAL_SERVER_ERROR, 899 + Json(json!({"error": "InternalError"})), 900 + ) 901 + .into_response(); 902 + } 903 + }; 904 + 905 + if user.password_required { 906 + return ( 907 + StatusCode::BAD_REQUEST, 908 + Json(json!({"error": "InvalidAccount"})), 909 + ) 910 + .into_response(); 911 + } 912 + 913 + let token_hash = match &user.recovery_token { 914 + Some(h) => h, 915 + None => { 916 + return ( 917 + StatusCode::BAD_REQUEST, 918 + Json(json!({"error": "SetupExpired"})), 919 + ) 920 + .into_response(); 921 + } 922 + }; 923 + 924 + if let Some(expires_at) = user.recovery_token_expires_at 925 + && expires_at < Utc::now() 926 + { 927 + return ( 928 + StatusCode::BAD_REQUEST, 929 + Json(json!({"error": "SetupExpired"})), 930 + ) 931 + .into_response(); 932 + } 933 + 934 + if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 935 + return ( 936 + StatusCode::UNAUTHORIZED, 937 + Json(json!({"error": "InvalidToken"})), 938 + ) 939 + .into_response(); 940 + } 941 + 942 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 943 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 944 + Ok(w) => w, 945 + Err(e) => { 946 + error!("Failed to create WebAuthn config: {:?}", e); 947 + return ( 948 + StatusCode::INTERNAL_SERVER_ERROR, 949 + Json(json!({"error": "InternalError"})), 950 + ) 951 + .into_response(); 952 + } 953 + }; 954 + 955 + let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did) 956 + .await 957 + .unwrap_or_default(); 958 + 959 + let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys 960 + .iter() 961 + .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone())) 962 + .collect(); 963 + 964 + let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle); 965 + 966 + let (ccr, reg_state) = match webauthn.start_registration( 967 + &input.did, 968 + &user.handle, 969 + display_name, 970 + exclude_credentials, 971 + ) { 972 + Ok(result) => result, 973 + Err(e) => { 974 + error!("Failed to start passkey registration: {:?}", e); 975 + return ( 976 + StatusCode::INTERNAL_SERVER_ERROR, 977 + Json(json!({"error": "InternalError"})), 978 + ) 979 + .into_response(); 980 + } 981 + }; 982 + 983 + if let Err(e) = 984 + crate::auth::webauthn::save_registration_state(&state.db, &input.did, &reg_state).await 985 + { 986 + error!("Failed to save registration state: {:?}", e); 987 + return ( 988 + StatusCode::INTERNAL_SERVER_ERROR, 989 + Json(json!({"error": "InternalError"})), 990 + ) 991 + .into_response(); 992 + } 993 + 994 + let options = serde_json::to_value(&ccr).unwrap_or(json!({})); 995 + Json(json!({"options": options})).into_response() 996 + } 997 + 998 + #[derive(Deserialize)] 999 + #[serde(rename_all = "camelCase")] 1000 + pub struct StartPasskeyRegistrationInput { 1001 + pub did: String, 1002 + pub setup_token: String, 1003 + pub friendly_name: Option<String>, 1004 + } 1005 + 1006 + #[derive(Deserialize)] 1007 + #[serde(rename_all = "camelCase")] 1008 + pub struct RequestPasskeyRecoveryInput { 1009 + #[serde(alias = "identifier")] 1010 + pub email: String, 1011 + } 1012 + 1013 + pub async fn request_passkey_recovery( 1014 + State(state): State<AppState>, 1015 + headers: HeaderMap, 1016 + Json(input): Json<RequestPasskeyRecoveryInput>, 1017 + ) -> Response { 1018 + let client_ip = extract_client_ip(&headers); 1019 + if !state 1020 + .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) 1021 + .await 1022 + { 1023 + return ( 1024 + StatusCode::TOO_MANY_REQUESTS, 1025 + Json(json!({"error": "RateLimitExceeded"})), 1026 + ) 1027 + .into_response(); 1028 + } 1029 + 1030 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1031 + let identifier = input.email.trim().to_lowercase(); 1032 + let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); 1033 + let normalized_handle = if identifier.contains('@') || identifier.contains('.') { 1034 + identifier.to_string() 1035 + } else { 1036 + format!("{}.{}", identifier, pds_hostname) 1037 + }; 1038 + 1039 + let user = sqlx::query!( 1040 + "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", 1041 + identifier, 1042 + normalized_handle 1043 + ) 1044 + .fetch_optional(&state.db) 1045 + .await; 1046 + 1047 + let user = match user { 1048 + Ok(Some(u)) if !u.password_required => u, 1049 + _ => { 1050 + return Json(json!({"success": true})).into_response(); 1051 + } 1052 + }; 1053 + 1054 + let recovery_token = generate_setup_token(); 1055 + let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { 1056 + Ok(h) => h, 1057 + Err(_) => { 1058 + return ( 1059 + StatusCode::INTERNAL_SERVER_ERROR, 1060 + Json(json!({"error": "InternalError"})), 1061 + ) 1062 + .into_response(); 1063 + } 1064 + }; 1065 + let expires_at = Utc::now() + Duration::hours(1); 1066 + 1067 + if let Err(e) = sqlx::query!( 1068 + "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", 1069 + recovery_token_hash, 1070 + expires_at, 1071 + user.did 1072 + ) 1073 + .execute(&state.db) 1074 + .await 1075 + { 1076 + error!("Error updating recovery token: {:?}", e); 1077 + return ( 1078 + StatusCode::INTERNAL_SERVER_ERROR, 1079 + Json(json!({"error": "InternalError"})), 1080 + ) 1081 + .into_response(); 1082 + } 1083 + 1084 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1085 + let recovery_url = format!( 1086 + "https://{}/#/recover-passkey?did={}&token={}", 1087 + hostname, 1088 + urlencoding::encode(&user.did), 1089 + urlencoding::encode(&recovery_token) 1090 + ); 1091 + 1092 + let _ = 1093 + crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await; 1094 + 1095 + info!(did = %user.did, "Passkey recovery requested"); 1096 + Json(json!({"success": true})).into_response() 1097 + } 1098 + 1099 + #[derive(Deserialize)] 1100 + #[serde(rename_all = "camelCase")] 1101 + pub struct RecoverPasskeyAccountInput { 1102 + pub did: String, 1103 + pub recovery_token: String, 1104 + pub new_password: String, 1105 + } 1106 + 1107 + pub async fn recover_passkey_account( 1108 + State(state): State<AppState>, 1109 + Json(input): Json<RecoverPasskeyAccountInput>, 1110 + ) -> Response { 1111 + if input.new_password.len() < 8 { 1112 + return ( 1113 + StatusCode::BAD_REQUEST, 1114 + Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})), 1115 + ) 1116 + .into_response(); 1117 + } 1118 + 1119 + let user = sqlx::query!( 1120 + "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", 1121 + input.did 1122 + ) 1123 + .fetch_optional(&state.db) 1124 + .await; 1125 + 1126 + let user = match user { 1127 + Ok(Some(u)) => u, 1128 + _ => { 1129 + return ( 1130 + StatusCode::NOT_FOUND, 1131 + Json(json!({"error": "InvalidRecoveryLink"})), 1132 + ) 1133 + .into_response(); 1134 + } 1135 + }; 1136 + 1137 + let token_hash = match &user.recovery_token { 1138 + Some(h) => h, 1139 + None => { 1140 + return ( 1141 + StatusCode::BAD_REQUEST, 1142 + Json(json!({"error": "InvalidRecoveryLink"})), 1143 + ) 1144 + .into_response(); 1145 + } 1146 + }; 1147 + 1148 + if let Some(expires_at) = user.recovery_token_expires_at 1149 + && expires_at < Utc::now() 1150 + { 1151 + return ( 1152 + StatusCode::BAD_REQUEST, 1153 + Json(json!({"error": "RecoveryLinkExpired"})), 1154 + ) 1155 + .into_response(); 1156 + } 1157 + 1158 + if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { 1159 + return ( 1160 + StatusCode::UNAUTHORIZED, 1161 + Json(json!({"error": "InvalidRecoveryLink"})), 1162 + ) 1163 + .into_response(); 1164 + } 1165 + 1166 + let password_hash = match hash(&input.new_password, DEFAULT_COST) { 1167 + Ok(h) => h, 1168 + Err(_) => { 1169 + return ( 1170 + StatusCode::INTERNAL_SERVER_ERROR, 1171 + Json(json!({"error": "InternalError"})), 1172 + ) 1173 + .into_response(); 1174 + } 1175 + }; 1176 + 1177 + if let Err(e) = sqlx::query!( 1178 + "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", 1179 + password_hash, 1180 + input.did 1181 + ) 1182 + .execute(&state.db) 1183 + .await 1184 + { 1185 + error!("Error updating password: {:?}", e); 1186 + return ( 1187 + StatusCode::INTERNAL_SERVER_ERROR, 1188 + Json(json!({"error": "InternalError"})), 1189 + ) 1190 + .into_response(); 1191 + } 1192 + 1193 + let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did) 1194 + .execute(&state.db) 1195 + .await; 1196 + match deleted { 1197 + Ok(result) => { 1198 + if result.rows_affected() > 0 { 1199 + info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery"); 1200 + } 1201 + } 1202 + Err(e) => { 1203 + warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); 1204 + } 1205 + } 1206 + 1207 + info!(did = %input.did, "Passkey-only account recovered with temporary password"); 1208 + Json(json!({"success": true})).into_response() 1209 + }
+5 -3
src/api/server/passkeys.rs
··· 371 371 } 372 372 373 373 pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool { 374 - webauthn::has_passkeys(&state.db, did) 375 - .await 376 - .unwrap_or(false) 374 + has_passkeys_for_user_db(&state.db, did).await 375 + } 376 + 377 + pub async fn has_passkeys_for_user_db(db: &sqlx::PgPool, did: &str) -> bool { 378 + webauthn::has_passkeys(db, did).await.unwrap_or(false) 377 379 }
+123 -8
src/api/server/password.rs
··· 33 33 34 34 #[derive(Deserialize)] 35 35 pub struct RequestPasswordResetInput { 36 + #[serde(alias = "identifier")] 36 37 pub email: String, 37 38 } 38 39 ··· 56 57 ) 57 58 .into_response(); 58 59 } 59 - let email = input.email.trim().to_lowercase(); 60 - if email.is_empty() { 60 + let identifier = input.email.trim(); 61 + if identifier.is_empty() { 61 62 return ( 62 63 StatusCode::BAD_REQUEST, 63 - Json(json!({"error": "InvalidRequest", "message": "email is required"})), 64 + Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})), 64 65 ) 65 66 .into_response(); 66 67 } 67 - let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email) 68 - .fetch_optional(&state.db) 69 - .await; 68 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 69 + let normalized = identifier.to_lowercase(); 70 + let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); 71 + let normalized_handle = if normalized.contains('@') || normalized.contains('.') { 72 + normalized.to_string() 73 + } else { 74 + format!("{}.{}", normalized, pds_hostname) 75 + }; 76 + let user = sqlx::query!( 77 + "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2", 78 + normalized, 79 + normalized_handle 80 + ) 81 + .fetch_optional(&state.db) 82 + .await; 70 83 let user_id = match user { 71 84 Ok(Some(row)) => row.id, 72 85 Ok(None) => { 73 - info!("Password reset requested for unknown email"); 86 + info!("Password reset requested for unknown identifier"); 74 87 return (StatusCode::OK, Json(json!({}))).into_response(); 75 88 } 76 89 Err(e) => { ··· 225 238 } 226 239 }; 227 240 if let Err(e) = sqlx::query!( 228 - "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", 241 + "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2", 229 242 password_hash, 230 243 user_id 231 244 ) ··· 404 417 info!(did = %auth.0.did, "Password changed successfully"); 405 418 (StatusCode::OK, Json(json!({}))).into_response() 406 419 } 420 + 421 + pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response { 422 + let user = sqlx::query!( 423 + "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1", 424 + auth.0.did 425 + ) 426 + .fetch_optional(&state.db) 427 + .await; 428 + 429 + match user { 430 + Ok(Some(row)) => { 431 + Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response() 432 + } 433 + Ok(None) => ( 434 + StatusCode::NOT_FOUND, 435 + Json(json!({"error": "AccountNotFound"})), 436 + ) 437 + .into_response(), 438 + Err(e) => { 439 + error!("DB error: {:?}", e); 440 + ( 441 + StatusCode::INTERNAL_SERVER_ERROR, 442 + Json(json!({"error": "InternalError"})), 443 + ) 444 + .into_response() 445 + } 446 + } 447 + } 448 + 449 + pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response { 450 + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 451 + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 452 + } 453 + 454 + let has_passkeys = 455 + crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await; 456 + if !has_passkeys { 457 + return ( 458 + StatusCode::BAD_REQUEST, 459 + Json(json!({ 460 + "error": "NoPasskeys", 461 + "message": "You must have at least one passkey registered before removing your password" 462 + })), 463 + ) 464 + .into_response(); 465 + } 466 + 467 + let user = sqlx::query!( 468 + "SELECT id, password_hash FROM users WHERE did = $1", 469 + auth.0.did 470 + ) 471 + .fetch_optional(&state.db) 472 + .await; 473 + 474 + let user = match user { 475 + Ok(Some(u)) => u, 476 + Ok(None) => { 477 + return ( 478 + StatusCode::NOT_FOUND, 479 + Json(json!({"error": "AccountNotFound"})), 480 + ) 481 + .into_response(); 482 + } 483 + Err(e) => { 484 + error!("DB error: {:?}", e); 485 + return ( 486 + StatusCode::INTERNAL_SERVER_ERROR, 487 + Json(json!({"error": "InternalError"})), 488 + ) 489 + .into_response(); 490 + } 491 + }; 492 + 493 + if user.password_hash.is_none() { 494 + return ( 495 + StatusCode::BAD_REQUEST, 496 + Json(json!({ 497 + "error": "NoPassword", 498 + "message": "Account already has no password" 499 + })), 500 + ) 501 + .into_response(); 502 + } 503 + 504 + if let Err(e) = sqlx::query!( 505 + "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1", 506 + user.id 507 + ) 508 + .execute(&state.db) 509 + .await 510 + { 511 + error!("DB error removing password: {:?}", e); 512 + return ( 513 + StatusCode::INTERNAL_SERVER_ERROR, 514 + Json(json!({"error": "InternalError"})), 515 + ) 516 + .into_response(); 517 + } 518 + 519 + info!(did = %auth.0.did, "Password removed - account is now passkey-only"); 520 + (StatusCode::OK, Json(json!({"success": true}))).into_response() 521 + }
+482
src/api/server/reauth.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::State, 4 + http::StatusCode, 5 + response::{IntoResponse, Response}, 6 + }; 7 + use chrono::{DateTime, Utc}; 8 + use serde::{Deserialize, Serialize}; 9 + use serde_json::json; 10 + use sqlx::PgPool; 11 + use tracing::{error, info, warn}; 12 + 13 + use crate::auth::BearerAuth; 14 + use crate::state::AppState; 15 + 16 + const REAUTH_WINDOW_SECONDS: i64 = 300; 17 + 18 + #[derive(Serialize)] 19 + #[serde(rename_all = "camelCase")] 20 + pub struct ReauthStatusResponse { 21 + pub last_reauth_at: Option<DateTime<Utc>>, 22 + pub reauth_required: bool, 23 + pub available_methods: Vec<String>, 24 + } 25 + 26 + pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response { 27 + let session = sqlx::query!( 28 + "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 29 + auth.0.did 30 + ) 31 + .fetch_optional(&state.db) 32 + .await; 33 + 34 + let last_reauth_at = match session { 35 + Ok(Some(row)) => row.last_reauth_at, 36 + Ok(None) => None, 37 + Err(e) => { 38 + error!("DB error: {:?}", e); 39 + return ( 40 + StatusCode::INTERNAL_SERVER_ERROR, 41 + Json(json!({"error": "InternalError"})), 42 + ) 43 + .into_response(); 44 + } 45 + }; 46 + 47 + let reauth_required = is_reauth_required(last_reauth_at); 48 + let available_methods = get_available_reauth_methods(&state.db, &auth.0.did).await; 49 + 50 + Json(ReauthStatusResponse { 51 + last_reauth_at, 52 + reauth_required, 53 + available_methods, 54 + }) 55 + .into_response() 56 + } 57 + 58 + #[derive(Deserialize)] 59 + #[serde(rename_all = "camelCase")] 60 + pub struct PasswordReauthInput { 61 + pub password: String, 62 + } 63 + 64 + #[derive(Serialize)] 65 + #[serde(rename_all = "camelCase")] 66 + pub struct ReauthResponse { 67 + pub reauthed_at: DateTime<Utc>, 68 + } 69 + 70 + pub async fn reauth_password( 71 + State(state): State<AppState>, 72 + auth: BearerAuth, 73 + Json(input): Json<PasswordReauthInput>, 74 + ) -> Response { 75 + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 76 + .fetch_optional(&state.db) 77 + .await; 78 + 79 + let password_hash = match user { 80 + Ok(Some(row)) => row.password_hash, 81 + Ok(None) => { 82 + return ( 83 + StatusCode::NOT_FOUND, 84 + Json(json!({"error": "AccountNotFound"})), 85 + ) 86 + .into_response(); 87 + } 88 + Err(e) => { 89 + error!("DB error: {:?}", e); 90 + return ( 91 + StatusCode::INTERNAL_SERVER_ERROR, 92 + Json(json!({"error": "InternalError"})), 93 + ) 94 + .into_response(); 95 + } 96 + }; 97 + 98 + let password_valid = password_hash 99 + .as_ref() 100 + .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) 101 + .unwrap_or(false); 102 + 103 + if !password_valid { 104 + let app_passwords = sqlx::query!( 105 + "SELECT ap.password_hash FROM app_passwords ap 106 + JOIN users u ON ap.user_id = u.id 107 + WHERE u.did = $1", 108 + auth.0.did 109 + ) 110 + .fetch_all(&state.db) 111 + .await 112 + .unwrap_or_default(); 113 + 114 + let app_password_valid = app_passwords 115 + .iter() 116 + .any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false)); 117 + 118 + if !app_password_valid { 119 + warn!(did = %auth.0.did, "Re-auth failed: invalid password"); 120 + return ( 121 + StatusCode::UNAUTHORIZED, 122 + Json(json!({ 123 + "error": "InvalidPassword", 124 + "message": "Password is incorrect" 125 + })), 126 + ) 127 + .into_response(); 128 + } 129 + } 130 + 131 + match update_last_reauth(&state.db, &auth.0.did).await { 132 + Ok(reauthed_at) => { 133 + info!(did = %auth.0.did, "Re-auth successful via password"); 134 + Json(ReauthResponse { reauthed_at }).into_response() 135 + } 136 + Err(e) => { 137 + error!("DB error updating reauth: {:?}", e); 138 + ( 139 + StatusCode::INTERNAL_SERVER_ERROR, 140 + Json(json!({"error": "InternalError"})), 141 + ) 142 + .into_response() 143 + } 144 + } 145 + } 146 + 147 + #[derive(Deserialize)] 148 + #[serde(rename_all = "camelCase")] 149 + pub struct TotpReauthInput { 150 + pub code: String, 151 + } 152 + 153 + pub async fn reauth_totp( 154 + State(state): State<AppState>, 155 + auth: BearerAuth, 156 + Json(input): Json<TotpReauthInput>, 157 + ) -> Response { 158 + let valid = 159 + crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) 160 + .await; 161 + 162 + if !valid { 163 + warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code"); 164 + return ( 165 + StatusCode::UNAUTHORIZED, 166 + Json(json!({ 167 + "error": "InvalidCode", 168 + "message": "Invalid TOTP or backup code" 169 + })), 170 + ) 171 + .into_response(); 172 + } 173 + 174 + match update_last_reauth(&state.db, &auth.0.did).await { 175 + Ok(reauthed_at) => { 176 + info!(did = %auth.0.did, "Re-auth successful via TOTP"); 177 + Json(ReauthResponse { reauthed_at }).into_response() 178 + } 179 + Err(e) => { 180 + error!("DB error updating reauth: {:?}", e); 181 + ( 182 + StatusCode::INTERNAL_SERVER_ERROR, 183 + Json(json!({"error": "InternalError"})), 184 + ) 185 + .into_response() 186 + } 187 + } 188 + } 189 + 190 + #[derive(Serialize)] 191 + #[serde(rename_all = "camelCase")] 192 + pub struct PasskeyReauthStartResponse { 193 + pub options: serde_json::Value, 194 + } 195 + 196 + pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAuth) -> Response { 197 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 198 + 199 + let stored_passkeys = 200 + match crate::auth::webauthn::get_passkeys_for_user(&state.db, &auth.0.did).await { 201 + Ok(pks) => pks, 202 + Err(e) => { 203 + error!("Failed to get passkeys: {:?}", e); 204 + return ( 205 + StatusCode::INTERNAL_SERVER_ERROR, 206 + Json(json!({"error": "InternalError"})), 207 + ) 208 + .into_response(); 209 + } 210 + }; 211 + 212 + if stored_passkeys.is_empty() { 213 + return ( 214 + StatusCode::BAD_REQUEST, 215 + Json(json!({ 216 + "error": "NoPasskeys", 217 + "message": "No passkeys registered for this account" 218 + })), 219 + ) 220 + .into_response(); 221 + } 222 + 223 + let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys 224 + .iter() 225 + .filter_map(|sp| sp.to_security_key().ok()) 226 + .collect(); 227 + 228 + if passkeys.is_empty() { 229 + return ( 230 + StatusCode::INTERNAL_SERVER_ERROR, 231 + Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})), 232 + ) 233 + .into_response(); 234 + } 235 + 236 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 237 + Ok(w) => w, 238 + Err(e) => { 239 + error!("Failed to create WebAuthn config: {:?}", e); 240 + return ( 241 + StatusCode::INTERNAL_SERVER_ERROR, 242 + Json(json!({"error": "InternalError"})), 243 + ) 244 + .into_response(); 245 + } 246 + }; 247 + 248 + let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { 249 + Ok(result) => result, 250 + Err(e) => { 251 + error!("Failed to start passkey authentication: {:?}", e); 252 + return ( 253 + StatusCode::INTERNAL_SERVER_ERROR, 254 + Json(json!({"error": "InternalError"})), 255 + ) 256 + .into_response(); 257 + } 258 + }; 259 + 260 + if let Err(e) = 261 + crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await 262 + { 263 + error!("Failed to save authentication state: {:?}", e); 264 + return ( 265 + StatusCode::INTERNAL_SERVER_ERROR, 266 + Json(json!({"error": "InternalError"})), 267 + ) 268 + .into_response(); 269 + } 270 + 271 + let options = serde_json::to_value(&rcr).unwrap_or(json!({})); 272 + Json(PasskeyReauthStartResponse { options }).into_response() 273 + } 274 + 275 + #[derive(Deserialize)] 276 + #[serde(rename_all = "camelCase")] 277 + pub struct PasskeyReauthFinishInput { 278 + pub credential: serde_json::Value, 279 + } 280 + 281 + pub async fn reauth_passkey_finish( 282 + State(state): State<AppState>, 283 + auth: BearerAuth, 284 + Json(input): Json<PasskeyReauthFinishInput>, 285 + ) -> Response { 286 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 287 + 288 + let auth_state = 289 + match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await { 290 + Ok(Some(s)) => s, 291 + Ok(None) => { 292 + return ( 293 + StatusCode::BAD_REQUEST, 294 + Json(json!({ 295 + "error": "NoChallengeInProgress", 296 + "message": "No passkey authentication in progress or challenge expired" 297 + })), 298 + ) 299 + .into_response(); 300 + } 301 + Err(e) => { 302 + error!("Failed to load authentication state: {:?}", e); 303 + return ( 304 + StatusCode::INTERNAL_SERVER_ERROR, 305 + Json(json!({"error": "InternalError"})), 306 + ) 307 + .into_response(); 308 + } 309 + }; 310 + 311 + let credential: webauthn_rs::prelude::PublicKeyCredential = 312 + match serde_json::from_value(input.credential) { 313 + Ok(c) => c, 314 + Err(e) => { 315 + warn!("Failed to parse credential: {:?}", e); 316 + return ( 317 + StatusCode::BAD_REQUEST, 318 + Json(json!({ 319 + "error": "InvalidCredential", 320 + "message": "Failed to parse credential response" 321 + })), 322 + ) 323 + .into_response(); 324 + } 325 + }; 326 + 327 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 328 + Ok(w) => w, 329 + Err(e) => { 330 + error!("Failed to create WebAuthn config: {:?}", e); 331 + return ( 332 + StatusCode::INTERNAL_SERVER_ERROR, 333 + Json(json!({"error": "InternalError"})), 334 + ) 335 + .into_response(); 336 + } 337 + }; 338 + 339 + let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { 340 + Ok(r) => r, 341 + Err(e) => { 342 + warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e); 343 + return ( 344 + StatusCode::UNAUTHORIZED, 345 + Json(json!({ 346 + "error": "AuthenticationFailed", 347 + "message": "Passkey authentication failed" 348 + })), 349 + ) 350 + .into_response(); 351 + } 352 + }; 353 + 354 + let cred_id_bytes = auth_result.cred_id().as_ref(); 355 + if let Err(e) = crate::auth::webauthn::update_passkey_counter( 356 + &state.db, 357 + cred_id_bytes, 358 + auth_result.counter(), 359 + ) 360 + .await 361 + { 362 + error!("Failed to update passkey counter: {:?}", e); 363 + } 364 + 365 + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 366 + 367 + match update_last_reauth(&state.db, &auth.0.did).await { 368 + Ok(reauthed_at) => { 369 + info!(did = %auth.0.did, "Re-auth successful via passkey"); 370 + Json(ReauthResponse { reauthed_at }).into_response() 371 + } 372 + Err(e) => { 373 + error!("DB error updating reauth: {:?}", e); 374 + ( 375 + StatusCode::INTERNAL_SERVER_ERROR, 376 + Json(json!({"error": "InternalError"})), 377 + ) 378 + .into_response() 379 + } 380 + } 381 + } 382 + 383 + async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> { 384 + let now = Utc::now(); 385 + sqlx::query!( 386 + "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", 387 + now, 388 + did 389 + ) 390 + .execute(db) 391 + .await?; 392 + Ok(now) 393 + } 394 + 395 + fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool { 396 + match last_reauth_at { 397 + None => true, 398 + Some(t) => { 399 + let elapsed = Utc::now().signed_duration_since(t); 400 + elapsed.num_seconds() > REAUTH_WINDOW_SECONDS 401 + } 402 + } 403 + } 404 + 405 + async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> { 406 + let mut methods = Vec::new(); 407 + 408 + let has_password = sqlx::query_scalar!( 409 + "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", 410 + did 411 + ) 412 + .fetch_optional(db) 413 + .await 414 + .ok() 415 + .flatten() 416 + .unwrap_or(Some(false)); 417 + 418 + if has_password == Some(true) { 419 + methods.push("password".to_string()); 420 + } 421 + 422 + let has_app_password = sqlx::query_scalar!( 423 + "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", 424 + did 425 + ) 426 + .fetch_optional(db) 427 + .await 428 + .ok() 429 + .flatten() 430 + .is_some(); 431 + 432 + if has_app_password && !methods.contains(&"password".to_string()) { 433 + methods.push("password".to_string()); 434 + } 435 + 436 + let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await; 437 + if has_totp { 438 + methods.push("totp".to_string()); 439 + } 440 + 441 + let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await; 442 + if has_passkeys { 443 + methods.push("passkey".to_string()); 444 + } 445 + 446 + methods 447 + } 448 + 449 + pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool { 450 + let session = sqlx::query!( 451 + "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 452 + did 453 + ) 454 + .fetch_optional(db) 455 + .await; 456 + 457 + match session { 458 + Ok(Some(row)) => is_reauth_required(row.last_reauth_at), 459 + _ => true, 460 + } 461 + } 462 + 463 + #[derive(Serialize)] 464 + #[serde(rename_all = "camelCase")] 465 + pub struct ReauthRequiredError { 466 + pub error: String, 467 + pub message: String, 468 + pub reauth_methods: Vec<String>, 469 + } 470 + 471 + pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response { 472 + let methods = get_available_reauth_methods(db, did).await; 473 + ( 474 + StatusCode::UNAUTHORIZED, 475 + Json(ReauthRequiredError { 476 + error: "ReauthRequired".to_string(), 477 + message: "Re-authentication required for this action".to_string(), 478 + reauth_methods: methods, 479 + }), 480 + ) 481 + .into_response() 482 + }
+43 -19
src/api/server/session.rs
··· 63 63 headers: HeaderMap, 64 64 Json(input): Json<CreateSessionInput>, 65 65 ) -> Response { 66 - info!("create_session called with identifier: {}", input.identifier); 66 + info!( 67 + "create_session called with identifier: {}", 68 + input.identifier 69 + ); 67 70 let client_ip = extract_client_ip(&headers); 68 71 if !state 69 72 .check_rate_limit(RateLimitKind::Login, &client_ip) ··· 81 84 } 82 85 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 83 86 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); 84 - info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier); 87 + info!( 88 + "Normalized identifier: {} -> {}", 89 + input.identifier, normalized_identifier 90 + ); 85 91 let row = match sqlx::query!( 86 92 r#"SELECT 87 93 u.id, u.did, u.handle, u.password_hash, ··· 117 123 return ApiError::InternalError.into_response(); 118 124 } 119 125 }; 120 - let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) { 126 + let password_valid = if row 127 + .password_hash 128 + .as_ref() 129 + .map(|h| verify(&input.password, h).unwrap_or(false)) 130 + .unwrap_or(false) 131 + { 121 132 true 122 133 } else { 123 134 let app_passwords = sqlx::query!( ··· 523 534 } 524 535 }; 525 536 537 + let channel_str = match row.channel { 538 + crate::comms::CommsChannel::Email => "email", 539 + crate::comms::CommsChannel::Discord => "discord", 540 + crate::comms::CommsChannel::Telegram => "telegram", 541 + crate::comms::CommsChannel::Signal => "signal", 542 + }; 526 543 let verification = match sqlx::query!( 527 - "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 528 - row.id 544 + "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", 545 + row.id, 546 + channel_str as _ 529 547 ) 530 548 .fetch_optional(&state.db) 531 549 .await ··· 574 592 } 575 593 576 594 if let Err(e) = sqlx::query!( 577 - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 578 - row.id 595 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", 596 + row.id, 597 + channel_str as _ 579 598 ) 580 599 .execute(&state.db) 581 600 .await ··· 676 695 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 677 696 let code_expires_at = Utc::now() + chrono::Duration::minutes(30); 678 697 679 - let email = row.email.clone(); 698 + let (channel_str, recipient) = match row.channel { 699 + crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), 700 + crate::comms::CommsChannel::Discord => { 701 + ("discord", row.discord_id.clone().unwrap_or_default()) 702 + } 703 + crate::comms::CommsChannel::Telegram => ( 704 + "telegram", 705 + row.telegram_username.clone().unwrap_or_default(), 706 + ), 707 + crate::comms::CommsChannel::Signal => { 708 + ("signal", row.signal_number.clone().unwrap_or_default()) 709 + } 710 + }; 680 711 681 712 if let Err(e) = sqlx::query!( 682 713 r#" 683 714 INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 684 - VALUES ($1, 'email', $2, $3, $4) 715 + VALUES ($1, $2::comms_channel, $3, $4, $5) 685 716 ON CONFLICT (user_id, channel) DO UPDATE 686 - SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW() 717 + SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() 687 718 "#, 688 719 row.id, 720 + channel_str as _, 689 721 verification_code, 690 - email, 722 + recipient, 691 723 code_expires_at 692 724 ) 693 725 .execute(&state.db) ··· 696 728 error!("Failed to update verification code: {:?}", e); 697 729 return ApiError::InternalError.into_response(); 698 730 } 699 - let (channel_str, recipient) = match row.channel { 700 - crate::comms::CommsChannel::Email => ("email", row.email.unwrap_or_default()), 701 - crate::comms::CommsChannel::Discord => ("discord", row.discord_id.unwrap_or_default()), 702 - crate::comms::CommsChannel::Telegram => { 703 - ("telegram", row.telegram_username.unwrap_or_default()) 704 - } 705 - crate::comms::CommsChannel::Signal => ("signal", row.signal_number.unwrap_or_default()), 706 - }; 707 731 if let Err(e) = crate::comms::enqueue_signup_verification( 708 732 &state.db, 709 733 row.id,
+13 -3
src/api/server/totp.rs
··· 332 332 } 333 333 }; 334 334 335 - let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); 335 + let password_valid = password_hash 336 + .as_ref() 337 + .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) 338 + .unwrap_or(false); 336 339 if !password_valid { 337 340 return ( 338 341 StatusCode::UNAUTHORIZED, ··· 536 539 } 537 540 }; 538 541 539 - let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); 542 + let password_valid = password_hash 543 + .as_ref() 544 + .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) 545 + .unwrap_or(false); 540 546 if !password_valid { 541 547 return ( 542 548 StatusCode::UNAUTHORIZED, ··· 741 747 } 742 748 743 749 pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool { 750 + has_totp_enabled_db(&state.db, did).await 751 + } 752 + 753 + pub async fn has_totp_enabled_db(db: &sqlx::PgPool, did: &str) -> bool { 744 754 let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did) 745 - .fetch_optional(&state.db) 755 + .fetch_optional(db) 746 756 .await; 747 757 748 758 matches!(result, Ok(Some(true)))
+246
src/api/server/trusted_devices.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::State, 4 + http::StatusCode, 5 + response::{IntoResponse, Response}, 6 + }; 7 + use chrono::{DateTime, Duration, Utc}; 8 + use serde::{Deserialize, Serialize}; 9 + use serde_json::json; 10 + use sqlx::PgPool; 11 + use tracing::{error, info}; 12 + 13 + use crate::auth::BearerAuth; 14 + use crate::state::AppState; 15 + 16 + const TRUST_DURATION_DAYS: i64 = 30; 17 + 18 + #[derive(Serialize)] 19 + #[serde(rename_all = "camelCase")] 20 + pub struct TrustedDevice { 21 + pub id: String, 22 + pub user_agent: Option<String>, 23 + pub friendly_name: Option<String>, 24 + pub trusted_at: Option<DateTime<Utc>>, 25 + pub trusted_until: Option<DateTime<Utc>>, 26 + pub last_seen_at: DateTime<Utc>, 27 + } 28 + 29 + #[derive(Serialize)] 30 + #[serde(rename_all = "camelCase")] 31 + pub struct ListTrustedDevicesResponse { 32 + pub devices: Vec<TrustedDevice>, 33 + } 34 + 35 + pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAuth) -> Response { 36 + let devices = sqlx::query!( 37 + r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at 38 + FROM oauth_device od 39 + JOIN oauth_account_device oad ON od.id = oad.device_id 40 + WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW() 41 + ORDER BY od.last_seen_at DESC"#, 42 + auth.0.did 43 + ) 44 + .fetch_all(&state.db) 45 + .await; 46 + 47 + match devices { 48 + Ok(rows) => { 49 + let devices = rows 50 + .into_iter() 51 + .map(|row| TrustedDevice { 52 + id: row.id, 53 + user_agent: row.user_agent, 54 + friendly_name: row.friendly_name, 55 + trusted_at: row.trusted_at, 56 + trusted_until: row.trusted_until, 57 + last_seen_at: row.last_seen_at, 58 + }) 59 + .collect(); 60 + Json(ListTrustedDevicesResponse { devices }).into_response() 61 + } 62 + Err(e) => { 63 + error!("DB error: {:?}", e); 64 + ( 65 + StatusCode::INTERNAL_SERVER_ERROR, 66 + Json(json!({"error": "InternalError"})), 67 + ) 68 + .into_response() 69 + } 70 + } 71 + } 72 + 73 + #[derive(Deserialize)] 74 + #[serde(rename_all = "camelCase")] 75 + pub struct RevokeTrustedDeviceInput { 76 + pub device_id: String, 77 + } 78 + 79 + pub async fn revoke_trusted_device( 80 + State(state): State<AppState>, 81 + auth: BearerAuth, 82 + Json(input): Json<RevokeTrustedDeviceInput>, 83 + ) -> Response { 84 + let device_exists = sqlx::query_scalar!( 85 + r#"SELECT 1 as one FROM oauth_device od 86 + JOIN oauth_account_device oad ON od.id = oad.device_id 87 + WHERE oad.did = $1 AND od.id = $2"#, 88 + auth.0.did, 89 + input.device_id 90 + ) 91 + .fetch_optional(&state.db) 92 + .await; 93 + 94 + match device_exists { 95 + Ok(Some(_)) => {} 96 + Ok(None) => { 97 + return ( 98 + StatusCode::NOT_FOUND, 99 + Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})), 100 + ) 101 + .into_response(); 102 + } 103 + Err(e) => { 104 + error!("DB error: {:?}", e); 105 + return ( 106 + StatusCode::INTERNAL_SERVER_ERROR, 107 + Json(json!({"error": "InternalError"})), 108 + ) 109 + .into_response(); 110 + } 111 + } 112 + 113 + let result = sqlx::query!( 114 + "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1", 115 + input.device_id 116 + ) 117 + .execute(&state.db) 118 + .await; 119 + 120 + match result { 121 + Ok(_) => { 122 + info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked"); 123 + Json(json!({"success": true})).into_response() 124 + } 125 + Err(e) => { 126 + error!("DB error: {:?}", e); 127 + ( 128 + StatusCode::INTERNAL_SERVER_ERROR, 129 + Json(json!({"error": "InternalError"})), 130 + ) 131 + .into_response() 132 + } 133 + } 134 + } 135 + 136 + #[derive(Deserialize)] 137 + #[serde(rename_all = "camelCase")] 138 + pub struct UpdateTrustedDeviceInput { 139 + pub device_id: String, 140 + pub friendly_name: Option<String>, 141 + } 142 + 143 + pub async fn update_trusted_device( 144 + State(state): State<AppState>, 145 + auth: BearerAuth, 146 + Json(input): Json<UpdateTrustedDeviceInput>, 147 + ) -> Response { 148 + let device_exists = sqlx::query_scalar!( 149 + r#"SELECT 1 as one FROM oauth_device od 150 + JOIN oauth_account_device oad ON od.id = oad.device_id 151 + WHERE oad.did = $1 AND od.id = $2"#, 152 + auth.0.did, 153 + input.device_id 154 + ) 155 + .fetch_optional(&state.db) 156 + .await; 157 + 158 + match device_exists { 159 + Ok(Some(_)) => {} 160 + Ok(None) => { 161 + return ( 162 + StatusCode::NOT_FOUND, 163 + Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})), 164 + ) 165 + .into_response(); 166 + } 167 + Err(e) => { 168 + error!("DB error: {:?}", e); 169 + return ( 170 + StatusCode::INTERNAL_SERVER_ERROR, 171 + Json(json!({"error": "InternalError"})), 172 + ) 173 + .into_response(); 174 + } 175 + } 176 + 177 + let result = sqlx::query!( 178 + "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2", 179 + input.friendly_name, 180 + input.device_id 181 + ) 182 + .execute(&state.db) 183 + .await; 184 + 185 + match result { 186 + Ok(_) => { 187 + info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated"); 188 + Json(json!({"success": true})).into_response() 189 + } 190 + Err(e) => { 191 + error!("DB error: {:?}", e); 192 + ( 193 + StatusCode::INTERNAL_SERVER_ERROR, 194 + Json(json!({"error": "InternalError"})), 195 + ) 196 + .into_response() 197 + } 198 + } 199 + } 200 + 201 + pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool { 202 + let result = sqlx::query_scalar!( 203 + r#"SELECT trusted_until FROM oauth_device od 204 + JOIN oauth_account_device oad ON od.id = oad.device_id 205 + WHERE od.id = $1 AND oad.did = $2"#, 206 + device_id, 207 + did 208 + ) 209 + .fetch_optional(db) 210 + .await; 211 + 212 + match result { 213 + Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(), 214 + _ => false, 215 + } 216 + } 217 + 218 + pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> { 219 + let now = Utc::now(); 220 + let trusted_until = now + Duration::days(TRUST_DURATION_DAYS); 221 + 222 + sqlx::query!( 223 + "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3", 224 + now, 225 + trusted_until, 226 + device_id 227 + ) 228 + .execute(db) 229 + .await?; 230 + 231 + Ok(()) 232 + } 233 + 234 + pub async fn extend_device_trust(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> { 235 + let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS); 236 + 237 + sqlx::query!( 238 + "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL", 239 + trusted_until, 240 + device_id 241 + ) 242 + .execute(db) 243 + .await?; 244 + 245 + Ok(()) 246 + }
+81 -20
src/api/validation.rs
··· 22 22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 23 match self { 24 24 Self::Empty => write!(f, "Handle cannot be empty"), 25 - Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH), 26 - Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH), 27 - Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"), 28 - Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"), 25 + Self::TooShort => write!( 26 + f, 27 + "Handle must be at least {} characters", 28 + MIN_HANDLE_LENGTH 29 + ), 30 + Self::TooLong => write!( 31 + f, 32 + "Handle exceeds maximum length of {} characters", 33 + MAX_HANDLE_LENGTH 34 + ), 35 + Self::InvalidCharacters => write!( 36 + f, 37 + "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed" 38 + ), 39 + Self::StartsWithInvalidChar => { 40 + write!(f, "Handle cannot start with a hyphen or underscore") 41 + } 29 42 Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), 30 43 Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 31 44 } ··· 125 138 fn test_valid_handles() { 126 139 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 127 140 assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); 128 - assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string())); 129 - assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string())); 130 - assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string())); 131 - assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string())); 141 + assert_eq!( 142 + validate_short_handle("user-name"), 143 + Ok("user-name".to_string()) 144 + ); 145 + assert_eq!( 146 + validate_short_handle("user_name"), 147 + Ok("user_name".to_string()) 148 + ); 149 + assert_eq!( 150 + validate_short_handle("UPPERCASE"), 151 + Ok("uppercase".to_string()) 152 + ); 153 + assert_eq!( 154 + validate_short_handle("MixedCase123"), 155 + Ok("mixedcase123".to_string()) 156 + ); 132 157 assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); 133 158 } 134 159 135 160 #[test] 136 161 fn test_invalid_handles() { 137 162 assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); 138 - assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty)); 139 - assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort)); 140 - assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort)); 141 - assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces)); 142 - assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces)); 143 - assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar)); 144 - assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar)); 145 - assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar)); 146 - assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar)); 147 - assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters)); 148 - assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters)); 149 - assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters)); 163 + assert_eq!( 164 + validate_short_handle(" "), 165 + Err(HandleValidationError::Empty) 166 + ); 167 + assert_eq!( 168 + validate_short_handle("ab"), 169 + Err(HandleValidationError::TooShort) 170 + ); 171 + assert_eq!( 172 + validate_short_handle("a"), 173 + Err(HandleValidationError::TooShort) 174 + ); 175 + assert_eq!( 176 + validate_short_handle("test spaces"), 177 + Err(HandleValidationError::ContainsSpaces) 178 + ); 179 + assert_eq!( 180 + validate_short_handle("test\ttab"), 181 + Err(HandleValidationError::ContainsSpaces) 182 + ); 183 + assert_eq!( 184 + validate_short_handle("-starts"), 185 + Err(HandleValidationError::StartsWithInvalidChar) 186 + ); 187 + assert_eq!( 188 + validate_short_handle("_starts"), 189 + Err(HandleValidationError::StartsWithInvalidChar) 190 + ); 191 + assert_eq!( 192 + validate_short_handle("ends-"), 193 + Err(HandleValidationError::EndsWithInvalidChar) 194 + ); 195 + assert_eq!( 196 + validate_short_handle("ends_"), 197 + Err(HandleValidationError::EndsWithInvalidChar) 198 + ); 199 + assert_eq!( 200 + validate_short_handle("test@user"), 201 + Err(HandleValidationError::InvalidCharacters) 202 + ); 203 + assert_eq!( 204 + validate_short_handle("test!user"), 205 + Err(HandleValidationError::InvalidCharacters) 206 + ); 207 + assert_eq!( 208 + validate_short_handle("test.user"), 209 + Err(HandleValidationError::InvalidCharacters) 210 + ); 150 211 } 151 212 152 213 #[test]
+2 -2
src/comms/mod.rs
··· 9 9 10 10 pub use service::{ 11 11 CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, 12 - enqueue_email_update, enqueue_email_verification, enqueue_password_reset, 13 - enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 12 + enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery, 13 + enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 14 14 }; 15 15 16 16 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+25
src/comms/service.rs
··· 457 457 .await 458 458 } 459 459 460 + pub async fn enqueue_passkey_recovery( 461 + db: &PgPool, 462 + user_id: Uuid, 463 + recovery_url: &str, 464 + hostname: &str, 465 + ) -> Result<Uuid, sqlx::Error> { 466 + let prefs = get_user_comms_prefs(db, user_id).await?; 467 + let body = format!( 468 + "Hello @{},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", 469 + prefs.handle, recovery_url 470 + ); 471 + enqueue_comms( 472 + db, 473 + NewComms::new( 474 + user_id, 475 + prefs.channel, 476 + super::types::CommsType::PasskeyRecovery, 477 + prefs.email.clone().unwrap_or_default(), 478 + Some(format!("Account Recovery - {}", hostname)), 479 + body, 480 + ), 481 + ) 482 + .await 483 + } 484 + 460 485 pub fn channel_display_name(channel: CommsChannel) -> &'static str { 461 486 match channel { 462 487 CommsChannel::Email => "email",
+1
src/comms/types.rs
··· 32 32 AdminEmail, 33 33 PlcOperation, 34 34 TwoFactorCode, 35 + PasskeyRecovery, 35 36 } 36 37 37 38 #[derive(Debug, Clone, FromRow)]
+68
src/lib.rs
··· 203 203 post(api::server::change_password), 204 204 ) 205 205 .route( 206 + "/xrpc/com.tranquil.account.removePassword", 207 + post(api::server::remove_password), 208 + ) 209 + .route( 210 + "/xrpc/com.tranquil.account.getPasswordStatus", 211 + get(api::server::get_password_status), 212 + ) 213 + .route( 214 + "/xrpc/com.tranquil.account.getReauthStatus", 215 + get(api::server::get_reauth_status), 216 + ) 217 + .route( 218 + "/xrpc/com.tranquil.account.reauthPassword", 219 + post(api::server::reauth_password), 220 + ) 221 + .route( 222 + "/xrpc/com.tranquil.account.reauthTotp", 223 + post(api::server::reauth_totp), 224 + ) 225 + .route( 226 + "/xrpc/com.tranquil.account.reauthPasskeyStart", 227 + post(api::server::reauth_passkey_start), 228 + ) 229 + .route( 230 + "/xrpc/com.tranquil.account.reauthPasskeyFinish", 231 + post(api::server::reauth_passkey_finish), 232 + ) 233 + .route( 234 + "/xrpc/com.tranquil.account.listTrustedDevices", 235 + get(api::server::list_trusted_devices), 236 + ) 237 + .route( 238 + "/xrpc/com.tranquil.account.revokeTrustedDevice", 239 + post(api::server::revoke_trusted_device), 240 + ) 241 + .route( 242 + "/xrpc/com.tranquil.account.updateTrustedDevice", 243 + post(api::server::update_trusted_device), 244 + ) 245 + .route( 246 + "/xrpc/com.tranquil.account.createPasskeyAccount", 247 + post(api::server::create_passkey_account), 248 + ) 249 + .route( 250 + "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", 251 + post(api::server::start_passkey_registration_for_setup), 252 + ) 253 + .route( 254 + "/xrpc/com.tranquil.account.completePasskeySetup", 255 + post(api::server::complete_passkey_setup), 256 + ) 257 + .route( 258 + "/xrpc/com.tranquil.account.requestPasskeyRecovery", 259 + post(api::server::request_passkey_recovery), 260 + ) 261 + .route( 262 + "/xrpc/com.tranquil.account.recoverPasskeyAccount", 263 + post(api::server::recover_passkey_account), 264 + ) 265 + .route( 206 266 "/xrpc/com.atproto.server.requestEmailUpdate", 207 267 post(api::server::request_email_update), 208 268 ) ··· 398 458 .route( 399 459 "/oauth/authorize/2fa", 400 460 post(oauth::endpoints::authorize_2fa_post), 461 + ) 462 + .route( 463 + "/oauth/authorize/passkey", 464 + get(oauth::endpoints::authorize_passkey_start), 465 + ) 466 + .route( 467 + "/oauth/authorize/passkey", 468 + post(oauth::endpoints::authorize_passkey_finish), 401 469 ) 402 470 .route( 403 471 "/oauth/passkey/check",
+437 -19
src/oauth/endpoints/authorize.rs
··· 441 441 ); 442 442 let user = match sqlx::query!( 443 443 r#" 444 - SELECT id, did, email, password_hash, two_factor_enabled, 444 + SELECT id, did, email, password_hash, password_required, two_factor_enabled, 445 445 preferred_comms_channel as "preferred_comms_channel: CommsChannel", 446 446 deactivated_at, takedown_ref, 447 447 email_verified, discord_verified, telegram_verified, signal_verified ··· 479 479 json_response, 480 480 ); 481 481 } 482 - let password_valid = match bcrypt::verify(&form.password, &user.password_hash) { 483 - Ok(valid) => valid, 484 - Err(_) => return show_login_error("An error occurred. Please try again.", json_response), 485 - }; 486 - if !password_valid { 487 - return show_login_error("Invalid handle/email or password.", json_response); 488 - } 489 - let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await; 490 - if has_totp { 482 + 483 + if !user.password_required { 491 484 if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) 492 485 .await 493 486 .is_err() 494 487 { 495 488 return show_login_error("An error occurred. Please try again.", json_response); 496 489 } 490 + let redirect_url = format!( 491 + "/#/oauth/passkey?request_uri={}", 492 + url_encode(&form.request_uri) 493 + ); 497 494 if json_response { 498 - return Json(serde_json::json!({ 499 - "needs_totp": true 500 - })) 501 - .into_response(); 495 + return ( 496 + StatusCode::OK, 497 + Json(serde_json::json!({ 498 + "next": "passkey", 499 + "redirect": redirect_url 500 + })), 501 + ) 502 + .into_response(); 502 503 } 503 - return redirect_see_other(&format!( 504 - "/#/oauth/totp?request_uri={}", 505 - url_encode(&form.request_uri) 506 - )); 504 + return redirect_see_other(&redirect_url); 505 + } 506 + 507 + let password_valid = match &user.password_hash { 508 + Some(hash) => match bcrypt::verify(&form.password, hash) { 509 + Ok(valid) => valid, 510 + Err(_) => { 511 + return show_login_error("An error occurred. Please try again.", json_response); 512 + } 513 + }, 514 + None => false, 515 + }; 516 + if !password_valid { 517 + return show_login_error("Invalid handle/email or password.", json_response); 518 + } 519 + let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await; 520 + if has_totp { 521 + let device_cookie = extract_device_cookie(&headers); 522 + let device_is_trusted = if let Some(ref dev_id) = device_cookie { 523 + crate::api::server::is_device_trusted(&state.db, dev_id, &user.did).await 524 + } else { 525 + false 526 + }; 527 + 528 + if device_is_trusted { 529 + if let Some(ref dev_id) = device_cookie { 530 + let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await; 531 + } 532 + } else { 533 + if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) 534 + .await 535 + .is_err() 536 + { 537 + return show_login_error("An error occurred. Please try again.", json_response); 538 + } 539 + if json_response { 540 + return Json(serde_json::json!({ 541 + "needs_totp": true 542 + })) 543 + .into_response(); 544 + } 545 + return redirect_see_other(&format!( 546 + "/#/oauth/totp?request_uri={}", 547 + url_encode(&form.request_uri) 548 + )); 549 + } 507 550 } 508 551 if user.two_factor_enabled { 509 552 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; ··· 934 977 pub struct Authorize2faSubmit { 935 978 pub request_uri: String, 936 979 pub code: String, 980 + #[serde(default)] 981 + pub trust_device: bool, 937 982 } 938 983 939 984 const MAX_2FA_ATTEMPTS: i32 = 5; ··· 1475 1520 "Invalid verification code. Please try again.", 1476 1521 ); 1477 1522 } 1523 + let device_id = extract_device_cookie(&headers); 1524 + if form.trust_device 1525 + && let Some(ref dev_id) = device_id 1526 + { 1527 + let _ = crate::api::server::trust_device(&state.db, dev_id).await; 1528 + } 1478 1529 let requested_scope_str = request_data 1479 1530 .parameters 1480 1531 .scope ··· 1500 1551 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 1501 1552 } 1502 1553 let code = Code::generate(); 1503 - let device_id = extract_device_cookie(&headers); 1504 1554 if db::update_authorization_request( 1505 1555 &state.db, 1506 1556 &form.request_uri, ··· 2139 2189 })) 2140 2190 .into_response() 2141 2191 } 2192 + 2193 + #[derive(Debug, Deserialize)] 2194 + pub struct AuthorizePasskeyQuery { 2195 + pub request_uri: String, 2196 + } 2197 + 2198 + #[derive(Debug, Serialize)] 2199 + #[serde(rename_all = "camelCase")] 2200 + pub struct PasskeyAuthResponse { 2201 + pub options: serde_json::Value, 2202 + pub request_uri: String, 2203 + } 2204 + 2205 + pub async fn authorize_passkey_start( 2206 + State(state): State<AppState>, 2207 + Query(query): Query<AuthorizePasskeyQuery>, 2208 + ) -> Response { 2209 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 2210 + 2211 + let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await { 2212 + Ok(Some(d)) => d, 2213 + Ok(None) => { 2214 + return ( 2215 + StatusCode::BAD_REQUEST, 2216 + Json(serde_json::json!({ 2217 + "error": "invalid_request", 2218 + "error_description": "Authorization request not found." 2219 + })), 2220 + ) 2221 + .into_response(); 2222 + } 2223 + Err(_) => { 2224 + return ( 2225 + StatusCode::INTERNAL_SERVER_ERROR, 2226 + Json(serde_json::json!({ 2227 + "error": "server_error", 2228 + "error_description": "An error occurred." 2229 + })), 2230 + ) 2231 + .into_response(); 2232 + } 2233 + }; 2234 + 2235 + if request_data.expires_at < Utc::now() { 2236 + let _ = db::delete_authorization_request(&state.db, &query.request_uri).await; 2237 + return ( 2238 + StatusCode::BAD_REQUEST, 2239 + Json(serde_json::json!({ 2240 + "error": "invalid_request", 2241 + "error_description": "Authorization request has expired." 2242 + })), 2243 + ) 2244 + .into_response(); 2245 + } 2246 + 2247 + let did = match &request_data.did { 2248 + Some(d) => d.clone(), 2249 + None => { 2250 + return ( 2251 + StatusCode::BAD_REQUEST, 2252 + Json(serde_json::json!({ 2253 + "error": "invalid_request", 2254 + "error_description": "User not authenticated yet." 2255 + })), 2256 + ) 2257 + .into_response(); 2258 + } 2259 + }; 2260 + 2261 + let stored_passkeys = match crate::auth::webauthn::get_passkeys_for_user(&state.db, &did).await 2262 + { 2263 + Ok(pks) => pks, 2264 + Err(e) => { 2265 + tracing::error!("Failed to get passkeys: {:?}", e); 2266 + return ( 2267 + StatusCode::INTERNAL_SERVER_ERROR, 2268 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2269 + ) 2270 + .into_response(); 2271 + } 2272 + }; 2273 + 2274 + if stored_passkeys.is_empty() { 2275 + return ( 2276 + StatusCode::BAD_REQUEST, 2277 + Json(serde_json::json!({ 2278 + "error": "invalid_request", 2279 + "error_description": "No passkeys registered for this account." 2280 + })), 2281 + ) 2282 + .into_response(); 2283 + } 2284 + 2285 + let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys 2286 + .iter() 2287 + .filter_map(|sp| sp.to_security_key().ok()) 2288 + .collect(); 2289 + 2290 + if passkeys.is_empty() { 2291 + return ( 2292 + StatusCode::INTERNAL_SERVER_ERROR, 2293 + Json(serde_json::json!({"error": "server_error", "error_description": "Failed to load passkeys."})), 2294 + ) 2295 + .into_response(); 2296 + } 2297 + 2298 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 2299 + Ok(w) => w, 2300 + Err(e) => { 2301 + tracing::error!("Failed to create WebAuthn config: {:?}", e); 2302 + return ( 2303 + StatusCode::INTERNAL_SERVER_ERROR, 2304 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2305 + ) 2306 + .into_response(); 2307 + } 2308 + }; 2309 + 2310 + let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { 2311 + Ok(result) => result, 2312 + Err(e) => { 2313 + tracing::error!("Failed to start passkey authentication: {:?}", e); 2314 + return ( 2315 + StatusCode::INTERNAL_SERVER_ERROR, 2316 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2317 + ) 2318 + .into_response(); 2319 + } 2320 + }; 2321 + 2322 + if let Err(e) = 2323 + crate::auth::webauthn::save_authentication_state(&state.db, &did, &auth_state).await 2324 + { 2325 + tracing::error!("Failed to save authentication state: {:?}", e); 2326 + return ( 2327 + StatusCode::INTERNAL_SERVER_ERROR, 2328 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2329 + ) 2330 + .into_response(); 2331 + } 2332 + 2333 + let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); 2334 + Json(PasskeyAuthResponse { 2335 + options, 2336 + request_uri: query.request_uri, 2337 + }) 2338 + .into_response() 2339 + } 2340 + 2341 + #[derive(Debug, Deserialize)] 2342 + #[serde(rename_all = "camelCase")] 2343 + pub struct AuthorizePasskeySubmit { 2344 + pub request_uri: String, 2345 + pub credential: serde_json::Value, 2346 + } 2347 + 2348 + pub async fn authorize_passkey_finish( 2349 + State(state): State<AppState>, 2350 + headers: HeaderMap, 2351 + Json(form): Json<AuthorizePasskeySubmit>, 2352 + ) -> Response { 2353 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 2354 + 2355 + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 2356 + Ok(Some(d)) => d, 2357 + Ok(None) => { 2358 + return ( 2359 + StatusCode::BAD_REQUEST, 2360 + Json(serde_json::json!({ 2361 + "error": "invalid_request", 2362 + "error_description": "Authorization request not found." 2363 + })), 2364 + ) 2365 + .into_response(); 2366 + } 2367 + Err(_) => { 2368 + return ( 2369 + StatusCode::INTERNAL_SERVER_ERROR, 2370 + Json(serde_json::json!({ 2371 + "error": "server_error", 2372 + "error_description": "An error occurred." 2373 + })), 2374 + ) 2375 + .into_response(); 2376 + } 2377 + }; 2378 + 2379 + if request_data.expires_at < Utc::now() { 2380 + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 2381 + return ( 2382 + StatusCode::BAD_REQUEST, 2383 + Json(serde_json::json!({ 2384 + "error": "invalid_request", 2385 + "error_description": "Authorization request has expired." 2386 + })), 2387 + ) 2388 + .into_response(); 2389 + } 2390 + 2391 + let did = match &request_data.did { 2392 + Some(d) => d.clone(), 2393 + None => { 2394 + return ( 2395 + StatusCode::BAD_REQUEST, 2396 + Json(serde_json::json!({ 2397 + "error": "invalid_request", 2398 + "error_description": "User not authenticated yet." 2399 + })), 2400 + ) 2401 + .into_response(); 2402 + } 2403 + }; 2404 + 2405 + let auth_state = match crate::auth::webauthn::load_authentication_state(&state.db, &did).await { 2406 + Ok(Some(s)) => s, 2407 + Ok(None) => { 2408 + return ( 2409 + StatusCode::BAD_REQUEST, 2410 + Json(serde_json::json!({ 2411 + "error": "invalid_request", 2412 + "error_description": "No passkey challenge found. Please start over." 2413 + })), 2414 + ) 2415 + .into_response(); 2416 + } 2417 + Err(e) => { 2418 + tracing::error!("Failed to load authentication state: {:?}", e); 2419 + return ( 2420 + StatusCode::INTERNAL_SERVER_ERROR, 2421 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2422 + ) 2423 + .into_response(); 2424 + } 2425 + }; 2426 + 2427 + let credential: webauthn_rs::prelude::PublicKeyCredential = 2428 + match serde_json::from_value(form.credential.clone()) { 2429 + Ok(c) => c, 2430 + Err(e) => { 2431 + tracing::error!("Failed to parse credential: {:?}", e); 2432 + return ( 2433 + StatusCode::BAD_REQUEST, 2434 + Json(serde_json::json!({ 2435 + "error": "invalid_request", 2436 + "error_description": "Invalid credential format." 2437 + })), 2438 + ) 2439 + .into_response(); 2440 + } 2441 + }; 2442 + 2443 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 2444 + Ok(w) => w, 2445 + Err(e) => { 2446 + tracing::error!("Failed to create WebAuthn config: {:?}", e); 2447 + return ( 2448 + StatusCode::INTERNAL_SERVER_ERROR, 2449 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2450 + ) 2451 + .into_response(); 2452 + } 2453 + }; 2454 + 2455 + let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { 2456 + Ok(r) => r, 2457 + Err(e) => { 2458 + tracing::warn!("Passkey authentication failed: {:?}", e); 2459 + return ( 2460 + StatusCode::FORBIDDEN, 2461 + Json(serde_json::json!({ 2462 + "error": "access_denied", 2463 + "error_description": "Passkey authentication failed." 2464 + })), 2465 + ) 2466 + .into_response(); 2467 + } 2468 + }; 2469 + 2470 + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await; 2471 + 2472 + if let Err(e) = crate::auth::webauthn::update_passkey_counter( 2473 + &state.db, 2474 + credential.id.as_ref(), 2475 + auth_result.counter(), 2476 + ) 2477 + .await 2478 + { 2479 + tracing::warn!("Failed to update passkey counter: {:?}", e); 2480 + } 2481 + 2482 + let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await; 2483 + if has_totp { 2484 + let device_cookie = extract_device_cookie(&headers); 2485 + let device_is_trusted = if let Some(ref dev_id) = device_cookie { 2486 + crate::api::server::is_device_trusted(&state.db, dev_id, &did).await 2487 + } else { 2488 + false 2489 + }; 2490 + 2491 + if device_is_trusted { 2492 + if let Some(ref dev_id) = device_cookie { 2493 + let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await; 2494 + } 2495 + } else { 2496 + let user = match sqlx::query!( 2497 + r#"SELECT id, preferred_comms_channel as "preferred_comms_channel: CommsChannel" FROM users WHERE did = $1"#, 2498 + did 2499 + ) 2500 + .fetch_optional(&state.db) 2501 + .await 2502 + { 2503 + Ok(Some(u)) => u, 2504 + _ => { 2505 + return ( 2506 + StatusCode::INTERNAL_SERVER_ERROR, 2507 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2508 + ) 2509 + .into_response(); 2510 + } 2511 + }; 2512 + 2513 + let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; 2514 + match db::create_2fa_challenge(&state.db, &did, &form.request_uri).await { 2515 + Ok(challenge) => { 2516 + if let Err(e) = 2517 + enqueue_2fa_code(&state.db, user.id, &challenge.code, &pds_hostname).await 2518 + { 2519 + tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); 2520 + } 2521 + let channel_name = channel_display_name(user.preferred_comms_channel); 2522 + let redirect_url = format!( 2523 + "/#/oauth/2fa?request_uri={}&channel={}", 2524 + url_encode(&form.request_uri), 2525 + url_encode(channel_name) 2526 + ); 2527 + return ( 2528 + StatusCode::OK, 2529 + Json(serde_json::json!({ 2530 + "next": "2fa", 2531 + "redirect": redirect_url 2532 + })), 2533 + ) 2534 + .into_response(); 2535 + } 2536 + Err(_) => { 2537 + return ( 2538 + StatusCode::INTERNAL_SERVER_ERROR, 2539 + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), 2540 + ) 2541 + .into_response(); 2542 + } 2543 + } 2544 + } 2545 + } 2546 + 2547 + let redirect_url = format!( 2548 + "/#/oauth/consent?request_uri={}", 2549 + url_encode(&form.request_uri) 2550 + ); 2551 + ( 2552 + StatusCode::OK, 2553 + Json(serde_json::json!({ 2554 + "next": "consent", 2555 + "redirect": redirect_url 2556 + })), 2557 + ) 2558 + .into_response() 2559 + }
+37 -18
tests/admin_search.rs
··· 10 10 let client = client(); 11 11 let (admin_jwt, _) = create_admin_account_and_login(&client).await; 12 12 let (user_did, _) = setup_new_user("search-target").await; 13 - let res = client 14 - .get(format!( 15 - "{}/xrpc/com.atproto.admin.searchAccounts?limit=1000", 16 - base_url().await 17 - )) 18 - .bearer_auth(&admin_jwt) 19 - .send() 20 - .await 21 - .expect("Failed to send request"); 22 - assert_eq!(res.status(), StatusCode::OK); 23 - let body: Value = res.json().await.unwrap(); 24 - let accounts = body["accounts"] 25 - .as_array() 26 - .expect("accounts should be array"); 27 - assert!(!accounts.is_empty(), "Should return some accounts"); 28 - let found = accounts 29 - .iter() 30 - .any(|a| a["did"].as_str() == Some(&user_did)); 13 + let mut found = false; 14 + let mut cursor: Option<String> = None; 15 + for _ in 0..10 { 16 + let url = match &cursor { 17 + Some(c) => format!( 18 + "{}/xrpc/com.atproto.admin.searchAccounts?limit=100&cursor={}", 19 + base_url().await, 20 + c 21 + ), 22 + None => format!( 23 + "{}/xrpc/com.atproto.admin.searchAccounts?limit=100", 24 + base_url().await 25 + ), 26 + }; 27 + let res = client 28 + .get(&url) 29 + .bearer_auth(&admin_jwt) 30 + .send() 31 + .await 32 + .expect("Failed to send request"); 33 + assert_eq!(res.status(), StatusCode::OK); 34 + let body: Value = res.json().await.unwrap(); 35 + let accounts = body["accounts"] 36 + .as_array() 37 + .expect("accounts should be array"); 38 + if accounts 39 + .iter() 40 + .any(|a| a["did"].as_str() == Some(&user_did)) 41 + { 42 + found = true; 43 + break; 44 + } 45 + cursor = body["cursor"].as_str().map(|s| s.to_string()); 46 + if cursor.is_none() { 47 + break; 48 + } 49 + } 31 50 assert!( 32 51 found, 33 52 "Should find the created user in results (DID: {})",
+27 -1
tests/did_web.rs
··· 97 97 let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); 98 98 let handle = format!("extweb_{}", uuid::Uuid::new_v4()); 99 99 let pds_endpoint = base_url().await.replace("http://", "https://"); 100 + 101 + let reserve_res = client 102 + .post(format!( 103 + "{}/xrpc/com.atproto.server.reserveSigningKey", 104 + base_url().await 105 + )) 106 + .json(&json!({ "did": did })) 107 + .send() 108 + .await 109 + .expect("Failed to reserve signing key"); 110 + assert_eq!(reserve_res.status(), StatusCode::OK); 111 + let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON"); 112 + let signing_key = reserve_body["signingKey"] 113 + .as_str() 114 + .expect("No signingKey returned"); 115 + let public_key_multibase = signing_key 116 + .strip_prefix("did:key:") 117 + .expect("signingKey should start with did:key:"); 118 + 100 119 let did_doc = json!({ 101 120 "@context": ["https://www.w3.org/ns/did/v1"], 102 121 "id": did, 122 + "verificationMethod": [{ 123 + "id": format!("{}#atproto", did), 124 + "type": "Multikey", 125 + "controller": did, 126 + "publicKeyMultibase": public_key_multibase 127 + }], 103 128 "service": [{ 104 129 "id": "#atproto_pds", 105 130 "type": "AtprotoPersonalDataServer", ··· 116 141 "email": format!("{}@example.com", handle), 117 142 "password": "password", 118 143 "didType": "web-external", 119 - "did": did 144 + "did": did, 145 + "signingKey": signing_key 120 146 }); 121 147 let res = client 122 148 .post(format!(
+58 -3
tests/identity.rs
··· 26 26 assert_eq!(res.status(), StatusCode::OK); 27 27 let body: Value = res.json().await.expect("Invalid JSON"); 28 28 let did = body["did"].as_str().expect("No DID").to_string(); 29 - let full_handle = body["handle"].as_str().expect("No handle in response").to_string(); 29 + let full_handle = body["handle"] 30 + .as_str() 31 + .expect("No handle in response") 32 + .to_string(); 30 33 let params = [("handle", full_handle.as_str())]; 31 34 let res = client 32 35 .get(format!( ··· 97 100 let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); 98 101 let handle = format!("webuser_{}", uuid::Uuid::new_v4()); 99 102 let pds_endpoint = base_url().await.replace("http://", "https://"); 103 + 104 + let reserve_res = client 105 + .post(format!( 106 + "{}/xrpc/com.atproto.server.reserveSigningKey", 107 + base_url().await 108 + )) 109 + .json(&json!({ "did": did })) 110 + .send() 111 + .await 112 + .expect("Failed to reserve signing key"); 113 + assert_eq!(reserve_res.status(), StatusCode::OK); 114 + let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON"); 115 + let signing_key = reserve_body["signingKey"] 116 + .as_str() 117 + .expect("No signingKey returned"); 118 + let public_key_multibase = signing_key 119 + .strip_prefix("did:key:") 120 + .expect("signingKey should start with did:key:"); 121 + 100 122 let did_doc = json!({ 101 123 "@context": ["https://www.w3.org/ns/did/v1"], 102 124 "id": did, 125 + "verificationMethod": [{ 126 + "id": format!("{}#atproto", did), 127 + "type": "Multikey", 128 + "controller": did, 129 + "publicKeyMultibase": public_key_multibase 130 + }], 103 131 "service": [{ 104 132 "id": "#atproto_pds", 105 133 "type": "AtprotoPersonalDataServer", ··· 115 143 "handle": handle, 116 144 "email": format!("{}@example.com", handle), 117 145 "password": "password", 118 - "did": did 146 + "did": did, 147 + "signingKey": signing_key 119 148 }); 120 149 let res = client 121 150 .post(format!( ··· 195 224 let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle); 196 225 let email = format!("{}@test.com", handle); 197 226 let pds_endpoint = base_url().await.replace("http://", "https://"); 227 + 228 + let reserve_res = client 229 + .post(format!( 230 + "{}/xrpc/com.atproto.server.reserveSigningKey", 231 + base_url().await 232 + )) 233 + .json(&json!({ "did": did })) 234 + .send() 235 + .await 236 + .expect("Failed to reserve signing key"); 237 + assert_eq!(reserve_res.status(), StatusCode::OK); 238 + let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON"); 239 + let signing_key = reserve_body["signingKey"] 240 + .as_str() 241 + .expect("No signingKey returned"); 242 + let public_key_multibase = signing_key 243 + .strip_prefix("did:key:") 244 + .expect("signingKey should start with did:key:"); 245 + 198 246 let did_doc = json!({ 199 247 "@context": ["https://www.w3.org/ns/did/v1"], 200 248 "id": did, 249 + "verificationMethod": [{ 250 + "id": format!("{}#atproto", did), 251 + "type": "Multikey", 252 + "controller": did, 253 + "publicKeyMultibase": public_key_multibase 254 + }], 201 255 "service": [{ 202 256 "id": "#atproto_pds", 203 257 "type": "AtprotoPersonalDataServer", ··· 213 267 "handle": handle, 214 268 "email": email, 215 269 "password": "password", 216 - "did": did 270 + "did": did, 271 + "signingKey": signing_key 217 272 }); 218 273 let res = client 219 274 .post(format!(