mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-05 09:46:53 +00:00
Streamplace oauth compatibility, mock plc dir in tests
This commit is contained in:
-22
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
-54
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-17
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
-108
@@ -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"
|
||||
}
|
||||
-34
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
-60
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
-28
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user