Passkey-only accounts

This commit is contained in:
lewis
2025-12-21 23:16:39 +02:00
parent adfa9a3812
commit fa6c4cc177
81 changed files with 6683 additions and 203 deletions

View File

@@ -1,10 +1,23 @@
[store]
dir = "target/nextest"
[profile.default]
retries = 0
fail-fast = true
test-threads = "num-cpus"
[profile.ci]
retries = 2
fail-fast = false
test-threads = "num-cpus"
[test-groups]
serial-env-tests = { max-threads = 1 }
[[profile.default.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80"
}

View File

@@ -26,7 +26,7 @@
},
"nullable": [
false,
false,
true,
false
]
},

View File

@@ -38,7 +38,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification"
"channel_verification",
"passkey_recovery"
]
}
}

View File

@@ -46,7 +46,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification"
"channel_verification",
"passkey_recovery"
]
}
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ap.password_hash FROM app_passwords ap\n JOIN users u ON ap.user_id = u.id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d"
}

View File

@@ -38,7 +38,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification"
"channel_verification",
"passkey_recovery"
]
}
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"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 ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"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",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "trusted_until",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f"
}

View File

@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
},
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
"describe": {
"columns": [
{
@@ -16,7 +16,20 @@
],
"parameters": {
"Left": [
"Uuid"
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
]
},
"nullable": [
@@ -24,5 +37,5 @@
false
]
},
"hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32"
"hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_password",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"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",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "one",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "one",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "recovery_token",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "recovery_token_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "password_required",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
false
]
},
"hash": "73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, password_hash FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM passkeys WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM users WHERE LOWER(email) = $1",
"query": "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
"describe": {
"columns": [
{
@@ -11,6 +11,7 @@
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -18,5 +19,5 @@
false
]
},
"hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4"
"hash": "7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "password_required",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_reauth_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a"
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "recovery_token",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "recovery_token_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
}

View File

@@ -63,7 +63,7 @@
false,
false,
false,
false,
true,
false,
false,
false,

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
false
true
]
},
"hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745"

View File

@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"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",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "user_agent",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "friendly_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "trusted_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "trusted_until",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "last_seen_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true,
true,
false
]
},
"hash": "d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2",
"query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b"
"hash": "e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"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 ",
"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 ",
"describe": {
"columns": [
{
@@ -25,11 +25,16 @@
},
{
"ordinal": 4,
"name": "two_factor_enabled",
"name": "password_required",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "two_factor_enabled",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "preferred_comms_channel: CommsChannel",
"type_info": {
"Custom": {
@@ -46,32 +51,32 @@
}
},
{
"ordinal": 6,
"ordinal": 7,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "takedown_ref",
"type_info": "Text"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 9,
"ordinal": 10,
"name": "discord_verified",
"type_info": "Bool"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "telegram_verified",
"type_info": "Bool"
},
{
"ordinal": 11,
"ordinal": 12,
"name": "signal_verified",
"type_info": "Bool"
}
@@ -85,6 +90,7 @@
false,
false,
true,
true,
false,
false,
false,
@@ -96,5 +102,5 @@
false
]
},
"hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0"
"hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f"
}

View File

@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "recovery_token",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "recovery_token_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "password_required",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
true,
false
]
},
"hash": "f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_pw",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba"
}

View File

@@ -41,7 +41,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification"
"channel_verification",
"passkey_recovery"
]
}
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\" FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "preferred_comms_channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4"
}

View File

@@ -3,8 +3,11 @@
import { initAuth, getAuthState } from './lib/auth.svelte'
import Login from './routes/Login.svelte'
import Register from './routes/Register.svelte'
import RegisterPasskey from './routes/RegisterPasskey.svelte'
import Verify from './routes/Verify.svelte'
import ResetPassword from './routes/ResetPassword.svelte'
import RecoverPasskey from './routes/RecoverPasskey.svelte'
import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte'
import Dashboard from './routes/Dashboard.svelte'
import AppPasswords from './routes/AppPasswords.svelte'
import InviteCodes from './routes/InviteCodes.svelte'
@@ -18,8 +21,10 @@
import OAuthAccounts from './routes/OAuthAccounts.svelte'
import OAuth2FA from './routes/OAuth2FA.svelte'
import OAuthTotp from './routes/OAuthTotp.svelte'
import OAuthPasskey from './routes/OAuthPasskey.svelte'
import OAuthError from './routes/OAuthError.svelte'
import Security from './routes/Security.svelte'
import TrustedDevices from './routes/TrustedDevices.svelte'
const auth = getAuthState()
@@ -33,10 +38,16 @@
return Login
case '/register':
return Register
case '/register-passkey':
return RegisterPasskey
case '/verify':
return Verify
case '/reset-password':
return ResetPassword
case '/recover-passkey':
return RecoverPasskey
case '/request-passkey-recovery':
return RequestPasskeyRecovery
case '/dashboard':
return Dashboard
case '/app-passwords':
@@ -63,10 +74,14 @@
return OAuth2FA
case '/oauth/totp':
return OAuthTotp
case '/oauth/passkey':
return OAuthPasskey
case '/oauth/error':
return OAuthError
case '/security':
return Security
case '/trusted-devices':
return TrustedDevices
default:
return auth.session ? Dashboard : Login
}

View File

@@ -0,0 +1,430 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
interface Props {
show: boolean
availableMethods?: string[]
onSuccess: () => void
onCancel: () => void
}
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
const auth = getAuthState()
let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
let password = $state('')
let totpCode = $state('')
let loading = $state(false)
let error = $state('')
$effect(() => {
if (show) {
password = ''
totpCode = ''
error = ''
if (availableMethods.includes('password')) {
activeMethod = 'password'
} else if (availableMethods.includes('totp')) {
activeMethod = 'totp'
} else if (availableMethods.includes('passkey')) {
activeMethod = 'passkey'
}
}
})
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function handlePasswordSubmit(e: Event) {
e.preventDefault()
if (!auth.session || !password) return
loading = true
error = ''
try {
await api.reauthPassword(auth.session.accessJwt, password)
show = false
onSuccess()
} catch (e) {
error = e instanceof ApiError ? e.message : 'Authentication failed'
} finally {
loading = false
}
}
async function handleTotpSubmit(e: Event) {
e.preventDefault()
if (!auth.session || !totpCode) return
loading = true
error = ''
try {
await api.reauthTotp(auth.session.accessJwt, totpCode)
show = false
onSuccess()
} catch (e) {
error = e instanceof ApiError ? e.message : 'Invalid code'
} finally {
loading = false
}
}
async function handlePasskeyAuth() {
if (!auth.session) return
if (!window.PublicKeyCredential) {
error = 'Passkeys are not supported in this browser'
return
}
loading = true
error = ''
try {
const { options } = await api.reauthPasskeyStart(auth.session.accessJwt)
const publicKeyOptions = prepareAuthOptions(options)
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
})
if (!credential) {
error = 'Passkey authentication was cancelled'
return
}
const pkCredential = credential as PublicKeyCredential
const response = pkCredential.response as AuthenticatorAssertionResponse
const credentialResponse = {
id: pkCredential.id,
type: pkCredential.type,
rawId: arrayBufferToBase64Url(pkCredential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
signature: arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
},
}
await api.reauthPasskeyFinish(auth.session.accessJwt, credentialResponse)
show = false
onSuccess()
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
error = 'Passkey authentication was cancelled'
} else {
error = e instanceof ApiError ? e.message : 'Passkey authentication failed'
}
} finally {
loading = false
}
}
function handleClose() {
show = false
onCancel()
}
</script>
{#if show}
<div class="modal-backdrop" onclick={handleClose} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Re-authentication Required</h2>
<button class="close-btn" onclick={handleClose} aria-label="Close">&times;</button>
</div>
<p class="modal-description">
This action requires you to verify your identity.
</p>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if availableMethods.length > 1}
<div class="method-tabs">
{#if availableMethods.includes('password')}
<button
class="tab"
class:active={activeMethod === 'password'}
onclick={() => activeMethod = 'password'}
>
Password
</button>
{/if}
{#if availableMethods.includes('totp')}
<button
class="tab"
class:active={activeMethod === 'totp'}
onclick={() => activeMethod = 'totp'}
>
TOTP
</button>
{/if}
{#if availableMethods.includes('passkey')}
<button
class="tab"
class:active={activeMethod === 'passkey'}
onclick={() => activeMethod = 'passkey'}
>
Passkey
</button>
{/if}
</div>
{/if}
<div class="modal-content">
{#if activeMethod === 'password'}
<form onsubmit={handlePasswordSubmit}>
<div class="form-group">
<label for="reauth-password">Password</label>
<input
id="reauth-password"
type="password"
bind:value={password}
required
autocomplete="current-password"
/>
</div>
<button type="submit" class="btn-primary" disabled={loading || !password}>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
{:else if activeMethod === 'totp'}
<form onsubmit={handleTotpSubmit}>
<div class="form-group">
<label for="reauth-totp">Authenticator Code</label>
<input
id="reauth-totp"
type="text"
bind:value={totpCode}
required
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
/>
</div>
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
{:else if activeMethod === 'passkey'}
<div class="passkey-auth">
<p>Click the button below to authenticate with your passkey.</p>
<button
class="btn-primary"
onclick={handlePasskeyAuth}
disabled={loading}
>
{loading ? 'Authenticating...' : 'Use Passkey'}
</button>
</div>
{/if}
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
Cancel
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 400px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text-primary);
}
.modal-description {
padding: 1rem 1.5rem 0;
margin: 0;
color: var(--text-secondary);
}
.error-message {
margin: 1rem 1.5rem 0;
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
font-size: 0.875rem;
}
.method-tabs {
display: flex;
gap: 0.5rem;
padding: 1rem 1.5rem 0;
}
.tab {
flex: 1;
padding: 0.5rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
}
.tab:hover {
background: var(--bg-secondary);
}
.tab.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.modal-content {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.passkey-auth {
text-align: center;
}
.passkey-auth p {
margin-bottom: 1rem;
color: var(--text-secondary);
}
.btn-primary {
width: 100%;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-footer {
padding: 0 1.5rem 1.5rem;
display: flex;
justify-content: flex-end;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.875rem;
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -2,10 +2,12 @@ const API_BASE = '/xrpc'
export class ApiError extends Error {
public did?: string
constructor(public status: number, public error: string, message: string, did?: string) {
public reauthMethods?: string[]
constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) {
super(message)
this.name = 'ApiError'
this.did = did
this.reauthMethods = reauthMethods
}
}
@@ -35,7 +37,7 @@ async function xrpc<T>(method: string, options?: {
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
throw new ApiError(res.status, err.error, err.message, err.did)
throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods)
}
return res.json()
}
@@ -208,10 +210,11 @@ export const api = {
})
},
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> {
return xrpc('com.atproto.server.requestEmailUpdate', {
method: 'POST',
token,
body: { email },
})
},
@@ -317,6 +320,17 @@ export const api = {
})
},
async removePassword(token: string): Promise<{ success: boolean }> {
return xrpc('com.tranquil.account.removePassword', {
method: 'POST',
token,
})
},
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
return xrpc('com.tranquil.account.getPasswordStatus', { token })
},
async listSessions(token: string): Promise<{
sessions: Array<{
id: string
@@ -569,4 +583,128 @@ export const api = {
body: { id, friendlyName },
})
},
async listTrustedDevices(token: string): Promise<{
devices: Array<{
id: string
userAgent: string | null
friendlyName: string | null
trustedAt: string | null
trustedUntil: string | null
lastSeenAt: string
}>
}> {
return xrpc('com.tranquil.account.listTrustedDevices', { token })
},
async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> {
return xrpc('com.tranquil.account.revokeTrustedDevice', {
method: 'POST',
token,
body: { deviceId },
})
},
async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> {
return xrpc('com.tranquil.account.updateTrustedDevice', {
method: 'POST',
token,
body: { deviceId, friendlyName },
})
},
async getReauthStatus(token: string): Promise<{
requiresReauth: boolean
lastReauthAt: string | null
availableMethods: string[]
}> {
return xrpc('com.tranquil.account.getReauthStatus', { token })
},
async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> {
return xrpc('com.tranquil.account.reauthPassword', {
method: 'POST',
token,
body: { password },
})
},
async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> {
return xrpc('com.tranquil.account.reauthTotp', {
method: 'POST',
token,
body: { code },
})
},
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
return xrpc('com.tranquil.account.reauthPasskeyStart', {
method: 'POST',
token,
})
},
async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> {
return xrpc('com.tranquil.account.reauthPasskeyFinish', {
method: 'POST',
token,
body: { credential },
})
},
async createPasskeyAccount(params: {
handle: string
email?: string
inviteCode?: string
didType?: DidType
did?: string
signingKey?: string
verificationChannel?: VerificationChannel
discordId?: string
telegramUsername?: string
signalNumber?: string
}): Promise<{
did: string
handle: string
setupToken: string
setupExpiresAt: string
}> {
return xrpc('com.tranquil.account.createPasskeyAccount', {
method: 'POST',
body: params,
})
},
async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', {
method: 'POST',
body: { did, setupToken, friendlyName },
})
},
async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{
did: string
handle: string
appPassword: string
appPasswordName: string
}> {
return xrpc('com.tranquil.account.completePasskeySetup', {
method: 'POST',
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
})
},
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
return xrpc('com.tranquil.account.requestPasskeyRecovery', {
method: 'POST',
body: { email },
})
},
async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> {
return xrpc('com.tranquil.account.recoverPasskeyAccount', {
method: 'POST',
body: { did, recoveryToken, newPassword },
})
},
}

View File

@@ -142,7 +142,7 @@
{submitting ? 'Redirecting...' : 'Sign In'}
</button>
<p class="forgot-link">
<a href="#/reset-password">Forgot password?</a>
<a href="#/reset-password">Forgot password?</a> &middot; <a href="#/request-passkey-recovery">Lost passkey?</a>
</p>
<p class="register-link">
Don't have an account? <a href="#/register">Create one</a>

View File

@@ -115,8 +115,8 @@
try {
const response = await fetch('/oauth/authorize/deny', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `request_uri=${encodeURIComponent(consentData.request_uri)}`
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_uri: consentData.request_uri })
})
if (response.redirected) {

View File

@@ -399,9 +399,28 @@
</button>
</div>
</form>
<p class="help-links">
<a href="#/reset-password">Forgot password?</a> &middot; <a href="#/request-passkey-recovery">Lost passkey?</a>
</p>
</div>
<style>
.help-links {
text-align: center;
margin-top: 1rem;
font-size: 0.875rem;
}
.help-links a {
color: var(--accent);
text-decoration: none;
}
.help-links a:hover {
text-decoration: underline;
}
.oauth-login-container {
max-width: 400px;
margin: 4rem auto;

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
let loading = $state(false)
let error = $state<string | null>(null)
let autoStarted = $state(false)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('request_uri')
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function startPasskeyAuth() {
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
return
}
if (!window.PublicKeyCredential) {
error = 'Passkeys are not supported in this browser'
return
}
loading = true
error = null
try {
const startResponse = await fetch(`/oauth/authorize/passkey?request_uri=${encodeURIComponent(requestUri)}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
if (!startResponse.ok) {
const data = await startResponse.json()
error = data.error_description || data.error || 'Failed to start passkey authentication'
loading = false
return
}
const { options } = await startResponse.json()
const publicKeyOptions = prepareAuthOptions(options)
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
})
if (!credential) {
error = 'Passkey authentication was cancelled'
loading = false
return
}
const pkCredential = credential as PublicKeyCredential
const response = pkCredential.response as AuthenticatorAssertionResponse
const credentialResponse = {
id: pkCredential.id,
type: pkCredential.type,
rawId: arrayBufferToBase64Url(pkCredential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
signature: arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
},
}
const finishResponse = await fetch('/oauth/authorize/passkey', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
credential: credentialResponse
})
})
const finishData = await finishResponse.json()
if (!finishResponse.ok) {
error = finishData.error_description || finishData.error || 'Passkey verification failed'
loading = false
return
}
if (finishData.redirect_uri) {
window.location.href = finishData.redirect_uri
return
}
error = 'Unexpected response from server'
loading = false
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
error = 'Passkey authentication was cancelled'
} else {
error = 'Failed to authenticate with passkey'
}
loading = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
} else {
window.history.back()
}
}
$effect(() => {
if (!autoStarted) {
autoStarted = true
startPasskeyAuth()
}
})
</script>
<div class="oauth-passkey-container">
<h1>Sign In with Passkey</h1>
<p class="subtitle">
Your account uses a passkey for authentication. Use your fingerprint, face, or security key to sign in.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
<div class="passkey-status">
{#if loading}
<div class="loading-indicator">
<div class="spinner"></div>
<p>Waiting for passkey...</p>
</div>
{:else}
<button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}>
Use Passkey
</button>
{/if}
</div>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}>
Cancel
</button>
</div>
<p class="help-text">
If you've lost access to your passkey, you can recover your account using email.
</p>
</div>
<style>
.oauth-passkey-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1.5rem;
text-align: left;
}
.passkey-status {
padding: 2rem;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-indicator p {
margin: 0;
color: var(--text-secondary);
}
.passkey-btn {
width: 100%;
padding: 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.passkey-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.passkey-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.actions {
display: flex;
justify-content: center;
margin-bottom: 1.5rem;
}
.cancel-btn {
padding: 0.75rem 2rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.cancel-btn:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.cancel-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.help-text {
font-size: 0.875rem;
color: var(--text-muted);
margin: 0;
}
</style>

View File

@@ -2,6 +2,7 @@
import { navigate } from '../lib/router.svelte'
let code = $state('')
let trustDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(null)
@@ -30,7 +31,8 @@
},
body: JSON.stringify({
request_uri: requestUri,
code: code.trim().toUpperCase()
code: code.trim().toUpperCase(),
trust_device: trustDevice
})
})
@@ -104,6 +106,15 @@
</p>
</div>
<label class="trust-device-label">
<input
type="checkbox"
bind:checked={trustDevice}
disabled={submitting}
/>
<span>Trust this device for 30 days</span>
</label>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
Cancel
@@ -222,4 +233,19 @@
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.trust-device-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.trust-device-label input[type="checkbox"] {
width: auto;
margin: 0;
}
</style>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
let newPassword = $state('')
let confirmPassword = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
let success = $state(false)
function getUrlParams(): { did: string | null; token: string | null } {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return {
did: params.get('did'),
token: params.get('token'),
}
}
let { did, token } = getUrlParams()
function validateForm(): string | null {
if (!newPassword) return 'New password is required'
if (newPassword.length < 8) return 'Password must be at least 8 characters'
if (newPassword !== confirmPassword) return 'Passwords do not match'
return null
}
async function handleSubmit(e: Event) {
e.preventDefault()
if (!did || !token) {
error = 'Invalid recovery link. Please request a new one.'
return
}
const validationError = validateForm()
if (validationError) {
error = validationError
return
}
submitting = true
error = null
try {
await api.recoverPasskeyAccount(did, token, newPassword)
success = true
} catch (err) {
if (err instanceof ApiError) {
if (err.error === 'RecoveryLinkExpired') {
error = 'This recovery link has expired. Please request a new one.'
} else if (err.error === 'InvalidRecoveryLink') {
error = 'Invalid recovery link. Please request a new one.'
} else {
error = err.message || 'Recovery failed'
}
} else if (err instanceof Error) {
error = err.message || 'Recovery failed'
} else {
error = 'Recovery failed'
}
} finally {
submitting = false
}
}
function goToLogin() {
navigate('/login')
}
function requestNewLink() {
navigate('/login')
}
</script>
<div class="recover-container">
{#if !did || !token}
<h1>Invalid Recovery Link</h1>
<p class="error-message">
This recovery link is invalid or has been corrupted. Please request a new recovery email.
</p>
<button onclick={requestNewLink}>Go to Login</button>
{:else if success}
<div class="success-content">
<div class="success-icon">&#x2714;</div>
<h1>Password Set!</h1>
<p class="success-message">
Your temporary password has been set. You can now sign in with this password.
</p>
<p class="next-steps">
After signing in, we recommend adding a new passkey in your security settings
to restore passkey-only authentication.
</p>
<button onclick={goToLogin}>Sign In</button>
</div>
{:else}
<h1>Recover Your Account</h1>
<p class="subtitle">
Set a temporary password to regain access to your passkey-only account.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="new-password">New Password</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
placeholder="At least 8 characters"
disabled={submitting}
required
minlength="8"
/>
</div>
<div class="field">
<label for="confirm-password">Confirm Password</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder="Confirm your password"
disabled={submitting}
required
/>
</div>
<div class="info-box">
<strong>What happens next?</strong>
<p>
After setting this password, you can sign in and add a new passkey in your security settings.
Once you have a new passkey, you can optionally remove the temporary password.
</p>
</div>
<button type="submit" disabled={submitting}>
{submitting ? 'Setting password...' : 'Set Password'}
</button>
</form>
{/if}
</div>
<style>
.recover-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
input {
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.info-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
font-size: 0.875rem;
}
.info-box strong {
display: block;
margin-bottom: 0.5rem;
}
.info-box p {
margin: 0;
color: var(--text-secondary);
}
button {
padding: 0.75rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
margin-top: 0.5rem;
}
button:hover:not(:disabled) {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.error-message {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.success-content {
text-align: center;
}
.success-icon {
font-size: 4rem;
color: var(--success-text);
margin-bottom: 1rem;
}
.success-message {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.next-steps {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
</style>

View File

@@ -342,6 +342,9 @@
<p class="login-link">
Already have an account? <a href="#/login">Sign in</a>
</p>
<p class="login-link">
Want passwordless security? <a href="#/register-passkey">Create a passkey account</a>
</p>
{/if}
</div>
<style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
let identifier = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
let success = $state(false)
async function handleSubmit(e: Event) {
e.preventDefault()
submitting = true
error = null
try {
await api.requestPasskeyRecovery(identifier)
success = true
} catch (err) {
if (err instanceof ApiError) {
error = err.message || 'Failed to send recovery link'
} else if (err instanceof Error) {
error = err.message || 'Failed to send recovery link'
} else {
error = 'Failed to send recovery link'
}
} finally {
submitting = false
}
}
</script>
<div class="recovery-container">
{#if success}
<div class="success-content">
<h1>Recovery Link Sent</h1>
<p class="subtitle">
If your account exists and is a passkey-only account, you'll receive a recovery link
at your preferred notification channel.
</p>
<p class="info">
The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal
depending on your account settings.
</p>
<button onclick={() => navigate('/login')}>Back to Sign In</button>
</div>
{:else}
<h1>Recover Passkey Account</h1>
<p class="subtitle">
Lost access to your passkey? Enter your handle or email and we'll send you a recovery link.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="identifier">Handle or Email</label>
<input
id="identifier"
type="text"
bind:value={identifier}
placeholder="handle or you@example.com"
disabled={submitting}
required
/>
</div>
<div class="info-box">
<strong>How it works</strong>
<p>
We'll send a secure link to your registered notification channel.
Click the link to set a temporary password. Then you can sign in
and add a new passkey.
</p>
</div>
<button type="submit" disabled={submitting || !identifier.trim()}>
{submitting ? 'Sending...' : 'Send Recovery Link'}
</button>
</form>
{/if}
<p class="back-link">
<a href="#/login">Back to Sign In</a>
</p>
</div>
<style>
.recovery-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
input {
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.info-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
font-size: 0.875rem;
}
.info-box strong {
display: block;
margin-bottom: 0.5rem;
}
.info-box p {
margin: 0;
color: var(--text-secondary);
}
button {
padding: 0.75rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.success-content {
text-align: center;
}
.info {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.back-link {
text-align: center;
margin-top: 2rem;
}
.back-link a {
color: var(--accent);
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
</style>

View File

@@ -25,7 +25,7 @@
try {
await api.requestPasswordReset(email)
tokenSent = true
success = 'Password reset code sent to your email'
success = 'Password reset code sent! Check your preferred notification channel.'
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
} finally {
@@ -66,7 +66,7 @@
{/if}
{#if tokenSent}
<h1>Reset Password</h1>
<p class="subtitle">Enter the code from your email and choose a new password.</p>
<p class="subtitle">Enter the code you received and choose a new password.</p>
<form onsubmit={handleReset}>
<div class="field">
<label for="token">Reset Code</label>
@@ -74,7 +74,7 @@
id="token"
type="text"
bind:value={token}
placeholder="Enter code from email"
placeholder="Enter reset code"
disabled={submitting}
required
/>
@@ -111,15 +111,15 @@
</form>
{:else}
<h1>Forgot Password</h1>
<p class="subtitle">Enter your email address and we'll send you a code to reset your password.</p>
<p class="subtitle">Enter your handle or email and we'll send you a code to reset your password.</p>
<form onsubmit={handleRequestReset}>
<div class="field">
<label for="email">Email</label>
<label for="email">Handle or Email</label>
<input
id="email"
type="email"
type="text"
bind:value={email}
placeholder="you@example.com"
placeholder="handle or you@example.com"
disabled={submitting}
required
/>

View File

@@ -2,6 +2,7 @@
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import ReauthModal from '../components/ReauthModal.svelte'
const auth = getAuthState()
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
@@ -38,6 +39,15 @@
let editingPasskeyId = $state<string | null>(null)
let editPasskeyName = $state('')
let hasPassword = $state(true)
let passwordLoading = $state(true)
let showRemovePasswordForm = $state(false)
let removePasswordLoading = $state(false)
let showReauthModal = $state(false)
let reauthMethods = $state<string[]>(['password'])
let pendingAction = $state<(() => Promise<void>) | null>(null)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
@@ -48,9 +58,59 @@
if (auth.session) {
loadTotpStatus()
loadPasskeys()
loadPasswordStatus()
}
})
async function loadPasswordStatus() {
if (!auth.session) return
passwordLoading = true
try {
const status = await api.getPasswordStatus(auth.session.accessJwt)
hasPassword = status.hasPassword
} catch {
hasPassword = true
} finally {
passwordLoading = false
}
}
async function handleRemovePassword() {
if (!auth.session) return
removePasswordLoading = true
try {
await api.removePassword(auth.session.accessJwt)
hasPassword = false
showRemovePasswordForm = false
showMessage('success', 'Password removed. Your account is now passkey-only.')
} catch (e) {
if (e instanceof ApiError) {
if (e.error === 'ReauthRequired') {
reauthMethods = e.reauthMethods || ['password']
pendingAction = handleRemovePassword
showReauthModal = true
} else {
showMessage('error', e.message)
}
} else {
showMessage('error', 'Failed to remove password')
}
} finally {
removePasswordLoading = false
}
}
function handleReauthSuccess() {
if (pendingAction) {
pendingAction()
pendingAction = null
}
}
function handleReauthCancel() {
pendingAction = null
}
async function loadTotpStatus() {
if (!auth.session) return
loading = true
@@ -543,9 +603,83 @@
</div>
{/if}
</section>
<section>
<h2>Password</h2>
<p class="description">
Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience.
</p>
{#if passwordLoading}
<div class="loading">Loading...</div>
{:else if hasPassword}
<div class="status enabled">
<span>Password authentication is <strong>enabled</strong></span>
</div>
{#if passkeys.length > 0}
{#if !showRemovePasswordForm}
<button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
Remove Password
</button>
{:else}
<div class="inline-form danger-form">
<h3>Remove Password</h3>
<p class="warning-text">
This will make your account passkey-only. You'll only be able to sign in using your registered passkeys.
If you lose access to all your passkeys, you can recover your account using your notification channel.
</p>
<div class="info-box-inline">
<strong>Before proceeding:</strong>
<ul>
<li>Make sure you have at least one reliable passkey registered</li>
<li>Consider registering passkeys on multiple devices</li>
<li>Ensure your recovery notification channel is up to date</li>
</ul>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}>
Cancel
</button>
<button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}>
{removePasswordLoading ? 'Removing...' : 'Remove Password'}
</button>
</div>
</div>
{/if}
{:else}
<p class="hint">Add at least one passkey before you can remove your password.</p>
{/if}
{:else}
<div class="status passkey-only">
<span>Your account is <strong>passkey-only</strong></span>
</div>
<p class="hint">
You sign in using passkeys only. If you ever lose access to your passkeys,
you can recover your account using the "Lost passkey?" link on the login page.
</p>
{/if}
</section>
<section>
<h2>Trusted Devices</h2>
<p class="description">
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.
</p>
<a href="#/trusted-devices" class="section-link">
Manage Trusted Devices &rarr;
</a>
</section>
{/if}
</div>
<ReauthModal
bind:show={showReauthModal}
availableMethods={reauthMethods}
onSuccess={handleReauthSuccess}
onCancel={handleReauthCancel}
/>
<style>
.page {
max-width: 600px;
@@ -894,4 +1028,52 @@
.add-passkey .field {
margin-bottom: 0.75rem;
}
.section-link {
display: inline-block;
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.section-link:hover {
text-decoration: underline;
}
.status.passkey-only {
background: var(--accent);
background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15));
border: 1px solid var(--accent);
color: var(--accent);
}
.hint {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
}
.info-box-inline {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.info-box-inline strong {
display: block;
margin-bottom: 0.5rem;
}
.info-box-inline ul {
margin: 0;
padding-left: 1.25rem;
color: var(--text-secondary);
}
.info-box-inline li {
margin-bottom: 0.25rem;
}
</style>

View File

@@ -37,7 +37,7 @@
emailLoading = true
message = null
try {
const result = await api.requestEmailUpdate(auth.session.accessJwt)
const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
emailTokenRequired = result.tokenRequired
if (emailTokenRequired) {
showMessage('success', 'Verification code sent to your current email')

View File

@@ -0,0 +1,409 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
interface TrustedDevice {
id: string
userAgent: string | null
friendlyName: string | null
trustedAt: string | null
trustedUntil: string | null
lastSeenAt: string
}
const auth = getAuthState()
let devices = $state<TrustedDevice[]>([])
let loading = $state(true)
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let editingDeviceId = $state<string | null>(null)
let editDeviceName = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
$effect(() => {
if (auth.session) {
loadDevices()
}
})
async function loadDevices() {
if (!auth.session) return
loading = true
try {
const result = await api.listTrustedDevices(auth.session.accessJwt)
devices = result.devices
} catch {
showMessage('error', 'Failed to load trusted devices')
} finally {
loading = false
}
}
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
async function handleRevoke(deviceId: string) {
if (!auth.session) return
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
try {
await api.revokeTrustedDevice(auth.session.accessJwt, deviceId)
await loadDevices()
showMessage('success', 'Device trust revoked')
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to revoke device')
}
}
function startEditDevice(device: TrustedDevice) {
editingDeviceId = device.id
editDeviceName = device.friendlyName || ''
}
function cancelEditDevice() {
editingDeviceId = null
editDeviceName = ''
}
async function handleSaveDeviceName() {
if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return
try {
await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim())
await loadDevices()
editingDeviceId = null
editDeviceName = ''
showMessage('success', 'Device renamed')
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename device')
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function parseUserAgent(ua: string | null): string {
if (!ua) return 'Unknown device'
if (ua.includes('Firefox')) return 'Firefox'
if (ua.includes('Chrome')) return 'Chrome'
if (ua.includes('Safari')) return 'Safari'
if (ua.includes('Edge')) return 'Edge'
return 'Browser'
}
function getDaysRemaining(trustedUntil: string | null): number {
if (!trustedUntil) return 0
const now = new Date()
const until = new Date(trustedUntil)
const diff = until.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
</script>
<div class="page">
<header>
<a href="#/security" class="back">&larr; Security Settings</a>
<h1>Trusted Devices</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
<div class="description">
<p>
Trusted devices can skip two-factor authentication when logging in.
Trust is granted for 30 days and automatically extends when you use the device.
</p>
</div>
{#if loading}
<div class="loading">Loading...</div>
{:else if devices.length === 0}
<div class="empty-state">
<p>No trusted devices yet.</p>
<p class="hint">When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.</p>
</div>
{:else}
<div class="device-list">
{#each devices as device}
<div class="device-card">
<div class="device-header">
{#if editingDeviceId === device.id}
<input
type="text"
class="edit-name-input"
bind:value={editDeviceName}
placeholder="Device name"
/>
<div class="edit-actions">
<button class="btn-small btn-primary" onclick={handleSaveDeviceName}>Save</button>
<button class="btn-small btn-secondary" onclick={cancelEditDevice}>Cancel</button>
</div>
{:else}
<h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3>
<button class="btn-icon" onclick={() => startEditDevice(device)} title="Rename">
&#9998;
</button>
{/if}
</div>
<div class="device-details">
{#if device.userAgent && !device.friendlyName}
<p class="detail"><span class="label">Browser:</span> {device.userAgent}</p>
{:else if device.userAgent}
<p class="detail"><span class="label">Browser:</span> {parseUserAgent(device.userAgent)}</p>
{/if}
<p class="detail">
<span class="label">Last seen:</span> {formatDate(device.lastSeenAt)}
</p>
{#if device.trustedAt}
<p class="detail">
<span class="label">Trusted since:</span> {formatDate(device.trustedAt)}
</p>
{/if}
{#if device.trustedUntil}
{@const daysRemaining = getDaysRemaining(device.trustedUntil)}
<p class="detail trust-expiry" class:expiring-soon={daysRemaining <= 7}>
<span class="label">Trust expires:</span>
{#if daysRemaining <= 0}
Expired
{:else if daysRemaining === 1}
Tomorrow
{:else}
In {daysRemaining} days
{/if}
</p>
{/if}
</div>
<div class="device-actions">
<button class="btn-danger" onclick={() => handleRevoke(device.id)}>
Revoke Trust
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.page {
max-width: 600px;
margin: 0 auto;
padding: 2rem 1rem;
}
header {
margin-bottom: 2rem;
}
.back {
display: inline-block;
margin-bottom: 1rem;
color: var(--accent);
text-decoration: none;
font-size: 0.875rem;
}
.back:hover {
text-decoration: underline;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.message {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.message.success {
background: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
}
.message.error {
background: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--error-border);
}
.description {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.description p {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.empty-state p {
margin: 0;
color: var(--text-secondary);
}
.empty-state .hint {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.device-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.device-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
}
.device-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.device-header h3 {
margin: 0;
flex: 1;
font-size: 1rem;
}
.edit-name-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 0.9rem;
}
.edit-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
font-size: 1rem;
}
.btn-icon:hover {
color: var(--text-primary);
}
.device-details {
margin-bottom: 0.75rem;
}
.detail {
margin: 0.25rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.detail .label {
color: var(--text-muted);
}
.trust-expiry.expiring-soon {
color: var(--warning-text);
}
.device-actions {
display: flex;
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.btn-small {
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-primary {
background: var(--accent);
color: white;
border: none;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-secondary:hover {
background: var(--bg-secondary);
}
.btn-danger {
background: transparent;
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-danger:hover {
background: var(--error-bg);
}
</style>

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
ALTER TABLE users ADD COLUMN password_required BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE users ADD COLUMN recovery_token TEXT;
ALTER TABLE users ADD COLUMN recovery_token_expires_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_users_recovery_token ON users(recovery_token) WHERE recovery_token IS NOT NULL;

View File

@@ -0,0 +1,4 @@
ALTER TABLE oauth_device ADD COLUMN trusted_at TIMESTAMPTZ;
ALTER TABLE oauth_device ADD COLUMN trusted_until TIMESTAMPTZ;
ALTER TABLE oauth_device ADD COLUMN friendly_name TEXT;
CREATE INDEX IF NOT EXISTS idx_oauth_device_trusted ON oauth_device(trusted_until) WHERE trusted_until IS NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE session_tokens ADD COLUMN last_reauth_at TIMESTAMPTZ;

View File

@@ -0,0 +1 @@
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'passkey_recovery';

View File

@@ -139,12 +139,18 @@ pub async fn create_account(
info!(did = %migration_did, "Processing account migration");
}
let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_validation =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_suffix = format!(".{}", hostname_for_validation);
let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
let validated_short_handle = if !input.handle.contains('.')
|| input.handle.ends_with(&pds_suffix)
{
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle)
input
.handle
.strip_suffix(&pds_suffix)
.unwrap_or(&input.handle)
} else {
&input.handle
};
@@ -166,6 +172,15 @@ pub async fn create_account(
)
.into_response();
}
for c in input.handle.chars() {
if !c.is_ascii_alphanumeric() && c != '.' && c != '-' {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})),
)
.into_response();
}
}
input.handle.to_lowercase()
};
let email: Option<String> = input
@@ -319,7 +334,9 @@ pub async fn create_account(
)
.into_response();
}
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
if let Err(e) =
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
@@ -335,7 +352,10 @@ pub async fn create_account(
info!(did = %d, "Migration with existing did:plc");
d.clone()
} else if d.starts_with("did:web:") {
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
if let Err(e) =
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref())
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
@@ -758,22 +778,24 @@ pub async fn create_account(
};
if !is_migration
&& let Err(e) = sqlx::query!(
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
user_id,
verification_code,
email,
code_expires_at
)
.execute(&mut *tx)
.await {
error!("Error inserting verification code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
&& let Some(ref recipient) = verification_recipient
&& let Err(e) = sqlx::query!(
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
user_id,
verification_channel as _,
verification_code,
recipient,
code_expires_at
)
.into_response();
}
.execute(&mut *tx)
.await {
error!("Error inserting verification code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(enc) => enc,
Err(e) => {
@@ -919,8 +941,7 @@ pub async fn create_account(
}
if !is_migration {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
.await
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}

View File

@@ -674,13 +674,9 @@ pub async fn update_handle(
if let Some(old) = old_handle {
let _ = state.cache.delete(&format!("handle:{}", old)).await;
}
let _ = state
.cache
.delete(&format!("handle:{}", handle))
.await;
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
.await
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for handle update: {}", e);
}

View File

@@ -419,7 +419,11 @@ pub async fn delete_account(
.into_response();
}
};
let password_valid = if verify(password, &password_hash).unwrap_or(false) {
let password_valid = if password_hash
.as_ref()
.map(|h| verify(password, h).unwrap_or(false))
.unwrap_or(false)
{
true
} else {
let app_pass_rows = sqlx::query!(

View File

@@ -3,12 +3,15 @@ pub mod app_password;
pub mod email;
pub mod invite;
pub mod meta;
pub mod passkey_account;
pub mod passkeys;
pub mod password;
pub mod reauth;
pub mod service_auth;
pub mod session;
pub mod signing_key;
pub mod totp;
pub mod trusted_devices;
pub use account_status::{
activate_account, check_account_status, deactivate_account, delete_account,
@@ -18,11 +21,21 @@ pub use app_password::{create_app_password, list_app_passwords, revoke_app_passw
pub use email::{confirm_email, request_email_update, update_email};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use meta::{describe_server, health, robots_txt};
pub use passkeys::{
delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys,
start_passkey_registration, update_passkey,
pub use passkey_account::{
complete_passkey_setup, create_passkey_account, recover_passkey_account,
request_passkey_recovery, start_passkey_registration_for_setup,
};
pub use passkeys::{
delete_passkey, finish_passkey_registration, has_passkeys_for_user, has_passkeys_for_user_db,
list_passkeys, start_passkey_registration, update_passkey,
};
pub use password::{
change_password, get_password_status, remove_password, request_password_reset, reset_password,
};
pub use reauth::{
check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start,
reauth_password, reauth_required_response, reauth_totp,
};
pub use password::{change_password, request_password_reset, reset_password};
pub use service_auth::get_service_auth;
pub use session::{
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
@@ -31,5 +44,9 @@ pub use session::{
pub use signing_key::reserve_signing_key;
pub use totp::{
create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
regenerate_backup_codes, verify_totp_or_backup_for_user,
has_totp_enabled_db, regenerate_backup_codes, verify_totp_or_backup_for_user,
};
pub use trusted_devices::{
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
trust_device, update_trusted_device,
};

File diff suppressed because it is too large Load Diff

View File

@@ -371,7 +371,9 @@ pub async fn update_passkey(
}
pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
webauthn::has_passkeys(&state.db, did)
.await
.unwrap_or(false)
has_passkeys_for_user_db(&state.db, did).await
}
pub async fn has_passkeys_for_user_db(db: &sqlx::PgPool, did: &str) -> bool {
webauthn::has_passkeys(db, did).await.unwrap_or(false)
}

View File

@@ -33,6 +33,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
#[derive(Deserialize)]
pub struct RequestPasswordResetInput {
#[serde(alias = "identifier")]
pub email: String,
}
@@ -56,21 +57,33 @@ pub async fn request_password_reset(
)
.into_response();
}
let email = input.email.trim().to_lowercase();
if email.is_empty() {
let identifier = input.email.trim();
if identifier.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "email is required"})),
Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})),
)
.into_response();
}
let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email)
.fetch_optional(&state.db)
.await;
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized = identifier.to_lowercase();
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
let normalized_handle = if normalized.contains('@') || normalized.contains('.') {
normalized.to_string()
} else {
format!("{}.{}", normalized, pds_hostname)
};
let user = sqlx::query!(
"SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
normalized,
normalized_handle
)
.fetch_optional(&state.db)
.await;
let user_id = match user {
Ok(Some(row)) => row.id,
Ok(None) => {
info!("Password reset requested for unknown email");
info!("Password reset requested for unknown identifier");
return (StatusCode::OK, Json(json!({}))).into_response();
}
Err(e) => {
@@ -225,7 +238,7 @@ pub async fn reset_password(
}
};
if let Err(e) = sqlx::query!(
"UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2",
"UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
password_hash,
user_id
)
@@ -404,3 +417,105 @@ pub async fn change_password(
info!(did = %auth.0.did, "Password changed successfully");
(StatusCode::OK, Json(json!({}))).into_response()
}
pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let user = sqlx::query!(
"SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
auth.0.did
)
.fetch_optional(&state.db)
.await;
match user {
Ok(Some(row)) => {
Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
}
let has_passkeys =
crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await;
if !has_passkeys {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoPasskeys",
"message": "You must have at least one passkey registered before removing your password"
})),
)
.into_response();
}
let user = sqlx::query!(
"SELECT id, password_hash FROM users WHERE did = $1",
auth.0.did
)
.fetch_optional(&state.db)
.await;
let user = match user {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if user.password_hash.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoPassword",
"message": "Account already has no password"
})),
)
.into_response();
}
if let Err(e) = sqlx::query!(
"UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
user.id
)
.execute(&state.db)
.await
{
error!("DB error removing password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!(did = %auth.0.did, "Password removed - account is now passkey-only");
(StatusCode::OK, Json(json!({"success": true}))).into_response()
}

482
src/api/server/reauth.rs Normal file
View File

@@ -0,0 +1,482 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing::{error, info, warn};
use crate::auth::BearerAuth;
use crate::state::AppState;
const REAUTH_WINDOW_SECONDS: i64 = 300;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReauthStatusResponse {
pub last_reauth_at: Option<DateTime<Utc>>,
pub reauth_required: bool,
pub available_methods: Vec<String>,
}
pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let session = sqlx::query!(
"SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
auth.0.did
)
.fetch_optional(&state.db)
.await;
let last_reauth_at = match session {
Ok(Some(row)) => row.last_reauth_at,
Ok(None) => None,
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let reauth_required = is_reauth_required(last_reauth_at);
let available_methods = get_available_reauth_methods(&state.db, &auth.0.did).await;
Json(ReauthStatusResponse {
last_reauth_at,
reauth_required,
available_methods,
})
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordReauthInput {
pub password: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReauthResponse {
pub reauthed_at: DateTime<Utc>,
}
pub async fn reauth_password(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<PasswordReauthInput>,
) -> Response {
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let password_valid = password_hash
.as_ref()
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
.unwrap_or(false);
if !password_valid {
let app_passwords = sqlx::query!(
"SELECT ap.password_hash FROM app_passwords ap
JOIN users u ON ap.user_id = u.id
WHERE u.did = $1",
auth.0.did
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let app_password_valid = app_passwords
.iter()
.any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false));
if !app_password_valid {
warn!(did = %auth.0.did, "Re-auth failed: invalid password");
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidPassword",
"message": "Password is incorrect"
})),
)
.into_response();
}
}
match update_last_reauth(&state.db, &auth.0.did).await {
Ok(reauthed_at) => {
info!(did = %auth.0.did, "Re-auth successful via password");
Json(ReauthResponse { reauthed_at }).into_response()
}
Err(e) => {
error!("DB error updating reauth: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TotpReauthInput {
pub code: String,
}
pub async fn reauth_totp(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<TotpReauthInput>,
) -> Response {
let valid =
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code)
.await;
if !valid {
warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code");
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid TOTP or backup code"
})),
)
.into_response();
}
match update_last_reauth(&state.db, &auth.0.did).await {
Ok(reauthed_at) => {
info!(did = %auth.0.did, "Re-auth successful via TOTP");
Json(ReauthResponse { reauthed_at }).into_response()
}
Err(e) => {
error!("DB error updating reauth: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyReauthStartResponse {
pub options: serde_json::Value,
}
pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAuth) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let stored_passkeys =
match crate::auth::webauthn::get_passkeys_for_user(&state.db, &auth.0.did).await {
Ok(pks) => pks,
Err(e) => {
error!("Failed to get passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if stored_passkeys.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoPasskeys",
"message": "No passkeys registered for this account"
})),
)
.into_response();
}
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
.iter()
.filter_map(|sp| sp.to_security_key().ok())
.collect();
if passkeys.is_empty() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})),
)
.into_response();
}
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
Ok(result) => result,
Err(e) => {
error!("Failed to start passkey authentication: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) =
crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await
{
error!("Failed to save authentication state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let options = serde_json::to_value(&rcr).unwrap_or(json!({}));
Json(PasskeyReauthStartResponse { options }).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyReauthFinishInput {
pub credential: serde_json::Value,
}
pub async fn reauth_passkey_finish(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<PasskeyReauthFinishInput>,
) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let auth_state =
match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoChallengeInProgress",
"message": "No passkey authentication in progress or challenge expired"
})),
)
.into_response();
}
Err(e) => {
error!("Failed to load authentication state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let credential: webauthn_rs::prelude::PublicKeyCredential =
match serde_json::from_value(input.credential) {
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidCredential",
"message": "Failed to parse credential response"
})),
)
.into_response();
}
};
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
Ok(r) => r,
Err(e) => {
warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AuthenticationFailed",
"message": "Passkey authentication failed"
})),
)
.into_response();
}
};
let cred_id_bytes = auth_result.cred_id().as_ref();
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
&state.db,
cred_id_bytes,
auth_result.counter(),
)
.await
{
error!("Failed to update passkey counter: {:?}", e);
}
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
match update_last_reauth(&state.db, &auth.0.did).await {
Ok(reauthed_at) => {
info!(did = %auth.0.did, "Re-auth successful via passkey");
Json(ReauthResponse { reauthed_at }).into_response()
}
Err(e) => {
error!("DB error updating reauth: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> {
let now = Utc::now();
sqlx::query!(
"UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
now,
did
)
.execute(db)
.await?;
Ok(now)
}
fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool {
match last_reauth_at {
None => true,
Some(t) => {
let elapsed = Utc::now().signed_duration_since(t);
elapsed.num_seconds() > REAUTH_WINDOW_SECONDS
}
}
}
async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> {
let mut methods = Vec::new();
let has_password = sqlx::query_scalar!(
"SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
.unwrap_or(Some(false));
if has_password == Some(true) {
methods.push("password".to_string());
}
let has_app_password = sqlx::query_scalar!(
"SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
.is_some();
if has_app_password && !methods.contains(&"password".to_string()) {
methods.push("password".to_string());
}
let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await;
if has_totp {
methods.push("totp".to_string());
}
let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await;
if has_passkeys {
methods.push("passkey".to_string());
}
methods
}
pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool {
let session = sqlx::query!(
"SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
did
)
.fetch_optional(db)
.await;
match session {
Ok(Some(row)) => is_reauth_required(row.last_reauth_at),
_ => true,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReauthRequiredError {
pub error: String,
pub message: String,
pub reauth_methods: Vec<String>,
}
pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response {
let methods = get_available_reauth_methods(db, did).await;
(
StatusCode::UNAUTHORIZED,
Json(ReauthRequiredError {
error: "ReauthRequired".to_string(),
message: "Re-authentication required for this action".to_string(),
reauth_methods: methods,
}),
)
.into_response()
}

View File

@@ -63,7 +63,10 @@ pub async fn create_session(
headers: HeaderMap,
Json(input): Json<CreateSessionInput>,
) -> Response {
info!("create_session called with identifier: {}", input.identifier);
info!(
"create_session called with identifier: {}",
input.identifier
);
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::Login, &client_ip)
@@ -81,7 +84,10 @@ pub async fn create_session(
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier);
info!(
"Normalized identifier: {} -> {}",
input.identifier, normalized_identifier
);
let row = match sqlx::query!(
r#"SELECT
u.id, u.did, u.handle, u.password_hash,
@@ -117,7 +123,12 @@ pub async fn create_session(
return ApiError::InternalError.into_response();
}
};
let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) {
let password_valid = if row
.password_hash
.as_ref()
.map(|h| verify(&input.password, h).unwrap_or(false))
.unwrap_or(false)
{
true
} else {
let app_passwords = sqlx::query!(
@@ -523,9 +534,16 @@ pub async fn confirm_signup(
}
};
let channel_str = match row.channel {
crate::comms::CommsChannel::Email => "email",
crate::comms::CommsChannel::Discord => "discord",
crate::comms::CommsChannel::Telegram => "telegram",
crate::comms::CommsChannel::Signal => "signal",
};
let verification = match sqlx::query!(
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
row.id
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
row.id,
channel_str as _
)
.fetch_optional(&state.db)
.await
@@ -574,8 +592,9 @@ pub async fn confirm_signup(
}
if let Err(e) = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
row.id
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
row.id,
channel_str as _
)
.execute(&state.db)
.await
@@ -676,18 +695,31 @@ pub async fn resend_verification(
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
let email = row.email.clone();
let (channel_str, recipient) = match row.channel {
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
crate::comms::CommsChannel::Discord => {
("discord", row.discord_id.clone().unwrap_or_default())
}
crate::comms::CommsChannel::Telegram => (
"telegram",
row.telegram_username.clone().unwrap_or_default(),
),
crate::comms::CommsChannel::Signal => {
("signal", row.signal_number.clone().unwrap_or_default())
}
};
if let Err(e) = sqlx::query!(
r#"
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
VALUES ($1, 'email', $2, $3, $4)
VALUES ($1, $2::comms_channel, $3, $4, $5)
ON CONFLICT (user_id, channel) DO UPDATE
SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
"#,
row.id,
channel_str as _,
verification_code,
email,
recipient,
code_expires_at
)
.execute(&state.db)
@@ -696,14 +728,6 @@ pub async fn resend_verification(
error!("Failed to update verification code: {:?}", e);
return ApiError::InternalError.into_response();
}
let (channel_str, recipient) = match row.channel {
crate::comms::CommsChannel::Email => ("email", row.email.unwrap_or_default()),
crate::comms::CommsChannel::Discord => ("discord", row.discord_id.unwrap_or_default()),
crate::comms::CommsChannel::Telegram => {
("telegram", row.telegram_username.unwrap_or_default())
}
crate::comms::CommsChannel::Signal => ("signal", row.signal_number.unwrap_or_default()),
};
if let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
row.id,

View File

@@ -332,7 +332,10 @@ pub async fn disable_totp(
}
};
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
let password_valid = password_hash
.as_ref()
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
.unwrap_or(false);
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
@@ -536,7 +539,10 @@ pub async fn regenerate_backup_codes(
}
};
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
let password_valid = password_hash
.as_ref()
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
.unwrap_or(false);
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
@@ -741,8 +747,12 @@ pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &
}
pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
has_totp_enabled_db(&state.db, did).await
}
pub async fn has_totp_enabled_db(db: &sqlx::PgPool, did: &str) -> bool {
let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
.fetch_optional(&state.db)
.fetch_optional(db)
.await;
matches!(result, Ok(Some(true)))

View File

@@ -0,0 +1,246 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing::{error, info};
use crate::auth::BearerAuth;
use crate::state::AppState;
const TRUST_DURATION_DAYS: i64 = 30;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustedDevice {
pub id: String,
pub user_agent: Option<String>,
pub friendly_name: Option<String>,
pub trusted_at: Option<DateTime<Utc>>,
pub trusted_until: Option<DateTime<Utc>>,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListTrustedDevicesResponse {
pub devices: Vec<TrustedDevice>,
}
pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAuth) -> Response {
let devices = sqlx::query!(
r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at
FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()
ORDER BY od.last_seen_at DESC"#,
auth.0.did
)
.fetch_all(&state.db)
.await;
match devices {
Ok(rows) => {
let devices = rows
.into_iter()
.map(|row| TrustedDevice {
id: row.id,
user_agent: row.user_agent,
friendly_name: row.friendly_name,
trusted_at: row.trusted_at,
trusted_until: row.trusted_until,
last_seen_at: row.last_seen_at,
})
.collect();
Json(ListTrustedDevicesResponse { devices }).into_response()
}
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeTrustedDeviceInput {
pub device_id: String,
}
pub async fn revoke_trusted_device(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<RevokeTrustedDeviceInput>,
) -> Response {
let device_exists = sqlx::query_scalar!(
r#"SELECT 1 as one FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE oad.did = $1 AND od.id = $2"#,
auth.0.did,
input.device_id
)
.fetch_optional(&state.db)
.await;
match device_exists {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
let result = sqlx::query!(
"UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
input.device_id
)
.execute(&state.db)
.await;
match result {
Ok(_) => {
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked");
Json(json!({"success": true})).into_response()
}
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTrustedDeviceInput {
pub device_id: String,
pub friendly_name: Option<String>,
}
pub async fn update_trusted_device(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<UpdateTrustedDeviceInput>,
) -> Response {
let device_exists = sqlx::query_scalar!(
r#"SELECT 1 as one FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE oad.did = $1 AND od.id = $2"#,
auth.0.did,
input.device_id
)
.fetch_optional(&state.db)
.await;
match device_exists {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
let result = sqlx::query!(
"UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
input.friendly_name,
input.device_id
)
.execute(&state.db)
.await;
match result {
Ok(_) => {
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
Json(json!({"success": true})).into_response()
}
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
let result = sqlx::query_scalar!(
r#"SELECT trusted_until FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE od.id = $1 AND oad.did = $2"#,
device_id,
did
)
.fetch_optional(db)
.await;
match result {
Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(),
_ => false,
}
}
pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
let now = Utc::now();
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
sqlx::query!(
"UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
now,
trusted_until,
device_id
)
.execute(db)
.await?;
Ok(())
}
pub async fn extend_device_trust(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
sqlx::query!(
"UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
trusted_until,
device_id
)
.execute(db)
.await?;
Ok(())
}

View File

@@ -22,10 +22,23 @@ impl std::fmt::Display for HandleValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "Handle cannot be empty"),
Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH),
Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH),
Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"),
Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"),
Self::TooShort => write!(
f,
"Handle must be at least {} characters",
MIN_HANDLE_LENGTH
),
Self::TooLong => write!(
f,
"Handle exceeds maximum length of {} characters",
MAX_HANDLE_LENGTH
),
Self::InvalidCharacters => write!(
f,
"Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"
),
Self::StartsWithInvalidChar => {
write!(f, "Handle cannot start with a hyphen or underscore")
}
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"),
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
}
@@ -125,28 +138,76 @@ mod tests {
fn test_valid_handles() {
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string()));
assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string()));
assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string()));
assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string()));
assert_eq!(
validate_short_handle("user-name"),
Ok("user-name".to_string())
);
assert_eq!(
validate_short_handle("user_name"),
Ok("user_name".to_string())
);
assert_eq!(
validate_short_handle("UPPERCASE"),
Ok("uppercase".to_string())
);
assert_eq!(
validate_short_handle("MixedCase123"),
Ok("mixedcase123".to_string())
);
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
}
#[test]
fn test_invalid_handles() {
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty));
assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort));
assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort));
assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces));
assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces));
assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar));
assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar));
assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar));
assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar));
assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters));
assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters));
assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters));
assert_eq!(
validate_short_handle(" "),
Err(HandleValidationError::Empty)
);
assert_eq!(
validate_short_handle("ab"),
Err(HandleValidationError::TooShort)
);
assert_eq!(
validate_short_handle("a"),
Err(HandleValidationError::TooShort)
);
assert_eq!(
validate_short_handle("test spaces"),
Err(HandleValidationError::ContainsSpaces)
);
assert_eq!(
validate_short_handle("test\ttab"),
Err(HandleValidationError::ContainsSpaces)
);
assert_eq!(
validate_short_handle("-starts"),
Err(HandleValidationError::StartsWithInvalidChar)
);
assert_eq!(
validate_short_handle("_starts"),
Err(HandleValidationError::StartsWithInvalidChar)
);
assert_eq!(
validate_short_handle("ends-"),
Err(HandleValidationError::EndsWithInvalidChar)
);
assert_eq!(
validate_short_handle("ends_"),
Err(HandleValidationError::EndsWithInvalidChar)
);
assert_eq!(
validate_short_handle("test@user"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("test!user"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("test.user"),
Err(HandleValidationError::InvalidCharacters)
);
}
#[test]

View File

@@ -9,8 +9,8 @@ pub use sender::{
pub use service::{
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
enqueue_email_update, enqueue_email_verification, enqueue_password_reset,
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};

View File

@@ -457,6 +457,31 @@ pub async fn enqueue_2fa_code(
.await
}
pub async fn enqueue_passkey_recovery(
db: &PgPool,
user_id: Uuid,
recovery_url: &str,
hostname: &str,
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let body = format!(
"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.",
prefs.handle, recovery_url
);
enqueue_comms(
db,
NewComms::new(
user_id,
prefs.channel,
super::types::CommsType::PasskeyRecovery,
prefs.email.clone().unwrap_or_default(),
Some(format!("Account Recovery - {}", hostname)),
body,
),
)
.await
}
pub fn channel_display_name(channel: CommsChannel) -> &'static str {
match channel {
CommsChannel::Email => "email",

View File

@@ -32,6 +32,7 @@ pub enum CommsType {
AdminEmail,
PlcOperation,
TwoFactorCode,
PasskeyRecovery,
}
#[derive(Debug, Clone, FromRow)]

View File

@@ -202,6 +202,66 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.changePassword",
post(api::server::change_password),
)
.route(
"/xrpc/com.tranquil.account.removePassword",
post(api::server::remove_password),
)
.route(
"/xrpc/com.tranquil.account.getPasswordStatus",
get(api::server::get_password_status),
)
.route(
"/xrpc/com.tranquil.account.getReauthStatus",
get(api::server::get_reauth_status),
)
.route(
"/xrpc/com.tranquil.account.reauthPassword",
post(api::server::reauth_password),
)
.route(
"/xrpc/com.tranquil.account.reauthTotp",
post(api::server::reauth_totp),
)
.route(
"/xrpc/com.tranquil.account.reauthPasskeyStart",
post(api::server::reauth_passkey_start),
)
.route(
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
post(api::server::reauth_passkey_finish),
)
.route(
"/xrpc/com.tranquil.account.listTrustedDevices",
get(api::server::list_trusted_devices),
)
.route(
"/xrpc/com.tranquil.account.revokeTrustedDevice",
post(api::server::revoke_trusted_device),
)
.route(
"/xrpc/com.tranquil.account.updateTrustedDevice",
post(api::server::update_trusted_device),
)
.route(
"/xrpc/com.tranquil.account.createPasskeyAccount",
post(api::server::create_passkey_account),
)
.route(
"/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup",
post(api::server::start_passkey_registration_for_setup),
)
.route(
"/xrpc/com.tranquil.account.completePasskeySetup",
post(api::server::complete_passkey_setup),
)
.route(
"/xrpc/com.tranquil.account.requestPasskeyRecovery",
post(api::server::request_passkey_recovery),
)
.route(
"/xrpc/com.tranquil.account.recoverPasskeyAccount",
post(api::server::recover_passkey_account),
)
.route(
"/xrpc/com.atproto.server.requestEmailUpdate",
post(api::server::request_email_update),
@@ -399,6 +459,14 @@ pub fn app(state: AppState) -> Router {
"/oauth/authorize/2fa",
post(oauth::endpoints::authorize_2fa_post),
)
.route(
"/oauth/authorize/passkey",
get(oauth::endpoints::authorize_passkey_start),
)
.route(
"/oauth/authorize/passkey",
post(oauth::endpoints::authorize_passkey_finish),
)
.route(
"/oauth/passkey/check",
get(oauth::endpoints::check_user_has_passkeys),

View File

@@ -441,7 +441,7 @@ pub async fn authorize_post(
);
let user = match sqlx::query!(
r#"
SELECT id, did, email, password_hash, two_factor_enabled,
SELECT id, did, email, password_hash, password_required, two_factor_enabled,
preferred_comms_channel as "preferred_comms_channel: CommsChannel",
deactivated_at, takedown_ref,
email_verified, discord_verified, telegram_verified, signal_verified
@@ -479,31 +479,74 @@ pub async fn authorize_post(
json_response,
);
}
let password_valid = match bcrypt::verify(&form.password, &user.password_hash) {
Ok(valid) => valid,
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
};
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
}
let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await;
if has_totp {
if !user.password_required {
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
if json_response {
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
return redirect_see_other(&format!(
"/#/oauth/totp?request_uri={}",
let redirect_url = format!(
"/#/oauth/passkey?request_uri={}",
url_encode(&form.request_uri)
));
);
if json_response {
return (
StatusCode::OK,
Json(serde_json::json!({
"next": "passkey",
"redirect": redirect_url
})),
)
.into_response();
}
return redirect_see_other(&redirect_url);
}
let password_valid = match &user.password_hash {
Some(hash) => match bcrypt::verify(&form.password, hash) {
Ok(valid) => valid,
Err(_) => {
return show_login_error("An error occurred. Please try again.", json_response);
}
},
None => false,
};
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
}
let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await;
if has_totp {
let device_cookie = extract_device_cookie(&headers);
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
crate::api::server::is_device_trusted(&state.db, dev_id, &user.did).await
} else {
false
};
if device_is_trusted {
if let Some(ref dev_id) = device_cookie {
let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await;
}
} else {
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
if json_response {
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
return redirect_see_other(&format!(
"/#/oauth/totp?request_uri={}",
url_encode(&form.request_uri)
));
}
}
if user.two_factor_enabled {
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
@@ -934,6 +977,8 @@ pub struct Authorize2faQuery {
pub struct Authorize2faSubmit {
pub request_uri: String,
pub code: String,
#[serde(default)]
pub trust_device: bool,
}
const MAX_2FA_ATTEMPTS: i32 = 5;
@@ -1475,6 +1520,12 @@ pub async fn authorize_2fa_post(
"Invalid verification code. Please try again.",
);
}
let device_id = extract_device_cookie(&headers);
if form.trust_device
&& let Some(ref dev_id) = device_id
{
let _ = crate::api::server::trust_device(&state.db, dev_id).await;
}
let requested_scope_str = request_data
.parameters
.scope
@@ -1500,7 +1551,6 @@ pub async fn authorize_2fa_post(
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let device_id = extract_device_cookie(&headers);
if db::update_authorization_request(
&state.db,
&form.request_uri,
@@ -2139,3 +2189,371 @@ pub async fn passkey_finish(
}))
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct AuthorizePasskeyQuery {
pub request_uri: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAuthResponse {
pub options: serde_json::Value,
pub request_uri: String,
}
pub async fn authorize_passkey_start(
State(state): State<AppState>,
Query(query): Query<AuthorizePasskeyQuery>,
) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
Ok(Some(d)) => d,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request not found."
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &query.request_uri).await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
let did = match &request_data.did {
Some(d) => d.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "User not authenticated yet."
})),
)
.into_response();
}
};
let stored_passkeys = match crate::auth::webauthn::get_passkeys_for_user(&state.db, &did).await
{
Ok(pks) => pks,
Err(e) => {
tracing::error!("Failed to get passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
};
if stored_passkeys.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkeys registered for this account."
})),
)
.into_response();
}
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
.iter()
.filter_map(|sp| sp.to_security_key().ok())
.collect();
if passkeys.is_empty() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "Failed to load passkeys."})),
)
.into_response();
}
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
tracing::error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
};
let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
Ok(result) => result,
Err(e) => {
tracing::error!("Failed to start passkey authentication: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
};
if let Err(e) =
crate::auth::webauthn::save_authentication_state(&state.db, &did, &auth_state).await
{
tracing::error!("Failed to save authentication state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
Json(PasskeyAuthResponse {
options,
request_uri: query.request_uri,
})
.into_response()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorizePasskeySubmit {
pub request_uri: String,
pub credential: serde_json::Value,
}
pub async fn authorize_passkey_finish(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<AuthorizePasskeySubmit>,
) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(d)) => d,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request not found."
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
let did = match &request_data.did {
Some(d) => d.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "User not authenticated yet."
})),
)
.into_response();
}
};
let auth_state = match crate::auth::webauthn::load_authentication_state(&state.db, &did).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey challenge found. Please start over."
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to load authentication state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
};
let credential: webauthn_rs::prelude::PublicKeyCredential =
match serde_json::from_value(form.credential.clone()) {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid credential format."
})),
)
.into_response();
}
};
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
tracing::error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
};
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
Ok(r) => r,
Err(e) => {
tracing::warn!("Passkey authentication failed: {:?}", e);
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey authentication failed."
})),
)
.into_response();
}
};
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await;
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
&state.db,
credential.id.as_ref(),
auth_result.counter(),
)
.await
{
tracing::warn!("Failed to update passkey counter: {:?}", e);
}
let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
if has_totp {
let device_cookie = extract_device_cookie(&headers);
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
crate::api::server::is_device_trusted(&state.db, dev_id, &did).await
} else {
false
};
if device_is_trusted {
if let Some(ref dev_id) = device_cookie {
let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await;
}
} else {
let user = match sqlx::query!(
r#"SELECT id, preferred_comms_channel as "preferred_comms_channel: CommsChannel" FROM users WHERE did = $1"#,
did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
};
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
match db::create_2fa_challenge(&state.db, &did, &form.request_uri).await {
Ok(challenge) => {
if let Err(e) =
enqueue_2fa_code(&state.db, user.id, &challenge.code, &pds_hostname).await
{
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
}
let channel_name = channel_display_name(user.preferred_comms_channel);
let redirect_url = format!(
"/#/oauth/2fa?request_uri={}&channel={}",
url_encode(&form.request_uri),
url_encode(channel_name)
);
return (
StatusCode::OK,
Json(serde_json::json!({
"next": "2fa",
"redirect": redirect_url
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
)
.into_response();
}
}
}
}
let redirect_url = format!(
"/#/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
(
StatusCode::OK,
Json(serde_json::json!({
"next": "consent",
"redirect": redirect_url
})),
)
.into_response()
}

View File

@@ -10,24 +10,43 @@ async fn test_search_accounts_as_admin() {
let client = client();
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
let (user_did, _) = setup_new_user("search-target").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts?limit=1000",
base_url().await
))
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"]
.as_array()
.expect("accounts should be array");
assert!(!accounts.is_empty(), "Should return some accounts");
let found = accounts
.iter()
.any(|a| a["did"].as_str() == Some(&user_did));
let mut found = false;
let mut cursor: Option<String> = None;
for _ in 0..10 {
let url = match &cursor {
Some(c) => format!(
"{}/xrpc/com.atproto.admin.searchAccounts?limit=100&cursor={}",
base_url().await,
c
),
None => format!(
"{}/xrpc/com.atproto.admin.searchAccounts?limit=100",
base_url().await
),
};
let res = client
.get(&url)
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"]
.as_array()
.expect("accounts should be array");
if accounts
.iter()
.any(|a| a["did"].as_str() == Some(&user_did))
{
found = true;
break;
}
cursor = body["cursor"].as_str().map(|s| s.to_string());
if cursor.is_none() {
break;
}
}
assert!(
found,
"Should find the created user in results (DID: {})",

View File

@@ -97,9 +97,34 @@ async fn test_external_did_web_no_local_doc() {
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
let pds_endpoint = base_url().await.replace("http://", "https://");
let reserve_res = client
.post(format!(
"{}/xrpc/com.atproto.server.reserveSigningKey",
base_url().await
))
.json(&json!({ "did": did }))
.send()
.await
.expect("Failed to reserve signing key");
assert_eq!(reserve_res.status(), StatusCode::OK);
let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
let signing_key = reserve_body["signingKey"]
.as_str()
.expect("No signingKey returned");
let public_key_multibase = signing_key
.strip_prefix("did:key:")
.expect("signingKey should start with did:key:");
let did_doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
@@ -116,7 +141,8 @@ async fn test_external_did_web_no_local_doc() {
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "web-external",
"did": did
"did": did,
"signingKey": signing_key
});
let res = client
.post(format!(

View File

@@ -26,7 +26,10 @@ async fn test_resolve_handle_success() {
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No DID").to_string();
let full_handle = body["handle"].as_str().expect("No handle in response").to_string();
let full_handle = body["handle"]
.as_str()
.expect("No handle in response")
.to_string();
let params = [("handle", full_handle.as_str())];
let res = client
.get(format!(
@@ -97,9 +100,34 @@ async fn test_create_did_web_account_and_resolve() {
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
let handle = format!("webuser_{}", uuid::Uuid::new_v4());
let pds_endpoint = base_url().await.replace("http://", "https://");
let reserve_res = client
.post(format!(
"{}/xrpc/com.atproto.server.reserveSigningKey",
base_url().await
))
.json(&json!({ "did": did }))
.send()
.await
.expect("Failed to reserve signing key");
assert_eq!(reserve_res.status(), StatusCode::OK);
let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
let signing_key = reserve_body["signingKey"]
.as_str()
.expect("No signingKey returned");
let public_key_multibase = signing_key
.strip_prefix("did:key:")
.expect("signingKey should start with did:key:");
let did_doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
@@ -115,7 +143,8 @@ async fn test_create_did_web_account_and_resolve() {
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"did": did
"did": did,
"signingKey": signing_key
});
let res = client
.post(format!(
@@ -195,9 +224,34 @@ async fn test_did_web_lifecycle() {
let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle);
let email = format!("{}@test.com", handle);
let pds_endpoint = base_url().await.replace("http://", "https://");
let reserve_res = client
.post(format!(
"{}/xrpc/com.atproto.server.reserveSigningKey",
base_url().await
))
.json(&json!({ "did": did }))
.send()
.await
.expect("Failed to reserve signing key");
assert_eq!(reserve_res.status(), StatusCode::OK);
let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
let signing_key = reserve_body["signingKey"]
.as_str()
.expect("No signingKey returned");
let public_key_multibase = signing_key
.strip_prefix("did:key:")
.expect("signingKey should start with did:key:");
let did_doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
@@ -213,7 +267,8 @@ async fn test_did_web_lifecycle() {
"handle": handle,
"email": email,
"password": "password",
"did": did
"did": did,
"signingKey": signing_key
});
let res = client
.post(format!(