Streamplace oauth compatibility, mock plc dir in tests

This commit is contained in:
lewis
2026-01-11 17:05:38 +02:00
parent 8f595b5ffb
commit 7985f76c0d
56 changed files with 689 additions and 1189 deletions
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO backup_codes (did, code_hash, created_at)\n SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n SELECT $1, * FROM UNNEST($2::text[], $3::text[])\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"TextArray",
"TextArray"
]
},
"nullable": []
},
"hash": "2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n VALUES ($1, $2, $3)\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),\n previous_refresh_token = $5, rotated_at = NOW()\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Text",
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f"
}
@@ -1,54 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "comms_type: String",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
false,
false
]
},
"hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "metadata",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email_verified FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email_verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)\n SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Int4",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ",
"query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ",
"describe": {
"columns": [
{
@@ -30,5 +30,5 @@
false
]
},
"hash": "6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47"
"hash": "779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "subject",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "public_key_did_key",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002"
}
@@ -1,108 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, recipient, subject, body,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\"\n FROM comms_queue\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 6,
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
]
}
}
}
},
{
"ordinal": 7,
"name": "status: CommsStatus",
"type_info": {
"Custom": {
"name": "comms_status",
"kind": {
"Enum": [
"pending",
"processing",
"sent",
"failed"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false
]
},
"hash": "9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "private_key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_token\n SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(),\n previous_refresh_token = $4, rotated_at = NOW()\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6"
}
@@ -1,60 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n recipient, subject, body,\n comms_type as \"comms_type: CommsType\"\n FROM comms_queue\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_reset_code FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_reset_code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET is_admin = TRUE WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_reset_code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "password_reset_code_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382"
}
Generated
+15
View File
@@ -776,6 +776,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
dependencies = [
"fastrand",
"gloo-timers",
"tokio",
]
[[package]]
@@ -2297,6 +2299,18 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
@@ -6427,6 +6441,7 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"axum",
"backon",
"base32",
"base64 0.22.1",
"bcrypt",
+1
View File
@@ -32,6 +32,7 @@ tranquil-oauth = { path = "crates/tranquil-oauth" }
tranquil-comms = { path = "crates/tranquil-comms" }
aes-gcm = "0.10"
backon = "1"
anyhow = "1.0"
async-trait = "0.1"
aws-config = "1.8"
+1
View File
@@ -17,6 +17,7 @@ tranquil-oauth = { workspace = true }
tranquil-comms = { workspace = true }
aes-gcm = { workspace = true }
backon = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
aws-config = { workspace = true }
+1 -1
View File
@@ -222,7 +222,7 @@ async fn proxy_handler(
) {
let token = extracted.token;
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = uri.to_string();
let http_uri = crate::util::build_full_url(&uri.to_string());
match crate::auth::validate_token_with_dpop(
&state.db,
+48 -29
View File
@@ -5,6 +5,7 @@ use crate::auth::BearerAuthAllowDeactivated;
use crate::state::AppState;
use crate::sync::import::{ImportError, apply_import, parse_car};
use crate::sync::verify::CarVerifier;
use crate::types::Did;
use axum::{
body::Bytes,
extract::State,
@@ -196,35 +197,46 @@ pub async fn import_repo(
import_result.records.len(),
did
);
let mut blob_ref_count = 0;
for record in &import_result.records {
for blob_ref in &record.blob_refs {
let blob_refs: Vec<(String, String)> = import_result
.records
.iter()
.flat_map(|record| {
let record_uri = format!("at://{}/{}/{}", did, record.collection, record.rkey);
if let Err(e) = sqlx::query!(
r#"
INSERT INTO record_blobs (repo_id, record_uri, blob_cid)
VALUES ($1, $2, $3)
ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING
"#,
user_id,
record_uri,
blob_ref.cid
)
.execute(&state.db)
.await
{
warn!("Failed to insert record_blob for {}: {:?}", record_uri, e);
} else {
blob_ref_count += 1;
record
.blob_refs
.iter()
.map(move |blob_ref| (record_uri.clone(), blob_ref.cid.clone()))
})
.collect();
if !blob_refs.is_empty() {
let (record_uris, blob_cids): (Vec<String>, Vec<String>) =
blob_refs.into_iter().unzip();
match sqlx::query!(
r#"
INSERT INTO record_blobs (repo_id, record_uri, blob_cid)
SELECT $1, * FROM UNNEST($2::text[], $3::text[])
ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING
"#,
user_id,
&record_uris,
&blob_cids
)
.execute(&state.db)
.await
{
Ok(result) => {
info!(
"Recorded {} blob references for imported repo",
result.rows_affected()
);
}
Err(e) => {
warn!("Failed to insert record_blobs: {:?}", e);
}
}
}
if blob_ref_count > 0 {
info!(
"Recorded {} blob references for imported repo",
blob_ref_count
);
}
let key_row = match sqlx::query!(
r#"SELECT uk.key_bytes, uk.encryption_version
FROM user_keys uk
@@ -383,7 +395,7 @@ pub async fn import_repo(
async fn sequence_import_event(
state: &AppState,
did: &str,
did: &Did,
commit_cid: &str,
) -> Result<(), sqlx::Error> {
let prev_cid: Option<String> = None;
@@ -391,13 +403,17 @@ async fn sequence_import_event(
let ops = serde_json::json!([]);
let blobs: Vec<String> = vec![];
let blocks_cids: Vec<String> = vec![];
let did_str = did.as_str();
let mut tx = state.db.begin().await?;
let seq_row = sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids)
VALUES ($1, 'commit', $2, $3, $4, $5, $6, $7)
RETURNING seq
"#,
did,
did_str,
commit_cid,
prev_cid,
prev_data_cid,
@@ -405,10 +421,13 @@ async fn sequence_import_event(
&blobs,
&blocks_cids
)
.fetch_one(&state.db)
.fetch_one(&mut *tx)
.await?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
.execute(&state.db)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
@@ -10,6 +10,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use backon::{ExponentialBuilder, Retryable};
use bcrypt::verify;
use chrono::{Duration, Utc};
use cid::Cid;
@@ -19,6 +20,7 @@ use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{error, info, warn};
use uuid::Uuid;
@@ -177,63 +179,69 @@ async fn assert_valid_did_document_for_service(
let expected_endpoint = format!("https://{}", hostname);
if did.starts_with("did:plc:") {
let plc_client = PlcClient::with_cache(None, Some(cache.clone()));
let max_attempts = if with_retry { 5 } else { 1 };
let mut last_error = None;
let mut doc_data = None;
for attempt in 0..max_attempts {
if attempt > 0 {
let delay_ms = 500 * (1 << (attempt - 1));
info!(
"Waiting {}ms before retry {} for DID document validation ({})",
delay_ms, attempt, did
);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
let cache_for_retry = cache.clone();
let did_owned = did.to_string();
let expected_owned = expected_endpoint.clone();
let attempt_counter = Arc::new(AtomicUsize::new(0));
match plc_client.get_document_data(did).await {
Ok(data) => {
let pds_endpoint = data
.get("services")
.and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds")))
.and_then(|p| p.get("endpoint"))
.and_then(|e| e.as_str());
let doc_data: serde_json::Value = (|| {
let cache_ref = cache_for_retry.clone();
let did_ref = did_owned.clone();
let expected_ref = expected_owned.clone();
let counter = attempt_counter.clone();
async move {
let attempt = counter.fetch_add(1, Ordering::SeqCst);
if attempt > 0 {
info!(
"Retry {} for DID document validation ({})",
attempt, did_ref
);
}
let plc_client = PlcClient::with_cache(None, Some(cache_ref));
match plc_client.get_document_data(&did_ref).await {
Ok(data) => {
let pds_endpoint = data
.get("services")
.and_then(|s: &serde_json::Value| s.get("atproto_pds").or_else(|| s.get("atprotoPds")))
.and_then(|p: &serde_json::Value| p.get("endpoint"))
.and_then(|e: &serde_json::Value| e.as_str());
if pds_endpoint == Some(&expected_endpoint) {
doc_data = Some(data);
break;
} else {
info!(
"Attempt {}: DID {} has endpoint {:?}, expected {} - retrying",
if pds_endpoint == Some(expected_ref.as_str()) {
Ok(data)
} else {
info!(
"Attempt {}: DID {} has endpoint {:?}, expected {}",
attempt + 1,
did_ref,
pds_endpoint,
expected_ref
);
Err(format!(
"DID document endpoint {:?} does not match expected {}",
pds_endpoint, expected_ref
))
}
}
Err(e) => {
warn!(
"Attempt {}: Failed to fetch PLC document for {}: {:?}",
attempt + 1,
did,
pds_endpoint,
expected_endpoint
did_ref,
e
);
last_error = Some(format!(
"DID document endpoint {:?} does not match expected {}",
pds_endpoint, expected_endpoint
));
Err(format!("Could not resolve DID document: {}", e))
}
}
Err(e) => {
warn!(
"Attempt {}: Failed to fetch PLC document for {}: {:?}",
attempt + 1,
did,
e
);
last_error = Some(format!("Could not resolve DID document: {}", e));
}
}
}
let Some(doc_data) = doc_data else {
return Err(ApiError::InvalidRequest(
last_error.unwrap_or_else(|| "DID document validation failed".to_string()),
));
};
})
.retry(
ExponentialBuilder::default()
.with_min_delay(std::time::Duration::from_millis(500))
.with_max_times(max_attempts),
)
.await
.map_err(ApiError::InvalidRequest)?;
let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok();
if let Some(ref expected_rotation_key) = server_rotation_key {
@@ -254,10 +254,14 @@ pub async fn revoke_app_password(
error!("DB error revoking sessions for app password: {:?}", e);
return ApiError::InternalError(None).into_response();
}
for jti in &sessions_to_invalidate {
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti);
let _ = state.cache.delete(&cache_key).await;
}
let cache = state.cache.clone();
async move {
let _ = cache.delete(&cache_key).await;
}
}))
.await;
if let Err(e) = sqlx::query!(
"DELETE FROM app_passwords WHERE user_id = $1 AND name = $2",
user_id,
+77 -75
View File
@@ -15,15 +15,12 @@ const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
fn gen_random_token() -> String {
let mut rng = rand::thread_rng();
let mut token = String::with_capacity(11);
for i in 0..10 {
if i == 5 {
token.push('-');
}
let idx = rng.gen_range(0..32);
token.push(BASE32_ALPHABET[idx] as char);
}
token
let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String {
(0..len)
.map(|_| BASE32_ALPHABET[rng.gen_range(0..32)] as char)
.collect()
};
format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5))
}
fn gen_invite_code() -> String {
@@ -132,34 +129,38 @@ pub async fn create_invite_codes(
}
};
let mut result_codes = Vec::new();
for account in for_accounts {
let mut codes = Vec::new();
for _ in 0..code_count {
let code = gen_invite_code();
if let Err(e) = sqlx::query!(
"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)",
code,
input.use_count,
let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| {
let db = state.db.clone();
let use_count = input.use_count;
async move {
let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect();
sqlx::query!(
r#"
INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)
"#,
&codes[..],
use_count,
admin_user_id,
account
)
.execute(&state.db)
.execute(&db)
.await
{
error!("DB error creating invite code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
codes.push(code);
.map(|_| AccountCodes { account, codes })
}
result_codes.push(AccountCodes { account, codes });
}
}))
.await;
Json(CreateInviteCodesOutput {
codes: result_codes,
})
.into_response()
match result {
Ok(result_codes) => Json(CreateInviteCodesOutput {
codes: result_codes,
})
.into_response(),
Err(e) => {
error!("DB error creating invite codes: {:?}", e);
ApiError::InternalError(None).into_response()
}
}
}
#[derive(Deserialize)]
@@ -227,52 +228,53 @@ pub async fn get_account_invite_codes(
}
};
let mut codes = Vec::new();
for row in codes_rows {
let disabled = row.disabled.unwrap_or(false);
if disabled {
continue;
}
let use_count = row.use_count;
if !include_used && use_count >= row.available_uses {
continue;
}
let uses = sqlx::query!(
r#"
SELECT u.did, u.handle, icu.used_at
FROM invite_code_uses icu
JOIN users u ON icu.used_by_user = u.id
WHERE icu.code = $1
ORDER BY icu.used_at DESC
"#,
row.code
)
.fetch_all(&state.db)
.await
.map(|use_rows| {
use_rows
.iter()
.map(|u| InviteCodeUse {
used_by: u.did.clone(),
used_by_handle: Some(u.handle.clone()),
used_at: u.used_at.to_rfc3339(),
})
.collect()
let filtered_rows: Vec<_> = codes_rows
.into_iter()
.filter(|row| {
let disabled = row.disabled.unwrap_or(false);
!disabled && (include_used || row.use_count < row.available_uses)
})
.unwrap_or_default();
.collect();
codes.push(InviteCode {
code: row.code,
available: row.available_uses,
disabled,
for_account: row.for_account,
created_by: "admin".to_string(),
created_at: row.created_at.to_rfc3339(),
uses,
});
}
let codes = futures::future::join_all(filtered_rows.into_iter().map(|row| {
let db = state.db.clone();
async move {
let uses = sqlx::query!(
r#"
SELECT u.did, u.handle, icu.used_at
FROM invite_code_uses icu
JOIN users u ON icu.used_by_user = u.id
WHERE icu.code = $1
ORDER BY icu.used_at DESC
"#,
row.code
)
.fetch_all(&db)
.await
.map(|use_rows| {
use_rows
.iter()
.map(|u| InviteCodeUse {
used_by: u.did.clone(),
used_by_handle: Some(u.handle.clone()),
used_at: u.used_at.to_rfc3339(),
})
.collect()
})
.unwrap_or_default();
InviteCode {
code: row.code,
available: row.available_uses,
disabled: false,
for_account: row.for_account,
created_by: "admin".to_string(),
created_at: row.created_at.to_rfc3339(),
uses,
}
}
}))
.await;
Json(GetAccountInviteCodesOutput { codes }).into_response()
}
+16 -26
View File
@@ -97,38 +97,28 @@ pub async fn update_did_document(
return ApiError::InvalidRequest("verification_methods cannot be empty".into())
.into_response();
}
for method in methods {
let validation_error = methods.iter().find_map(|method| {
if method.id.is_empty() {
return ApiError::InvalidRequest("verification method id is required".into())
.into_response();
}
if method.method_type != "Multikey" {
return ApiError::InvalidRequest(
"verification method type must be 'Multikey'".into(),
)
.into_response();
}
if !method.public_key_multibase.starts_with('z') {
return ApiError::InvalidRequest(
"publicKeyMultibase must start with 'z' (base58btc)".into(),
)
.into_response();
}
if method.public_key_multibase.len() < 40 {
return ApiError::InvalidRequest(
"publicKeyMultibase appears too short for a valid key".into(),
)
.into_response();
Some("verification method id is required")
} else if method.method_type != "Multikey" {
Some("verification method type must be 'Multikey'")
} else if !method.public_key_multibase.starts_with('z') {
Some("publicKeyMultibase must start with 'z' (base58btc)")
} else if method.public_key_multibase.len() < 40 {
Some("publicKeyMultibase appears too short for a valid key")
} else {
None
}
});
if let Some(err) = validation_error {
return ApiError::InvalidRequest(err.into()).into_response();
}
}
if let Some(ref handles) = input.also_known_as {
for handle in handles {
if !handle.starts_with("at://") {
return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
.into_response();
}
if handles.iter().any(|h| !h.starts_with("at://")) {
return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
.into_response();
}
}
@@ -813,8 +813,6 @@ pub async fn complete_passkey_setup(
return ApiError::InternalError(None).into_response();
}
let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
let app_password = generate_app_password();
let app_password_name = "bsky.app".to_string();
let password_hash = match hash(&app_password, DEFAULT_COST) {
@@ -825,13 +823,21 @@ pub async fn complete_passkey_setup(
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
user.id,
app_password_name,
password_hash
)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("Error creating app password: {:?}", e);
@@ -842,12 +848,20 @@ pub async fn complete_passkey_setup(
"UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
input.did.as_str()
)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("Error clearing setup token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if let Err(e) = tx.commit().await {
error!("Failed to commit setup transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
info!(did = %input.did, "Passkey-only account setup completed");
Json(CompletePasskeySetupResponse {
@@ -1090,12 +1104,20 @@ pub async fn recover_passkey_account(
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
password_hash,
input.did.as_str()
)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("Error updating password: {:?}", e);
@@ -1103,19 +1125,24 @@ pub async fn recover_passkey_account(
}
let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str())
.execute(&state.db)
.execute(&mut *tx)
.await;
match deleted {
Ok(result) => {
if result.rows_affected() > 0 {
info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery");
}
}
let passkeys_deleted = match deleted {
Ok(result) => result.rows_affected(),
Err(e) => {
warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e);
error!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = tx.commit().await {
error!("Failed to commit recovery transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if passkeys_deleted > 0 {
info!(did = %input.did, count = passkeys_deleted, "Deleted lost passkeys during account recovery");
}
info!(did = %input.did, "Passkey-only account recovered with temporary password");
SuccessResponse::ok().into_response()
}
+11 -7
View File
@@ -239,15 +239,19 @@ pub async fn reset_password(
error!("Failed to commit password reset transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
for jti in session_jtis {
futures::future::join_all(session_jtis.into_iter().map(|jti| {
let cache_key = format!("auth:session:{}:{}", user_did, jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!(
"Failed to invalidate session cache for {}: {:?}",
cache_key, e
);
let cache = state.cache.clone();
async move {
if let Err(e) = cache.delete(&cache_key).await {
warn!(
"Failed to invalidate session cache for {}: {:?}",
cache_key, e
);
}
}
}
}))
.await;
info!("Password reset completed for user {}", user_id);
EmptyResponse::ok().into_response()
}
+84 -54
View File
@@ -705,21 +705,6 @@ pub async fn confirm_signup(
return ApiError::InternalError(None).into_response();
}
};
let verified_column = match row.channel {
crate::comms::CommsChannel::Email => "email_verified",
crate::comms::CommsChannel::Discord => "discord_verified",
crate::comms::CommsChannel::Telegram => "telegram_verified",
crate::comms::CommsChannel::Signal => "signal_verified",
};
let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column);
if let Err(e) = sqlx::query(&update_query)
.bind(input.did.as_str())
.execute(&state.db)
.await
{
error!("Failed to update verification status: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
Ok(m) => m,
@@ -735,6 +720,31 @@ pub async fn confirm_signup(
return ApiError::InternalError(None).into_response();
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let verified_column = match row.channel {
crate::comms::CommsChannel::Email => "email_verified",
crate::comms::CommsChannel::Discord => "discord_verified",
crate::comms::CommsChannel::Telegram => "telegram_verified",
crate::comms::CommsChannel::Signal => "signal_verified",
};
let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column);
if let Err(e) = sqlx::query(&update_query)
.bind(input.did.as_str())
.execute(&mut *tx)
.await
{
error!("Failed to update verification status: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let no_scope: Option<String> = None;
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
@@ -747,12 +757,18 @@ pub async fn confirm_signup(
false,
no_scope
)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("Failed to insert session: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await {
warn!("Failed to enqueue welcome notification: {:?}", e);
@@ -878,9 +894,7 @@ pub async fn list_sessions(
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
let mut sessions: Vec<SessionInfo> = Vec::new();
let jwt_result = sqlx::query_as::<
let jwt_rows = match sqlx::query_as::<
_,
(
i32,
@@ -898,28 +912,16 @@ pub async fn list_sessions(
)
.bind(&auth.0.did)
.fetch_all(&state.db)
.await;
match jwt_result {
Ok(rows) => {
for (id, access_jti, created_at, expires_at) in rows {
sessions.push(SessionInfo {
id: format!("jwt:{}", id),
session_type: "legacy".to_string(),
client_name: None,
created_at: created_at.to_rfc3339(),
expires_at: expires_at.to_rfc3339(),
is_current: current_jti.as_ref() == Some(&access_jti),
});
}
}
.await
{
Ok(rows) => rows,
Err(e) => {
error!("DB error fetching JWT sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
}
};
let oauth_result = sqlx::query_as::<
let oauth_rows = match sqlx::query_as::<
_,
(
i32,
@@ -938,29 +940,44 @@ pub async fn list_sessions(
)
.bind(&auth.0.did)
.fetch_all(&state.db)
.await;
.await
{
Ok(rows) => rows,
Err(e) => {
error!("DB error fetching OAuth sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
match oauth_result {
Ok(rows) => {
for (id, token_id, created_at, expires_at, client_id) in rows {
let jwt_sessions = jwt_rows.into_iter().map(|(id, access_jti, created_at, expires_at)| {
SessionInfo {
id: format!("jwt:{}", id),
session_type: "legacy".to_string(),
client_name: None,
created_at: created_at.to_rfc3339(),
expires_at: expires_at.to_rfc3339(),
is_current: current_jti.as_ref() == Some(&access_jti),
}
});
let is_oauth = auth.0.is_oauth;
let oauth_sessions =
oauth_rows
.into_iter()
.map(|(id, token_id, created_at, expires_at, client_id)| {
let client_name = extract_client_name(&client_id);
let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id);
sessions.push(SessionInfo {
let is_current_oauth = is_oauth && current_jti.as_ref() == Some(&token_id);
SessionInfo {
id: format!("oauth:{}", id),
session_type: "oauth".to_string(),
client_name: Some(client_name),
created_at: created_at.to_rfc3339(),
expires_at: expires_at.to_rfc3339(),
is_current: is_current_oauth,
});
}
}
Err(e) => {
error!("DB error fetching OAuth sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
}
}
});
let mut sessions: Vec<SessionInfo> = jwt_sessions.chain(oauth_sessions).collect();
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
@@ -1061,10 +1078,18 @@ pub async fn revoke_all_sessions(
return ApiError::InvalidToken(None).into_response();
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if auth.0.is_oauth {
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
@@ -1073,7 +1098,7 @@ pub async fn revoke_all_sessions(
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
@@ -1084,7 +1109,7 @@ pub async fn revoke_all_sessions(
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
@@ -1092,7 +1117,7 @@ pub async fn revoke_all_sessions(
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.execute(&mut *tx)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
@@ -1100,6 +1125,11 @@ pub async fn revoke_all_sessions(
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return ApiError::InternalError(None).into_response();
}
info!(did = %&auth.0.did, "All other sessions revoked");
SuccessResponse::ok().into_response()
}
+48 -41
View File
@@ -195,26 +195,28 @@ pub async fn enable_totp(
return ApiError::InternalError(None).into_response();
}
for code in &backup_codes {
let hash = match hash_backup_code(code) {
Ok(h) => h,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
&auth.0.did,
hash
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup code: {:?}", e);
let backup_hashes: Result<Vec<_>, _> = backup_codes.iter().map(|c| hash_backup_code(c)).collect();
let backup_hashes = match backup_hashes {
Ok(hashes) => hashes,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
r#"
INSERT INTO backup_codes (did, code_hash, created_at)
SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash)
"#,
&auth.0.did,
&backup_hashes[..]
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup codes: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if let Err(e) = tx.commit().await {
@@ -482,26 +484,28 @@ pub async fn regenerate_backup_codes(
return ApiError::InternalError(None).into_response();
}
for code in &backup_codes {
let hash = match hash_backup_code(code) {
Ok(h) => h,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
&auth.0.did,
hash
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup code: {:?}", e);
let backup_hashes: Result<Vec<_>, _> = backup_codes.iter().map(|c| hash_backup_code(c)).collect();
let backup_hashes = match backup_hashes {
Ok(hashes) => hashes,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
r#"
INSERT INTO backup_codes (did, code_hash, created_at)
SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash)
"#,
&auth.0.did,
&backup_hashes[..]
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup codes: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if let Err(e) = tx.commit().await {
@@ -532,8 +536,12 @@ async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) ->
}
};
for row in backup_codes {
if verify_backup_code(&code, &row.code_hash) {
let matched = backup_codes
.iter()
.find(|row| verify_backup_code(&code, &row.code_hash));
match matched {
Some(row) => {
let _ = sqlx::query!(
"UPDATE backup_codes SET used_at = $1 WHERE id = $2",
Utc::now(),
@@ -541,11 +549,10 @@ async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) ->
)
.execute(&state.db)
.await;
return true;
true
}
None => false,
}
false
}
pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
+2 -4
View File
@@ -179,7 +179,6 @@ pub async fn get_token_by_refresh_token(
pub async fn rotate_token(
pool: &PgPool,
old_db_id: i32,
new_token_id: &str,
new_refresh_token: &str,
new_expires_at: DateTime<Utc>,
) -> Result<(), OAuthError> {
@@ -207,12 +206,11 @@ pub async fn rotate_token(
sqlx::query!(
r#"
UPDATE oauth_token
SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),
previous_refresh_token = $5, rotated_at = NOW()
SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(),
previous_refresh_token = $4, rotated_at = NOW()
WHERE id = $1
"#,
old_db_id,
new_token_id,
new_refresh_token,
new_expires_at,
old_refresh
@@ -24,6 +24,11 @@ pub async fn handle_authorization_code_grant(
request: ValidatedTokenRequest,
dpop_proof: Option<String>,
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
tracing::info!(
has_dpop = dpop_proof.is_some(),
client_id = ?request.client_auth.client_id,
"Authorization code grant requested"
);
let (code, code_verifier, redirect_uri) = match request.grant {
TokenGrant::AuthorizationCode {
code,
@@ -178,6 +183,12 @@ pub async fn handle_authorization_code_grant(
controller_did: controller_did.clone(),
};
db::create_token(&state.db, &token_data).await?;
tracing::info!(
did = %did,
token_id = %token_id.0,
client_id = %auth_request.client_id,
"Authorization code grant completed, token created"
);
tokio::spawn({
let pool = state.db.clone();
let did_clone = did.clone();
@@ -316,7 +327,6 @@ pub async fn handle_refresh_token_grant(
} else {
None
};
let new_token_id = TokenId::generate();
let new_refresh_token = RefreshToken::generate();
let refresh_expiry_days = if matches!(token_data.client_auth, ClientAuth::None) {
REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC
@@ -327,7 +337,6 @@ pub async fn handle_refresh_token_grant(
db::rotate_token(
&state.db,
db_id,
&new_token_id.0,
&new_refresh_token.0,
new_expires_at,
)
@@ -338,7 +347,7 @@ pub async fn handle_refresh_token_grant(
"Refresh token rotated successfully"
);
let access_token = create_access_token_with_delegation(
&new_token_id.0,
&token_data.token_id,
&token_data.did,
dpop_jkt.as_deref(),
token_data.scope.as_deref(),
+1 -1
View File
@@ -48,7 +48,7 @@ impl RateLimiters {
NonZeroU32::new(10).unwrap(),
))),
oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
NonZeroU32::new(300).unwrap(),
))),
oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
+1 -1
View File
@@ -71,7 +71,7 @@ impl RateLimitKind {
Self::PasswordReset => (5, 3_600_000),
Self::ResetPassword => (10, 60_000),
Self::RefreshSession => (60, 60_000),
Self::OAuthToken => (30, 60_000),
Self::OAuthToken => (300, 60_000),
Self::OAuthAuthorize => (10, 60_000),
Self::OAuthPar => (30, 60_000),
Self::OAuthIntrospect => (30, 60_000),
+200 -3
View File
@@ -5,19 +5,19 @@ use chrono::Utc;
use reqwest::{Client, StatusCode, header};
use serde_json::{Value, json};
use sqlx::postgres::PgPoolOptions;
#[allow(unused_imports)]
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock, RwLock};
#[allow(unused_imports)]
use std::time::Duration;
use tokio::net::TcpListener;
use tranquil_pds::state::AppState;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
static SERVER_URL: OnceLock<String> = OnceLock::new();
static APP_PORT: OnceLock<u16> = OnceLock::new();
static MOCK_APPVIEW: OnceLock<MockServer> = OnceLock::new();
static MOCK_PLC: OnceLock<MockServer> = OnceLock::new();
static TEST_DB_POOL: OnceLock<sqlx::PgPool> = OnceLock::new();
#[cfg(not(feature = "external-infra"))]
@@ -117,6 +117,7 @@ async fn setup_with_external_infra() -> String {
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra");
let s3_endpoint =
std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT must be set when using external infra");
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var(
"S3_BUCKET",
@@ -137,6 +138,7 @@ async fn setup_with_external_infra() -> String {
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
}
let mock_server = MockServer::start().await;
setup_mock_appview(&mock_server).await;
@@ -164,6 +166,7 @@ async fn setup_with_testcontainers() -> String {
.await
.expect("Failed to get S3 port");
let s3_endpoint = format!("http://127.0.0.1:{}", s3_port);
let plc_url = setup_mock_plc_directory().await;
unsafe {
std::env::set_var("S3_BUCKET", "test-bucket");
std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin");
@@ -172,6 +175,7 @@ async fn setup_with_testcontainers() -> String {
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
std::env::set_var("PLC_DIRECTORY_URL", &plc_url);
}
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
.region("us-east-1")
@@ -239,6 +243,199 @@ async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_en
async fn setup_mock_appview(_mock_server: &MockServer) {}
type PlcOperationStore = Arc<RwLock<HashMap<String, Value>>>;
struct PlcPostResponder {
store: PlcOperationStore,
}
impl Respond for PlcPostResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
let path = request.url.path();
let did = urlencoding::decode(path.trim_start_matches('/'))
.unwrap_or_default()
.to_string();
if let Ok(body) = serde_json::from_slice::<Value>(request.body.as_slice()) {
if let Ok(mut store) = self.store.write() {
store.insert(did, body);
}
}
ResponseTemplate::new(200)
}
}
struct PlcGetResponder {
store: PlcOperationStore,
}
impl Respond for PlcGetResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
let path = request.url.path();
let path_clean = path.trim_start_matches('/');
let (did, endpoint) = path_clean
.find("/log/")
.or_else(|| path_clean.find("/data"))
.map(|idx| {
let did = urlencoding::decode(&path_clean[..idx])
.unwrap_or_default()
.to_string();
let endpoint = &path_clean[idx..];
(did, endpoint)
})
.unwrap_or_else(|| {
(
urlencoding::decode(path_clean)
.unwrap_or_default()
.to_string(),
"",
)
});
let store = self.store.read().unwrap();
let operation = store.get(&did);
match endpoint {
"/log/last" => {
let response = operation
.cloned()
.unwrap_or_else(|| {
json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"prev": null
})
});
ResponseTemplate::new(200).set_body_json(response)
}
"/log/audit" => ResponseTemplate::new(200).set_body_json(json!([])),
"/data" => {
let response = operation
.map(|op| {
json!({
"rotationKeys": op.get("rotationKeys").cloned().unwrap_or(json!([])),
"verificationMethods": op.get("verificationMethods").cloned().unwrap_or(json!({})),
"alsoKnownAs": op.get("alsoKnownAs").cloned().unwrap_or(json!([])),
"services": op.get("services").cloned().unwrap_or(json!({}))
})
})
.unwrap_or_else(|| {
json!({
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {}
})
});
ResponseTemplate::new(200).set_body_json(response)
}
_ => {
let did_doc = operation
.map(|op| operation_to_did_document(&did, op))
.unwrap_or_else(|| {
json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"alsoKnownAs": [],
"verificationMethod": [],
"service": []
})
});
ResponseTemplate::new(200).set_body_json(did_doc)
}
}
}
}
fn operation_to_did_document(did: &str, op: &Value) -> Value {
let also_known_as = op
.get("alsoKnownAs")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let verification_methods: Vec<Value> = op
.get("verificationMethods")
.and_then(|v| v.as_object())
.map(|methods| {
methods
.iter()
.map(|(key, value)| {
let did_key = value.as_str().unwrap_or("");
let multikey = did_key_to_multikey(did_key);
json!({
"id": format!("{}#{}", did, key),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": multikey
})
})
.collect()
})
.unwrap_or_default();
let services: Vec<Value> = op
.get("services")
.and_then(|v| v.as_object())
.map(|svcs| {
svcs.iter()
.map(|(key, value)| {
json!({
"id": format!("#{}", key),
"type": value.get("type").and_then(|t| t.as_str()).unwrap_or(""),
"serviceEndpoint": value.get("endpoint").and_then(|e| e.as_str()).unwrap_or("")
})
})
.collect()
})
.unwrap_or_default();
json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": verification_methods,
"service": services
})
}
fn did_key_to_multikey(did_key: &str) -> String {
if !did_key.starts_with("did:key:z") {
return String::new();
}
did_key[8..].to_string()
}
async fn setup_mock_plc_directory() -> String {
let mock_plc = MockServer::start().await;
let store: PlcOperationStore = Arc::new(RwLock::new(HashMap::new()));
Mock::given(method("POST"))
.respond_with(PlcPostResponder {
store: store.clone(),
})
.mount(&mock_plc)
.await;
Mock::given(method("GET"))
.respond_with(PlcGetResponder {
store: store.clone(),
})
.mount(&mock_plc)
.await;
let plc_url = mock_plc.uri();
MOCK_PLC.set(mock_plc).ok();
plc_url
}
async fn spawn_app(database_url: String) -> String {
use tranquil_pds::rate_limit::RateLimiters;
let pool = PgPoolOptions::new()