+13
.config/nextest.toml
+13
.config/nextest.toml
···
1
[store]
2
dir = "target/nextest"
3
+
4
[profile.default]
5
retries = 0
6
fail-fast = true
7
test-threads = "num-cpus"
8
+
9
[profile.ci]
10
retries = 2
11
fail-fast = false
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
+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
+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
+1
-1
.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json
-28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
-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
+2
-1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
+2
-1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+2
-1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+22
.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
+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
+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
+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
+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
+2
-1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
-17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+1
-1
.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
+1
-1
.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
+1
-1
.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
+3
-2
.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json
.sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json
+3
-2
.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json
.sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT id FROM users WHERE LOWER(email) = $1",
4
"describe": {
5
"columns": [
6
{
···
11
],
12
"parameters": {
13
"Left": [
14
"Text"
15
]
16
},
···
18
false
19
]
20
},
21
-
"hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4"
22
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
4
"describe": {
5
"columns": [
6
{
···
11
],
12
"parameters": {
13
"Left": [
14
+
"Text",
15
"Text"
16
]
17
},
···
19
false
20
]
21
},
22
+
"hash": "7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317"
23
}
-15
.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json
-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
+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
+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
+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
+15
-9
.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json
.sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json
···
1
{
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 ",
4
"describe": {
5
"columns": [
6
{
···
25
},
26
{
27
"ordinal": 4,
28
-
"name": "two_factor_enabled",
29
"type_info": "Bool"
30
},
31
{
32
"ordinal": 5,
33
"name": "preferred_comms_channel: CommsChannel",
34
"type_info": {
35
"Custom": {
···
46
}
47
},
48
{
49
-
"ordinal": 6,
50
"name": "deactivated_at",
51
"type_info": "Timestamptz"
52
},
53
{
54
-
"ordinal": 7,
55
"name": "takedown_ref",
56
"type_info": "Text"
57
},
58
{
59
-
"ordinal": 8,
60
"name": "email_verified",
61
"type_info": "Bool"
62
},
63
{
64
-
"ordinal": 9,
65
"name": "discord_verified",
66
"type_info": "Bool"
67
},
68
{
69
-
"ordinal": 10,
70
"name": "telegram_verified",
71
"type_info": "Bool"
72
},
73
{
74
-
"ordinal": 11,
75
"name": "signal_verified",
76
"type_info": "Bool"
77
}
···
85
false,
86
false,
87
true,
88
false,
89
false,
90
false,
···
96
false
97
]
98
},
99
-
"hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0"
100
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
4
"describe": {
5
"columns": [
6
{
···
25
},
26
{
27
"ordinal": 4,
28
+
"name": "password_required",
29
"type_info": "Bool"
30
},
31
{
32
"ordinal": 5,
33
+
"name": "two_factor_enabled",
34
+
"type_info": "Bool"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
"name": "preferred_comms_channel: CommsChannel",
39
"type_info": {
40
"Custom": {
···
51
}
52
},
53
{
54
+
"ordinal": 7,
55
"name": "deactivated_at",
56
"type_info": "Timestamptz"
57
},
58
{
59
+
"ordinal": 8,
60
"name": "takedown_ref",
61
"type_info": "Text"
62
},
63
{
64
+
"ordinal": 9,
65
"name": "email_verified",
66
"type_info": "Bool"
67
},
68
{
69
+
"ordinal": 10,
70
"name": "discord_verified",
71
"type_info": "Bool"
72
},
73
{
74
+
"ordinal": 11,
75
"name": "telegram_verified",
76
"type_info": "Bool"
77
},
78
{
79
+
"ordinal": 12,
80
"name": "signal_verified",
81
"type_info": "Bool"
82
}
···
90
false,
91
false,
92
true,
93
+
true,
94
false,
95
false,
96
false,
···
102
false
103
]
104
},
105
+
"hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f"
106
}
+46
.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
+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
+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
+2
-1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
+40
.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
+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
+15
frontend/src/App.svelte
···
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
import Login from './routes/Login.svelte'
5
import Register from './routes/Register.svelte'
6
import Verify from './routes/Verify.svelte'
7
import ResetPassword from './routes/ResetPassword.svelte'
8
import Dashboard from './routes/Dashboard.svelte'
9
import AppPasswords from './routes/AppPasswords.svelte'
10
import InviteCodes from './routes/InviteCodes.svelte'
···
18
import OAuthAccounts from './routes/OAuthAccounts.svelte'
19
import OAuth2FA from './routes/OAuth2FA.svelte'
20
import OAuthTotp from './routes/OAuthTotp.svelte'
21
import OAuthError from './routes/OAuthError.svelte'
22
import Security from './routes/Security.svelte'
23
24
const auth = getAuthState()
25
···
33
return Login
34
case '/register':
35
return Register
36
case '/verify':
37
return Verify
38
case '/reset-password':
39
return ResetPassword
40
case '/dashboard':
41
return Dashboard
42
case '/app-passwords':
···
63
return OAuth2FA
64
case '/oauth/totp':
65
return OAuthTotp
66
case '/oauth/error':
67
return OAuthError
68
case '/security':
69
return Security
70
default:
71
return auth.session ? Dashboard : Login
72
}
···
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
import Login from './routes/Login.svelte'
5
import Register from './routes/Register.svelte'
6
+
import RegisterPasskey from './routes/RegisterPasskey.svelte'
7
import Verify from './routes/Verify.svelte'
8
import ResetPassword from './routes/ResetPassword.svelte'
9
+
import RecoverPasskey from './routes/RecoverPasskey.svelte'
10
+
import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte'
11
import Dashboard from './routes/Dashboard.svelte'
12
import AppPasswords from './routes/AppPasswords.svelte'
13
import InviteCodes from './routes/InviteCodes.svelte'
···
21
import OAuthAccounts from './routes/OAuthAccounts.svelte'
22
import OAuth2FA from './routes/OAuth2FA.svelte'
23
import OAuthTotp from './routes/OAuthTotp.svelte'
24
+
import OAuthPasskey from './routes/OAuthPasskey.svelte'
25
import OAuthError from './routes/OAuthError.svelte'
26
import Security from './routes/Security.svelte'
27
+
import TrustedDevices from './routes/TrustedDevices.svelte'
28
29
const auth = getAuthState()
30
···
38
return Login
39
case '/register':
40
return Register
41
+
case '/register-passkey':
42
+
return RegisterPasskey
43
case '/verify':
44
return Verify
45
case '/reset-password':
46
return ResetPassword
47
+
case '/recover-passkey':
48
+
return RecoverPasskey
49
+
case '/request-passkey-recovery':
50
+
return RequestPasskeyRecovery
51
case '/dashboard':
52
return Dashboard
53
case '/app-passwords':
···
74
return OAuth2FA
75
case '/oauth/totp':
76
return OAuthTotp
77
+
case '/oauth/passkey':
78
+
return OAuthPasskey
79
case '/oauth/error':
80
return OAuthError
81
case '/security':
82
return Security
83
+
case '/trusted-devices':
84
+
return TrustedDevices
85
default:
86
return auth.session ? Dashboard : Login
87
}
+430
frontend/src/components/ReauthModal.svelte
+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">×</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
+141
-3
frontend/src/lib/api.ts
···
2
3
export class ApiError extends Error {
4
public did?: string
5
-
constructor(public status: number, public error: string, message: string, did?: string) {
6
super(message)
7
this.name = 'ApiError'
8
this.did = did
9
}
10
}
11
···
35
})
36
if (!res.ok) {
37
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
38
-
throw new ApiError(res.status, err.error, err.message, err.did)
39
}
40
return res.json()
41
}
···
208
})
209
},
210
211
-
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
212
return xrpc('com.atproto.server.requestEmailUpdate', {
213
method: 'POST',
214
token,
215
})
216
},
217
···
317
})
318
},
319
320
async listSessions(token: string): Promise<{
321
sessions: Array<{
322
id: string
···
567
method: 'POST',
568
token,
569
body: { id, friendlyName },
570
})
571
},
572
}
···
2
3
export class ApiError extends Error {
4
public did?: string
5
+
public reauthMethods?: string[]
6
+
constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) {
7
super(message)
8
this.name = 'ApiError'
9
this.did = did
10
+
this.reauthMethods = reauthMethods
11
}
12
}
13
···
37
})
38
if (!res.ok) {
39
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
40
+
throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods)
41
}
42
return res.json()
43
}
···
210
})
211
},
212
213
+
async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> {
214
return xrpc('com.atproto.server.requestEmailUpdate', {
215
method: 'POST',
216
token,
217
+
body: { email },
218
})
219
},
220
···
320
})
321
},
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
+
334
async listSessions(token: string): Promise<{
335
sessions: Array<{
336
id: string
···
581
method: 'POST',
582
token,
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 },
708
})
709
},
710
}
+1
-1
frontend/src/routes/Login.svelte
+1
-1
frontend/src/routes/Login.svelte
···
142
{submitting ? 'Redirecting...' : 'Sign In'}
143
</button>
144
<p class="forgot-link">
145
+
<a href="#/reset-password">Forgot password?</a> · <a href="#/request-passkey-recovery">Lost passkey?</a>
146
</p>
147
<p class="register-link">
148
Don't have an account? <a href="#/register">Create one</a>
+2
-2
frontend/src/routes/OAuthConsent.svelte
+2
-2
frontend/src/routes/OAuthConsent.svelte
+19
frontend/src/routes/OAuthLogin.svelte
+19
frontend/src/routes/OAuthLogin.svelte
···
399
</button>
400
</div>
401
</form>
402
+
403
+
<p class="help-links">
404
+
<a href="#/reset-password">Forgot password?</a> · <a href="#/request-passkey-recovery">Lost passkey?</a>
405
+
</p>
406
</div>
407
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
+
424
.oauth-login-container {
425
max-width: 400px;
426
margin: 4rem auto;
+304
frontend/src/routes/OAuthPasskey.svelte
+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
+27
-1
frontend/src/routes/OAuthTotp.svelte
···
2
import { navigate } from '../lib/router.svelte'
3
4
let code = $state('')
5
let submitting = $state(false)
6
let error = $state<string | null>(null)
7
···
30
},
31
body: JSON.stringify({
32
request_uri: requestUri,
33
-
code: code.trim().toUpperCase()
34
})
35
})
36
···
103
{/if}
104
</p>
105
</div>
106
107
<div class="actions">
108
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
···
221
222
.submit-btn:hover:not(:disabled) {
223
background: var(--accent-hover);
224
}
225
</style>
···
2
import { navigate } from '../lib/router.svelte'
3
4
let code = $state('')
5
+
let trustDevice = $state(false)
6
let submitting = $state(false)
7
let error = $state<string | null>(null)
8
···
31
},
32
body: JSON.stringify({
33
request_uri: requestUri,
34
+
code: code.trim().toUpperCase(),
35
+
trust_device: trustDevice
36
})
37
})
38
···
105
{/if}
106
</p>
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>
117
118
<div class="actions">
119
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
···
232
233
.submit-btn:hover:not(:disabled) {
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;
250
}
251
</style>
+266
frontend/src/routes/RecoverPasskey.svelte
+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">✔</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
+3
frontend/src/routes/Register.svelte
+1011
frontend/src/routes/RegisterPasskey.svelte
+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">✔</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
+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
+7
-7
frontend/src/routes/ResetPassword.svelte
···
25
try {
26
await api.requestPasswordReset(email)
27
tokenSent = true
28
-
success = 'Password reset code sent to your email'
29
} catch (e) {
30
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
31
} finally {
···
66
{/if}
67
{#if tokenSent}
68
<h1>Reset Password</h1>
69
-
<p class="subtitle">Enter the code from your email and choose a new password.</p>
70
<form onsubmit={handleReset}>
71
<div class="field">
72
<label for="token">Reset Code</label>
···
74
id="token"
75
type="text"
76
bind:value={token}
77
-
placeholder="Enter code from email"
78
disabled={submitting}
79
required
80
/>
···
111
</form>
112
{:else}
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>
115
<form onsubmit={handleRequestReset}>
116
<div class="field">
117
-
<label for="email">Email</label>
118
<input
119
id="email"
120
-
type="email"
121
bind:value={email}
122
-
placeholder="you@example.com"
123
disabled={submitting}
124
required
125
/>
···
25
try {
26
await api.requestPasswordReset(email)
27
tokenSent = true
28
+
success = 'Password reset code sent! Check your preferred notification channel.'
29
} catch (e) {
30
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
31
} finally {
···
66
{/if}
67
{#if tokenSent}
68
<h1>Reset Password</h1>
69
+
<p class="subtitle">Enter the code you received and choose a new password.</p>
70
<form onsubmit={handleReset}>
71
<div class="field">
72
<label for="token">Reset Code</label>
···
74
id="token"
75
type="text"
76
bind:value={token}
77
+
placeholder="Enter reset code"
78
disabled={submitting}
79
required
80
/>
···
111
</form>
112
{:else}
113
<h1>Forgot Password</h1>
114
+
<p class="subtitle">Enter your handle or email and we'll send you a code to reset your password.</p>
115
<form onsubmit={handleRequestReset}>
116
<div class="field">
117
+
<label for="email">Handle or Email</label>
118
<input
119
id="email"
120
+
type="text"
121
bind:value={email}
122
+
placeholder="handle or you@example.com"
123
disabled={submitting}
124
required
125
/>
+182
frontend/src/routes/Security.svelte
+182
frontend/src/routes/Security.svelte
···
2
import { getAuthState } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
6
const auth = getAuthState()
7
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
···
38
let editingPasskeyId = $state<string | null>(null)
39
let editPasskeyName = $state('')
40
41
$effect(() => {
42
if (!auth.loading && !auth.session) {
43
navigate('/login')
···
48
if (auth.session) {
49
loadTotpStatus()
50
loadPasskeys()
51
}
52
})
53
54
async function loadTotpStatus() {
55
if (!auth.session) return
···
543
</div>
544
{/if}
545
</section>
546
{/if}
547
</div>
548
549
<style>
550
.page {
···
893
894
.add-passkey .field {
895
margin-bottom: 0.75rem;
896
}
897
</style>
···
2
import { getAuthState } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
+
import ReauthModal from '../components/ReauthModal.svelte'
6
7
const auth = getAuthState()
8
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
···
39
let editingPasskeyId = $state<string | null>(null)
40
let editPasskeyName = $state('')
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
+
51
$effect(() => {
52
if (!auth.loading && !auth.session) {
53
navigate('/login')
···
58
if (auth.session) {
59
loadTotpStatus()
60
loadPasskeys()
61
+
loadPasswordStatus()
62
}
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
+
}
113
114
async function loadTotpStatus() {
115
if (!auth.session) return
···
603
</div>
604
{/if}
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 →
671
+
</a>
672
+
</section>
673
{/if}
674
</div>
675
+
676
+
<ReauthModal
677
+
bind:show={showReauthModal}
678
+
availableMethods={reauthMethods}
679
+
onSuccess={handleReauthSuccess}
680
+
onCancel={handleReauthCancel}
681
+
/>
682
683
<style>
684
.page {
···
1027
1028
.add-passkey .field {
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;
1078
}
1079
</style>
+1
-1
frontend/src/routes/Settings.svelte
+1
-1
frontend/src/routes/Settings.svelte
+409
frontend/src/routes/TrustedDevices.svelte
+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">← 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
+
✎
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
+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
+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
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
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
+43
-22
src/api/identity/account.rs
···
139
info!(did = %migration_did, "Processing account migration");
140
}
141
142
-
let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
143
let pds_suffix = format!(".{}", hostname_for_validation);
144
145
-
let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
146
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
147
-
input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle)
148
} else {
149
&input.handle
150
};
···
165
Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})),
166
)
167
.into_response();
168
}
169
input.handle.to_lowercase()
170
};
···
319
)
320
.into_response();
321
}
322
-
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
323
return (
324
StatusCode::BAD_REQUEST,
325
Json(json!({"error": "InvalidDid", "message": e})),
···
335
info!(did = %d, "Migration with existing did:plc");
336
d.clone()
337
} 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 {
339
return (
340
StatusCode::BAD_REQUEST,
341
Json(json!({"error": "InvalidDid", "message": e})),
···
758
};
759
760
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"})),
774
)
775
-
.into_response();
776
-
}
777
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
778
Ok(enc) => enc,
779
Err(e) => {
···
919
}
920
if !is_migration {
921
if let Err(e) =
922
-
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
923
-
.await
924
{
925
warn!("Failed to sequence identity event for {}: {}", did, e);
926
}
···
139
info!(did = %migration_did, "Processing account migration");
140
}
141
142
+
let hostname_for_validation =
143
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
144
let pds_suffix = format!(".{}", hostname_for_validation);
145
146
+
let validated_short_handle = if !input.handle.contains('.')
147
+
|| input.handle.ends_with(&pds_suffix)
148
+
{
149
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
150
+
input
151
+
.handle
152
+
.strip_suffix(&pds_suffix)
153
+
.unwrap_or(&input.handle)
154
} else {
155
&input.handle
156
};
···
171
Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})),
172
)
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
+
}
183
}
184
input.handle.to_lowercase()
185
};
···
334
)
335
.into_response();
336
}
337
+
if let Err(e) =
338
+
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
339
+
{
340
return (
341
StatusCode::BAD_REQUEST,
342
Json(json!({"error": "InvalidDid", "message": e})),
···
352
info!(did = %d, "Migration with existing did:plc");
353
d.clone()
354
} else if d.starts_with("did:web:") {
355
+
if let Err(e) =
356
+
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref())
357
+
.await
358
+
{
359
return (
360
StatusCode::BAD_REQUEST,
361
Json(json!({"error": "InvalidDid", "message": e})),
···
778
};
779
780
if !is_migration
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
789
)
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
+
}
799
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
800
Ok(enc) => enc,
801
Err(e) => {
···
941
}
942
if !is_migration {
943
if let Err(e) =
944
+
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
945
{
946
warn!("Failed to sequence identity event for {}: {}", did, e);
947
}
+2
-6
src/api/identity/did.rs
+2
-6
src/api/identity/did.rs
···
674
if let Some(old) = old_handle {
675
let _ = state.cache.delete(&format!("handle:{}", old)).await;
676
}
677
-
let _ = state
678
-
.cache
679
-
.delete(&format!("handle:{}", handle))
680
-
.await;
681
if let Err(e) =
682
-
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
683
-
.await
684
{
685
warn!("Failed to sequence identity event for handle update: {}", e);
686
}
···
674
if let Some(old) = old_handle {
675
let _ = state.cache.delete(&format!("handle:{}", old)).await;
676
}
677
+
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
678
if let Err(e) =
679
+
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
680
{
681
warn!("Failed to sequence identity event for handle update: {}", e);
682
}
+5
-1
src/api/server/account_status.rs
+5
-1
src/api/server/account_status.rs
+21
-4
src/api/server/mod.rs
+21
-4
src/api/server/mod.rs
···
3
pub mod email;
4
pub mod invite;
5
pub mod meta;
6
pub mod passkeys;
7
pub mod password;
8
pub mod service_auth;
9
pub mod session;
10
pub mod signing_key;
11
pub mod totp;
12
13
pub use account_status::{
14
activate_account, check_account_status, deactivate_account, delete_account,
···
18
pub use email::{confirm_email, request_email_update, update_email};
19
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
20
pub use meta::{describe_server, health, robots_txt};
21
pub use passkeys::{
22
-
delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys,
23
-
start_passkey_registration, update_passkey,
24
};
25
-
pub use password::{change_password, request_password_reset, reset_password};
26
pub use service_auth::get_service_auth;
27
pub use session::{
28
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
···
31
pub use signing_key::reserve_signing_key;
32
pub use totp::{
33
create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
34
-
regenerate_backup_codes, verify_totp_or_backup_for_user,
35
};
···
3
pub mod email;
4
pub mod invite;
5
pub mod meta;
6
+
pub mod passkey_account;
7
pub mod passkeys;
8
pub mod password;
9
+
pub mod reauth;
10
pub mod service_auth;
11
pub mod session;
12
pub mod signing_key;
13
pub mod totp;
14
+
pub mod trusted_devices;
15
16
pub use account_status::{
17
activate_account, check_account_status, deactivate_account, delete_account,
···
21
pub use email::{confirm_email, request_email_update, update_email};
22
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
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
+
};
28
pub use passkeys::{
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,
38
};
39
pub use service_auth::get_service_auth;
40
pub use session::{
41
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
···
44
pub use signing_key::reserve_signing_key;
45
pub use totp::{
46
create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
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,
52
};
+1209
src/api/server/passkey_account.rs
+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, ®_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, ®_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
+5
-3
src/api/server/passkeys.rs
···
371
}
372
373
pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
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)
379
}
+123
-8
src/api/server/password.rs
+123
-8
src/api/server/password.rs
···
33
34
#[derive(Deserialize)]
35
pub struct RequestPasswordResetInput {
36
pub email: String,
37
}
38
···
56
)
57
.into_response();
58
}
59
-
let email = input.email.trim().to_lowercase();
60
-
if email.is_empty() {
61
return (
62
StatusCode::BAD_REQUEST,
63
-
Json(json!({"error": "InvalidRequest", "message": "email is required"})),
64
)
65
.into_response();
66
}
67
-
let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email)
68
-
.fetch_optional(&state.db)
69
-
.await;
70
let user_id = match user {
71
Ok(Some(row)) => row.id,
72
Ok(None) => {
73
-
info!("Password reset requested for unknown email");
74
return (StatusCode::OK, Json(json!({}))).into_response();
75
}
76
Err(e) => {
···
225
}
226
};
227
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",
229
password_hash,
230
user_id
231
)
···
404
info!(did = %auth.0.did, "Password changed successfully");
405
(StatusCode::OK, Json(json!({}))).into_response()
406
}
···
33
34
#[derive(Deserialize)]
35
pub struct RequestPasswordResetInput {
36
+
#[serde(alias = "identifier")]
37
pub email: String,
38
}
39
···
57
)
58
.into_response();
59
}
60
+
let identifier = input.email.trim();
61
+
if identifier.is_empty() {
62
return (
63
StatusCode::BAD_REQUEST,
64
+
Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})),
65
)
66
.into_response();
67
}
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;
83
let user_id = match user {
84
Ok(Some(row)) => row.id,
85
Ok(None) => {
86
+
info!("Password reset requested for unknown identifier");
87
return (StatusCode::OK, Json(json!({}))).into_response();
88
}
89
Err(e) => {
···
238
}
239
};
240
if let Err(e) = sqlx::query!(
241
+
"UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
242
password_hash,
243
user_id
244
)
···
417
info!(did = %auth.0.did, "Password changed successfully");
418
(StatusCode::OK, Json(json!({}))).into_response()
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
+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
+43
-19
src/api/server/session.rs
···
63
headers: HeaderMap,
64
Json(input): Json<CreateSessionInput>,
65
) -> Response {
66
-
info!("create_session called with identifier: {}", input.identifier);
67
let client_ip = extract_client_ip(&headers);
68
if !state
69
.check_rate_limit(RateLimitKind::Login, &client_ip)
···
81
}
82
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
83
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
84
-
info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier);
85
let row = match sqlx::query!(
86
r#"SELECT
87
u.id, u.did, u.handle, u.password_hash,
···
117
return ApiError::InternalError.into_response();
118
}
119
};
120
-
let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) {
121
true
122
} else {
123
let app_passwords = sqlx::query!(
···
523
}
524
};
525
526
let verification = match sqlx::query!(
527
-
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
528
-
row.id
529
)
530
.fetch_optional(&state.db)
531
.await
···
574
}
575
576
if let Err(e) = sqlx::query!(
577
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
578
-
row.id
579
)
580
.execute(&state.db)
581
.await
···
676
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
677
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
678
679
-
let email = row.email.clone();
680
681
if let Err(e) = sqlx::query!(
682
r#"
683
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
684
-
VALUES ($1, 'email', $2, $3, $4)
685
ON CONFLICT (user_id, channel) DO UPDATE
686
-
SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()
687
"#,
688
row.id,
689
verification_code,
690
-
email,
691
code_expires_at
692
)
693
.execute(&state.db)
···
696
error!("Failed to update verification code: {:?}", e);
697
return ApiError::InternalError.into_response();
698
}
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
if let Err(e) = crate::comms::enqueue_signup_verification(
708
&state.db,
709
row.id,
···
63
headers: HeaderMap,
64
Json(input): Json<CreateSessionInput>,
65
) -> Response {
66
+
info!(
67
+
"create_session called with identifier: {}",
68
+
input.identifier
69
+
);
70
let client_ip = extract_client_ip(&headers);
71
if !state
72
.check_rate_limit(RateLimitKind::Login, &client_ip)
···
84
}
85
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
86
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
87
+
info!(
88
+
"Normalized identifier: {} -> {}",
89
+
input.identifier, normalized_identifier
90
+
);
91
let row = match sqlx::query!(
92
r#"SELECT
93
u.id, u.did, u.handle, u.password_hash,
···
123
return ApiError::InternalError.into_response();
124
}
125
};
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
+
{
132
true
133
} else {
134
let app_passwords = sqlx::query!(
···
534
}
535
};
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
+
};
543
let verification = match sqlx::query!(
544
+
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
545
+
row.id,
546
+
channel_str as _
547
)
548
.fetch_optional(&state.db)
549
.await
···
592
}
593
594
if let Err(e) = sqlx::query!(
595
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
596
+
row.id,
597
+
channel_str as _
598
)
599
.execute(&state.db)
600
.await
···
695
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
696
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
697
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
+
};
711
712
if let Err(e) = sqlx::query!(
713
r#"
714
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
715
+
VALUES ($1, $2::comms_channel, $3, $4, $5)
716
ON CONFLICT (user_id, channel) DO UPDATE
717
+
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
718
"#,
719
row.id,
720
+
channel_str as _,
721
verification_code,
722
+
recipient,
723
code_expires_at
724
)
725
.execute(&state.db)
···
728
error!("Failed to update verification code: {:?}", e);
729
return ApiError::InternalError.into_response();
730
}
731
if let Err(e) = crate::comms::enqueue_signup_verification(
732
&state.db,
733
row.id,
+13
-3
src/api/server/totp.rs
+13
-3
src/api/server/totp.rs
···
332
}
333
};
334
335
-
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
336
if !password_valid {
337
return (
338
StatusCode::UNAUTHORIZED,
···
536
}
537
};
538
539
-
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
540
if !password_valid {
541
return (
542
StatusCode::UNAUTHORIZED,
···
741
}
742
743
pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
744
let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
745
-
.fetch_optional(&state.db)
746
.await;
747
748
matches!(result, Ok(Some(true)))
···
332
}
333
};
334
335
+
let password_valid = password_hash
336
+
.as_ref()
337
+
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
338
+
.unwrap_or(false);
339
if !password_valid {
340
return (
341
StatusCode::UNAUTHORIZED,
···
539
}
540
};
541
542
+
let password_valid = password_hash
543
+
.as_ref()
544
+
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
545
+
.unwrap_or(false);
546
if !password_valid {
547
return (
548
StatusCode::UNAUTHORIZED,
···
747
}
748
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 {
754
let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
755
+
.fetch_optional(db)
756
.await;
757
758
matches!(result, Ok(Some(true)))
+246
src/api/server/trusted_devices.rs
+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
+81
-20
src/api/validation.rs
···
22
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23
match self {
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"),
29
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"),
30
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
31
}
···
125
fn test_valid_handles() {
126
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
127
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()));
132
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
133
}
134
135
#[test]
136
fn test_invalid_handles() {
137
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));
150
}
151
152
#[test]
···
22
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23
match self {
24
Self::Empty => write!(f, "Handle cannot be empty"),
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
+
}
42
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"),
43
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
44
}
···
138
fn test_valid_handles() {
139
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
140
assert_eq!(validate_short_handle("bob123"), Ok("bob123".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
+
);
157
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
158
}
159
160
#[test]
161
fn test_invalid_handles() {
162
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
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
+
);
211
}
212
213
#[test]
+2
-2
src/comms/mod.rs
+2
-2
src/comms/mod.rs
···
9
10
pub use service::{
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,
14
};
15
16
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
···
9
10
pub use service::{
11
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
12
+
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
13
+
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
14
};
15
16
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+25
src/comms/service.rs
+25
src/comms/service.rs
···
457
.await
458
}
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
+
485
pub fn channel_display_name(channel: CommsChannel) -> &'static str {
486
match channel {
487
CommsChannel::Email => "email",
+1
src/comms/types.rs
+1
src/comms/types.rs
+68
src/lib.rs
+68
src/lib.rs
···
203
post(api::server::change_password),
204
)
205
.route(
206
"/xrpc/com.atproto.server.requestEmailUpdate",
207
post(api::server::request_email_update),
208
)
···
398
.route(
399
"/oauth/authorize/2fa",
400
post(oauth::endpoints::authorize_2fa_post),
401
)
402
.route(
403
"/oauth/passkey/check",
···
203
post(api::server::change_password),
204
)
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(
266
"/xrpc/com.atproto.server.requestEmailUpdate",
267
post(api::server::request_email_update),
268
)
···
458
.route(
459
"/oauth/authorize/2fa",
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),
469
)
470
.route(
471
"/oauth/passkey/check",
+37
-18
tests/admin_search.rs
+37
-18
tests/admin_search.rs
···
10
let client = client();
11
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
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));
31
assert!(
32
found,
33
"Should find the created user in results (DID: {})",
···
10
let client = client();
11
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
12
let (user_did, _) = setup_new_user("search-target").await;
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
+
}
50
assert!(
51
found,
52
"Should find the created user in results (DID: {})",
+27
-1
tests/did_web.rs
+27
-1
tests/did_web.rs
···
97
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
98
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
99
let pds_endpoint = base_url().await.replace("http://", "https://");
100
let did_doc = json!({
101
"@context": ["https://www.w3.org/ns/did/v1"],
102
"id": did,
103
"service": [{
104
"id": "#atproto_pds",
105
"type": "AtprotoPersonalDataServer",
···
116
"email": format!("{}@example.com", handle),
117
"password": "password",
118
"didType": "web-external",
119
-
"did": did
120
});
121
let res = client
122
.post(format!(
···
97
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
98
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
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
+
119
let did_doc = json!({
120
"@context": ["https://www.w3.org/ns/did/v1"],
121
"id": did,
122
+
"verificationMethod": [{
123
+
"id": format!("{}#atproto", did),
124
+
"type": "Multikey",
125
+
"controller": did,
126
+
"publicKeyMultibase": public_key_multibase
127
+
}],
128
"service": [{
129
"id": "#atproto_pds",
130
"type": "AtprotoPersonalDataServer",
···
141
"email": format!("{}@example.com", handle),
142
"password": "password",
143
"didType": "web-external",
144
+
"did": did,
145
+
"signingKey": signing_key
146
});
147
let res = client
148
.post(format!(
+58
-3
tests/identity.rs
+58
-3
tests/identity.rs
···
26
assert_eq!(res.status(), StatusCode::OK);
27
let body: Value = res.json().await.expect("Invalid JSON");
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();
30
let params = [("handle", full_handle.as_str())];
31
let res = client
32
.get(format!(
···
97
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
98
let handle = format!("webuser_{}", uuid::Uuid::new_v4());
99
let pds_endpoint = base_url().await.replace("http://", "https://");
100
let did_doc = json!({
101
"@context": ["https://www.w3.org/ns/did/v1"],
102
"id": did,
103
"service": [{
104
"id": "#atproto_pds",
105
"type": "AtprotoPersonalDataServer",
···
115
"handle": handle,
116
"email": format!("{}@example.com", handle),
117
"password": "password",
118
-
"did": did
119
});
120
let res = client
121
.post(format!(
···
195
let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle);
196
let email = format!("{}@test.com", handle);
197
let pds_endpoint = base_url().await.replace("http://", "https://");
198
let did_doc = json!({
199
"@context": ["https://www.w3.org/ns/did/v1"],
200
"id": did,
201
"service": [{
202
"id": "#atproto_pds",
203
"type": "AtprotoPersonalDataServer",
···
213
"handle": handle,
214
"email": email,
215
"password": "password",
216
-
"did": did
217
});
218
let res = client
219
.post(format!(
···
26
assert_eq!(res.status(), StatusCode::OK);
27
let body: Value = res.json().await.expect("Invalid JSON");
28
let did = body["did"].as_str().expect("No DID").to_string();
29
+
let full_handle = body["handle"]
30
+
.as_str()
31
+
.expect("No handle in response")
32
+
.to_string();
33
let params = [("handle", full_handle.as_str())];
34
let res = client
35
.get(format!(
···
100
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
101
let handle = format!("webuser_{}", uuid::Uuid::new_v4());
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
+
122
let did_doc = json!({
123
"@context": ["https://www.w3.org/ns/did/v1"],
124
"id": did,
125
+
"verificationMethod": [{
126
+
"id": format!("{}#atproto", did),
127
+
"type": "Multikey",
128
+
"controller": did,
129
+
"publicKeyMultibase": public_key_multibase
130
+
}],
131
"service": [{
132
"id": "#atproto_pds",
133
"type": "AtprotoPersonalDataServer",
···
143
"handle": handle,
144
"email": format!("{}@example.com", handle),
145
"password": "password",
146
+
"did": did,
147
+
"signingKey": signing_key
148
});
149
let res = client
150
.post(format!(
···
224
let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle);
225
let email = format!("{}@test.com", handle);
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
+
246
let did_doc = json!({
247
"@context": ["https://www.w3.org/ns/did/v1"],
248
"id": did,
249
+
"verificationMethod": [{
250
+
"id": format!("{}#atproto", did),
251
+
"type": "Multikey",
252
+
"controller": did,
253
+
"publicKeyMultibase": public_key_multibase
254
+
}],
255
"service": [{
256
"id": "#atproto_pds",
257
"type": "AtprotoPersonalDataServer",
···
267
"handle": handle,
268
"email": email,
269
"password": "password",
270
+
"did": did,
271
+
"signingKey": signing_key
272
});
273
let res = client
274
.post(format!(